mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
95 Commits
shared-data-impl
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c795113d | |||
| d94d520f45 | |||
| fcc1e9c42f | |||
| 64b9ba339e | |||
| 73700065bf | |||
| 07a82a442e | |||
| 55be895e14 | |||
| 58a36a1560 | |||
| 3d782efee4 | |||
| c054bb183e | |||
| 0d7904c91f | |||
| 75ec0b454c | |||
| f4baba2824 | |||
| 79fc74afbf | |||
| 30dfe5352c | |||
| f725443e30 | |||
| cae205a3d9 | |||
| d35c6f04ed | |||
| 4d021b4660 | |||
| f63f912178 | |||
| 6564d1442a | |||
| a5f3e9e735 | |||
| d24a24a2ca | |||
| 8610d90838 | |||
| 57b050e9fc | |||
| 51091be7e4 | |||
| c8584ec0c8 | |||
| 7301c5e798 | |||
| 41f70bfbb1 | |||
| e8fa6985bc | |||
| 66012fe65f | |||
| 8e66f83a50 | |||
| 1d59884434 | |||
| 6a69aa752f | |||
| 7086df5b11 | |||
| b57c1cc47c | |||
| 082423126c | |||
| 7c4fbdd291 | |||
| a01cd34acc | |||
| d5a58e756f | |||
| e57a1c7930 | |||
| 98f5e86122 | |||
| c42c7f7793 | |||
| ab7a0bf65c | |||
| 552fd40ef8 | |||
| 8afcb3870e | |||
| 014a364dfd | |||
| a45b12cfa4 | |||
| 7484e32f62 | |||
| 650f7e8cdd | |||
| 5c7f5840ee | |||
| 08a6d7b619 | |||
| 1580e7cc3a | |||
| 8640ea3374 | |||
| e6a3b951b5 | |||
| 62b67f2761 | |||
| 182fe17f12 | |||
| b6ea0c2209 | |||
| f7b222e95a | |||
| a7288d231e | |||
| f75244d795 | |||
| 1d44d56d9c | |||
| 9adc9bb5df | |||
| a609acbb67 | |||
| f4620939aa | |||
| 23258ada86 | |||
| 2ca6d8ee49 | |||
| d6c299cee7 | |||
| 737b6eb7b7 | |||
| 923cb28a0a | |||
| e79f3e8ca2 | |||
| 638cb414c4 | |||
| 445aacf92e | |||
| a2a53032d4 | |||
| 810156d081 | |||
| c0ae362996 | |||
| de32afe358 | |||
| 2d953a69de | |||
| e3bbfdf270 | |||
| 6d0c30a125 | |||
| 1cf97fac5c | |||
| f7d21babb6 | |||
| b0480a6f93 | |||
| bdbccca1f3 | |||
| 3d3bbd48f9 | |||
| 51f0b499f9 | |||
| 9872fb8a54 | |||
| baf5ce3b7e | |||
| 70efd3fb38 | |||
| a1a663d60e | |||
| 4a899bb730 | |||
| b6b313943f | |||
| 3a4c8cdf9d | |||
| 8d71099577 | |||
| c0b4a0ce91 |
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) })
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}`))
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user