Compare commits

...

95 Commits

Author SHA1 Message Date
opencode-agent[bot] e2c795113d Fix beta integration 2026-08-15 20:08:24 +00:00
opencode-agent[bot] d94d520f45 fix(tui): make running subagents clickable (#42797)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 19:54:40 +00:00
Dax Raad fcc1e9c42f docs(core): add MCP setup guidance 2026-08-15 15:13:28 -04:00
opencode-agent[bot] 64b9ba339e docs(console): prohibit abusive multi-account use (#42813)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 15:08:52 -04:00
opencode-agent[bot] 73700065bf chore: generate 2026-08-15 18:35:53 +00:00
Kit Langton 07a82a442e fix(core): keep queued work parked after interrupt (#42682) 2026-08-15 18:34:43 +00:00
Kit Langton 55be895e14 fix(tui): scope session picker by directory (#42804) 2026-08-15 18:30:35 +00:00
Kit Langton 58a36a1560 fix(tui): preserve path suffix in prompt footer (#42800) 2026-08-15 18:21:07 +00:00
Dax 3d782efee4 docs(core): fix opencode skill source link (#42792) 2026-08-15 16:37:34 +00:00
opencode-agent[bot] c054bb183e chore: generate 2026-08-15 16:30:19 +00:00
Dax 0d7904c91f refactor(core): model provider activation explicitly (#42791)
Co-authored-by: Michael Hart <mhart@cloudflare.com>
2026-08-15 16:29:09 +00:00
opencode-agent[bot] 75ec0b454c chore: generate 2026-08-15 12:13:32 +00:00
Luke Parker f4baba2824 fix(app): use fixed message page size (#42743) 2026-08-15 22:12:20 +10:00
Luke Parker 79fc74afbf fix(app): show skill names (#42749) 2026-08-15 20:20:53 +10:00
Luke Parker 30dfe5352c fix(app): show read tool filenames (#42740) 2026-08-15 20:05:13 +10:00
Luke Parker f725443e30 fix(core): normalize Windows location keys (#42745) 2026-08-15 19:56:43 +10:00
opencode-agent[bot] cae205a3d9 fix(core): bound fuzzy search memory (#42741)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 19:40:24 +10:00
Luke Parker d35c6f04ed fix(ui): add keybind label padding (#42726) 2026-08-15 17:54:31 +10:00
opencode-agent[bot] 4d021b4660 fix(app): fade tab titles beneath close button (#42730)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 17:10:56 +10:00
Luke Parker f63f912178 test(app): typecheck complete e2e suite (#42728) 2026-08-15 17:06:58 +10:00
opencode-agent[bot] 6564d1442a chore: generate 2026-08-15 06:56:31 +00:00
Luke Parker a5f3e9e735 fix(app): preserve assistant content order (#42721) 2026-08-15 16:55:22 +10:00
Luke Parker d24a24a2ca fix(app): route global events without directory sentinel (#42719) 2026-08-15 16:47:37 +10:00
Luke Parker 8610d90838 fix(app): project sent messages through inbox events (#42714) 2026-08-15 15:57:46 +10:00
Luke Parker 57b050e9fc fix(desktop): render v2 patch metadata (#42716) 2026-08-15 15:57:34 +10:00
Luke Parker 51091be7e4 fix(desktop): verify updates before install (#42715) 2026-08-15 14:39:01 +10:00
Luke Parker c8584ec0c8 fix(app): derive popular providers from integrations (#42713) 2026-08-15 14:29:19 +10:00
Luke Parker 7301c5e798 fix(desktop): keep staged updates fresh with silent re-checks (#42707) 2026-08-15 14:22:24 +10:00
Luke Parker 41f70bfbb1 fix(app): generate blob ids without crypto.subtle in non-secure contexts (#42706)
Co-authored-by: OpenCode Vite Investigation <opencode-vite-investigation@local>
2026-08-15 14:12:57 +10:00
Kit Langton e8fa6985bc fix(core): unify patch path resolution (#42667) 2026-08-15 03:52:34 +00:00
Kit Langton 66012fe65f docs: remove stale service lifecycle design 2026-08-14 23:51:26 -04:00
Kit Langton 8e66f83a50 docs: retire superseded session design 2026-08-14 23:48:36 -04:00
Kit Langton 1d59884434 docs: remove pre-release schema ledger 2026-08-14 23:47:03 -04:00
Kit Langton 6a69aa752f docs: remove completed implementation plans 2026-08-14 23:45:48 -04:00
Kit Langton 7086df5b11 docs(tui): move theme work to issue 2026-08-14 23:39:27 -04:00
Kit Langton b57c1cc47c docs: align v2 specification index 2026-08-14 23:36:15 -04:00
Kit Langton 082423126c docs: remove obsolete project api sketch 2026-08-14 23:34:24 -04:00
Kit Langton 7c4fbdd291 docs: remove obsolete sqlite proposal 2026-08-14 23:33:22 -04:00
Kit Langton a01cd34acc docs: remove completed storage plan 2026-08-14 23:32:23 -04:00
Kit Langton d5a58e756f docs: remove obsolete migration plan 2026-08-14 23:28:46 -04:00
Kit Langton e57a1c7930 docs: refresh agent guidance 2026-08-14 23:25:47 -04:00
Kit Langton 98f5e86122 fix(tui): refresh moved tab metadata (#42696) 2026-08-14 23:25:25 -04:00
Kit Langton c42c7f7793 docs(tui): remove completed extraction plan 2026-08-14 23:18:46 -04:00
Kit Langton ab7a0bf65c fix(tui): ignore stray releases on new session controls (#42673) 2026-08-14 20:57:49 -04:00
Kit Langton 552fd40ef8 docs: update contributing guide 2026-08-14 20:54:35 -04:00
Kit Langton 8afcb3870e fix(plugin): derive promise adapter from protocol schemas (#42669) 2026-08-15 00:52:06 +00:00
Kit Langton 014a364dfd docs(core): refresh session architecture 2026-08-14 20:32:06 -04:00
opencode-agent[bot] a45b12cfa4 fix(app): use location VCS state (#42666) 2026-08-15 10:30:46 +10:00
Kit Langton 7484e32f62 refactor(protocol): harden simulation wire contract (#42628) 2026-08-14 20:29:53 -04:00
opencode-agent[bot] 650f7e8cdd chore: generate 2026-08-15 00:20:16 +00:00
Dax 5c7f5840ee feat(core): persist web search provider selection (#42663) 2026-08-14 20:19:02 -04:00
opencode-agent[bot] 08a6d7b619 docs: fix package manager code blocks (#42313)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-14 20:03:48 -04:00
opencode-agent[bot] 1580e7cc3a chore: generate 2026-08-14 23:41:12 +00:00
Aiden Cline 8640ea3374 minimize system prompt (#42638) 2026-08-14 18:39:27 -05:00
opencode-agent[bot] e6a3b951b5 chore: generate 2026-08-14 22:19:55 +00:00
James Long 62b67f2761 refactor(protocol): move worktree routes out of experimental namespace (#42656) 2026-08-14 18:18:40 -04:00
James Long 182fe17f12 fix(tui): remove shadow scrim under horizontal tab strip (#42650) 2026-08-14 16:06:32 -04:00
opencode-agent[bot] b6ea0c2209 chore: generate 2026-08-14 19:44:43 +00:00
Kit Langton f7b222e95a fix(tui): open footer status dialogs on click 2026-08-14 15:43:07 -04:00
Kit Langton a7288d231e feat(tui): add working directory actions (#42624) 2026-08-14 15:29:22 -04:00
Kit Langton f75244d795 fix(tui): preserve tab drag source (#42619) 2026-08-14 17:59:18 +00:00
Kit Langton 1d44d56d9c fix(tui): use semantic form tokens (#42599) 2026-08-14 17:49:06 +00:00
Kit Langton 9adc9bb5df fix(tui): preserve full-width tab hover 2026-08-14 13:41:39 -04:00
Kit Langton a609acbb67 fix(tui): refine tab marquee motion (#42617) 2026-08-14 13:30:21 -04:00
Kit Langton f4620939aa test(server): remove workerd spike (#42618) 2026-08-14 13:21:26 -04:00
Kit Langton 23258ada86 feat(tui): resize vertical tab rail (#42615) 2026-08-14 13:16:49 -04:00
opencode-agent[bot] 2ca6d8ee49 chore: generate 2026-08-14 17:10:13 +00:00
opencode-agent[bot] d6c299cee7 fix(ai): remove over-safe provider error handling (#42610)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-14 12:08:59 -05:00
Kit Langton 737b6eb7b7 chore: extract Drive and catalog (#42612) 2026-08-14 13:05:24 -04:00
opencode-agent[bot] 923cb28a0a chore: generate 2026-08-14 16:39:28 +00:00
Kit Langton e79f3e8ca2 feat(tui): animate interrupt confirmation (#42602) 2026-08-14 16:38:06 +00:00
Shoubhit Dash 638cb414c4 test(ai): harden websocket lifecycle coverage (#42600) 2026-08-14 22:07:06 +05:30
Shoubhit Dash 445aacf92e feat(ai): add Responses websocket continuation (#42596) 2026-08-14 22:06:29 +05:30
Shoubhit Dash a2a53032d4 feat(core): manage session websocket channels (#41564) 2026-08-14 22:05:43 +05:30
Shoubhit Dash 810156d081 refactor(core): expose session transport eligibility (#41558) 2026-08-14 22:05:02 +05:30
Shoubhit Dash c0ae362996 refactor(ai): unify OpenAI Responses transport (#41048) 2026-08-14 22:04:16 +05:30
Shoubhit Dash de32afe358 refactor(ai): add sequential websocket channel seam (#41045) 2026-08-14 22:03:31 +05:30
Shoubhit Dash 2d953a69de fix(ai): harden websocket error contracts (#40695) 2026-08-14 22:01:37 +05:30
Kit Langton e3bbfdf270 feat(simulation): slim production bridge (#42584) 2026-08-14 15:57:44 +00:00
Shoubhit Dash 6d0c30a125 fix(tui): defer background work hint (#41563) 2026-08-14 20:57:36 +05:30
Kit Langton 1cf97fac5c fix(tui): optimize Mermaid terminal rendering (#42595) 2026-08-14 11:10:32 -04:00
opencode-agent[bot] f7d21babb6 chore: generate 2026-08-14 15:07:36 +00:00
Kit Langton b0480a6f93 feat(core): make portable shell scanner authoritative (#42581) 2026-08-14 11:06:04 -04:00
Kit Langton bdbccca1f3 feat(tui): refine question form interactions (#42493) 2026-08-14 14:54:32 +00:00
Shoubhit Dash 3d3bbd48f9 feat(core): add tavily web search (#42591) 2026-08-14 20:20:10 +05:30
opencode-agent[bot] 51f0b499f9 chore: generate 2026-08-14 14:40:07 +00:00
opencode-agent[bot] 9872fb8a54 refactor(api): remove legacy question service (#42562)
Co-authored-by: Filip Hejmowski <fhejmowski@simplito.com>
Co-authored-by: neriousy <hejmowskifilip@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 14:38:32 +00:00
Kit Langton baf5ce3b7e fix(tui): debounce shared storage reloads (#42579) 2026-08-14 14:14:42 +00:00
Kit Langton 70efd3fb38 fix(tui): preserve background hint timing (#42577) 2026-08-14 13:18:53 +00:00
Kit Langton a1a663d60e refactor(cli): replace updater semver dependency (#42492) 2026-08-14 09:09:52 -04:00
Kit Langton 4a899bb730 refactor(tui): rebuild tab pulse on gated envelopes (#42496) 2026-08-14 08:54:16 -04:00
opencode-agent[bot] b6b313943f chore: generate 2026-08-14 12:41:52 +00:00
opencode-agent[bot] 3a4c8cdf9d revert(core): remove portable shell permission scanner (#42571)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 08:40:40 -04:00
opencode-agent[bot] 8d71099577 chore: generate 2026-08-14 12:23:07 +00:00
Simon Klee c0b4a0ce91 tui: update OpenTUI to 0.5.3 (#42568) 2026-08-14 14:21:52 +02:00
790 changed files with 11216 additions and 161851 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
+2 -1
View File
@@ -5,4 +5,5 @@
"@opencode-ai/client": minor "@opencode-ai/client": minor
--- ---
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners. Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -11
View File
@@ -72,20 +72,10 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }} run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify PowerShell 7 scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=pwsh bun run research:powershell
- name: Verify Windows PowerShell scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=powershell.exe bun run research:powershell
- name: Verify compiled service lifecycle - name: Verify compiled service lifecycle
if: always() if: always()
timeout-minutes: 10 timeout-minutes: 10
+1 -1
View File
@@ -2,7 +2,7 @@
description: "Bump AI sdk dependencies minor / patch versions only" description: "Bump AI sdk dependencies minor / patch versions only"
--- ---
Please read @package.json and @packages/opencode/package.json. Please read @package.json and @packages/core/package.json.
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
+1 -9
View File
@@ -6,15 +6,7 @@ subtask: true
commit and push commit and push
make sure it includes a prefix like Use `type(scope): summary` with one of these types: `feat`, `fix`, `docs`, `chore`, `refactor`, or `test`. The scope is optional.
docs:
tui:
core:
ci:
ignore:
wip:
For anything in the packages/web use the docs: prefix.
prefer to explain WHY something was done from an end user perspective instead of prefer to explain WHY something was done from an end user perspective instead of
WHAT was done. WHAT was done.
+1 -1
View File
@@ -2,7 +2,7 @@
description: Remove AI code slop description: Remove AI code slop
--- ---
Check the diff against dev, and remove all AI generated slop introduced in this branch. Check the diff against `origin/v2`, and remove all AI generated slop introduced in this branch.
This includes: This includes:
+7 -7
View File
@@ -1,6 +1,6 @@
--- ---
name: effect name: effect
description: Work with Effect v4 / effect-smol TypeScript code in this repo description: Work with Effect v4 TypeScript code in this repo
--- ---
# Effect # Effect
@@ -9,10 +9,10 @@ This codebase uses Effect for typed, composable TypeScript services, schemas, an
## Source Of Truth ## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples. Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder. 1. If `.opencode/references/effect` is missing, clone `https://github.com/Effect-TS/effect` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code. 2. Search `.opencode/references/effect` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns. 3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples. 4. Prefer answers and implementations backed by specific source files or nearby repo examples.
@@ -27,12 +27,12 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see. - Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. - In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. - Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. - Do not answer from memory. Verify against `.opencode/references/effect` or nearby code first.
## Testing Patterns ## Testing Patterns
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations. - Use `testEffect(...)` from `packages/core/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior. - Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root. - Run tests from package directories such as `packages/core`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file. - Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state. - Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
-253
View File
@@ -1,253 +0,0 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+25 -11
View File
@@ -1,6 +1,6 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`. - The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. - Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs. - Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
@@ -12,6 +12,20 @@
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. - The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. - Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
## TUI Theme Tokens
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
## Branch Names ## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
@@ -152,23 +166,23 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
## Type Checking ## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. - Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
## V2 Session Core ## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row. - Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once. - Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep event replay ownership separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. - Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta. - `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
+60 -220
View File
@@ -1,272 +1,112 @@
# Contributing to OpenCode # Contributing to OpenCode
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged: The changes most likely to be accepted are:
- Bug fixes - Bug fixes
- Additional LSPs / Formatters - Additional LSPs and formatters
- Improvements to LLM performance - LLM performance improvements
- Support for new providers - Environment-specific fixes
- Fixes for environment-specific quirks
- Missing standard behavior - Missing standard behavior
- Documentation improvements - Documentation improvements
However, any UI or core product feature must go through a design review with the core team before implementation. UI and core product features require design review before implementation. If you are unsure whether a change fits, ask a maintainer or choose an issue labeled [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted), [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22), [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug), or [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22).
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels: Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE] > [!NOTE]
> PRs that ignore these guardrails will likely be closed. > PRs that ignore these guardrails will likely be closed.
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. ## Adding Providers
## Adding New Providers New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to: ## Development
https://github.com/anomalyco/models.dev
## Developing OpenCode OpenCode requires Bun 1.3 or newer. From the repository root:
- Requirements: Bun 1.3+
- Install dependencies and start the dev server from the repo root:
```bash ```bash
bun install bun install
bun dev bun dev [directory]
``` ```
### Running against a different directory `bun dev` runs the V2 CLI and TUI. Pass a directory to open another project, or `.` to open this repository.
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository: To test a development TUI against your installed OpenCode V2 background service and live sessions:
```bash ```bash
bun dev <directory> bun run dev:live [directory]
``` ```
To run OpenCode in the root of the opencode repo itself: For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
```bash ```bash
bun dev . bun dev serve --port 4096
bun run dev:web
bun run dev:desktop
bun run dev:www
``` ```
### Building a "localcode" ### Packages
To compile a standalone executable: - `packages/schema`: shared wire and storage contracts
- `packages/core`: domain behavior and persistence
- `packages/protocol`: public API definitions
- `packages/server`: HTTP server and runtime composition
- `packages/client`: generated TypeScript clients
- `packages/cli`: command-line entrypoint and service lifecycle
- `packages/tui`: terminal interface
- `packages/app`: shared web interface
- `packages/desktop`: Electron desktop application
- `packages/plugin`: plugin API
### Verification
Run typechecks, and tests where defined, from the affected package rather than the repository root:
```bash ```bash
./packages/opencode/script/build.ts --single cd packages/core
bun run test
bun typecheck
``` ```
Then run it with: Follow package-specific instructions in nearby `AGENTS.md` files. After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`; never edit generated client files directly.
```bash Follow the repository [style guide](./AGENTS.md).
./packages/opencode/dist/opencode-<platform>/bin/opencode
```
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`). ## Pull Requests
- Core pieces: ### Link Issues When Required
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
### Understanding bun dev vs opencode Bug fixes, chores, and tests must reference an existing issue. Documentation, refactor, and feature PRs are exempt from the automated linked-issue check. When required, use `Fixes #123` or `Closes #123` in the PR description.
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface: Before implementing new functionality, open a feature request describing the problem, why it belongs in OpenCode, and your proposed approach if you have one. Wait for design approval before opening the implementation PR.
```bash Base branches on `v2`, not `dev`, and complete the provided pull request template.
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
bun dev <directory> # Start TUI in specific directory
# Production ### Keep It Focused
opencode --help # Show all available commands
opencode serve # Start headless API server
opencode web # Start server + open web interface
opencode <directory> # Start TUI in specific directory
```
### Running the API Server - Keep PRs small and focused.
- Explain the problem and why the change fixes it.
- Check whether the functionality already exists.
- For UI changes, include before-and-after screenshots or video.
- For logic changes, explain what you tested and how a reviewer can verify it.
To start the OpenCode headless API server: ### Keep It Brief
```bash Long, AI-generated PR descriptions and issues may be ignored. Write a short explanation in your own words. If the change cannot be explained briefly, the PR may be too large.
bun dev serve
```
This starts the headless server on port 4096 by default. You can specify a different port: ### Use Conventional Titles
```bash Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
bun dev serve --port 8080
```
### Running the Web App
To test UI changes during development:
1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above)
2. **Then run the web app:**
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Running the Desktop App
The desktop app is an Electron application that wraps the web UI.
To run the desktop app in development:
```bash
bun run --cwd packages/desktop dev
```
To create a production build and package the app:
```bash
bun run --cwd packages/desktop build
bun run --cwd packages/desktop package
```
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./AGENTS.md)
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples: Examples:
- `docs: update contributing guidelines` - `docs: update contributing guide`
- `fix: resolve crash on startup` - `fix(tui): restore scroll position`
- `feat: add dark mode support` - `feat(app): add workspace search`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
### Style Preferences ## Issues
These are not strictly enforced, they are just general guidelines: Bug reports and feature requests must use their issue templates. Blank issues are not allowed; ask support and how-to questions in the [Discord community](https://discord.gg/opencode).
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits. Automated checks flag missing templates, placeholder text, AI-generated walls of text, and missing meaningful content. You have two hours to correct a flagged issue before it closes automatically. Ask a maintainer if an issue was flagged incorrectly.
- **Destructuring:** Do not do unnecessary destructuring of variables.
- **Control flow:** Avoid `else` statements.
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
- **Types:** Reach for precise types and avoid `any`.
- **Variables:** Stick to immutable patterns and avoid `let`.
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Issue Requirements
All issues **must** use one of our issue templates:
- **Bug report** — for reporting bugs (requires a description)
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
- **Question** — for asking questions (requires the question)
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
Issues may be flagged for:
- Not using a template
- Required fields left empty or filled with placeholder text
- AI-generated walls of text
- Missing meaningful content
If you believe your issue was incorrectly flagged, let a maintainer know.
+66 -674
View File
File diff suppressed because it is too large Load Diff
-685
View File
@@ -1,685 +0,0 @@
# Service Lifecycle: Election, Restart, and Reconnect
Status: in progress
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
## Summary
The managed V2 service keeps its current update policy: the background updater
may install a new package, but only a freshly launched TUI activates that update
after finding an older running service. Existing TUIs never replace a service;
they only reconnect.
The restart path changes in three places:
1. A process-held OS lock, not the HTTP port or registration file, elects
exactly one server owner for its lifetime.
2. The elected process binds and registers a minimal lifecycle surface before
it initializes the application, so clients can distinguish a slow winner
from an absent server.
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
terminal error by itself.
Several clients may spawn small contenders during a restart. This is safe and
intentional: one contender acquires the lock and initializes, while every loser
exits before expensive server boot. The design does not require clients to
agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
framework.
## Architecture at a Glance
```text
╭───────────────────╮
│ CLI ServiceConfig │
╰─────────┬─────────╯
╭──────────────────────╮
│ CLI ServerConnection │
╰───────────┬──────────╯
╭──────────────────╰───────────────────╮
▼ ▼
╭──────────────────────────╮ ╭─────────────────────────╮
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
╰─────╮ │
▼ ▼
╭────────────────────────────╮ ╭─────────────╮
│ Background service process │ │ TUI / Solid │
╰──────────────┬─────────────╯ ╰──────┬──────╯
│ │
╰────────────◀────────────────────╯
╭───────────────────────╮
│ Server HTTP transport │
╰───────────┬───────────╯
╭──────────────────╮
│ Core application │
╰──────────────────╯
```
| Owner | Responsibility |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
| `packages/core` | Application behavior behind the transport |
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
## Implementation Status
| Area | State |
| ------------------------- | --------------------------------------------------------------------- |
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
| Contender behavior | Implemented; losers exit before the server module is imported |
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
## Context
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
plugins, permissions, and tool execution. The service updater can replace the
installed package while the current process continues running the old image.
A later TUI launch then detects the version mismatch and replaces the service.
Incident #36688 showed four failures in that replacement path:
- Multiple TUIs spawned heavyweight server contenders.
- A winner remained unobservable while it cold-booted, so another wave treated
it as absent and displaced it.
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
transport defect.
- A losing contender remained alive and consumed about 1 GB of RSS.
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
contender acquires a three-second heartbeat lease, checks whether another
service became discoverable, and only the winner crosses the application-boot
boundary. This already prevents simultaneous heavy boots and makes startup
losers exit.
The lease is released immediately after registration, however, so it is not
lifetime ownership. Registration then reverts to last-writer-wins authority: a
deleted or corrupt registration can admit a second boot, a displaced server
terminates itself through its 10-second registration self-check, and a stalled
lease holder can be displaced after the three-second service staleness timeout.
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
config writes, MCP auth, npm installs, and repository caching. Despite the
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
takeover, not an OS-held lock. It remains appropriate for bounded critical
sections, including today's startup fence, but is not lifetime service
ownership.
The current implementation also mixes three different concepts:
- **Ownership:** which process is allowed to be the managed server.
- **Discovery:** where clients can reach that process.
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
This design gives each concept one authority.
```definitions
[
{
"term": "Owner",
"definition": "The one process holding the process-held OS service lock."
},
{
"term": "Contender",
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
},
{
"term": "Registration",
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
},
{
"term": "Lifecycle shell",
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
},
{
"term": "Application",
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
}
]
```
## Goals
- At most one process initializes and serves the managed application.
- Losing contenders exit before database, route, plugin, MCP, or location boot.
- A slow winner becomes observable before expensive initialization.
- Existing and freshly launched TUIs survive retryable service unavailability.
- Reconnect follows service state instead of displaying retry counts or raw
transport failures.
- Version-mismatch replacement remains triggered by a fresh TUI launch.
- A stale or malformed registration cannot create a second owner.
- An unresponsive owner is never killed automatically by an arbitrary TUI.
- Every spawned contender has a bounded path to ownership or exit.
## Non-goals
- Restarting automatically when a background update finds an idle window.
- Running old and candidate application servers concurrently.
- Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions.
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs.
- Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement.
## Invariants
1. **The service lock is ownership.** Exactly one process may hold the OS lock
for one installation channel and service profile.
2. **Ownership precedes boot.** A contender performs no expensive application
initialization before it acquires the lock.
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
handle until the managed server exits. The OS releases it on process death
without a cleanup callback.
4. **The port is transport, not election.** The owner may select a dynamic port
after acquiring the lock.
5. **Registration is discovery, not election.** Deleting, corrupting, or
replacing registration does not invalidate a live owner's lock.
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
the current owner without initiating version replacement.
7. **Transport loss is retryable.** It never terminates a TUI without a separate
diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement
invokes Session suspension and resumption hooks, but tool-level continuity
belongs to a separate design.
## System Model
```text
╭───────────────────────╮ ╭──────────────────────────────╮
│ Fresh or existing TUI │ │ Process-held OS service lock │
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
╰─────┬ normal requests observe ───────────────────────╮ │
│ discover │ ├──╯ authorizes one owner
▼ │ ▼
╭───────────────────╮ │ ╭─────────────────╮
│ Registration file │ │ │ Lifecycle shell │
╰───────────────────╯ │ ╰────────┬────────╯
│ │
├────────────────────────╯
╭──────────────────────╮
│ OpenCode application │
╰──────────────────────╯
```
The lifecycle shell and application run in the same process. The distinction is
initialization order and responsibility, not process topology.
## Service Status
The server reports one small status value:
```typescript
type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
```
The client adds only the discovery states needed by callers:
```typescript
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
```
The health response retains the existing fields for old clients and adds the
status discriminant:
```typescript
type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID: string
status: ServiceStatus
}
```
`healthy: true` means the registered lifecycle shell is responding and its
identity matches registration. New clients use `status.type === "ready"` as
the application-readiness signal.
During `starting` or `stopping`, application requests are not held in memory.
They receive an immediate retryable response:
```http
HTTP/1.1 503 Service Unavailable
Retry-After: 1
Content-Type: application/json
```
`stopping` uses `service_stopping`. A failed application boot uses
`service_failed` and includes a safe diagnostic message.
A failed owner remains bound and keeps holding the service lock. Exiting on
failure would let every waiting client's `ensureRunning` loop elect a new
contender that repeats the same heavy failing boot, so staying bound turns a
deterministic boot failure into one observable `failed` state instead of a
client-driven respawn loop. Recovery still works: a fresh launch observes the
failed instance through the stop path, and explicit `service restart` replaces
it.
## Registration Contract
Registration contains only discovery identity:
```typescript
type ServiceRegistration = {
schema: 1
instanceID: string
version: string
url: string
pid: number
}
```
Authentication continues to use the existing private service credential
storage. The registration schema does not change that policy.
The owner writes registration only after the lifecycle shell has bound:
1. Bind the lifecycle shell.
2. Write a temporary registration file with mode `0600`.
3. Atomically rename it over the old registration.
4. Serve lifecycle health as `starting`.
On shutdown, the owner removes registration only if the current file still has
its `instanceID`. An old finalizer can never remove a successor's registration.
While running, the owner periodically asserts its registration. Because the
lock guarantees exactly one live owner, any registration that does not name the
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
registration therefore heals within one assertion interval instead of leaving
clients waiting on absent discovery. This inverts today's self-check loop,
which terminates the displaced process instead of repairing discovery.
Legacy registration shapes are decoded by a compatibility adapter. The new
domain type does not make fields optional to represent old formats.
## Election
This design promotes today's startup fence into lifetime ownership.
Last-writer-wins registration is replaced by a process-held OS lock that is
acquired before any expensive boot work and held for the entire service
lifetime.
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
sufficient for service ownership: the service configures a three-second stale
timeout, after which its lock can be broken and recreated. An event-loop stall,
a suspended machine, or a debugger pause can therefore make a live owner appear
stale and allow a contender to displace it. Service ownership requires a
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
death releases the lock through the OS.
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
lockfile packages are staleness-based leases as well. The platform layer uses
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
Windows, where Bun FFI is not available on every shipped architecture. It lives
alongside the existing utility in `packages/core/src/util`. This primitive is
the foundation of the design, so the delivery sequence spikes it first.
```text
Contender Lock Lifecycle Application
│ │ │ │
├─ try acquire ───▶ │ │
│ │ │ │
╭─ alt: lock held ────────────────────────────────────────────────╮
│ │ │ │ │ │
│ ◀─ busy ──────────┤ │ │ │
│ │ │ │ │ │
│ ├─────────╮ │ │ │ │
│ │ exit │ │ │ │ │
│ ◀─────────╯ │ │ │ │
│ │ │ │ │ │
├─ else: lock acquired ───────────────────────────────────────────┤
│ │ │ │ │ │
│ ◀─ owner ─────────┤ │ │ │
│ │ │ │ │ │
│ ├─ bind, register, starting ────────▶ │ │
│ │ │ │ │ │
│ ├─ initialize ──────────────────────────────────────────────▶ │
│ │ │ │ │ │
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
││ │ │ │ │ ││
││ │ │ ◀─ ready ───────────────┤ ││
││ │ │ │ │ ││
│├─ else: boot fails ────────────────────────────────────────────┤│
││ │ │ │ │ ││
││ │ │ ◀─ failed, stay bound ──┤ ││
││ │ │ │ │ ││
│╰───────────────────────────────────────────────────────────────╯│
│ │ │ │ │ │
╰─────────────────────────────────────────────────────────────────╯
│ │ │ │
```
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
must exit before constructing application routes or importing startup-heavy
modules.
Several clients may spawn contenders concurrently. The design guarantees one
heavy winner, not one process spawn. If the winner crashes during startup, the
OS releases the lock and a later client retry starts another election.
The lock is scoped by installation channel and service profile. Local, preview,
and stable installations cannot displace one another.
## Update Activation
Background update behavior remains unchanged:
1. The running service checks for an update.
2. The updater installs the package in the background.
3. The running process continues using its existing process image.
4. No idle check or automatic restart occurs.
A fresh TUI launch activates the installed update:
1. Read registration and authenticate the responding service.
2. If its package version matches the fresh client, attach normally.
3. If the version differs, request graceful stop of that exact registered
instance using the existing authenticated stop path.
4. Re-check instance identity before every signal or escalation in that path.
5. Wait for the old process to exit and release the service lock.
6. Call `ensureRunning` until a compatible service becomes ready.
Concurrent fresh launchers may all observe the same old instance. Stopping that
exact instance must be idempotent. Once registration names a different instance,
a stale launcher stops signaling and returns to discovery.
No durable restart-transition record is introduced. The initiating fresh TUI
already knows the source and target versions and can display its update
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
otherwise `Waiting for background service...` is the honest fallback.
## Fresh Launch Versus Reconnect
Fresh launch and reconnect deliberately have different version policies:
```typescript
type ManagedConnection =
| {
type: "launch"
requiredVersion: string
}
| {
type: "reconnect"
}
```
- `launch` requires the installed package version and may activate replacement.
- `reconnect` accepts the current owner and never activates replacement.
This preserves today's permissive reconnect behavior. Explicit application
protocol negotiation and automatic TUI re-exec remain follow-ups.
## Client Reconnect
Fresh and existing TUIs use the same status loop after startup:
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
2. If registration is absent, call `ensureRunning` and continue waiting.
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
contenders from acquiring the lock; a dead owner does not.
4. If status is `starting` or `stopping`, wait.
5. If status is `failed`, show its actionable message.
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
endpoint and perform authoritative state reconciliation.
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
state. The TUI waits until the service is ready or the user exits.
Transport failures are handled at the TUI run boundary. A raw client transport
error or Effect defect must not escape to the terminal. Hard exit is reserved
for diagnosed causes such as invalid local configuration, failed authentication,
or a foreign process occupying an explicitly configured port.
The UI derives text from status:
| Status | User-facing state |
| ------------------------ | ----------------------------------- |
| No registration | `Starting background service...` |
| Registration unreachable | `Waiting for background service...` |
| `starting` | `Starting OpenCode vX...` |
| `stopping` | `Updating to vX...` |
| `failed` | Actionable failure message |
| `ready` | Normal TUI |
## Graceful Session Continuity
Version-mismatch replacement uses the existing graceful Session suspension and
resumption hooks:
1. The old server snapshots active Session IDs during graceful teardown.
2. The successor schedules those Sessions for continuation.
3. The runner reloads durable Session history before continuing.
This lifecycle design does not define what an interrupted physical provider
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender
attempts the service lock:
- If the lock is free, the contender starts a replacement.
- If the lock is held, the contender exits and the client keeps waiting.
After a bounded diagnostic threshold, the client may show:
```text
The background service owns the service lock but is not responding.
Run `opencode service restart` to recover it.
```
Only explicit `service restart` may perform destructive recovery. It verifies
the complete registration and process instance before signaling, waits for
graceful exit, re-checks identity before escalation, and refuses to kill a
process it cannot positively identify.
Automatic frozen-owner recovery is deferred.
## Failure Walkthroughs
### Update with open TUIs
1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping`, suspends active Sessions, and exits.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready
1. The endpoint becomes unreachable and registration may remain stale.
2. Clients call `ensureRunning`.
3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally.
5. Detailed active-execution recovery is outside this design.
### Winner crashes during startup
1. Clients observed `starting` and remain alive.
2. Process death releases the service lock.
3. A later reconnect attempt starts another election.
4. One new contender wins; all other contenders exit.
### Registration is deleted while the owner is healthy
1. Clients may call `ensureRunning` because discovery is absent.
2. Every contender fails to acquire the owner's lock and exits.
3. No second application initializes.
4. The owner's next registration assertion republishes discovery.
### Owner is alive but unresponsive
1. Health fails, but the process still holds the service lock.
2. Contenders fail lock acquisition and exit.
3. Clients wait and eventually show explicit recovery guidance.
4. No TUI kills the owner automatically.
## TDD Verification
Implementation should proceed test-first with real subprocesses and real locks.
Mocks cannot establish process death, lock release, loser cleanup, or port
behavior.
### Election tests
| Scenario | Required result |
| ----------------------------------------------------- | ------------------------------------------------------- |
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
| Winner pauses after lock acquisition | No loser initializes or remains alive |
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
| Winner crashes before bind | Lock releases; a later attempt wins |
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
| Registration is deleted while owner runs | No second owner initializes |
| Registration is malformed | Lock still prevents a second owner |
| Registration names a dead PID | New contender can acquire the released lock |
| Two installation channels start | Each elects an independent owner |
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
The fixture records a marker immediately before application initialization. The
tests assert that only one process writes that marker and that every loser exits
within a bounded interval. The harness should also assert that a loser's peak
RSS stays an order of magnitude below an application boot, since import weight
was the observed incident cost.
### Lifecycle tests
| Scenario | Required result |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| Winner owns lock but application boot is paused | Health reports `starting` |
| Application request arrives during startup | Immediate retryable `503` |
| Application becomes ready | Status changes once from `starting` to `ready` |
| Graceful replacement begins | Status reports `stopping` before disconnect |
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
| Owner exits | Registration is removed only if it still names that owner |
### Update tests
| Scenario | Required result |
| -------------------------------------- | -------------------------------------------------------- |
| Background update installs vNext | Running vOld service does not restart |
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
| Two fresh vNext launches race | One heavy successor; both clients attach |
| Existing vOld TUI reconnects to vNext | It never requests replacement |
| Stale launcher observes a new instance | It does not signal the new instance |
### Reconnect tests
| Scenario | Required result |
| --------------------------------------------------- | -------------------------------------------------- |
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
| Service remains unavailable beyond old retry budget | TUI remains alive |
| Event stream reconnects | Client performs authoritative state reconciliation |
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
## Delivery Sequence
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
named pipe on Windows), including release on hard kill and behavior across
containers and network filesystems used in CI.
2. **Expand the subprocess test harness.** Begin from the baseline
two-contender test and cover ten contenders, lock release on crash, a paused
winner, deleted or corrupt registration, and bounded loser exit before
changing ownership.
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
every cycle, and format unexpected failures at the TUI boundary.
4. **Promote the startup fence to process-held ownership.** Preserve the
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
it until process exit, and invert the registration self-check from
self-termination to reassertion.
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
return retryable `503` for application requests, then initialize the app.
The health contract change is public API: regenerate clients from
`packages/client` with `bun run generate`.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement.
7. **Integrate graceful replacement.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward.
## Acceptance Criteria
- Ten concurrent restart observers produce one application initialization.
- No losing contender survives or builds a location graph.
- A 30-second application boot remains continuously observable as `starting`.
- A TUI remains alive through a service outage longer than the previous retry
budget.
- A service endpoint change does not require restarting an existing TUI.
- Background installation alone does not restart the service.
- A fresh mismatched TUI eventually attaches to the installed service version.
- Existing reconnecting TUIs never replace the current owner.
- Registration corruption cannot produce two owners.
- A deleted registration heals without restarting the owner or any client.
- An unresponsive owner is not killed without an explicit recovery command.
- Raw transport defects never escape to the terminal.
## Follow-ups
- Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec.
- Durable execution recovery for provider attempts and tools.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
a real requirement.
a real requirement.
-298
View File
@@ -1,298 +0,0 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
ID, model ID, and variant, normalizing an absent variant to `default`.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
unmanaged legacy storage.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
exists.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
+7 -5
View File
@@ -33,7 +33,6 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2", "@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.2", "@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.2", "@opentui/solid": "0.5.3",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -145,6 +144,10 @@
"esbuild", "esbuild",
"node-pty", "node-pty",
"protobufjs", "protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron" "electron"
], ],
"overrides": { "overrides": {
@@ -170,7 +173,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
} }
} }
+10 -14
View File
@@ -23,14 +23,9 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model. This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
Primary in-repo integration point: Session integration lives in `packages/core/src/session`: `runner/llm.ts` owns orchestration, `model-request.ts` lowers Session state into `LLMRequest`, and `model-transport.ts` selects transport behavior.
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime. Keep this package independent of Session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in Core.
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls raw `LLMClient.stream(request)` and bridges one provider turn of opencode tool calls through this package's typed dispatcher.
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
### Request Flow ### Request Flow
@@ -80,7 +75,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport. When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
### URL Construction ### URL Construction
@@ -106,7 +101,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
Keep provider facades small and explicit: Keep provider facades small and explicit:
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly. - Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`. - Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` and `chat`.
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path. - Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them. - Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`. - Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
@@ -124,11 +119,10 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey, apiKey,
transport: "websocket",
}) })
``` ```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`. Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Transport is execution policy: OpenAI Responses uses HTTP by default and may receive a per-call WebSocket channel executor through `StreamOptions` without changing model or route identity.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -154,14 +148,16 @@ packages/ai/src/
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse framing.ts Framing type + Framing.sse
transport/ transport implementations transport/ transport implementations
index.ts Transport type + HttpTransport / WebSocketTransport namespaces index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing http.ts HttpTransport.httpJson — POST + framing
websocket.ts WebSocketTransport.json + WebSocketExecutor service websocket.ts direct one-request channel executor + raw socket adapter
protocols/ protocols/
shared.ts ProviderShared toolkit used inside protocol impls shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol) openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline open-responses.ts provider-neutral Responses protocol baseline
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts anthropic-messages.ts
gemini.ts gemini.ts
bedrock-converse.ts bedrock-converse.ts
+1 -2
View File
@@ -315,7 +315,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
transport: "websocket",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 }, limits: { context: 200_000, output: 64_000 },
}) })
@@ -332,7 +331,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/google-vertex/responses` - `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages` - `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
+6 -7
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status # LLM Provider Parity Status
Last reviewed: 2026-07-24 Last reviewed: 2026-08-07
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -16,8 +16,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| Native slice | Source | Current state | Main gaps | | Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | | OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | | OpenAI Responses | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable over HTTP by default, with optional per-call WebSocket channel execution on the same model and route identity. | No incremental `previous_response_id` path or persistent Session channel manager yet. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | | OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. | | Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. | | Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
@@ -49,8 +48,8 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## AI SDK Package Parity Matrix ## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps | | AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. | | `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. | | `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | | `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
@@ -79,9 +78,9 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
These are implementation/API slices, not separate npm packages. These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose | | API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | | Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
+18 -41
View File
@@ -67,7 +67,6 @@ Examples:
```ts ```ts
OpenAI.responses("gpt-4o") OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o") OpenAI.chat("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment") Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0") AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
@@ -250,11 +249,6 @@ const openAIChat = Route.make({
auth: Auth.envBearer("OPENAI_API_KEY"), auth: Auth.envBearer("OPENAI_API_KEY"),
}) })
const openAIResponsesWebSocket = openAIResponses.with({
id: "openai-responses-websocket",
transport: WebSocketTransport.json,
})
const openAIConfig = (input: OpenAIConfig) => ({ const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint, endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
@@ -266,13 +260,11 @@ const openAIConfig = (input: OpenAIConfig) => ({
const configureOpenAI = (input: OpenAIConfig = {}) => { const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input)) const responses = openAIResponses.with(openAIConfig(input))
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input)) const chat = openAIChat.with(openAIConfig(input))
return { return {
id: openAIProvider, id: openAIProvider,
responses: responses.model, responses: responses.model,
responsesWebSocket: responsesWebSocket.model,
chat: chat.model, chat: chat.model,
model: responses.model, model: responses.model,
configure: configureOpenAI, configure: configureOpenAI,
@@ -342,22 +334,19 @@ const response =
) )
``` ```
For direct provider-facade calls, HTTP versus WebSocket is represented as named For direct provider-facade calls, Responses has one semantic model and route:
route selectors, not as model or request overrides. Same protocol, different
transport, different route:
```ts ```ts
OpenAI.responses("gpt-4o") OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
``` ```
The package-like OpenAI Responses entrypoint instead keeps transport scoped to The package-like OpenAI Responses entrypoint has the same transport-neutral
Responses settings while preserving the same `model(...)` contract: `model(...)` contract:
```ts ```ts
import { model } from "@opencode-ai/ai/providers/openai/responses" import { model } from "@opencode-ai/ai/providers/openai/responses"
model("gpt-4o", { apiKey, transport: "websocket" }) model("gpt-4o", { apiKey })
``` ```
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints, Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
@@ -387,11 +376,9 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project, location: "global" }) model("claude-sonnet-4-6", { project, location: "global" })
``` ```
The client should not require a different public layer just because a selected The client does not require a different public layer for WebSocket execution.
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime Responses routes use HTTP by default, and callers may pass a channel executor per
capabilities available; routes that do not need WebSocket simply never touch it. call. Routes without channel support simply ignore that execution capability.
If a WebSocket route is selected in an environment without WebSocket support,
fail with a typed transport configuration error.
Azure is a route specialization with auth/path/default changes plus input Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects mapping. The public API configures the Azure resource once, then selects
@@ -497,18 +484,13 @@ generic dynamic resolver:
```ts ```ts
const model = const model =
providerID === "azure" providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
: endpoint.websocket
? OpenAI.responsesWebSocket(apiModelID)
: OpenAI.responses(apiModelID)
``` ```
That boundary can branch on durable config/catalog metadata and call typed That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. A direct provider-facade boundary maps metadata like provider APIs directly. Transport selection remains execution policy: a Session
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading or other caller may pass a WebSocket channel executor per call without changing
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint. the model constructed by this boundary.
The client runtime only executes the route carried by the resulting model.
## Competitive Shape ## Competitive Shape
@@ -544,9 +526,8 @@ App boundary = explicit durable-config -> typed-provider call
id. id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id; - No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first. endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on an executable model or request. Direct provider - No transport setting on a provider or executable model. OpenAI Responses uses
facades use `responses` versus `responsesWebSocket`; the package-like Responses HTTP by default and accepts an optional per-call channel executor as execution policy.
entrypoint maps its scoped `transport` setting before constructing the model.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities. client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model - No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
@@ -580,12 +561,10 @@ App boundary = explicit durable-config -> typed-provider call
- [x] Make unconfigured transports reusable constants such as - [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh `HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction. state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` - [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
exposes available transport capabilities and selected routes fail with typed optional per-call channel execution without changing route identity.
transport config errors when a required capability is missing.
- [x] Convert OpenAI provider APIs to provider-facade shape: - [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and `OpenAI.configure(config).responses(id)` and `.chat(id)`.
`.responsesWebSocket(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version - [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids. setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as - [x] Split Cloudflare products into separate facades such as
@@ -599,10 +578,8 @@ App boundary = explicit durable-config -> typed-provider call
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two - [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not or three provider conversions; start with plain objects if duplication is not
yet painful. yet painful.
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct - [x] Keep executable model construction transport-neutral at the Session boundary;
executable models at the session boundary with explicit provider facade Session-scoped execution policy supplies channel capability separately.
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
named route selector.
- [ ] Update tests so direct route/provider tests assert route values are carried - [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route by executable models, and opencode/native tests assert boundary-based route
selection. selection.
+3 -4
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai" import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers" import { OpenAI } from "@opencode-ai/ai/providers"
/** /**
@@ -213,8 +213,7 @@ const FakeEcho = {
// enabled at a time so the tutorial can demonstrate generate, stream, or // enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example. // tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
@@ -222,6 +221,6 @@ const program = Effect.gen(function* () {
// yield* generateStructuredObject // yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
yield* streamWithTools yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer))) }).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
Effect.runPromise(program) Effect.runPromise(program)
+1
View File
@@ -7,3 +7,4 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js" export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js" export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js" export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js"
@@ -0,0 +1,191 @@
import { Effect, Schema, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Framing } from "../route/framing.js"
import {
HttpTransport,
WebSocketTransport,
type Transport,
type WebSocketChannelDriver,
type WebSocketChannelExchange,
} from "../route/transport/index.js"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown),
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
}
export interface Prepared {
readonly http: HttpTransport.HttpPrepared<string>
readonly channel?: {
readonly url: string
readonly headers: Headers.Headers
readonly rotateAfterMs?: number
readonly driver: WebSocketChannelDriver
}
}
const message = (body: unknown) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("Open Responses WebSocket body must be a JSON object")
const { stream: _stream, stream_options: _streamOptions, background: _background, ...request } = body
const decoded = yield* decodeMessage({ ...request, type: "response.create" })
return { request: decoded, message: encodeMessage(decoded) }
})
const driver = (options: Options, body: string): WebSocketChannelDriver => {
let responseID: string | undefined
let terminal = false
return {
create: () =>
Effect.sync(() => {
responseID = undefined
terminal = false
return { message: body, mode: "full" }
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
),
)
if (terminal)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} after a terminal event`,
frame,
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
}
}
if (event.type === "response.failed") {
terminal = true
if (responseID && event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
}
}
if (event.type === "response.created") {
const created = event.response?.id
if (responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted duplicate response.created`,
frame,
)
if (!created)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response.created is missing response.id`,
frame,
)
responseID = created
return { type: "frame", frame }
}
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} before response.created`,
frame,
)
if (event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
if (event.type === "response.completed") {
terminal = true
return { type: "completed", frame }
}
if (event.type === "response.incomplete") {
terminal = true
return { type: "incomplete", frame }
}
return { type: "frame", frame }
}),
}
}
export const transport = <Body>(options: Options): Transport<Body, Prepared, string> => {
const http = HttpTransport.sseJson.with<Body>()
return {
id: http.id,
prepare: (input) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
framing: Framing.sse,
middleware: input.middleware,
},
channel,
}
}),
execute: (prepared, request, runtime, executeOptions) => {
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: {
url: prepared.channel.url,
headers: prepared.channel.headers,
rotateAfterMs: prepared.channel.rotateAfterMs,
},
fallback: () =>
Stream.unwrap(
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
),
driver: prepared.channel.driver,
}
return executeOptions.webSocket.execute(exchange)
},
}
}
export const OpenResponsesChannel = { transport } as const
+54 -7
View File
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
// event-level `error` envelope, so accept all three shapes here. // event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification // https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({ const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String), code: optionalNull(Schema.String),
message: optionalNull(Schema.String), message: optionalNull(Schema.String),
param: optionalNull(Schema.String), param: optionalNull(Schema.String),
}) })
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest( export const Event = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
type: Schema.String, type: Schema.String,
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
message: Schema.optional(Schema.String), message: Schema.optional(Schema.String),
param: optionalNull(Schema.String), param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload), error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
) )
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = [] const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a // `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure. All three end the stream, // `finish` event; `response.failed` and `error` are hard failures. All four end
// so keep this set aligned with `step` and the protocol's terminal predicate. // the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type) export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
@@ -966,16 +1001,24 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
return message || code || fallback return message || code || fallback
} }
const providerError = (state: ParserState, event: Event, fallback: string) => { export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback) const message = providerErrorMessage(event, fallback)
const status =
typeof event.status === "number"
? event.status
: typeof event.status_code === "number"
? event.status_code
: undefined
return new AIError({ return new AIError({
module: state.id, module: id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code }), reason: classifyProviderFailure({ message, code, status }),
}) })
} }
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => { export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") { if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1015,7 +1058,11 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.completed" || event.type === "response.incomplete") if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event)) return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`) if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`) if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS]) return Effect.succeed<StepResult>([state, NO_EVENTS])
} }
@@ -0,0 +1,164 @@
import { AIError, TransportReason } from "../schema/index.js"
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "openai-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
readonly responseID: string
readonly request: Readonly<Record<string, unknown>>
readonly output: ReadonlyArray<unknown>
}
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
if (checkpoint?.protocol !== PROTOCOL || !ProviderShared.isRecord(checkpoint.value)) return undefined
if (checkpoint.value.version !== VERSION) return undefined
if (typeof checkpoint.value.responseID !== "string" || checkpoint.value.responseID.trim().length === 0)
return undefined
if (!ProviderShared.isRecord(checkpoint.value.request) || !Array.isArray(checkpoint.value.output)) return undefined
return {
version: VERSION,
responseID: checkpoint.value.responseID,
request: checkpoint.value.request,
output: checkpoint.value.output,
}
}
const canonical = (value: unknown): string => {
if (value === undefined) return "undefined"
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
}
const json = (value: unknown) => {
if (typeof value !== "string") return value
return Option.getOrElse(Schema.decodeUnknownOption(ProviderShared.Json)(value), () => value)
}
const comparable = (value: unknown) => {
if (!ProviderShared.isRecord(value)) return value
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
return {
type: value.type,
call_id: value.call_id,
name: value.name,
arguments: json(value.arguments),
}
if (value.type === "reasoning")
return {
type: value.type,
summary: value.summary,
encrypted_content: value.encrypted_content,
}
return value
}
const invariant = (request: Readonly<Record<string, unknown>>) => {
const { type: _type, input: _input, previous_response_id: _previousResponseID, ...rest } = request
return rest
}
const incremental = (
request: Readonly<Record<string, unknown>>,
checkpoint: CheckpointValue,
): ReadonlyArray<unknown> | undefined => {
const input = request.input
const previousInput = checkpoint.request.input
if (!Array.isArray(input) || !Array.isArray(previousInput)) return undefined
if (canonical(invariant(request)) !== canonical(invariant(checkpoint.request))) return undefined
const baseline = [...previousInput, ...checkpoint.output]
if (input.length <= baseline.length) return undefined
if (!baseline.every((item, index) => canonical(comparable(item)) === canonical(comparable(input[index]))))
return undefined
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
input: DriverInput,
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
recovery: "retry-full" | "rotate-and-retry-full",
): ChannelObservation => ({
type: "rejected",
recovery,
error: new AIError({
module: input.id,
method: "stream",
reason: new TransportReason({
message: observation.error.message,
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "rejected",
recovery,
}),
}),
})
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
)
const observation = yield* input.base.observe(create, frame)
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
if (observation.type === "provider-failure") {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
if (rejection === "websocket_connection_limit_reached")
return rejected(input, observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
},
}
}),
}
}
export const OpenAIResponsesChannel = { driver } as const
+14 -41
View File
@@ -1,18 +1,23 @@
import { Effect, Encoding, Schema } from "effect" import { Effect, Encoding, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Route } from "../route/client.js" import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { HttpTransport, WebSocketTransport } from "../route/transport/index.js" import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js" import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js" import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js" import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js" import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js" import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses" const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses" const NAME = "OpenAI Responses"
const WEBSOCKET_PROTOCOL_HEADER = "responses_websockets=2026-02-06"
const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH export const PATH = OpenResponses.PATH
@@ -57,16 +62,6 @@ const OpenAIResponsesBody = Schema.Struct({
}) })
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody> export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("response.create"),
...OpenAIResponsesCoreFields,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
const extension = { const extension = {
id: ADAPTER, id: ADAPTER,
name: NAME, name: NAME,
@@ -249,6 +244,13 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>() export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
@@ -257,36 +259,7 @@ export const route = Route.make({
protocol, protocol,
endpoint, endpoint,
auth, auth,
transport: httpTransport, transport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
const { stream: _stream, ...message } = body
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
})
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage,
})
export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint,
auth,
transport: webSocketTransport,
defaults: { providerOptions: { openai: { store: false } } }, defaults: { providerOptions: { openai: { store: false } } },
}) })
+1
View File
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
"overloaded_error", "overloaded_error",
"server_error", "server_error",
"server_is_overloaded", "server_is_overloaded",
"slow_down",
"serviceunavailableexception", "serviceunavailableexception",
]) ])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"]) const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
+2 -13
View File
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
export const id = ProviderID.make("openai") export const id = ProviderID.make("openai")
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route] export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories // This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
@@ -63,7 +63,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly organization?: string readonly organization?: string
readonly project?: string readonly project?: string
readonly queryParams?: Readonly<Record<string, string>> readonly queryParams?: Readonly<Record<string, string>>
readonly transport?: "http" | "websocket"
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
@@ -82,17 +81,12 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input) const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (id: string | ModelID) => const responses = (id: string | ModelID) =>
responsesRoute responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id }) .model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) => const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id }) chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
@@ -111,7 +105,6 @@ export const configure = (input: Config = {}) => {
id, id,
model: responses, model: responses,
responses, responses,
responsesWebSocket,
chat, chat,
image, image,
configure, configure,
@@ -138,10 +131,7 @@ const config = (settings: Settings): Config => {
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const configured = configure(config(settings)) return configure(config(settings)).responses(modelID)
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
} }
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = ( export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
@@ -149,6 +139,5 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
settings, settings,
) => configure(config(settings)).chat(modelID) ) => configure(config(settings)).chat(modelID)
export const responses = provider.responses export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
+15 -10
View File
@@ -1,12 +1,10 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import * as Option from "effect/Option"
import { Auth } from "./auth.js" import { Auth } from "./auth.js"
import { Endpoint, type EndpointPatch } from "./endpoint.js" import { Endpoint, type EndpointPatch } from "./endpoint.js"
import { RequestExecutor } from "./executor.js" import { RequestExecutor } from "./executor.js"
import { Framing } from "./framing.js" import { Framing } from "./framing.js"
import { HttpTransport } from "./transport/index.js" import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import { WebSocketExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js" import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js" import { applyCachePolicy } from "../cache-policy.js"
import * as ProviderShared from "../protocols/shared.js" import * as ProviderShared from "../protocols/shared.js"
@@ -58,6 +56,7 @@ export interface Route<Body, Prepared = unknown> {
prepared: Prepared, prepared: Prepared,
request: LLMRequest, request: LLMRequest,
runtime: TransportRuntime, runtime: TransportRuntime,
options?: StreamOptions,
) => Stream.Stream<LLMEvent, AIError> ) => Stream.Stream<LLMEvent, AIError>
} }
@@ -157,6 +156,7 @@ export interface Interface {
export interface StreamOptions { export interface StreamOptions {
readonly http?: HttpMiddleware readonly http?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
} }
export interface StreamMethod { export interface StreamMethod {
@@ -314,16 +314,18 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
middleware: options?.http, middleware: options?.http,
webSocket: options?.webSocket,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, options?: StreamOptions) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
const events = routeInput.transport return Stream.unwrap(
.frames(prepared, request, runtime) routeInput.transport.execute(prepared, request, runtime, options).pipe(
.pipe( Effect.map((execution) => {
const events = execution.frames.pipe(
Stream.mapEffect(decodeEvent(route)), Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
) )
return events.pipe( const stream = events.pipe(
Stream.mapAccumEffect( Stream.mapAccumEffect(
() => protocol.stream.initial(request), () => protocol.stream.initial(request),
protocol.stream.step, protocol.stream.step,
@@ -332,6 +334,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route), requireTerminalEvent(route),
) )
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
}),
),
)
}, },
} satisfies Route<Body, Prepared> } satisfies Route<Body, Prepared>
return route return route
@@ -413,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const compiled = yield* compile(request, options) const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options)
}), }),
) )
@@ -451,7 +457,6 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () { Effect.gen(function* () {
const stream = streamRequestWith({ const stream = streamRequestWith({
http: yield* RequestExecutor.Service, http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}) })
return Service.of({ stream, generate: generateWith(stream) }) return Service.of({ stream, generate: generateWith(stream) })
}), }),
+33 -111
View File
@@ -34,44 +34,8 @@ export type HttpMiddleware = (
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384 const headerDetails = (headers: Headers.Headers) =>
const REDACTED = "<redacted>" Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
// One source of truth for what counts as a sensitive name across headers,
// URL query keys, and field names embedded inside request/response bodies.
//
// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
// names like `Authorization` / `X-API-Key`) and as the body-field alternation
// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
// that are too generic to redact substring-style without false positives.
const SENSITIVE_NAME_SOURCE =
"authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
const SHORT_QUERY_NAME = /^(key|sig)$/i
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
Object.fromEntries(
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
name,
String(value),
]),
)
const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
})
return url.toString()
}
const normalizedHeaders = (headers: Headers.Headers) => const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
@@ -144,58 +108,22 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
}) })
} }
const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) => const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({ new HttpRequestDetails({
method: request.method, method: request.method,
url: redactUrl(request.url), url: request.url,
headers: redactHeaders(request.headers, redactedNames), headers: headerDetails(request.headers),
}) })
const responseDetails = ( const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
response: HttpClientResponse.HttpClientResponse,
redactedNames: ReadonlyArray<string | RegExp>,
) =>
new HttpResponseDetails({ new HttpResponseDetails({
status: response.status, status: response.status,
headers: redactHeaders(response.headers, redactedNames), headers: headerDetails(response.headers),
}) })
const secretValues = (request: HttpClientRequest.HttpClientRequest) => { const responseBody = (body: string | void) => {
const values = new Set<string>()
const add = (value: string) => {
if (value.length < 4) return
values.add(value)
values.add(encodeURIComponent(value))
}
Object.entries(request.headers).forEach(([name, value]) => {
if (!isSensitiveHeaderName(name)) return
add(value)
const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
if (bearer) add(bearer)
})
if (!URL.canParse(request.url)) return values
new URL(request.url).searchParams.forEach((value, key) => {
if (isSensitiveQueryName(key)) add(value)
})
return values
}
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
if (body === undefined) return {} if (body === undefined) return {}
const redacted = redactBody(body, secrets) return { body }
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
} }
const decodeProviderBody = Schema.decodeUnknownOption( const decodeProviderBody = Schema.decodeUnknownOption(
@@ -207,52 +135,49 @@ const decodeProviderBody = Schema.decodeUnknownOption(
), ),
) )
const providerMessage = (status: number, body: { readonly body?: string }) => { const providerMessage = (status: number, body: string | void) => {
if (body.body && body.body.length <= 500) { const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
const decoded = Option.getOrUndefined(decodeProviderBody(body.body)) return (
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}` [decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
} `Provider request failed with HTTP ${status}`
return `Provider request failed with HTTP ${status}` )
} }
const responseHttp = (input: { const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse readonly response: HttpClientResponse.HttpClientResponse
readonly redactedNames: ReadonlyArray<string | RegExp>
readonly body: ReturnType<typeof responseBody> readonly body: ReturnType<typeof responseBody>
readonly requestId?: string | undefined readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined readonly rateLimit?: HttpRateLimitDetails | undefined
}) => }) =>
new HttpContext({ new HttpContext({
request: requestDetails(input.request, input.redactedNames), request: requestDetails(input.request),
response: responseDetails(input.response, input.redactedNames), response: responseDetails(input.response),
...input.body, ...input.body,
requestId: input.requestId, requestId: input.requestId,
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
const statusError = const statusError =
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) => (request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
(response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () { Effect.gen(function* () {
if (response.status < 400) return response if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.void)) const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
const headers = normalizedHeaders(response.headers) const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, secretValues(request)) const details = responseBody(body)
return yield* new AIError({ return yield* new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
status: response.status, status: response.status,
message: providerMessage(response.status, details), message: providerMessage(response.status, body),
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
rateLimit, rateLimit,
http: responseHttp({ http: responseHttp({
request, request,
response, response,
redactedNames,
body: details, body: details,
requestId: requestId(headers), requestId: requestId(headers),
rateLimit, rateLimit,
@@ -262,10 +187,10 @@ const statusError =
}) })
// Classifies an HTTP failure captured outside the executor (for example by the // Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that // AI SDK's own fetch) onto the same reason types and HttpContext that
// executor-driven requests produce. The originating request is not available on // executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST), // that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies. // request headers are empty.
export const classifyHttpFailure = (input: { export const classifyHttpFailure = (input: {
readonly message: string readonly message: string
readonly url: string readonly url: string
@@ -277,7 +202,7 @@ export const classifyHttpFailure = (input: {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders)) const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>()) const details = responseBody(input.responseBody)
return classifyProviderFailure({ return classifyProviderFailure({
message: input.message, message: input.message,
status: input.status, status: input.status,
@@ -285,11 +210,11 @@ export const classifyHttpFailure = (input: {
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
rateLimit, rateLimit,
http: new HttpContext({ http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }), request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
response: response:
input.status === undefined input.status === undefined
? undefined ? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }), : new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details, ...details,
requestId: requestId(headers), requestId: requestId(headers),
rateLimit, rateLimit,
@@ -319,7 +244,6 @@ const httpError = (input: {
readonly error: unknown readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => { }) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) => const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
@@ -331,8 +255,8 @@ const httpError = (input: {
transport: "http", transport: "http",
operation: input.operation, operation: input.operation,
code: failure.code, code: failure.code,
url: redactUrl(request.url), url: request.url,
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }), http: new HttpContext({ request: requestDetails(request) }),
}), }),
}) })
@@ -343,7 +267,7 @@ const httpError = (input: {
const native = nativeTransportFailure(source) const native = nativeTransportFailure(source)
const code = native?.code const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined) const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined const detail = raw
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source)) if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
@@ -369,10 +293,9 @@ export const stream = (
): Stream.Stream<Uint8Array, AIError> => ): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware) const response = yield* executor.execute(request, middleware)
return response.stream.pipe( return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })), Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
) )
}), }),
) )
@@ -383,19 +306,18 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) if (!middleware)
return yield* http.execute(request).pipe( return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })), Effect.mapError((error) => httpError({ error, request, operation: "request" })),
Effect.flatMap(statusError(request, redactedNames)), Effect.flatMap(statusError(request)),
) )
const response = yield* middleware(request, (input) => const response = yield* middleware(request, (input) =>
http http
.execute(input) .execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames }))) ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
return yield* statusError(response.request, redactedNames)(response) return yield* statusError(response.request)(response)
}) })
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
+19 -2
View File
@@ -16,11 +16,28 @@ export { AuthOptions } from "./auth-options.js"
export { Endpoint } from "./endpoint.js" export { Endpoint } from "./endpoint.js"
export { Framing } from "./framing.js" export { Framing } from "./framing.js"
export { Protocol } from "./protocol.js" export { Protocol } from "./protocol.js"
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport/index.js" export { HttpTransport, WebSocketTransport } from "./transport/index.js"
export * as Transport from "./transport/index.js" export * as Transport from "./transport/index.js"
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js" export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js" export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js" export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
export type { Definition as FramingDef } from "./framing.js" export type { Definition as FramingDef } from "./framing.js"
export type { Protocol as ProtocolDef } from "./protocol.js" export type { Protocol as ProtocolDef } from "./protocol.js"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js" export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
HttpHandler,
HttpMiddleware,
Transport as TransportDef,
TransportExecuteOptions,
TransportExecution,
TransportRuntime,
WebSocketConnection,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
WebSocketConnector,
WebSocketRequest,
} from "./transport/index.js"
+4 -2
View File
@@ -87,8 +87,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, _request, runtime) => execute: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)), Effect.succeed({
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
}) })
export const sseJson = { export const sseJson = {
+30 -5
View File
@@ -1,19 +1,33 @@
import type { Effect, Stream } from "effect" import type { Effect, Scope, Stream } from "effect"
import { Endpoint } from "../endpoint.js" import { Endpoint } from "../endpoint.js"
import { Auth } from "../auth.js" import { Auth } from "../auth.js"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js" import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
import type { Interface as WebSocketExecutorInterface } from "./websocket.js" import type { WebSocketChannelExecutor } from "./websocket-channel.js"
import type { AIError, LLMRequest } from "../../schema/index.js" import type { AIError, LLMRequest } from "../../schema/index.js"
export interface TransportRuntime { export interface TransportRuntime {
readonly http: RequestExecutorInterface readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface }
export interface TransportExecution<Frame> {
readonly frames: Stream.Stream<Frame, AIError>
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
readonly complete?: Effect.Effect<void>
}
export interface TransportExecuteOptions {
readonly webSocket?: WebSocketChannelExecutor
} }
export interface Transport<Body, Prepared, Frame> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError> readonly execute: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
options?: TransportExecuteOptions,
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
} }
export interface TransportPrepareInput<Body> { export interface TransportPrepareInput<Body> {
@@ -24,8 +38,19 @@ export interface TransportPrepareInput<Body> {
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware readonly middleware?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
} }
export * as HttpTransport from "./http.js" export * as HttpTransport from "./http.js"
export type { HttpHandler, HttpMiddleware } from "../executor.js" export type { HttpHandler, HttpMiddleware } from "../executor.js"
export { WebSocketExecutor, WebSocketTransport } from "./websocket.js" export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
export { WebSocketTransport } from "./websocket.js"
@@ -0,0 +1,50 @@
import type { Effect, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import type { AIError } from "../../schema/index.js"
export interface WebSocketChannelExecutor {
readonly execute: (
exchange: WebSocketChannelExchange,
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
}
export interface WebSocketChannelExecution {
readonly frames: Stream.Stream<string, AIError>
/** Commits staged state after the decoded Route stream ends successfully. */
readonly complete: Effect.Effect<void>
}
export interface WebSocketChannelExchange {
readonly id: string
readonly connect: {
readonly url: string
readonly headers: Headers.Headers
/** Provider-safe connection age after which Core should rotate before sending. */
readonly rotateAfterMs?: number
}
readonly fallback: () => Stream.Stream<string, AIError>
readonly driver: WebSocketChannelDriver
}
export interface WebSocketChannelDriver {
readonly create: (checkpoint: ChannelCheckpoint | undefined) => Effect.Effect<ChannelCreate, AIError>
readonly observe: (create: ChannelCreate, frame: string) => Effect.Effect<ChannelObservation, AIError>
}
export interface ChannelCreate {
readonly message: string
readonly mode: "full" | "incremental"
}
export type ChannelObservation =
| { readonly type: "frame"; readonly frame: string }
| { readonly type: "completed"; readonly frame: string; readonly checkpoint?: ChannelCheckpoint }
| { readonly type: "incomplete"; readonly frame: string }
| { readonly type: "provider-failure"; readonly error: AIError }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "retry-full" }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "rotate-and-retry-full" }
export interface ChannelCheckpoint {
readonly protocol: string
readonly value: unknown
}
+203 -40
View File
@@ -1,8 +1,15 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js" import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js" import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js" import type { Transport } from "./index.js"
import type {
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export interface WebSocketRequest { export interface WebSocketRequest {
readonly url: string readonly url: string
@@ -15,24 +22,29 @@ export interface WebSocketConnection {
readonly close: Effect.Effect<void, never> readonly close: Effect.Effect<void, never>
} }
export interface Interface { export interface WebSocketConnector {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError> readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
} }
type WebSocketConstructorWithHeaders = new ( type WebSocketConstructorWithHeaders = (
url: string, url: string,
options?: { readonly headers?: Headers.Headers }, options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket ) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {} const MAX_FRAME_BYTES = 16 * 1024 * 1024
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string }, input: {
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) => ) =>
new AIError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketConnector",
method, method,
reason: new TransportReason({ reason: new TransportReason({
message, message,
@@ -40,9 +52,33 @@ const transportError = (
operation: input.operation, operation: input.operation,
url: input.url, url: input.url,
code: input.code, code: input.code,
phase: input.phase,
delivery: input.delivery,
}), }),
}) })
const annotateTransportError = (
error: AIError,
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) =>
error.reason._tag === "Transport"
? new AIError({
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
phase: input.phase,
delivery: input.delivery,
recovery: error.reason.recovery,
}),
})
: error
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message if ("message" in event && typeof event.message === "string") return event.message
return event.type return event.type
@@ -63,6 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url, url: input.url,
operation: "request", operation: "request",
code: "closed", code: "closed",
phase: "connect",
delivery: "not-sent",
}), }),
) )
} }
@@ -89,6 +127,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url, url: input.url,
operation: "request", operation: "request",
phase: "connect",
delivery: "not-sent",
}), }),
), ),
) )
@@ -101,6 +141,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url, url: input.url,
operation: "request", operation: "request",
code: String(event.code), code: String(event.code),
phase: "connect",
delivery: "not-sent",
}), }),
), ),
) )
@@ -112,7 +154,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}) })
} }
const webSocketUrl = (value: string) => export const toWebSocketUrl = (value: string) =>
Effect.try({ Effect.try({
try: () => { try: () => {
const url = new URL(value) const url = new URL(value)
@@ -131,21 +173,31 @@ const webSocketUrl = (value: string) =>
url: value, url: value,
operation: "request", operation: "request",
code: "invalid-url", code: "invalid-url",
phase: "prepare",
delivery: "not-sent",
}), }),
}) })
export const open = (input: WebSocketRequest) => export const open = (input: WebSocketRequest) =>
Effect.try({ Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const ws = yield* Effect.try({
try: () => try: () =>
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), // Platform implementations may extend Effect's browser-compatible constructor with handshake options.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
headers: input.headers,
}),
catch: (error) => catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url, url: input.url,
operation: "request", operation: "request",
phase: "connect",
delivery: "not-sent",
}), }),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) })
return yield* fromWebSocket(ws, input)
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open })) })
export const fromWebSocket = ( export const fromWebSocket = (
ws: globalThis.WebSocket, ws: globalThis.WebSocket,
@@ -155,16 +207,52 @@ export const fromWebSocket = (
yield* waitOpen(ws, input) yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128) const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
const rejectOversized = (message: string | Uint8Array) => {
if (!oversized(message)) return false
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
url: input.url,
operation: "read",
code: "message-too-large",
phase: "receive",
}),
),
)
if (ws.readyState === globalThis.WebSocket.OPEN) ws.close(1009, "Message too large")
return true
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) if (typeof event.data === "string") return offer(event.data)
const binary = binaryMessage(event.data) const binary = binaryMessage(event.data)
if (binary) return Queue.offerUnsafe(messages, binary) if (binary) return offer(binary)
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { transportError("message", "Unsupported WebSocket message payload", {
url: input.url, url: input.url,
operation: "read", operation: "read",
code: "message",
phase: "receive",
}), }),
), ),
) )
@@ -176,12 +264,13 @@ export const fromWebSocket = (
transportError("message", `WebSocket error: ${eventMessage(event)}`, { transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url, url: input.url,
operation: "read", operation: "read",
code: "message",
phase: "receive",
}), }),
), ),
) )
} }
const onClose = (event: CloseEvent) => { const onClose = (event: CloseEvent) => {
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
@@ -189,6 +278,7 @@ export const fromWebSocket = (
url: input.url, url: input.url,
operation: "read", operation: "read",
code: String(event.code), code: String(event.code),
phase: "close",
}), }),
), ),
) )
@@ -205,13 +295,26 @@ export const fromWebSocket = (
return { return {
sendText: (message) => sendText: (message) =>
Effect.try({ Effect.suspend(() => {
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
operation: "write",
phase: "send",
delivery: "not-sent",
}),
)
return Effect.try({
try: () => ws.send(message), try: () => ws.send(message),
catch: (error) => catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url, url: input.url,
operation: "write", operation: "write",
phase: "send",
delivery: "not-sent",
}), }),
})
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
close: cleanup.pipe( close: cleanup.pipe(
@@ -228,6 +331,57 @@ export const fromWebSocket = (
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) => export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
typeof message === "string" ? message : decoder.decode(message) typeof message === "string" ? message : decoder.decode(message)
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
execute: (exchange) =>
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
connector
.open(exchange.connect)
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
(connection) => connection.close,
)
const create = yield* exchange.driver.create(undefined)
yield* connection.sendText(create.message)
const decoder = new TextDecoder()
let observed = false
return {
frames: connection.messages.pipe(
Stream.map((message) => {
observed = true
return messageText(message, decoder)
}),
Stream.mapError((error) =>
annotateTransportError(error, {
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery: observed ? "accepted" : "ambiguous",
}),
),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.void,
}
}),
})
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
function* () {
const constructor = yield* Socket.WebSocketConstructor
return makeDirect({
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
})
},
)
export interface JsonPrepared { export interface JsonPrepared {
readonly url: string readonly url: string
readonly headers: Headers.Headers readonly headers: Headers.Headers
@@ -254,33 +408,44 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
...prepareInput, ...prepareInput,
}) })
return { return {
url: yield* webSocketUrl(parts.url), url: yield* toWebSocketUrl(parts.url),
headers: parts.headers, headers: parts.headers,
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
} }
}), }),
frames: (prepared, _request, runtime) => { execute: (prepared, request, _runtime, options) => {
const webSocket = runtime.webSocket const webSocket = options?.webSocket
if (!webSocket) { if (!webSocket) {
return Stream.fail( return Effect.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
url: prepared.url, url: prepared.url,
operation: "request", operation: "request",
code: "unavailable", code: "unavailable",
phase: "prepare",
delivery: "not-sent",
}), }),
) )
} }
const decoder = new TextDecoder() const driver: WebSocketChannelDriver = {
return Stream.unwrap( create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
Effect.gen(function* () { observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
const connection = yield* Effect.acquireRelease( }
webSocket.open({ url: prepared.url, headers: prepared.headers }), const exchange: WebSocketChannelExchange = {
(connection) => connection.close, id: request.id ?? "request",
) connect: { url: prepared.url, headers: prepared.headers },
yield* connection.sendText(prepared.message) fallback: () =>
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder))) Stream.fail(
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
url: prepared.url,
operation: "request",
code: "websocket",
phase: "fallback",
delivery: "not-sent",
}), }),
) ),
driver,
}
return webSocket.execute(exchange)
}, },
}) })
@@ -289,15 +454,13 @@ export const jsonTransport = {
with: json, with: json,
} as const } as const
export const WebSocketExecutor = {
Service,
layer,
open,
fromWebSocket,
messageText,
} as const
export const WebSocketTransport = { export const WebSocketTransport = {
json, json,
jsonTransport, jsonTransport,
direct,
makeDirect,
open,
fromWebSocket,
messageText,
toWebSocketUrl,
} as const } as const
+7
View File
@@ -106,6 +106,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
code: Schema.optional(Schema.String), code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
phase: Schema.optional(
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
),
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
recovery: Schema.optional(
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
),
}) {} }) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>( export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
+209 -30
View File
@@ -1,12 +1,13 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect" import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js" import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js" import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js" import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js" import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js" import { sseEvents, sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js" import { it } from "./lib/effect.js"
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe( const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
@@ -65,6 +66,7 @@ const expectAIError = (error: unknown) => {
} }
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () => it.effect("parses response body failures at the executor seam", () =>
@@ -75,11 +77,11 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>", message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
transport: "http", transport: "http",
operation: "read", operation: "read",
code: "ECONNRESET", code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1", url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1",
}) })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -152,12 +154,12 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>", message: "ECONNRESET: proxy disconnected proxy-secret",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", url: "https://proxy.test/v1/chat?api_key=proxy-secret",
http: { http: {
request: { request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", url: "https://proxy.test/v1/chat?api_key=proxy-secret",
headers: { authorization: "<redacted>" }, headers: { authorization: "Bearer proxy-secret" },
}, },
}, },
}) })
@@ -217,9 +219,47 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
) )
it.effect("preserves structured provider messages from large error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
it.effect("falls back when structured provider messages are empty", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "Provider request failed with HTTP 400",
})
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () => it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
Effect.gen(function* () { Effect.gen(function* () {
const classify = (body: string) => const classify = (body: string) =>
@@ -253,7 +293,7 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("returns redacted diagnostics for rate limits", () => it.effect("returns complete diagnostics for rate limits", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
@@ -268,15 +308,15 @@ describe("RequestExecutor", () => {
requestId: "req_123", requestId: "req_123",
request: { request: {
method: "POST", method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" }, headers: { authorization: "Bearer secret", "x-safe": "visible" },
}, },
response: { response: {
status: 429, status: 429,
headers: { headers: {
"retry-after-ms": "0", "retry-after-ms": "0",
"x-request-id": "req_123", "x-request-id": "req_123",
"x-api-key": "<redacted>", "x-api-key": "secret",
}, },
}, },
}, },
@@ -295,14 +335,14 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("honors current redacted header names in diagnostics", () => it.effect("preserves configured header names in diagnostics", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
}).pipe( }).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
@@ -421,15 +461,15 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("truncates large authentication error bodies", () => it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" }) expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
expect(errorHttp(error)?.body).toHaveLength(16_384) expect(errorHttp(error)?.body).toHaveLength(20_000)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -440,16 +480,15 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("redacts common secret fields in response bodies", () => it.effect("preserves response body fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"') expect(errorHttp(error)?.body).toBe(
expect(errorHttp(error)?.body).toContain("api_key=<redacted>") '{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
expect(errorHttp(error)?.body).not.toContain("body-secret") )
expect(errorHttp(error)?.body).not.toContain("query-secret")
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -461,16 +500,13 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("redacts echoed request secret values in response bodies", () => it.effect("preserves echoed request values in response bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip) const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>") expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
expect(errorHttp(error)?.body).not.toContain("header-secret-456")
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -511,3 +547,146 @@ describe("RequestExecutor", () => {
}), }),
) )
}) })
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
]
it.effect("runs a channel driver through the direct executor", () =>
Effect.gen(function* () {
const sent = yield* Ref.make("")
const closed = yield* Ref.make(false)
const observed = yield* Ref.make(0)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) => Ref.set(sent, message),
messages: Stream.make("one", "done", "late"),
close: Ref.set(closed, true),
}),
})
const received = yield* Effect.scoped(
Effect.gen(function* () {
const execution = yield* webSocket.execute({
id: "exchange_1",
connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
fallback: () => Stream.empty,
driver: {
create: () => Effect.succeed({ message: "create", mode: "full" }),
observe: (_create, frame) =>
Ref.update(observed, (value) => value + 1).pipe(
Effect.as(
frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
),
),
},
})
return yield* Stream.runCollect(execution.frames)
}),
)
expect(Array.from(received)).toEqual(["one", "done"])
expect(yield* Ref.get(sent)).toBe("create")
expect(yield* Ref.get(observed)).toBe(2)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("rejects a closed socket before attempting to send", () =>
Effect.gen(function* () {
class ClosedBeforeSend extends EventTarget {
readyState = globalThis.WebSocket.OPEN
sends = 0
send() {
this.sends++
}
close() {}
}
const socket = new ClosedBeforeSend()
const connection = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
)
socket.readyState = globalThis.WebSocket.CLOSED
const error = yield* connection.sendText("create").pipe(Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "send", delivery: "not-sent" })
expect(socket.sends).toBe(0)
yield* connection.close
}),
)
it.effect("uses HTTP when no per-call WebSocket executor is provided", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...frames))))
expect(response.text).toBe("Hi")
}),
)
it.effect("commits channel execution only after complete consumption", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
execute: () =>
Effect.succeed({
frames: input,
complete: Ref.update(commits, (value) => value + 1),
}),
})
const response = yield* LLMClient.generate(request, {
webSocket: executor(Stream.fromArray(frames)),
}).pipe(Effect.provide(fixedResponse("")))
expect(response.text).toBe("Hi")
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
Effect.provide(fixedResponse("")),
Effect.flip,
)
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(fixedResponse("")),
)
expect(yield* Ref.get(commits)).toBe(1)
}),
)
it.effect("does not commit interrupted channel execution", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const started = yield* Deferred.make<void>()
const executor: WebSocketChannelExecutor = {
execute: () =>
Effect.succeed({
frames: Stream.fromEffect(
Deferred.succeed(started, undefined).pipe(
Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
),
).pipe(Stream.concat(Stream.never)),
complete: Ref.update(commits, (value) => value + 1),
}),
}
const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
Stream.runDrain,
Effect.provide(fixedResponse("")),
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Ref.get(commits)).toBe(0)
}),
)
})
+4 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai" import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route" import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider" import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import { import {
CloudflareAIGateway, CloudflareAIGateway,
@@ -16,6 +16,7 @@ import {
OpenAICompatibleResponses, OpenAICompatibleResponses,
OpenAIResponses, OpenAIResponses,
OpenResponses, OpenResponses,
OpenResponsesChannel,
} from "@opencode-ai/ai/protocols" } from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing" import { TestLLM } from "@opencode-ai/ai/testing"
@@ -36,6 +37,7 @@ describe("public exports", () => {
test("route barrel exposes route-authoring APIs", () => { test("route barrel exposes route-authoring APIs", () => {
expect(Route.make).toBeFunction() expect(Route.make).toBeFunction()
expect(Protocol.make).toBeFunction() expect(Protocol.make).toBeFunction()
expect(WebSocketTransport.makeDirect).toBeFunction()
}) })
test("provider barrels expose user-facing facades", async () => { test("provider barrels expose user-facing facades", async () => {
@@ -43,7 +45,6 @@ describe("public exports", () => {
expect(OpenAI.model).toBeFunction() expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAICompatible.deepseek.model).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction()
expect( expect(
@@ -65,10 +66,10 @@ describe("public exports", () => {
expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAIChat.route.id).toBe("openai-chat")
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
expect(OpenResponses.protocol.id).toBe("open-responses") expect(OpenResponses.protocol.id).toBe("open-responses")
expect(OpenResponsesChannel.transport).toBeFunction()
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses") expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.route.id).toBe("openai-responses")
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
expect(AnthropicMessages.route.id).toBe("anthropic-messages") expect(AnthropicMessages.route.id).toBe("anthropic-messages")
}) })
}) })
+4 -6
View File
@@ -1,9 +1,8 @@
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import { LLMClient, RequestExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js" import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js" import type { Service as RequestExecutorService } from "../../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket.js"
export type HandlerInput = { export type HandlerInput = {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
@@ -34,7 +33,7 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
), ),
) )
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | LLMClientService
export interface SystemError extends Error { export interface SystemError extends Error {
readonly code: string readonly code: string
@@ -44,9 +43,8 @@ export const systemError = (code: string, message: string): SystemError => Objec
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => { export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps)) return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
return Layer.mergeAll(deps, llmClientLayer)
} }
const SSE_HEADERS = { "content-type": "text/event-stream" } as const const SSE_HEADERS = { "content-type": "text/event-stream" } as const
+2 -2
View File
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
test("classifies V1 overloaded provider codes", () => { test("classifies V1 overloaded provider codes", () => {
expect( expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map( ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
(message) => classifyProviderFailure({ message })._tag, (message) => classifyProviderFailure({ message })._tag,
), ),
).toEqual(["ProviderInternal", "ProviderInternal"]) ).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
@@ -1,13 +1,18 @@
import { LLM } from "../../src/index.js" import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js" import { OpenAI } from "../../src/providers.js"
const model = OpenAI.responses("gpt-5") const selected = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ LLM.request({
model, model: selected,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string. // @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } }, providerOptions: { openai: { reasoningEffort: 1 } },
}) })
OpenAI.configure({
// @ts-expect-error Transport is execution policy, not provider configuration.
transport: "websocket",
})
@@ -80,11 +80,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
}) })
test("selects transport without changing the semantic API", () => {
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => { test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses") const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", { const selected = OpenAICompatibleResponses.model("custom-model", {
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}), }),
) )
}) })
@@ -1098,8 +1098,7 @@ describe("Anthropic Messages route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1275,8 +1275,7 @@ describe("OpenAI Chat route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
LLM, LLM,
AIError, AIError,
HttpOptions,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
Message, Message,
@@ -11,14 +12,23 @@ import {
ToolCallPart, ToolCallPart,
ToolDefinition, ToolDefinition,
ToolResultPart, ToolResultPart,
TransportReason,
Usage, Usage,
} from "../../src/index.js" } from "../../src/index.js"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import {
Auth,
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelObservation,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js" import { compileRequest } from "../../src/route/client.js"
import * as Azure from "../../src/providers/azure.js" import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js" import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js" import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js" import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
import * as ProviderShared from "../../src/protocols/shared.js" import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js" import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
@@ -31,6 +41,47 @@ const model = OpenAIResponses.route
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5") const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
create: () => Effect.succeed({ message, mode: "full" }),
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> => {
const event = ProviderShared.decodeJson(frame)
if (!ProviderShared.isRecord(event)) return Effect.die("Expected event")
if (event.type === "response.completed") return Effect.succeed({ type: "completed", frame })
if (event.type === "response.incomplete") return Effect.succeed({ type: "incomplete", frame })
if (event.type === "error" || event.type === "response.failed")
return Effect.succeed({
type: "provider-failure",
error: new AIError({
module: "test",
method: "stream",
reason: new TransportReason({
message: "provider rejected request",
transport: "websocket",
operation: "read",
phase: "receive",
}),
}),
})
return Effect.succeed({ type: "frame", frame })
},
})
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenAIResponsesChannel.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: baseChannelDriver(message),
})
}
const checkpoint = (observation: ChannelObservation) => {
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected checkpoint")
return observation.checkpoint
}
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model, model,
@@ -216,19 +267,19 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("prepares OpenAI Responses WebSocket target", () => it.effect("prepares one OpenAI Responses route for either transport", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute model: OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }), .model({ id: "gpt-4.1-mini" }),
}), }),
) )
expect(prepared.route).toBe("openai-responses-websocket") expect(prepared.route).toBe("openai-responses")
expect(prepared.protocol).toBe("openai-responses") expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "websocket-json" }) expect(prepared.metadata).toEqual({ transport: "http-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true }) expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
}), }),
) )
@@ -236,26 +287,32 @@ describe("OpenAI Responses route", () => {
it.effect("streams OpenAI Responses over WebSocket", () => it.effect("streams OpenAI Responses over WebSocket", () =>
Effect.gen(function* () { Effect.gen(function* () {
const sent: string[] = [] const sent: string[] = []
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] const opened: Array<{
readonly url: string
readonly authorization: string | undefined
readonly protocol: string | undefined
}> = []
let closed = false let closed = false
const deps = Layer.mergeAll( const deps = Layer.succeed(
Layer.succeed(
RequestExecutor.Service, RequestExecutor.Service,
RequestExecutor.Service.of({ RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"), execute: () => Effect.die("unexpected HTTP request"),
}), }),
), )
Layer.succeed( const webSocket = WebSocketTransport.makeDirect({
WebSocketExecutor.Service,
WebSocketExecutor.Service.of({
open: (input) => open: (input) =>
Effect.succeed({ Effect.succeed({
sendText: (message) => sendText: (message) =>
Effect.sync(() => { Effect.sync(() => {
opened.push({ url: input.url, authorization: input.headers.authorization }) opened.push({
url: input.url,
authorization: input.headers.authorization,
protocol: input.headers["openai-beta"],
})
sent.push(message) sent.push(message)
}), }),
messages: Stream.fromArray([ messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }), ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }), ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]), ]),
@@ -263,20 +320,27 @@ describe("OpenAI Responses route", () => {
closed = true closed = true
}), }),
}), }),
}), })
),
)
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket( model: OpenAI.configure({
"gpt-4.1-mini", baseURL: "https://api.openai.test/v1/",
), apiKey: "test",
headers: { "openai-beta": "custom-protocol" },
}).responses("gpt-4.1-mini"),
prompt: "Say hello.", prompt: "Say hello.",
}), }),
{ webSocket },
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))) ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.text).toBe("Hi") expect(response.text).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }]) expect(opened).toEqual([
{
url: "wss://api.openai.test/v1/responses",
authorization: "Bearer test",
protocol: "custom-protocol",
},
])
expect(closed).toBe(true) expect(closed).toBe(true)
expect(sent).toHaveLength(1) expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0])).toEqual({ expect(JSON.parse(sent[0])).toEqual({
@@ -288,15 +352,524 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("rejects out-of-order and mismatched WebSocket response events", () =>
Effect.gen(function* () {
const streams = [
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "late", delta: "Late" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_new" } }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
]
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["late", "mismatch"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual(["InvalidProviderOutput", "InvalidProviderOutput"])
expect(errors[0]?.message).toContain("before response.created")
expect(errors[1]?.message).toContain("response ID changed")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
}),
)
it.effect("uses a full request when any non-input invariant changes", () =>
Effect.gen(function* () {
const request = {
type: "response.create",
model: "gpt-5.2",
store: false,
metadata: { source: "one" },
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [...request.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
{ ...request, tools: [{ type: "function", name: "other" }], input: appended },
{ ...request, temperature: 0.5, input: appended },
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [{ role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
},
]
const creates = yield* Effect.forEach(changes, (changed) => continuationDriver(changed).create(saved))
expect(creates.map((item) => item.mode)).toEqual(changes.map(() => "full"))
expect(
creates
.map((item) => ProviderShared.decodeJson(item.message))
.every((item) => ProviderShared.isRecord(item) && !("previous_response_id" in item)),
).toBe(true)
}),
)
it.effect("stages no checkpoint for incomplete or ID-less completion", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const completed = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: {} }),
)
expect(completed).toMatchObject({ type: "completed" })
expect(completed).not.toHaveProperty("checkpoint")
expect(
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.incomplete", response: {} })),
).toMatchObject({ type: "incomplete" })
}),
)
it.effect("classifies explicit continuation rejection for runner-owned recovery", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const missing = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "previous_response_not_found", message: "Missing response" },
}),
)
const limit = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "websocket_connection_limit_reached", message: "Rotate" },
}),
)
expect(missing).toMatchObject({
type: "rejected",
recovery: "retry-full",
error: { reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" } },
})
expect(limit).toMatchObject({
type: "rejected",
recovery: "rotate-and-retry-full",
error: {
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
},
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const message = yield* Ref.make("")
const body = yield* Ref.make("")
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
http: {
body: {
model: "overlaid-model",
metadata: { source: "overlay" },
stream_options: { include_usage: true },
background: true,
},
headers: { "x-request": "request" },
query: { mode: "test" },
},
}),
{
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers["openai-beta"]).toBe("responses_websockets=2026-02-06")
expect(exchange.connect.headers["content-length"]).toBeUndefined()
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
yield* Ref.set(body, input.text)
expect(input.request.url).toBe("https://api.openai.test/v1/responses?mode=test")
expect(input.request.headers.authorization).toBe("Bearer test")
expect(input.request.headers["x-request"]).toBe("request")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
const httpBody = JSON.parse(yield* Ref.get(body))
const { stream: _stream, stream_options: _streamOptions, background: _background, ...shared } = httpBody
expect(response.finishReason?.normalized).toBe("stop")
expect(yield* Ref.get(attempts)).toBe(1)
expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
expect(httpBody).toMatchObject({
model: "overlaid-model",
metadata: { source: "overlay" },
stream: true,
stream_options: { include_usage: true },
background: true,
})
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
yield* LLMClient.generate(
LLMRequest.update(request, { http: new HttpOptions({ body: { input: "raw-http-input" } }) }),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
expect(JSON.parse(input.text).input).toBe("raw-http-input")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("closes a direct WebSocket execution after partial consumption", () =>
Effect.gen(function* () {
const closed = yield* Ref.make(false)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Ref.set(closed, true),
}),
})
yield* LLMClient.stream(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
}),
{ webSocket },
).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
Effect.gen(function* () {
const events = [
{ type: "error", error: { code: "slow_down", message: "Try later" } },
{
type: "error",
status_code: 429,
message: "Rate limited",
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
},
{
type: "response.failed",
response: { error: { code: "server_error", message: "Unavailable" } },
},
{ type: "error", status: "not-a-status", message: "Malformed status" },
]
const errors = yield* Effect.forEach(events, (event) =>
LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
),
prompt: "Say hello.",
}),
{
webSocket: WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
close: Effect.void,
}),
}),
},
).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual([
"ProviderInternal",
"RateLimit",
"ProviderInternal",
"UnknownProvider",
])
}),
)
it.effect("marks post-send WebSocket failures with delivery state", () =>
Effect.gen(function* () {
const failure = new AIError({
module: "test",
method: "receive",
reason: new TransportReason({
message: "socket closed",
transport: "websocket",
operation: "read",
phase: "close",
}),
})
const streams = [
Stream.fail(failure),
Stream.make(ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_observed" } })).pipe(
Stream.concat(Stream.fail(failure)),
),
]
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason)).toEqual([
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
])
}),
)
it.effect("fails immediately when WebSocket is already closed", () => it.effect("fails immediately when WebSocket is already closed", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* WebSocketExecutor.fromWebSocket( const error = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket, { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty }, { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
).pipe(Effect.flip) ).pipe(Effect.flip)
expect(error.message).toContain("closed before opening") expect(error.message).toContain("closed before opening")
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
}), }),
) )
@@ -329,7 +902,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -2037,8 +2610,7 @@ describe("OpenAI Responses route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
}) })
+5 -7
View File
@@ -2,12 +2,11 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Layer } from "effect" import { Layer } from "effect"
import * as path from "node:path" import * as path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
import { ImageClient } from "../src/image-client.js" import { ImageClient } from "../src/image-client.js"
import type { Service as ImageClientService } from "../src/image-client.js" import type { Service as ImageClientService } from "../src/image-client.js"
import type { Service as LLMClientService } from "../src/route/client.js" import type { Service as LLMClientService } from "../src/route/client.js"
import type { Service as RequestExecutorService } from "../src/route/executor.js" import type { Service as RequestExecutorService } from "../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket.js"
import { import {
recordedEffectGroup, recordedEffectGroup,
type RecordedCaseOptions as RunnerCaseOptions, type RecordedCaseOptions as RunnerCaseOptions,
@@ -17,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & { type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions readonly options?: HttpRecorder.RecorderOptions
@@ -82,11 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}), }),
), ),
) )
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll( return Layer.mergeAll(
deps, requestExecutor,
LLMClient.layer.pipe(Layer.provide(deps)), LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(deps)), ImageClient.layer.pipe(Layer.provide(requestExecutor)),
) )
}, },
}) })
+23
View File
@@ -11,6 +11,7 @@ import {
LanguageModel, LanguageModel,
ModelID, ModelID,
ProviderID, ProviderID,
TransportReason,
Usage, Usage,
} from "../src/schema/index.js" } from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js" import { ProviderShared } from "../src/protocols/shared.js"
@@ -108,3 +109,25 @@ test("AI errors expose the shared runtime tag", async () => {
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))), await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught") ).toBe("caught")
}) })
test("transport errors serialize execution facts", () => {
const reason = new TransportReason({
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
_tag: "Transport",
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
})
+1 -1
View File
@@ -11,7 +11,7 @@
- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there. - `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there.
- For local UI changes, run the backend and app dev servers separately. - For local UI changes, run the backend and app dev servers separately.
- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096` - Backend (from the repository root): `bun dev serve --port 4096`
- App (from `packages/app`): `bun dev -- --port 4444` - App (from `packages/app`): `bun dev -- --port 4444`
- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`). - Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
+1 -1
View File
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
try { try {
while (true) { while (true) {
const chunk = await session.send("IO.read", { handle }) const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data) await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
if (chunk.eof) break if (chunk.eof) break
} }
} finally { } finally {
@@ -125,17 +125,20 @@ export async function installTimelineStreamProbe(
const scrollTo = Element.prototype.scrollTo const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")! const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) { if (profileVisual) {
Element.prototype.scrollTo = function (...args) { function measuredScrollTo(this: Element, options?: ScrollToOptions): void
function measuredScrollTo(this: Element, x: number, y: number): void
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
state.scroll.calls += 1 state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1] const top = typeof first === "object" ? first?.top : second
if (typeof top === "number") { if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight) const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1 if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
} }
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1 if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args) Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
} }
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", { Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true, configurable: true,
get: scrollTop.get, get: scrollTop.get,
@@ -267,18 +267,19 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120), userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]), assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat() ]).flat()
const messages: Record<string, Message[]> = {
[sourceID]: sourceMessages,
[targetID]: targetMessages,
[childID]: childMessages,
}
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim() if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text.trim() if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
} }
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = { export const fixture = {
directory, directory,
project: { project: {
@@ -333,7 +334,7 @@ export const fixture = {
sourceID, sourceID,
targetID, targetID,
childID, childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages }, messages,
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -345,16 +346,12 @@ export const fixture = {
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id), childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
}, },
} }
export function pageMessages(sessionID: string, limit: number, before?: string) { export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] const messages = fixture.messages[sessionID] ?? []
const end = before const end = before
? Math.max( ? Math.max(
0, 0,
@@ -364,6 +361,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit) const start = Math.max(0, end - limit)
return { return {
items: messages.slice(start, end), items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined, cursor: start > 0 ? messages[start].info.id : undefined,
} }
} }
@@ -220,7 +220,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
} }
if (url.pathname === "/api/project/current") if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
permission: "bash", permission: "bash",
patterns: ["git status", "git diff"], patterns: ["git status", "git diff"],
metadata: {}, metadata: {},
always: ["git *"], always: [],
}, },
], ],
}) })
@@ -16,9 +16,8 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const messagePageSize = 200
const historyPageSize = 200 const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [ return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
@@ -26,7 +25,7 @@ const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id, parentID: id,
created: 1700000001000 + index * 2_000, created: 1700000001000 + index * 2_000,
completed: index < initialPageSize, completed: index < messagePageSize / 2,
}), }),
] ]
}).flat() }).flat()
@@ -160,21 +159,18 @@ for (const scenario of scenarios) {
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover() await viewport.hover()
const deadline = Date.now() + 10_000 const deadline = Date.now() + 30_000
while (requests.filter((request) => request.phase === "start").length < 2) { while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240) await page.mouse.wheel(0, -1_200)
await page.waitForTimeout(20) await page.waitForTimeout(20)
} }
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 3)).toEqual([ expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`messages:start:${messages.at(-initialPageSize)!.info.id}`, `messages:start:${messages.at(-messagePageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
@@ -186,15 +182,12 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([ expect(pages).toEqual([
{ before: undefined, limit: initialPageSize }, { before: undefined, limit: messagePageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, { before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
]) ])
expect(roots).toEqual([]) expect(roots).toEqual([])
@@ -82,6 +82,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
file: "src/retry.ts", file: "src/retry.ts",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
}, },
], ],
@@ -1,11 +1,10 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) => const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
({
id: "msg_assistant", id: "msg_assistant",
type: "assistant", type: "assistant",
agent: "build", agent: "build",
@@ -22,7 +21,7 @@ const assistant = (completed: boolean, tool = false, childID?: string) =>
] ]
: [{ type: "text", text: "Working" }], : [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) }, time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo })
test("renders current protocol notices in CLI order", async ({ page }) => { test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = [] const ownerWarnings: string[] = []
@@ -280,6 +280,7 @@ function summaryDiff(index: number) {
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified" as const,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
} }
} }
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
] ]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect( await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'), await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4) await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
}) })
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -131,6 +131,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
file: "src/summary.ts", file: "src/summary.ts",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
}, },
], ],
@@ -83,6 +83,40 @@ test("labels all web search provider variants", async ({ page }) => {
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
}) })
test("labels V2 read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
})
function questionInput() { function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
} }
@@ -21,7 +21,7 @@ type EventPayload = {
payload: Record<string, unknown> payload: Record<string, unknown>
} }
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) test.use({ viewport: { width: 1440, height: 900 } })
test("animates todo opening without replaying it across session tabs", async ({ page }) => { test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000) test.setTimeout(90_000)
@@ -57,7 +57,6 @@ test("animates todo opening without replaying it across session tabs", async ({
default: { providerID: "opencode", modelID: "claude-opus-4-6" }, default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}, },
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -90,7 +90,8 @@ async function mockServer(page: Page) {
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {}) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -222,30 +222,29 @@ function turn(index: number): Message[] {
return [user, assistantMessage(targetID, index, user.info.id, parts)] return [user, assistantMessage(targetID, index, user.info.id, parts)]
} }
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120), userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat() ]).flat()
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim() if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text.trim() if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
} }
function currentPartIDs(message: Message) { function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 } const ordinals = { text: 0, reasoning: 0 }
return message.parts return message.parts.flatMap((part) => {
.flatMap((part) => {
if (!renderable(part)) return [] if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return [] return []
}) })
.sort()
} }
export const fixture = { export const fixture = {
@@ -292,7 +291,7 @@ export const fixture = {
], ],
sourceID, sourceID,
targetID, targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, messages,
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -306,7 +305,7 @@ export const fixture = {
} }
export function pageMessages(sessionID: string, limit: number, before?: string) { export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] const messages = fixture.messages[sessionID] ?? []
const end = before const end = before
? Math.max( ? Math.max(
0, 0,
@@ -316,6 +315,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit) const start = Math.max(0, end - limit)
return { return {
items: messages.slice(start, end), items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined, cursor: start > 0 ? messages[start].info.id : undefined,
} }
} }
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
@@ -188,7 +188,11 @@ test.describe("smoke: session timeline", () => {
const bottom = root const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]') .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect() ?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) samples.push({
ids: visible,
last: visible.includes(last),
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
})
if ( if (
!firstPaint && !firstPaint &&
visible.includes(last) && visible.includes(last) &&
@@ -263,7 +267,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
@@ -723,7 +727,7 @@ function expectCompleteScroll(
).toEqual([]) ).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331) expect(expectedPartIDs.length).toBe(465)
} }
async function selectHomeProject(page: Page, projectName: string) { async function selectHomeProject(page: Page, projectName: string) {
+3 -13
View File
@@ -1,21 +1,11 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true, "noEmit": true,
"rootDir": "..", "rootDir": "..",
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": [ "include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
"./performance/timeline-stability/**/*.spec.ts",
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/types.ts",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
} }
+2 -2
View File
@@ -278,7 +278,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
} }
if (path === "/api/project/current") if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1] const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET") if (worktree && route.request().method() === "GET")
return json(route, [ return json(route, [
{ directory: config.directory }, { directory: config.directory },
@@ -294,7 +294,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
} }
if (worktree && route.request().method() === "DELETE") if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path)) if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request") if (path === "/api/permission/request")
return json(route, { return json(route, {
@@ -0,0 +1,321 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildPromptRequest } from "./build-prompt-request"
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
expect(result.text).toContain("hello @src/foo.ts @planner")
expect(result.text).toContain("check this")
expect(result.displayText).toBe("hello @src/foo.ts @planner")
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
start: 5,
end: 16,
text: "@src/foo.ts",
})
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
sessionDirectory: "/repo",
})
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
expect(uploads).toHaveLength(2)
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.files[0]?.name).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory files", () => {
const result = buildPromptRequest({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
expect(result.files[0]).toEqual({
uri: "file:///repo/../docs",
mime: "application/x-directory",
name: "docs",
mention: { start: 0, end: 5, text: "@docs" },
})
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
expect(fooFiles).toHaveLength(2)
expect(result.text).toContain("focus here")
})
test("adds files for @mentions inside comment text", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
sessionDirectory: "/repo",
})
expect(result.files).toHaveLength(2)
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(file!.uri).not.toContain("%5C")
// Should have normalized to forward slashes
expect(file!.uri).toContain("/src/foo.ts")
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Special chars should be encoded
expect(file!.uri).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
})
test("handles context files with Windows paths", () => {
const result = buildPromptRequest({
prompt: [],
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
expect(result.files).toHaveLength(2)
// All file URLs should be valid
result.files.forEach((file) => {
expect(() => new URL(file.uri)).not.toThrow()
expect(file.uri).not.toContain("%5C") // No encoded backslashes
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(file!.uri)).not.toThrow()
expect(file!.uri).toContain("/D:/other/project/file.ts")
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should have query parameters
expect(file!.uri).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Query params should parse correctly
const url = new URL(file!.uri)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(file!.uri).toContain("/..")
})
})
@@ -0,0 +1,115 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
text: string
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
if (!comment) return [file]
comments.push({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
agents,
comments,
}
}
@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildRequestParts } from "./build-request-parts"
describe("buildRequestParts", () => {
test("builds typed request and optimistic parts without cast path", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
sessionID: "ses_1",
sessionDirectory: "/repo",
})
expect(result.requestParts[0]?.type).toBe("text")
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
expect(
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
sessionDirectory: "/repo",
})
const fooFiles = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
)
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
// Should create valid file URLs
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(filePart.url).not.toContain("%5C")
// Should have normalized to forward slashes
expect(filePart.url).toContain("/src/foo.ts")
}
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
sessionDirectory: "/home/user/project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
}
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
}
})
test("handles context files with Windows paths", () => {
const prompt: Prompt = []
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
sessionDirectory: "D:\\workspace\\app",
})
const fileParts = result.requestParts.filter((part) => part.type === "file")
expect(fileParts).toHaveLength(2)
// All file URLs should be valid
fileParts.forEach((part) => {
if (part.type === "file") {
expect(() => new URL(part.url)).not.toThrow()
expect(part.url).not.toContain("%5C") // No encoded backslashes
}
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
sessionDirectory: "C:\\current\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
sessionDirectory: "C:\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should have query parameters
expect(filePart.url).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Query params should parse correctly
const url = new URL(filePart.url)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
}
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(filePart.url).toContain("/..")
}
})
})
@@ -1,216 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.synthetic,
ignored: part.ignored,
time: part.time,
metadata: part.metadata,
sessionID,
messageID,
}
}
if (part.type === "file") {
return {
id: part.id,
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: part.source,
sessionID,
messageID,
}
}
return {
id: part.id,
type: "agent",
name: part.name,
source: part.source,
sessionID,
messageID,
}
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime ?? "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path),
source,
} satisfies PromptRequestPart
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
} satisfies PromptRequestPart
})
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)
const filePart = {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(item.path),
} satisfies PromptRequestPart
if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [
{
id: Identifier.ascending("part"),
type: "text",
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart,
filePart,
...mentions,
]
})
const images = input.images.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
requestParts.push(...files, ...context, ...agents, ...images)
return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
}
}
@@ -11,15 +11,17 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }
const optimistic: Array<{ const admitted: Array<{
directory?: string directory?: string
sessionID?: string sessionID: string
message: { messageID: string
text: string
displayText: string
agent: string agent: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string; variant?: string }
variant?: string comments: unknown[]
}
}> = [] }> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const sentShellDirectories: string[] = []
@@ -35,9 +37,11 @@ const switchedModels: Array<{
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = [] const syncedServers: string[] = []
const optimisticServers: string[] = [] const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -47,6 +51,8 @@ let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined let worktreeFailure: Error | undefined
let locationFailure: Error | undefined let locationFailure: Error | undefined
let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0 let worktreeCreates = 0
let activeSDK = "server-a" let activeSDK = "server-a"
let activeServerSync = "server-a" let activeServerSync = "server-a"
@@ -74,7 +80,7 @@ const prompt = {
set: () => undefined, set: () => undefined,
}, },
reset: () => undefined, reset: () => undefined,
set: () => undefined, set: () => restoredPrompts++,
context: { context: {
add: () => undefined, add: () => undefined,
remove: () => undefined, remove: () => undefined,
@@ -116,7 +122,16 @@ const clientFor = (directory: string) => {
sessionRequestOrder.push("prompt") sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory) sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input) promptInputs.push(input)
return { data: undefined } if (promptFailure) throw promptFailure
const prompt = input as { sessionID: string; id: string; text: string }
return {
id: prompt.id,
sessionID: prompt.sessionID,
timeCreated: 1,
type: "user" as const,
delivery: "steer" as const,
payload: { text: prompt.text },
}
}, },
switchAgent: async (input: { sessionID: string; agent: string }) => { switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent") sessionRequestOrder.push("agent")
@@ -235,16 +250,27 @@ beforeAll(async () => {
return { return {
data: { command: commands, project: "project" }, data: { command: commands, project: "project" },
session: { session: {
optimistic: { inbox: {
add: (value: { echo: (value: {
directory?: string directory?: string
sessionID?: string sessionID: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => { }) => {
optimisticServers.push(server) admittedServers.push(server)
optimistic.push(value) admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
}, },
remove: () => undefined,
}, },
}, },
set: () => undefined, set: () => undefined,
@@ -304,7 +330,8 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
optimistic.length = 0 admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0 updatedDrafts.length = 0
sentCommands.length = 0 sentCommands.length = 0
@@ -314,8 +341,10 @@ beforeEach(() => {
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
syncedServers.length = 0 syncedServers.length = 0
optimisticServers.length = 0 admittedServers.length = 0
promptCaptures.length = 0 promptCaptures.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -333,6 +362,8 @@ beforeEach(() => {
createWorktreeGate = undefined createWorktreeGate = undefined
worktreeFailure = undefined worktreeFailure = undefined
locationFailure = undefined locationFailure = undefined
promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0 worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key] for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
@@ -421,7 +452,7 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true) expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"]) expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0) expect(submitted).toBe(0)
}) })
@@ -441,13 +472,15 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
expect(optimistic).toHaveLength(1) expect(admitted).toHaveLength(1)
expect(optimistic[0]).toMatchObject({ expect(admitted[0]).toMatchObject({
message: { sessionID: "session-1",
text: "ls",
agent: "agent", agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model", variant: "high" },
},
}) })
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"]) expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([ expect(switchedModels).toEqual([
@@ -466,6 +499,22 @@ describe("prompt submit worktree selection", () => {
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
}) })
test("keeps a confirmed echo when the prompt response is lost", async () => {
params = { id: "session-1" }
promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
})
await submit.handleSubmit(event)
await settle()
expect(admitted).toHaveLength(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
})
test("submits slash commands through the current session API", async () => { test("submits slash commands through the current session API", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
@@ -1,10 +1,9 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js" import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -15,7 +14,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path" import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
@@ -100,43 +99,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
dataUrl: await blobDataUrl(attachment.blob, attachment.mime), dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})), })),
) )
const { requestParts, optimisticParts } = buildRequestParts({ const request = buildPromptRequest({
prompt: input.draft.prompt, prompt: input.draft.prompt,
context: input.draft.context, context: input.draft.context,
images: encodedImages, images: encodedImages,
text, text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory, sessionDirectory: input.draft.sessionDirectory,
}) })
const message: Message = { setBusy()
id: messageID, input.sync.session.inbox.echo({
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
}
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory, directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
messageID, messageID,
}) agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
batch(() => { ...request,
setBusy()
add()
}) })
try { try {
@@ -159,40 +137,23 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
} }
await input.api.prompt({ const admitted = await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, id: messageID,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), text: request.text,
files: requestParts.flatMap((part) => { files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
if (part.type !== "file") return [] agents: request.agents,
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
}) })
input.sync.session.inbox.confirm(admitted)
return true return true
} catch (err) { } catch (err) {
batch(() => { const failed = input.sync.session.inbox.clearEcho({
setIdle() directory: input.draft.sessionDirectory,
remove() sessionID: input.draft.sessionID,
messageID,
}) })
if (!failed) return true
setIdle()
throw err throw err
} }
} }
@@ -538,14 +499,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key) for (const item of commentItems) submission.target().context.remove(item.key)
clearInput() clearInput()
@@ -565,7 +518,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err), description: errorMessage(err),
}) })
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}) })
} finally { } finally {
@@ -119,9 +119,11 @@ export function createProviderConnectionController(options: {
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const directory = options.directory() const directory = options.directory()
await queryClient const key = directory ? pathKey(directory) : null
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null)) await Promise.all([
.catch(() => undefined) queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
])
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -40,7 +41,9 @@ export const SettingsProvidersV2: Component<{
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
@@ -73,7 +76,14 @@ export const SettingsProvidersV2: Component<{
return items return items
}) })
// Connection state comes from the integration list like the TUI: credential
// connections mean an API key or OAuth grant, env connections mean detected
// environment variables, and a connectionless integration is config-provided.
const source = (item: ProviderItem): ProviderSource | undefined => { const source = (item: ProviderItem): ProviderSource | undefined => {
const current = integration(item.id)
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
if (current?.connections.some((connection) => connection.type === "env")) return "env"
if (current) return "config"
if (!("source" in item)) return if (!("source" in item)) return
const value = item.source const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value if (value === "env" || value === "api" || value === "config" || value === "custom") return value
@@ -92,7 +102,11 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id) const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id)
}
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -68,7 +68,8 @@
display: none; display: none;
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px; --tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to right, to right,
@@ -86,7 +87,8 @@
); );
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to left, to left,
black 0, black 0,
@@ -103,8 +105,7 @@
); );
} }
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px; --tab-title-fade-offset: 24px;
} }
+9 -27
View File
@@ -1,10 +1,10 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types" import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -82,34 +82,16 @@ export const createDirSyncContext = (
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session if (session?.location.directory === directory) return session
}, },
optimistic: { inbox: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { echo(input: PromptEcho & { directory?: string }) {
serverSync.session.optimistic.add(input) serverSync.session.inbox.echo(input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { confirm(input: SessionInboxInfo) {
serverSync.session.optimistic.remove(input) return serverSync.session.inbox.confirm(input)
}, },
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
}, },
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
serverSync.session.optimistic.add({
sessionID: input.sessionID,
message: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
},
parts: input.parts,
})
}, },
async sync(sessionID: string, options?: { force?: boolean }) { async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options) await serverSync.session.sync(sessionID, options)
+1 -1
View File
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
}) })
test("should handle mixed separator path (Windows + Unix)", () => { test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-request-parts.ts when concatenating paths // This is what happens in build-prompt-request.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md" const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath) const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
@@ -17,7 +17,6 @@ import type {
ReferenceListInput, ReferenceListInput,
ReferenceListOutput, ReferenceListOutput,
ReferenceInfo, ReferenceInfo,
QuestionRequest,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
@@ -112,7 +111,6 @@ type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<Locatio
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"] type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"] type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
@@ -303,7 +301,6 @@ export async function bootstrapDirectory(input: {
readonly mcp: McpApi readonly mcp: McpApi
readonly permission: PermissionApi readonly permission: PermissionApi
readonly project: ProjectApi readonly project: ProjectApi
readonly question: QuestionApi
readonly reference: ReferenceListApi readonly reference: ReferenceListApi
readonly session: SessionApi readonly session: SessionApi
readonly vcs: VcsApi readonly vcs: VcsApi
@@ -394,40 +391,6 @@ export async function bootstrapDirectory(input: {
) )
}), }),
), ),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))), (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
@@ -250,7 +250,6 @@ export function createChildStoreManager(input: {
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {}, permission: {},
question: {},
get mcp_ready() { get mcp_ready() {
return !mcpQuery.isLoading return !mcpQuery.isLoading
}, },
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types" import type { Message, Part, Project } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -45,19 +45,6 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
save: [], save: [],
}) as PermissionRequest }) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
questions: [
{
question: title,
header: title,
options: [{ label: title, description: title }],
},
],
}) as QuestionRequest
const baseState = (input: Partial<State> = {}) => const baseState = (input: Partial<State> = {}) =>
({ ({
status: "complete", status: "complete",
@@ -75,7 +62,6 @@ const baseState = (input: Partial<State> = {}) =>
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {}, permission: {},
question: {},
mcp: {}, mcp: {},
lsp: [], lsp: [],
vcs: undefined, vcs: undefined,
@@ -220,7 +206,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { ses_1: [] }, session_diff: { ses_1: [] },
todo: { ses_1: [] }, todo: { ses_1: [] },
permission: { ses_1: [] }, permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } }, session_status: { ses_1: { type: "busy" } },
}), }),
) )
@@ -241,7 +226,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
}) })
@@ -282,7 +266,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { [item.info.id]: [] }, session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] }, todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] }, permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } }, session_status: { [item.info.id]: { type: "busy" } },
}), }),
) )
@@ -306,7 +289,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff[item.info.id]).toBeUndefined() expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined() expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined() expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined() expect(store.session_status[item.info.id]).toBeUndefined()
} }
}) })
@@ -325,7 +307,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { [dropped.id]: [] }, session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] }, todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] }, permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } }, session_status: { [dropped.id]: { type: "busy" } },
}), }),
) )
@@ -349,7 +330,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff[dropped.id]).toBeUndefined() expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined() expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined() expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined() expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id]) expect(todos).toEqual([dropped.id])
}) })
@@ -486,12 +466,11 @@ describe("applyDirectoryEvent", () => {
expect(store.part[messageID]).toBeUndefined() expect(store.part[messageID]).toBeUndefined()
}) })
test("tracks permission and question request lifecycles", () => { test("tracks permission request lifecycles", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
}), }),
) )
@@ -524,36 +503,6 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
applyDirectoryEvent({
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
}) })
test("updates vcs branch in store and cache", () => { test("updates vcs branch in store and cache", () => {
@@ -2,13 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree" import { Worktree } from "@opencode-ai/schema/worktree"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types" import type { Message, Part, Project, Todo } from "@/types"
import type { import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
FileDiffInfo,
PermissionRequest,
QuestionRequest,
SessionInfo,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@@ -27,9 +21,6 @@ const SESSION_CONTENT_EVENTS = new Set([
"message.part.delta", "message.part.delta",
"permission.asked", "permission.asked",
"permission.replied", "permission.replied",
"question.asked",
"question.replied",
"question.rejected",
]) ])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
@@ -86,7 +77,6 @@ export function cleanupDroppedSessionCaches(
...Object.keys(store.session_diff), ...Object.keys(store.session_diff),
...Object.keys(store.todo), ...Object.keys(store.todo),
...Object.keys(store.permission), ...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status), ...Object.keys(store.session_status),
...Object.values(store.part) ...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
@@ -438,43 +428,6 @@ export function applyDirectoryEvent(input: {
) )
break break
} }
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
break
}
const result = Binary.search(questions, question.id, (q) => q.id)
if (result.found) {
input.setStore("question", question.sessionID, result.index, reconcile(question))
break
}
input.setStore(
"question",
question.sessionID,
produce((draft) => {
draft.splice(result.index, 0, question)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = input.store.question[props.sessionID]
if (!questions) break
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (!result.found) break
input.setStore(
"question",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": { case "lsp.updated": {
input.loadLsp() input.loadLsp()
break break
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,6 @@ describe("app session cache", () => {
session_message: Record<string, never[] | undefined> session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined> form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
@@ -44,7 +43,6 @@ describe("app session cache", () => {
session_message: {}, session_message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] }, permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] }, form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" }, part_text_accum_delta: { prt_1: "streamed text" },
} }
@@ -58,7 +56,6 @@ describe("app session cache", () => {
expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined() expect(store.form.ses_1).toBeUndefined()
}) })
@@ -72,7 +69,6 @@ describe("app session cache", () => {
session_message: Record<string, never[] | undefined> session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined> form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
@@ -83,7 +79,6 @@ describe("app session cache", () => {
session_message: {}, session_message: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {}, permission: {},
question: {},
form: {}, form: {},
part_text_accum_delta: {}, part_text_accum_delta: {},
} }
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -13,7 +13,6 @@ type SessionCache = {
session_message: Record<string, SessionMessageInfo[] | undefined> session_message: Record<string, SessionMessageInfo[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined> form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} }
@@ -38,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_diff[sessionID] delete store.session_diff[sessionID]
delete store.session_status[sessionID] delete store.session_status[sessionID]
delete store.permission[sessionID] delete store.permission[sessionID]
delete store.question[sessionID]
if (store.form) delete store.form[sessionID] if (store.form) delete store.form[sessionID]
} }
} }
@@ -2,7 +2,6 @@ import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } fro
import type { import type {
FileDiffInfo, FileDiffInfo,
PermissionRequest, PermissionRequest,
QuestionRequest,
ReferenceInfo, ReferenceInfo,
SessionInfo, SessionInfo,
SessionStatus, SessionStatus,
@@ -50,9 +49,6 @@ export type State = {
permission: { permission: {
[sessionID: string]: PermissionRequest[] [sessionID: string]: PermissionRequest[]
} }
question: {
[sessionID: string]: QuestionRequest[]
}
mcp_ready: boolean mcp_ready: boolean
mcp: { mcp: {
[name: string]: McpServer["status"] [name: string]: McpServer["status"]
+2 -1
View File
@@ -287,7 +287,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
) )
return return
const directory = e.name const directory = event.current?.location?.directory
if (!directory) return
const time = Date.now() const time = Date.now()
if (event.type === "session.execution.failed") { if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time) handleSessionError(directory, event, time)
+1 -1
View File
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
const handlePermission = (e: PermissionEvent) => { const handlePermission = (e: PermissionEvent) => {
const event = e.details const event = e.details
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name) void respondPending(event.properties, event.current?.location?.directory)
} }
const unsubscribe = input.sdk.event.listen((event) => { const unsubscribe = input.sdk.event.listen((event) => {
+12 -21
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@@ -45,23 +45,21 @@ describe("adaptServerEvent", () => {
}) })
describe("current event buffering", () => { describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({ const delta = (id: string, value: string, ordinal = 0) =>
directory: "/repo", adaptServerEvent({
payload: adaptServerEvent({
id, id,
created: 1, created: 1,
type: "session.text.delta", type: "session.text.delta",
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value }, data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent), } as OpenCodeEvent)
})
test("merges adjacent text deltas for the same message and ordinal", () => { test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")]) const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" }) expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
}) })
test("coalesces current tool input deltas by tool ID", () => { test("coalesces current tool input deltas by tool ID", () => {
@@ -74,26 +72,19 @@ describe("current event buffering", () => {
data: { sessionID: "ses", assistantMessageID: "msg", id, delta }, data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent) } as OpenCodeEvent)
const result = coalesceServerEvents([ const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") }, current("evt_1", "call_1", "{"),
{ directory: "/repo", payload: current("evt_2", "call_1", "}") }, current("evt_2", "call_1", "}"),
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") }, current("evt_3", "call_2", "[]"),
]) ])
expect(result).toHaveLength(2) expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } }) expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } }) expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
}) })
test("preserves boundaries between distinct delta streams", () => { test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")] const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"]) expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
}) })
}) })
+29 -27
View File
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server" import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent } type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
type CurrentDelta = Extract< type CurrentDelta = Extract<
OpenCodeEvent, OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
@@ -22,22 +22,17 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
} }
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { export function coalesceServerEvents(events: ServerEvent[]) {
queue.push(event) const output: ServerEvent[] = []
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => { events.forEach((event) => {
const current = currentDelta(event.payload.current) const current = currentDelta(event.current)
if (current) { if (current) {
const previous = output[output.length - 1] const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current) const prior = currentDelta(previous?.current)
if ( if (
previous && previous &&
prior && prior &&
previous.directory === event.directory && prior.location?.directory === current.location?.directory &&
currentDeltaKey(prior) === currentDeltaKey(current) currentDeltaKey(prior) === currentDeltaKey(current)
) { ) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
@@ -46,13 +41,10 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
? { ...current.data, text: fragment } ? { ...current.data, text: fragment }
: { ...current.data, delta: fragment } : { ...current.data, delta: fragment }
output[output.length - 1] = { output[output.length - 1] = {
directory: event.directory, ...event,
payload: {
...event.payload,
properties: data, properties: data,
current: { ...current, data } as CurrentDelta, current: { ...current, data } as CurrentDelta,
} as ServerEvent, } as ServerEvent
}
return return
} }
output.push(event) output.push(event)
@@ -89,7 +81,8 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start() start()
} }
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>> type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting" export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
@@ -104,6 +97,9 @@ type ServerSDKBase = {
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"] listen: ServerEventEmitter["listen"]
location: {
on: ServerLocationEventEmitter["on"]
}
} }
} }
@@ -123,18 +119,16 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})() })()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<ServerEventMap>()
[key: string]: ServerEvent const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
}>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16 const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8 const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000 const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000 const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = [] let queue: ServerEvent[] = []
let buffer: Queued[] = [] let buffer: ServerEvent[] = []
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
@@ -152,7 +146,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => {
emitter.emit(event.type, event)
const directory = event.current?.location?.directory
if (directory) locations.emit(directory, event)
})
}) })
buffer.length = 0 buffer.length = 0
@@ -165,8 +163,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
function publish(event: OpenCodeEvent) { function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global" queue.push(adaptServerEvent(event))
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule() schedule()
} }
function wait(delay: number, signal: AbortSignal) { function wait(delay: number, signal: AbortSignal) {
@@ -313,6 +311,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
queue = [] queue = []
buffer = [] buffer = []
emitter.clear() emitter.clear()
locations.clear()
}) })
const api = createApiForServer({ server: server.http, fetch: platform.fetch }) const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -330,6 +329,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
location: {
on: locations.on.bind(locations),
},
}, },
} }
} }
@@ -365,7 +367,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.event.location.on(directory, (event) => {
emitter.emit(event.type, event) emitter.emit(event.type, event)
}) })
onCleanup(unsub) onCleanup(unsub)
@@ -6,6 +6,32 @@ const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
describe("v2 session reducer", () => { describe("v2 session reducer", () => {
test("moves a repeated inbox payload to the current event position", () => {
const reducer = createV2SessionReducer()
const result = reducer.reduce(
[
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
],
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
},
}),
)
expect(result?.messages).toEqual([
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
])
expect(result?.touched).toEqual(["msg_user"])
})
test("projects promoted input and streaming assistant content", () => { test("projects promoted input and streaming assistant content", () => {
const reducer = createV2SessionReducer() const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = [] let messages: SessionMessageInfo[] = []
@@ -1,4 +1,10 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type {
OpenCodeEvent,
SessionInboxInfo,
SessionInboxItem,
SessionInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }> type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }> type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -29,12 +35,14 @@ export function createV2SessionReducer() {
}) })
const append = (message: SessionMessageInfo) => const append = (message: SessionMessageInfo) =>
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
const replace = (message: SessionMessageInfo) =>
result([...source.filter((item) => item.id !== message.id), message], [message.id])
switch (event.type) { switch (event.type) {
case "session.inbox.enqueued": case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item) pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user") if (event.data.item.type === "user")
return append({ return replace({
id: event.data.inboxID, id: event.data.inboxID,
type: "user", type: "user",
metadata: event.data.item.payload.metadata, metadata: event.data.item.payload.metadata,
@@ -44,7 +52,7 @@ export function createV2SessionReducer() {
time: { created: event.created }, time: { created: event.created },
}) })
if (event.data.item.type !== "synthetic") return result([...source]) if (event.data.item.type !== "synthetic") return result([...source])
return append({ return replace({
id: event.data.inboxID, id: event.data.inboxID,
type: "synthetic", type: "synthetic",
metadata: event.data.item.payload.metadata, metadata: event.data.item.payload.metadata,
@@ -480,6 +488,9 @@ export function createV2SessionReducer() {
return { return {
reduce, reduce,
confirm(item: SessionInboxInfo) {
pending.set(key(item.sessionID, item.id), item)
},
clear(sessionID: string) { clear(sessionID: string) {
for (const id of pending.keys()) { for (const id of pending.keys()) {
if (id.startsWith(`${sessionID}:`)) pending.delete(id) if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+429 -267
View File
@@ -185,6 +185,16 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`, id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
}) })
const promptEcho = (messageID: string, text = "hello") => ({
sessionID: "child",
messageID,
text,
displayText: text,
agent: "build",
model: { providerID: "provider", modelID: "model" },
comments: [],
})
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
data, data,
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
@@ -299,6 +309,26 @@ function setup(sessions: Record<string, SessionInfo>) {
} }
describe("server session", () => { describe("server session", () => {
test("hydrates session info after a native session.created event", async () => {
const ctx = setup({ created: session("created") })
ctx.store.apply({
type: "session.created",
properties: {
sessionID: "created",
projectID: "project",
location: { directory: "/repo" },
slug: "created",
version: "test",
},
})
expect(ctx.store.get("created")).toBeUndefined()
await ctx.store.resolve("created")
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
expect(ctx.get).toEqual([{ sessionID: "created" }])
})
test("projects V2 session events into current and legacy message state", () => { test("projects V2 session events into current and legacy message state", () => {
const ctx = setup({ child: session("child") }) const ctx = setup({ child: session("child") })
ctx.store.remember(session("child")) ctx.store.remember(session("child"))
@@ -340,14 +370,38 @@ describe("server session", () => {
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
}) })
apply({
id: "evt_tool_z",
created: 5,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 3, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
})
apply({
id: "evt_tool_a",
created: 6,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 4, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
})
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
id: "msg_2_assistant", id: "msg_2_assistant",
type: "assistant", type: "assistant",
content: [{ type: "text", text: "world" }], content: [
{ type: "text", text: "world" },
{ type: "tool", id: "call_z" },
{ type: "tool", id: "call_a" },
],
}) })
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
"msg_2_assistant:text:0",
"call_z",
"call_a",
])
}) })
test("projects V2 pending inputs and forms", () => { test("projects V2 pending inputs and forms", () => {
@@ -564,7 +618,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -575,8 +629,32 @@ describe("server session", () => {
ctx.store.invalidate() ctx.store.invalidate()
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.store.data.message.root).toEqual([])
expect(ctx.get).toHaveLength(2) expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2) expect(ctx.messages).toEqual([
{ sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 200, order: "desc" },
])
})
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
const client = messageClient(response(), response())
const store = createServerSession(client)
await store.sync("child")
Array.from({ length: 428 }, (_, index) =>
store.apply({
type: "message.updated",
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
}),
)
expect(store.data.message.child).toHaveLength(428)
await store.sync("child", { force: true })
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, order: "desc" },
])
}) })
test("loads current session content through the current message API", async () => { test("loads current session content through the current message API", async () => {
@@ -601,11 +679,50 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
test("preserves assistant content order from message history", async () => {
const source = [
{ id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "I will inspect it." },
{
type: "tool",
id: "call_z",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
{
type: "tool",
id: "call_a",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 3 },
},
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const messageApi = {
list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }),
} as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"])
})
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({ const assistant = (id: string, created: number) => ({
@@ -638,8 +755,8 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([ expect(requests).toEqual([
{ sessionID: "root", limit: 20, order: "desc" }, { sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 20, cursor: "older" }, { sessionID: "root", limit: 200, cursor: "older" },
]) ])
expect(store.data.message.root.map((message) => message.id)).toEqual([ expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id, user.id,
@@ -648,6 +765,26 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
}) })
test("loads older messages by cursor with the fixed page size", async () => {
const older = userMessage("message-1")
const latest = userMessage("message-2", { time: { created: 2 } })
const client = messageClient(
response([{ info: latest, parts: [] }], "older"),
response([{ info: older, parts: [] }]),
)
const store = createServerSession(client)
await store.sync("child")
await store.history.loadMore("child")
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, cursor: "older" },
])
expect(store.data.message.child).toEqual([older, latest])
expect(store.history.more("child")).toBe(false)
})
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => { describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
@@ -661,7 +798,7 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
@@ -710,19 +847,17 @@ describe("server session", () => {
expect(store.data.part[parent.id]).toBeUndefined() expect(store.data.part[parent.id]).toBeUndefined()
}) })
test("does not let an optimistic user suppress initial root backfill", async () => { test("does not let an admitted user suppress initial root backfill", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [response(assistants.map((info) => ({ info, parts: [] })))],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) store.inbox.echo(promptEcho(user.id, "text"))
await store.sync("child") await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
@@ -783,28 +918,6 @@ describe("server session", () => {
expect(store.data.part[stale.id]).toEqual([freshPart]) expect(store.data.part[stale.id]).toEqual([freshPart])
}) })
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
test("uses a parent received by SSE during the replacement load", async () => { test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
@@ -1040,30 +1153,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("preserves optimistic parts re-added after removal during a refresh", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
)
await store.sync("child")
const refreshing = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [part] })
pending.resolve(response([{ info: message, parts: [stale] }]))
await refreshing
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
})
test("drops stale event content omitted by a complete initial page", async () => { test("drops stale event content omitted by a complete initial page", async () => {
const stale = userMessage("stale") const stale = userMessage("stale")
const store = createServerSession(messageClient(response())) const store = createServerSession(messageClient(response()))
@@ -1085,170 +1174,309 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([live, fetched]) expect(store.data.message.child).toEqual([live, fetched])
}) })
test("does not restore removed optimistic content on refresh", async () => { test("echoes a prompt without changing durable message order", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "removed" })
const kept = { ...message, id: "kept" }
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
})
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([kept])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part[kept.id]).toBeUndefined()
})
test("replaces confirmed optimistic content with the initial page", async () => {
const optimistic = userMessage("message")
const fetched = { ...optimistic, time: { created: 2 } }
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
await store.sync("child")
expect(store.data.message.child).toEqual([fetched])
})
test("replaces a confirmed optimistic part with fetched content", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { text: "optimistic" })
const fetched = { ...optimistic, text: "fetched" }
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([fetched])
})
test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
pending.resolve(response([{ info: message, parts: [confirmed] }]))
await loading
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([confirmed])
})
test("updates confirmed optimistic parts from later pages", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
const updated = { ...confirmed, text: "updated" }
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
await store.sync("child", { force: true })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toEqual([updated])
})
test("does not restore a confirmed optimistic part after its removal event", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
})
await store.sync("child", { force: true })
expect(store.data.part[message.id]).toEqual([pendingPart])
})
test("clears delta buffers when removing optimistic content", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "optimistic" })
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ store.inbox.echo({
type: "message.part.delta", ...promptEcho("msg_prompt"),
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
agents: [{ name: "explore" }],
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
],
}) })
store.optimistic.remove({ sessionID: "child", messageID: message.id }) expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
{
id: "msg_prompt:comment:0",
type: "text",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
},
},
])
expect(store.data.part[message.id]).toBeUndefined() store.applyV2({
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: {
type: "user",
delivery: "steer",
payload: {
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
},
},
},
} as OpenCodeEvent)
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
}) })
test("removes projected messages when rolling back optimistic content", () => { test("preserves a local echo while message history omits pending input", async () => {
const message = userMessage("message") const store = createServerSession(messageClient(response()))
const store = setup({ child: session("child") }).store store.inbox.echo(promptEcho("msg_prompt"))
store.optimistic.add({ sessionID: "child", message, parts: [] }) store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id }) await store.sync("child")
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("preserves local comment presentation through message refresh", async () => {
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
const message = userMessage("msg_prompt")
const store = createServerSession(
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
)
store.inbox.echo({
...promptEcho(message.id),
text: `hello\n${note}`,
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
origin: "review",
},
],
})
await store.sync("child")
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
})
test("retires an admitted echo absent from authoritative reconnect state", async () => {
const store = createServerSession(messageClient(response()))
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
const store = createServerSession(messageClient(response()))
store.applyV2({
id: "evt_prompt",
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
await store.sync("child")
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.session_message.child).toEqual([]) expect(store.data.session_message.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
}) })
test("does not remove content confirmed by a message event", () => { test("deduplicates the durable admission event against its local echo", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.optimistic.remove({ sessionID: "child", messageID: message.id }) store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message]) expect(store.data.pending.child).toHaveLength(1)
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
}) })
test("does not remove parts confirmed by part events", () => { test("uses the prompt response when the admission event was missed", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) store.inbox.confirm({
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) id: "msg_prompt",
sessionID: "child",
store.optimistic.remove({ sessionID: "child", messageID: message.id }) timeCreated: 2,
type: "user",
expect(store.data.message.child).toEqual([message]) delivery: "steer",
expect(store.data.part[message.id]).toEqual([part]) payload: { text: "hello" },
}) })
test("treats a part event as confirmation when it precedes the message event", () => { store.applyV2({
const message = userMessage("message") id: "evt_delivered",
const part = textPart(message.id) created: Date.now() + 1,
type: "session.inbox.delivered",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.pending.child).toEqual([])
expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("keeps a durable admission when the HTTP request later fails", () => {
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
store.optimistic.remove({ sessionID: "child", messageID: message.id }) expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
expect(store.data.pending.child).toHaveLength(1)
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
})
expect(store.data.message.child).toEqual([message]) test("places durable admission after delayed selection events", () => {
expect(store.data.part[message.id]).toEqual([part]) const store = setup({ child: session("child") }).store
store.remember(session("child"))
store.inbox.echo(promptEcho("msg_prompt"))
store.applyV2({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child", agent: "review" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_model",
created: 2,
type: "session.model.selected",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
} as OpenCodeEvent)
store.applyV2({
id: "evt_prompt",
created: 3,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 3, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"user",
])
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
agent: "review",
model: { providerID: "new-provider", modelID: "new-model" },
})
})
test("removes an echoed prompt when submission fails", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
expect(store.data.pending.child).toEqual([])
expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("removes a response-confirmed echo when the server cancels it", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.applyV2({
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
}) })
test("clears stale parts when the initial page has none", async () => { test("clears stale parts when the initial page has none", async () => {
@@ -1469,28 +1697,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("preserves optimistic re-adds across message retries", async () => {
const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
const store = createServerSession(client, { retry: retryImmediately })
await store.sync("child")
const loading = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
failed.reject(new Error("failed to fetch"))
await client.requested(3)
retried.resolve(response([{ info: message, parts: [stale] }]))
await loading
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([optimistic])
})
test("accepts part omission from a successful retry after an earlier delta", async () => { test("accepts part omission from a successful retry after an earlier delta", async () => {
const failed = Promise.withResolvers<MessageResponse>() const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>() const retried = Promise.withResolvers<MessageResponse>()
@@ -1654,33 +1860,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("does not cache skipped optimistic parts", () => {
const message = userMessage("message")
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
expect(store.data.part[message.id]).toEqual([])
})
test("clears stale delta buffers when replacing optimistic parts", () => {
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
})
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
})
test("preserves removals during history prepend", async () => { test("preserves removals during history prepend", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const latest = userMessage("message-2", { time: { created: 2 } }) const latest = userMessage("message-2", { time: { created: 2 } })
@@ -1906,24 +2085,7 @@ describe("server session", () => {
test("preserves pinned session content under server-wide cache pressure", () => { test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({}) const ctx = setup({})
ctx.store.pin("active") ctx.store.pin("active")
ctx.store.optimistic.add({ ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
sessionID: "active",
message: {
id: "message",
sessionID: "active",
role: "assistant",
time: { created: 1 },
parentID: "parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "agent",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
})
for (let index = 0; index < 50; index++) { for (let index = 0; index < 50; index++) {
ctx.store.remember(session(`session-${index}`)) ctx.store.remember(session(`session-${index}`))
+271 -335
View File
@@ -10,7 +10,7 @@ import type {
SessionMessageInfo, SessionMessageInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
@@ -18,41 +18,22 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import {
createCommentMetadata,
formatCommentNote,
parseCommentNote,
readCommentMetadata,
type PromptComment,
} from "@/utils/comment-note"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const messagePageSize = 200
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [
{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
},
]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -64,13 +45,6 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
return boundary?.type === "assistant" return boundary?.type === "assistant"
} }
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = { type MessagePage = {
session: Message[] session: Message[]
part: { id: string; part: Part[] }[] part: { id: string; part: Part[] }[]
@@ -81,6 +55,18 @@ type MessagePage = {
complete: boolean complete: boolean
} }
export type PromptEcho = {
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = { type MessageLoadState = {
touchedMessages: Set<string> touchedMessages: Set<string>
@@ -90,7 +76,6 @@ type MessageLoadState = {
deltaParts: Map<string, Set<string>> deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>> carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>> removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string> orphanParents: Set<string>
clearedMessageParts: Set<string> clearedMessageParts: Set<string>
touchedSource: Set<string> touchedSource: Set<string>
@@ -101,34 +86,6 @@ type MessageLoadBaseline = Pick<
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
> >
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed,
}
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) { function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key) const pending = map.get(key)
if (pending) return pending if (pending) return pending
@@ -198,12 +155,12 @@ export function createServerSession(
session_diff: {} as Record<string, FileDiffInfo[]>, session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>, todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>, permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
form: {} as Record<string, FormInfo[]>, form: {} as Record<string, FormInfo[]>,
pending: {} as Record<string, SessionInboxInfo[]>, pending: {} as Record<string, SessionInboxInfo[]>,
input: {} as Record<string, string[]>, input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>, message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>, session_message: {} as Record<string, SessionMessageInfo[]>,
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
part: {} as Record<string, Part[]>, part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>, part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) { session_working(id: string) {
@@ -213,7 +170,6 @@ export function createServerSession(
const requests = new Map<string, Promise<SessionInfo>>() const requests = new Map<string, Promise<SessionInfo>>()
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer() const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>() const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>() const formRevision = new Map<string, number>()
@@ -224,7 +180,45 @@ export function createServerSession(
const pendingParts = new Map<string, Map<string, Set<string>>>() const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>() const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>() const removedMessages = new Map<string, Set<string>>()
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
const messageSnapshots = new Map<string, Set<string>>()
const settledInputs = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>() const deltaBases = new Map<string, { base: string; sessionID: string }>()
const markEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
messages.set(messageID, "sending")
echoes.set(sessionID, messages)
}
const confirmEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
if (!messages?.has(messageID)) return false
messages.set(messageID, "admitted")
return true
}
const releaseEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
const state = messages?.get(messageID)
if (!messages || !state) return
messages.delete(messageID)
if (messages.size === 0) echoes.delete(sessionID)
return state
}
const present = (messageID: string, parts: Part[]) => {
const local = data.part[messageID] ?? []
const comments = local.filter(
(part) =>
part.type === "text" &&
part.synthetic &&
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
)
if (!comments.length) return parts
const text = local.find((part) => part.type === "text" && !part.synthetic)
const projected = parts.flatMap((part) => {
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
})
return [...projected, ...comments]
}
const deleteMessageParts = ( const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> }, cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string, messageID: string,
@@ -247,25 +241,12 @@ export function createServerSession(
return created return created
} }
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>, complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>, loading: {} as Record<string, boolean | undefined>,
at: {} as Record<string, number | undefined>, at: {} as Record<string, number | undefined>,
}) })
const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
const projected = projectMessageSource(message)
const projectedIDs = new Set(projected.map((item) => item.id))
setData(
"session_message",
message.sessionID,
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
)
}
const remember = (session: SessionInfo) => { const remember = (session: SessionInfo) => {
setData("info", session.id, reconcile(session)) setData("info", session.id, reconcile(session))
infoSeen.delete(session.id) infoSeen.delete(session.id)
@@ -277,13 +258,10 @@ export function createServerSession(
...inflight.keys(), ...inflight.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(), ...messageLoads.keys(),
...optimistic.keys(), ...echoes.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form) ...Object.entries(data.form)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -356,65 +334,6 @@ export function createServerSession(
return { session, root } return { session, root }
} }
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => { const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID) const load = messageLoads.get(sessionID)
if (!load) return if (!load) return
@@ -452,14 +371,6 @@ export function createServerSession(
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
@@ -490,7 +401,9 @@ export function createServerSession(
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID) generations.delete(sessionID)
clearOptimistic(sessionID) echoes.delete(sessionID)
messageSnapshots.delete(sessionID)
settledInputs.delete(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
inflight.delete(sessionID) inflight.delete(sessionID)
inflightTodo.delete(sessionID) inflightTodo.delete(sessionID)
@@ -508,7 +421,6 @@ export function createServerSession(
setMeta( setMeta(
produce((draft) => { produce((draft) => {
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID] delete draft.cursor[sessionID]
delete draft.complete[sessionID] delete draft.complete[sessionID]
delete draft.loading[sessionID] delete draft.loading[sessionID]
@@ -525,13 +437,10 @@ export function createServerSession(
...inflight.keys(), ...inflight.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(), ...messageLoads.keys(),
...optimistic.keys(), ...echoes.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form) ...Object.entries(data.form)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -545,11 +454,13 @@ export function createServerSession(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
const request = (cursor?: string) => const request = (cursor?: string) =>
(options?.retry ?? retry)(() => { (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) return messageApi.list(
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
)
}) })
const first = await request(before) const first = await request(before)
const pages = [first] const pages = [first]
@@ -563,9 +474,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source, source,
sourceMode: before ? ("older" as const) : ("latest" as const), sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true, projectSource: true,
@@ -605,9 +514,10 @@ export function createServerSession(
) => { ) => {
for (const item of items) { for (const item of items) {
if (!messageIDs.has(item.id)) continue if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id) const fetched = present(
? [] item.id,
: item.part.filter((part) => !SKIP_PARTS.has(part.type)) load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
)
const fetchedIDs = new Set(fetched.map((part) => part.id)) const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id) const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
@@ -658,47 +568,56 @@ export function createServerSession(
preserveUnfetched: boolean | ((message: Message) => boolean), preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean, cleanupOrphans: boolean,
) => { ) => {
if (page.sourceMode === "latest")
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
const source = page.source const source = page.source
? (() => { ? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message])) const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? [] const existing = data.session_message[sessionID] ?? []
const boundary = Math.min(...page.source.map((message) => message.time.created)) const boundary = Math.min(...page.source.map((message) => message.time.created))
const inbox = new Set(data.input[sessionID] ?? [])
const current = existing.filter( const current = existing.filter(
(message) => (message) =>
!incoming.has(message.id) && !incoming.has(message.id) &&
!inbox.has(message.id) &&
(page.sourceMode === "older" || (page.sourceMode === "older" ||
load?.touchedSource.has(message.id) || load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)), (!page.complete && message.time.created < boundary)),
) )
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
// fetched history until a delivered or cancelled event resolves them.
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
const combined =
page.sourceMode === "older"
? [...page.source, ...current, ...admitted]
: [...current, ...page.source, ...admitted]
const live = new Map(existing.map((message) => [message.id, message])) const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( return combined.map((message) =>
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
) )
})() })()
: undefined : undefined
const projected = const merged =
page.projectSource && source page.projectSource && source
? (() => { ? (() => {
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
} }
})() })()
: page : page
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages, touched: touchedMessages,
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages, removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched: (message) =>
echoes.get(sessionID)?.has(message.id) === true ||
preserveUnfetched === true ||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
compare: compareMessages, compare: compareMessages,
}) })
batch(() => { batch(() => {
@@ -712,14 +631,13 @@ export function createServerSession(
} }
orphanParts.delete(sessionID) orphanParts.delete(sessionID)
} }
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor) setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete) setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now()) setMeta("at", sessionID, Date.now())
}) })
} }
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const active = generation(sessionID) const active = generation(sessionID)
const load: MessageLoadState = { const load: MessageLoadState = {
@@ -730,7 +648,6 @@ export function createServerSession(
deltaParts: new Map(), deltaParts: new Map(),
carriedDeltaParts: new Map(), carriedDeltaParts: new Map(),
removedParts: new Map(), removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(), orphanParents: new Set(),
clearedMessageParts: new Set(), clearedMessageParts: new Set(),
touchedSource: new Set(), touchedSource: new Set(),
@@ -739,7 +656,7 @@ export function createServerSession(
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
let applied = false let applied = false
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined, undefined,
@@ -751,11 +668,7 @@ export function createServerSession(
const users = new Set([ const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id), ...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? []) ...(data.message[sessionID] ?? [])
.filter((message) => { .filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
if (message.role !== "user") return false
const item = optimistic.get(sessionID)?.get(message.id)
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
})
.map((message) => message.id), .map((message) => message.id),
]) ])
const parentIDs = [ const parentIDs = [
@@ -822,32 +735,30 @@ export function createServerSession(
} }
} }
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { const sync = (sessionID: string, options?: { force?: boolean }) => {
touch(sessionID) touch(sessionID)
return runInflight(inflight, sessionID, async () => { return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
const invalid = invalidated.has(sessionID) const invalid = invalidated.has(sessionID)
const revision = invalidationRevision const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([ await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options), resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
]) ])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID) if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
}) })
} }
const prefetch = async (sessionID: string, limit: number) => { const prefetch = async (sessionID: string, messageCount: number) => {
touch(sessionID) touch(sessionID)
await inflight.get(sessionID) await inflight.get(sessionID)
if ( if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
) )
return return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) await runInflight(inflight, sessionID, () => loadMessages(sessionID))
} }
const eventSessionID = (event: { type: string; properties?: unknown }) => { const eventSessionID = (event: { type: string; properties?: unknown }) => {
@@ -900,12 +811,12 @@ export function createServerSession(
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
} }
for (const messageID of touched) { for (const messageID of touched) {
const next = normalized.parts.get(messageID) ?? [] const next = present(messageID, normalized.parts.get(messageID) ?? [])
const nextIDs = new Set(next.map((part) => part.id)) const nextIDs = new Set(next.map((part) => part.id))
for (const part of next) { for (const part of next) {
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
} }
for (const part of data.part[messageID] ?? []) { for (const part of [...(data.part[messageID] ?? [])]) {
if (nextIDs.has(part.id)) continue if (nextIDs.has(part.id)) continue
apply({ apply({
type: "message.part.removed", type: "message.part.removed",
@@ -933,6 +844,67 @@ export function createServerSession(
.catch(() => {}) .catch(() => {})
} }
const removeEcho = (sessionID: string, messageID: string) => {
if (!releaseEcho(sessionID, messageID)) return false
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
const load = messageLoads.get(sessionID)
load?.touchedMessages.add(messageID)
load?.removedMessages.add(messageID)
load?.clearedMessageParts.add(messageID)
batch(() => {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
setData(produce((draft) => deleteMessageParts(draft, messageID)))
})
return true
}
const confirmInbox = (item: SessionInboxInfo) => {
if (!confirmEcho(item.sessionID, item.id)) return false
v2.confirm(item)
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
const current = data.pending[item.sessionID] ?? []
const index = current.findIndex((entry) => entry.id === item.id)
if (index < 0) setData("pending", item.sessionID, [...current, item])
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
return true
}
const reconcileInbox = (sessionID: string) => {
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
)
settledInputs.delete(sessionID)
if (removed.length) {
const ids = new Set(removed)
const source = data.session_message[sessionID] ?? []
projectV2({
sessionID,
messages: source.filter((message) => !ids.has(message.id)),
touched: [],
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
})
}
const messages = echoes.get(sessionID)
if (!messages) return
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
for (const [messageID, state] of messages) {
if (projected.has(messageID)) {
releaseEcho(sessionID, messageID)
continue
}
if (pending.has(messageID)) {
confirmEcho(sessionID, messageID)
continue
}
if (state === "admitted") removeEcho(sessionID, messageID)
}
}
const applyV2 = (event: OpenCodeEvent) => { const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "form.created") { if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1) formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
@@ -956,6 +928,9 @@ export function createServerSession(
} }
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
releaseEcho(sessionID, event.data.inboxID)
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
if ( if (
event.type === "session.inbox.enqueued" || event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" || event.type === "session.inbox.delivery.changed" ||
@@ -967,11 +942,10 @@ export function createServerSession(
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1) pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") { if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? [] const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID)) const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
setData("pending", sessionID, [ const index = current.findIndex((entry) => entry.id === event.data.inboxID)
...current, if (index < 0) setData("pending", sessionID, [...current, item])
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }, if (index >= 0) setData("pending", sessionID, index, reconcile(item))
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID)) if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID]) setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
} }
@@ -1108,16 +1082,9 @@ export function createServerSession(
} }
case "message.updated": { case "message.updated": {
const info = (event.properties as { info: Message }).info const info = (event.properties as { info: Message }).info
indexProjectedMessage(info)
const load = messageLoads.get(info.sessionID) const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id) load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id) load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID) const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id) orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID) if (orphans?.size === 0) orphanParts.delete(info.sessionID)
@@ -1130,11 +1097,16 @@ export function createServerSession(
return return
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) if (result.found) {
if (!result.found) setData("message", info.sessionID, result.index, reconcile(info))
return
}
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
setData("message", info.sessionID, (value = []) => { setData("message", info.sessionID, (value = []) => {
const next = value.slice() const next = value.slice()
next.splice(result.index, 0, info) const moved = next.findIndex((message) => message.id === info.id)
if (moved >= 0) next.splice(moved, 1)
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
return next return next
}) })
return return
@@ -1151,13 +1123,11 @@ export function createServerSession(
load?.deltaParts.delete(props.messageID) load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID) load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID) load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID) pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>() const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID) removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession) removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData( setData(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
@@ -1203,12 +1173,8 @@ export function createServerSession(
pending?.delete(part.id) pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id) deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id) trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => void delete draft[part.id]), produce((draft) => void delete draft[part.id]),
@@ -1218,14 +1184,9 @@ export function createServerSession(
setData("part", part.messageID, [part]) setData("part", part.messageID, [part])
return return
} }
const result = Binary.search(parts, part.id, (item) => item.id) const index = parts.findIndex((item) => item.id === part.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part)) if (index >= 0) setData("part", part.messageID, index, reconcile(part))
if (!result.found) if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
return return
} }
case "message.part.removed": { case "message.part.removed": {
@@ -1247,20 +1208,16 @@ export function createServerSession(
const parts = load.removedParts.get(props.messageID) ?? new Set<string>() const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID) parts.add(props.partID)
load.removedParts.set(props.messageID, parts) load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
} }
trackPartChange(props.sessionID, props.messageID, props.partID) trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData( setData(
produce((draft) => { produce((draft) => {
delete draft.part_text_accum_delta[props.partID] delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID) deltaBases.delete(props.partID)
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const index = parts.findIndex((part) => part.id === props.partID)
if (result.found) parts.splice(result.index, 1) if (index >= 0) parts.splice(index, 1)
if (parts.length === 0) delete draft.part[props.messageID] if (parts.length === 0) delete draft.part[props.messageID]
}), }),
) )
@@ -1276,8 +1233,8 @@ export function createServerSession(
} }
const parts = data.part[props.messageID] const parts = data.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const index = parts.findIndex((part) => part.id === props.partID)
if (!result.found) return if (index < 0) return
trackPartChange(props.sessionID, props.messageID, props.partID) trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID) const load = messageLoads.get(props.sessionID)
if (load) { if (load) {
@@ -1289,7 +1246,7 @@ export function createServerSession(
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
} }
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string") if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData( setData(
@@ -1302,7 +1259,7 @@ export function createServerSession(
props.messageID, props.messageID,
produce((draft) => { produce((draft) => {
if (!draft) return if (!draft) return
const part = draft[result.index] const part = draft[index]
const field = props.field as keyof typeof part const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}), }),
@@ -1339,36 +1296,6 @@ export function createServerSession(
) )
return return
} }
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID]
if (!questions) {
setData("question", question.sessionID, [question])
return
}
const result = Binary.search(questions, question.id, (item) => item.id)
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
if (!result.found)
setData(
"question",
question.sessionID,
produce((draft) => void draft.splice(result.index, 0, question)),
)
return
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"question",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
}
} }
} }
@@ -1391,25 +1318,30 @@ export function createServerSession(
while (true) { while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0 const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0 const formAt = formRevision.get(sessionID) ?? 0
const previous = new Set(data.input[sessionID] ?? [])
const result = await load() const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) { if (pendingStable) {
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
const settled = settledInputs.get(sessionID) ?? new Set<string>()
previous.forEach((messageID) => {
if (!current.has(messageID)) settled.add(messageID)
})
if (settled.size) settledInputs.set(sessionID, settled)
result.pending.forEach(v2.confirm)
setData("pending", sessionID, reconcile(result.pending)) setData("pending", sessionID, reconcile(result.pending))
setData( setData("input", sessionID, reconcile([...current]))
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
} }
if (formStable) setData("form", sessionID, reconcile(result.forms)) if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return if (pendingStable && formStable) return
} }
}, },
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) { refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
const sessions = [...pinned.keys()]
return Promise.all( return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]), sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined) ).then(() => sessions.forEach(reconcileInbox))
}, },
invalidate() { invalidate() {
invalidationRevision += 1 invalidationRevision += 1
@@ -1418,77 +1350,81 @@ export function createServerSession(
setMeta("at", {}) setMeta("at", {})
}, },
prefetch, prefetch,
shouldPrefetch(sessionID: string, limit: number) { shouldPrefetch(sessionID: string, messageCount: number) {
if (data.message[sessionID] === undefined) return true if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit return (data.message[sessionID]?.length ?? 0) <= messageCount
}, },
fresh(sessionID: string, ttl: number) { fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
}, },
optimistic: { inbox: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { echo(input: PromptEcho) {
const parts = input.parts const created = Date.now()
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) const files = input.files?.map((file) => ({
.sort((a, b) => cmp(a.id, b.id)) data: "",
const load = messageLoads.get(input.sessionID) mime: file.mime,
if (load?.clearedMessageParts.has(input.message.id)) { source: { type: "uri" as const, uri: file.uri },
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>() name: file.name,
parts.forEach((part) => touched.add(part.id)) mention: file.mention,
load.touchedParts.set(input.message.id, touched) }))
const item: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
delivery: "steer",
payload: { text: input.text, files, agents: input.agents },
} }
if (load) { const projected = normalizeSessionMessages(input.sessionID, [
load.removedMessages.delete(input.message.id) { id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id))) {
} id: `${input.messageID}:model`,
const items = optimistic.get(input.sessionID) type: "model-switched",
const removedMessagesForSession = removedMessages.get(input.sessionID) model: {
removedMessagesForSession?.delete(input.message.id) id: input.model.modelID,
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) providerID: input.model.providerID,
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) variant: input.model.variant,
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
indexProjectedMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
)
setData("part", input.message.id, parts)
}, },
remove(input: { sessionID: string; messageID: string }) { time: { created },
const item = optimistic.get(input.sessionID)?.get(input.messageID) },
if (!item) return {
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID) id: input.messageID,
clearOptimistic(input.sessionID, input.messageID) type: "user",
if (item.confirmedMessage) { text: input.displayText,
const partIDs = new Set(item.parts.map((part) => part.id)) files,
setData( agents: input.agents,
produce((draft) => { time: { created },
for (const part of item.parts) { },
delete draft.part_text_accum_delta[part.id] ])
deltaBases.delete(part.id) const message = projected.messages[0]!
} const comments: Part[] = input.comments.map((comment, index) => ({
const parts = draft.part[input.messageID] id: `${input.messageID}:comment:${index}`,
if (!parts) return sessionID: input.sessionID,
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id)) messageID: input.messageID,
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID] type: "text",
}), text: formatCommentNote(comment),
) synthetic: true,
return metadata: createCommentMetadata(comment),
} }))
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id)) const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
setData("session_message", input.sessionID, (messages) => removedMessages.get(input.sessionID)?.delete(input.messageID)
messages?.filter((message) => !projectedIDs.has(message.id)), markEcho(input.sessionID, input.messageID)
) pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) batch(() => {
setData(produce((draft) => deleteMessageParts(draft, input.messageID))) setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
if (!data.input[input.sessionID]?.includes(input.messageID))
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
setData("part", input.messageID, parts)
})
},
confirm: confirmInbox,
reconcile: reconcileInbox,
clearEcho(input: { sessionID: string; messageID: string }) {
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
return removeEcho(input.sessionID, input.messageID)
}, },
}, },
async todo(sessionID: string, request?: { force?: boolean }) { async todo(sessionID: string, request?: { force?: boolean }) {
@@ -1500,14 +1436,14 @@ export function createServerSession(
history: { history: {
more: (sessionID: string) => more: (sessionID: string) =>
data.message[sessionID] !== undefined && data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined && meta.complete[sessionID] !== undefined &&
!meta.complete[sessionID] && !meta.complete[sessionID] &&
!!meta.cursor[sessionID], !!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false, loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) { async loadMore(sessionID: string) {
touch(sessionID) touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend") await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
}, },
}, },
evict(sessionID: string) { evict(sessionID: string) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
OpenCodeEvent,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
SessionListInput, SessionListInput,
@@ -15,11 +16,13 @@ import {
loadMcpResourcesQuery, loadMcpResourcesQuery,
reconcileActiveSessionStatuses, reconcileActiveSessionStatuses,
seedActiveSessionStatuses, seedActiveSessionStatuses,
sessionListEventDirectories,
shouldRefreshWorkspaceSessions, shouldRefreshWorkspaceSessions,
} from "./server-sync" } from "./server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import { adaptServerEvent } from "./server-sdk"
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
@@ -214,6 +217,23 @@ describe("workspace session inventory", () => {
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true) expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false) expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
}) })
test("invalidates both locations when a session moves", () => {
const event = adaptServerEvent({
id: "evt_moved",
created: 1,
type: "session.moved",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
location: { directory: "/source" },
data: {
sessionID: "ses_1",
location: { directory: "/destination" },
projectID: "project_2",
},
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
})
}) })
describe("canDisposeDirectory", () => { describe("canDisposeDirectory", () => {
+18 -16
View File
@@ -88,6 +88,12 @@ const SESSION_LIST_EVENTS = new Set([
"session.usage.updated", "session.usage.updated",
]) ])
export function sessionListEventDirectories(event: ServerEvent) {
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
}
type McpListApi = { type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput> readonly list: (input?: McpListInput) => Promise<McpListOutput>
} }
@@ -231,7 +237,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return { pending, forms } return { pending, forms }
}) })
} }
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)]) const hydrateSession = async (sessionID: string) => {
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
session.inbox.reconcile(sessionID)
}
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [
@@ -551,14 +560,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
type: "session.updated", type: "session.updated",
properties: { sessionID: info.id, info }, properties: { sessionID: info.id, info },
}) })
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => { const markSessionListsChanged = (event: ServerEvent) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) { sessionListEventDirectories(event).forEach((directory) => {
const key = directoryKey(directory) const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1) sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
} })
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
} }
const toDirectoryEvent = (event: ServerEvent) => { const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return if (event.current?.type === "session.created") return
@@ -569,15 +575,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
const unsub = serverSDK.event.listen((e) => { const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details const event = e.details
const directory = event.current?.location?.directory
const eventType: string = event.type const eventType: string = event.type
const previousDirectory = markSessionListsChanged(event)
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
if (event.current) session.applyV2(event.current) if (event.current) session.applyV2(event.current)
session.apply(event) session.apply(event)
if (event.current?.type === "session.moved") { if (event.current?.type === "session.moved") {
@@ -629,9 +630,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
homeSessions.refresh(event.type) homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory }) catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory }) connection.handleEvent({ type: eventType })
if (directory === "global") { if (!directory) {
applyGlobalEvent({ applyGlobalEvent({
event, event,
project: globalStore.project, project: globalStore.project,
@@ -644,6 +645,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return return
} }
const key = directoryKey(directory)
if (event.current?.type === "session.forked") if (event.current?.type === "session.forked")
void session void session
.resolve(event.current.data.sessionID, { force: true }) .resolve(event.current.data.sessionID, { force: true })
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
load: async () => {}, load: async () => {},
}) })
catalog.handleEvent({ type: "server.connected", directory: "global" }) catalog.handleEvent({ type: "server.connected" })
await Bun.sleep(0) await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true) expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = { type CatalogEvent = {
type: string type: string
directory: string directory?: string
} }
export function createCatalogSync(input: { export function createCatalogSync(input: {
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
event.type === "integration.updated" || event.type === "integration.updated" ||
event.type === "integration.connection.updated" event.type === "integration.connection.updated"
) { ) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined) void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
} }
} }
@@ -12,10 +12,8 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
connected: () => calls.push("connected"), connected: () => calls.push("connected"),
}) })
connection.handleEvent({ type: "server.connected", directory: "global" }) connection.handleEvent({ type: "server.connected" })
expect(calls).toContain("connected") expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected") setStatus("connected")
return dispose return dispose
}) })
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
}) })
let connectedOnce = false let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) { function handleEvent(event: { type: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return if (event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce }) input.connected({ reconnect: connectedOnce })
connectedOnce = true connectedOnce = true
} }
@@ -1,137 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id,
sessionID,
role: "user",
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
type: "text",
text: id,
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
part: {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record<string, Part[] | undefined>,
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})

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