mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f7d5366c | |||
| ca1064bf80 | |||
| bb77765056 | |||
| 0aab2ed553 | |||
| 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,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
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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/`.
|
||||||
|
|||||||
+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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,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 +106,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 +124,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 +153,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`.
|
||||||
|
|
||||||
|
|||||||
+33
-34
@@ -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. |
|
||||||
@@ -48,19 +47,19 @@ 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. |
|
||||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||||
|
|
||||||
## Highest-Risk Gaps
|
## Highest-Risk Gaps
|
||||||
|
|
||||||
@@ -78,24 +77,24 @@ 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`. |
|
||||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||||
|
|
||||||
## Suggested Next Work Slices
|
## Suggested Next Work Slices
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +314,29 @@ 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) => {
|
||||||
Stream.mapEffect(decodeEvent(route)),
|
const events = execution.frames.pipe(
|
||||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
Stream.mapEffect(decodeEvent(route)),
|
||||||
)
|
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||||
return events.pipe(
|
)
|
||||||
Stream.mapAccumEffect(
|
const stream = events.pipe(
|
||||||
() => protocol.stream.initial(request),
|
Stream.mapAccumEffect(
|
||||||
protocol.stream.step,
|
() => protocol.stream.initial(request),
|
||||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
protocol.stream.step,
|
||||||
|
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||||
|
),
|
||||||
|
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||||
|
requireTerminalEvent(route),
|
||||||
|
)
|
||||||
|
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
|
||||||
requireTerminalEvent(route),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} satisfies Route<Body, Prepared>
|
} satisfies Route<Body, Prepared>
|
||||||
@@ -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* () {
|
||||||
try: () =>
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
const ws = yield* Effect.try({
|
||||||
catch: (error) =>
|
try: () =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||||
url: input.url,
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||||
operation: "request",
|
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||||
}),
|
headers: input.headers,
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}),
|
||||||
|
catch: (error) =>
|
||||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
|
url: input.url,
|
||||||
|
operation: "request",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return yield* fromWebSocket(ws, input)
|
||||||
|
})
|
||||||
|
|
||||||
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(() => {
|
||||||
try: () => ws.send(message),
|
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||||
catch: (error) =>
|
return Effect.fail(
|
||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "write",
|
operation: "write",
|
||||||
}),
|
phase: "send",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.try({
|
||||||
|
try: () => ws.send(message),
|
||||||
|
catch: (error) =>
|
||||||
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
|
url: input.url,
|
||||||
|
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,47 +287,60 @@ 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(
|
|
||||||
WebSocketExecutor.Service,
|
|
||||||
WebSocketExecutor.Service.of({
|
|
||||||
open: (input) =>
|
|
||||||
Effect.succeed({
|
|
||||||
sendText: (message) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
|
||||||
sent.push(message)
|
|
||||||
}),
|
|
||||||
messages: Stream.fromArray([
|
|
||||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
|
||||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
|
||||||
]),
|
|
||||||
close: Effect.sync(() => {
|
|
||||||
closed = true
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: (input) =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: (message) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
opened.push({
|
||||||
|
url: input.url,
|
||||||
|
authorization: input.headers.authorization,
|
||||||
|
protocol: input.headers["openai-beta"],
|
||||||
|
})
|
||||||
|
sent.push(message)
|
||||||
|
}),
|
||||||
|
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: Effect.sync(() => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,6 +16,7 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
@@ -361,6 +362,7 @@ function ProviderConnection(props: {
|
|||||||
setBack: (handler: () => void) => void
|
setBack: (handler: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -384,7 +386,10 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(() => ({
|
const provider = createMemo(() => ({
|
||||||
id: props.provider,
|
id: props.provider,
|
||||||
name:
|
name:
|
||||||
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
providers.all().get(props.provider)?.name ??
|
||||||
|
serverSync.data.provider.all.get(props.provider)?.name ??
|
||||||
|
controller.integration()?.name ??
|
||||||
|
props.provider,
|
||||||
}))
|
}))
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { batch, For } from "solid-js"
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore<FormState>({
|
const [form, setForm] = createStore<FormState>({
|
||||||
@@ -119,7 +117,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
|||||||
import type { TextPart as SDKTextPart } from "@/types"
|
import type { TextPart as SDKTextPart } from "@/types"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { sessionHref } from "@/utils/session-route"
|
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
|
|
||||||
interface ForkableMessage {
|
interface ForkableMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -30,7 +27,6 @@ export const DialogFork: Component = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -77,7 +73,7 @@ export const DialogFork: Component = () => {
|
|||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, createMemo, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useData } from "@/context/server"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
@@ -15,13 +14,12 @@ const statusLabels = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const data = useData()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
Object.entries(sync().data.mcp ?? {})
|
||||||
.map((server) => ({ name: server.name, status: server.status.status }))
|
.map(([name, status]) => ({ name, status: status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,8 +47,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () =>
|
const mcpStatus = () => sync().data.mcp[i.name]
|
||||||
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
|
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { ExternalLink } from "./external-link"
|
import { ExternalLink } from "./external-link"
|
||||||
|
|
||||||
@@ -78,41 +77,30 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const directorySDK = useSDK()
|
const directorySDK = useSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
|
|
||||||
createEffect(() => {
|
const [serverMcp] = createResource(
|
||||||
if (serverSDK.connection.status() !== "connected") return
|
serverSDK,
|
||||||
const ref = { directory: directorySDK().directory }
|
(sdk) =>
|
||||||
void Promise.all([
|
sdk.api.mcp
|
||||||
data.location.mcp.server.sync(),
|
.list()
|
||||||
data.location.skill.sync(),
|
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||||
data.location.mcp.server.sync(ref),
|
.catch(() => ({})),
|
||||||
data.location.skill.sync(ref),
|
{ initialValue: {} },
|
||||||
]).catch(() => undefined)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const globalMcpNames = createMemo(() =>
|
const globalMcpNames = createMemo(() =>
|
||||||
[
|
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||||
...new Set([
|
|
||||||
...Object.keys(serverSync.data.config.mcp ?? {}),
|
|
||||||
...(data.location.mcp.server.list() ?? []).map((server) => server.name),
|
|
||||||
]),
|
|
||||||
].sort(),
|
|
||||||
)
|
)
|
||||||
const projectMcpNames = createMemo(() => {
|
const projectMcpNames = createMemo(() => {
|
||||||
const shared = new Set(globalMcpNames())
|
const shared = new Set(globalMcpNames())
|
||||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||||
if (configured.length > 0) return configured.sort()
|
if (configured.length > 0) return configured.sort()
|
||||||
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
return Object.keys(sync().data.mcp ?? {})
|
||||||
.map((server) => server.name)
|
|
||||||
.filter((name) => !shared.has(name))
|
.filter((name) => !shared.has(name))
|
||||||
.sort()
|
.sort()
|
||||||
})
|
})
|
||||||
const mcpEnabled = (name: string) =>
|
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
|
|
||||||
.status === "connected"
|
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||||
const projectPlugins = createMemo(() => {
|
const projectPlugins = createMemo(() => {
|
||||||
@@ -120,12 +108,23 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
const [serverSkills] = createResource(
|
||||||
|
serverSDK,
|
||||||
|
(sdk): Promise<SkillItem[]> =>
|
||||||
|
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
const [directorySkills] = createResource(
|
||||||
|
directorySDK,
|
||||||
|
(sdk): Promise<SkillItem[]> =>
|
||||||
|
sdk.api.skill
|
||||||
|
.list({ location: { directory: sdk.directory } })
|
||||||
|
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
const projectSkills = createMemo(() => {
|
const projectSkills = createMemo(() => {
|
||||||
const shared = new Set(serverSkills().map(skillKey))
|
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||||
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||||
(item) => !shared.has(skillKey(item)),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpRows = (items: string[]) => (
|
const mcpRows = (items: string[]) => (
|
||||||
@@ -206,7 +205,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
<Show when={projectSkills().length > 0}>
|
<Show when={projectSkills().length > 0}>
|
||||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||||
</Show>
|
</Show>
|
||||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -83,7 +82,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const data = useData()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -115,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
return [...result, path]
|
return [...result, path]
|
||||||
}, [])
|
}, [])
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||||
const attachments = createMemo(() =>
|
const attachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -248,7 +246,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
id: `resource:${resource.server}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
@@ -294,7 +292,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
const slashCommands = createMemo(() => [
|
const slashCommands = createMemo(() => [
|
||||||
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
...sync().data.command.map((item) => ({
|
||||||
id: `custom.${item.name}`,
|
id: `custom.${item.name}`,
|
||||||
trigger: item.name,
|
trigger: item.name,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -116,7 +115,6 @@ const EXAMPLES = [
|
|||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const data = useData()
|
|
||||||
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
@@ -252,8 +250,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
return paths
|
return paths
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -587,7 +585,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mcpResourceList = createMemo(() =>
|
const mcpResourceList = createMemo(() =>
|
||||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map(
|
Object.values(sync().data.mcp_resource).map(
|
||||||
(resource): AtOption => ({
|
(resource): AtOption => ({
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
@@ -704,7 +702,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "builtin" as const,
|
type: "builtin" as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const custom = (data.location.command.list({ directory: sdk().directory }) ?? []).map((cmd) => ({
|
const custom = sync().data.command.map((cmd) => ({
|
||||||
id: `custom.${cmd.name}`,
|
id: `custom.${cmd.name}`,
|
||||||
trigger: cmd.name,
|
trigger: cmd.name,
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
|
|||||||
@@ -286,20 +286,6 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
|
||||||
useData: () => ({
|
|
||||||
session: {
|
|
||||||
remember: () => undefined,
|
|
||||||
setStatus: () => undefined,
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
command: {
|
|
||||||
list: () => commands,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
usePlatform: () => ({
|
usePlatform: () => ({
|
||||||
fetch: fetch,
|
fetch: fetch,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Message } from "@/types"
|
import type { Message } from "@/types"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
|
||||||
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"
|
||||||
@@ -8,7 +7,6 @@ import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
|||||||
import { batch, startTransition, type Accessor } from "solid-js"
|
import { batch, 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 { useData } from "@/context/server"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -39,7 +37,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
data: Data
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -56,17 +54,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
input.data.session.setStatus(input.draft.sessionID, "running")
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIdle = () => {
|
const setIdle = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
input.data.session.setStatus(input.draft.sessionID, "idle")
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [head, ...tail] = text.split(" ")
|
const [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
setBusy()
|
setBusy()
|
||||||
try {
|
try {
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
@@ -230,7 +228,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = input.prompt
|
const prompt = input.prompt
|
||||||
@@ -284,7 +281,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||||
data.session.remember(info)
|
|
||||||
target.session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = target.child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
@@ -333,7 +329,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const submissionSDK = sdk()
|
const submissionSDK = sdk()
|
||||||
const submissionSync = sync()
|
const submissionSync = sync()
|
||||||
const submissionServerSync = serverSync
|
const submissionServerSync = serverSync
|
||||||
const submissionData = data
|
|
||||||
const submissionScope = submissionSDK.scope
|
const submissionScope = submissionSDK.scope
|
||||||
const projectDirectory = submissionSDK.directory
|
const projectDirectory = submissionSDK.directory
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
@@ -508,13 +503,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
const commandName = cmdName.slice(1)
|
const commandName = cmdName.slice(1)
|
||||||
const customCommand = submissionData.location.command
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
.list({ directory: sessionDirectory })
|
|
||||||
?.find((command) => command.name === commandName)
|
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
submissionData.session.setStatus(session.id, "running")
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
void submissionSDK.api.session
|
void submissionSDK.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
@@ -531,7 +524,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
submissionData.session.setStatus(session.id, "idle")
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
@@ -559,14 +552,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: submissionSDK.api.session,
|
api: submissionSDK.api.session,
|
||||||
sync: submissionSync,
|
sync: submissionSync,
|
||||||
data: submissionData,
|
serverSync: submissionServerSync,
|
||||||
session: () => session,
|
session: () => session,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
submissionData.session.setStatus(session.id, "idle")
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||||
|
import { useQueryClient } from "@tanstack/solid-query"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useData } from "@/context/server"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
|
||||||
@@ -16,7 +18,8 @@ export function createProviderConnectionController(options: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const location = () => {
|
const location = () => {
|
||||||
const directory = options.directory()
|
const directory = options.directory()
|
||||||
return directory ? { directory } : undefined
|
return directory ? { directory } : undefined
|
||||||
@@ -115,17 +118,10 @@ export function createProviderConnectionController(options: {
|
|||||||
}
|
}
|
||||||
const finish = async () => {
|
const finish = async () => {
|
||||||
cancelPolling()
|
cancelPolling()
|
||||||
const ref = location()
|
const directory = options.directory()
|
||||||
data.location.integration.invalidate(ref)
|
await queryClient
|
||||||
data.location.provider.invalidate(ref)
|
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
||||||
data.location.model.invalidate(ref)
|
.catch(() => undefined)
|
||||||
data.location.model.invalidateDefault(ref)
|
|
||||||
await Promise.all([
|
|
||||||
data.location.integration.sync(ref),
|
|
||||||
data.location.provider.sync(ref),
|
|
||||||
data.location.model.sync(ref),
|
|
||||||
data.location.model.syncDefault(ref),
|
|
||||||
]).catch(() => undefined)
|
|
||||||
if (polling.disposed) return
|
if (polling.disposed) return
|
||||||
options.onComplete()
|
options.onComplete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { createMediaQuery } from "@solid-primitives/media"
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -45,7 +44,6 @@ function openSessionContext(args: {
|
|||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -64,7 +62,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
|
|||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useData } from "@/context/server"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
const openWorkspaces = useSettingsDialog("workspaces")
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
||||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||||
const workspaces = () =>
|
const workspaces = () =>
|
||||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
@@ -97,13 +96,12 @@ const emptyUserMessages: UserMessage[] = []
|
|||||||
|
|
||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { DateTime } from "luxon"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
@@ -20,7 +19,6 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const data = useData()
|
|
||||||
|
|
||||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||||
@@ -39,7 +37,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||||
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
const branch = sync().data.vcs?.branch
|
||||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||||
return language.t("session.new.worktree.main")
|
return language.t("session.new.worktree.main")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
|
|||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
return draft?.type === "draft" ? draft.directory : undefined
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Component, For, createEffect, createMemo } from "solid-js"
|
import { Component, For, createMemo, createResource } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -23,7 +22,6 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
const configMcp = serverSync.data.config.mcp ?? {}
|
const configMcp = serverSync.data.config.mcp ?? {}
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
return Object.entries(configMcp).map(([name, config]) => ({
|
||||||
@@ -49,11 +47,9 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||||
if (serverSdk.connection.status() !== "connected") return
|
initialValue: [],
|
||||||
void data.location.skill.sync().catch(() => undefined)
|
|
||||||
})
|
})
|
||||||
const skills = () => data.location.skill.list() ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
|||||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -33,7 +34,7 @@ export function createPermissionScopeController(
|
|||||||
const s = server()
|
const s = server()
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!s || !id) return undefined
|
if (!s || !id) return undefined
|
||||||
return serverCtx()?.data.session.lineage.peek(id)?.session.location.directory
|
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
@@ -50,7 +49,6 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -73,19 +71,13 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
|||||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||||
const captureDeleteContext = () => {
|
const captureDeleteContext = () => {
|
||||||
const sdk = serverSDK
|
const sdk = serverSDK
|
||||||
return {
|
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||||
sdk,
|
|
||||||
data,
|
|
||||||
sync: serverSync,
|
|
||||||
server: ServerConnection.key(sdk.server),
|
|
||||||
activeDirectory: props.activeDirectory,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const loadSessions = async (context = captureDeleteContext()) => {
|
const loadSessions = async (context = captureDeleteContext()) => {
|
||||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
return mergeWorkspaceSessionInventory(
|
return mergeWorkspaceSessionInventory(
|
||||||
fetched,
|
fetched,
|
||||||
context.data.session.list(),
|
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const sessionQuery = useQuery(() => ({
|
const sessionQuery = useQuery(() => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useData, useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -103,7 +103,6 @@ type ServerStatusItem = {
|
|||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -120,9 +119,8 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
|
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
|
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||||
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
|
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
hasServiceNeedingAttention,
|
hasServiceNeedingAttention,
|
||||||
serverStatusDotClass,
|
serverStatusDotClass,
|
||||||
} from "./status-popover-indicator"
|
} from "./status-popover-indicator"
|
||||||
import { useData, useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
|
|
||||||
@@ -24,20 +23,17 @@ export function StatusPopover() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
|
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -91,20 +87,17 @@ export function StatusPopoverV2() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
|
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,18 +84,14 @@ function SessionTabEntry(props: {
|
|||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
const [loadedSession] = createResource(
|
const [loadedSession] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx
|
||||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||||
},
|
},
|
||||||
({ id, ctx }) =>
|
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||||
ctx.data.session
|
|
||||||
.sync(id)
|
|
||||||
.then(() => ctx.data.session.get(id))
|
|
||||||
.catch(() => undefined),
|
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||||
@@ -107,13 +103,13 @@ function SessionTabEntry(props: {
|
|||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx
|
||||||
if (!value || !ctx) return
|
if (!value || !ctx) return
|
||||||
|
|
||||||
ctx.data.session.remember({ ...value, title })
|
ctx.sync.session.remember({ ...value, title })
|
||||||
try {
|
try {
|
||||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const current = session()
|
const current = session()
|
||||||
const currentCtx = props.serverCtx
|
const currentCtx = props.serverCtx
|
||||||
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
|
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : undefined,
|
description: err instanceof Error ? err.message : undefined,
|
||||||
@@ -130,13 +126,9 @@ function SessionTabEntry(props: {
|
|||||||
prefetched = true
|
prefetched = true
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
try {
|
try {
|
||||||
void Promise.allSettled([
|
void ctx.sync
|
||||||
ctx.data.session.sync(value.id, { children: true }),
|
.ensureDirSyncContext(value.location.directory)
|
||||||
ctx.data.session.pending.sync(value.id),
|
.session.sync(value.id)
|
||||||
ctx.data.session.message.sync(value.id),
|
|
||||||
ctx.data.session.permission.sync(value.id),
|
|
||||||
ctx.data.session.form.sync(value.id),
|
|
||||||
])
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(dispose)
|
.finally(dispose)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
import {
|
||||||
|
loadAgentsQuery,
|
||||||
|
loadCommands,
|
||||||
|
loadPathQuery,
|
||||||
|
loadProjectsQuery,
|
||||||
|
loadProvidersQuery,
|
||||||
|
loadReferencesQuery,
|
||||||
|
} from "./bootstrap"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
@@ -10,11 +18,44 @@ type WorktreeApi = ServerApi["worktree"]
|
|||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
|
const api = {} as CatalogApi
|
||||||
const location = {} as ServerApi["location"]
|
const location = {} as ServerApi["location"]
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
|
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads the current provider and model catalog", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
provider: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["provider", input])
|
||||||
|
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["model", input])
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
default: async (input: unknown) => {
|
||||||
|
calls.push(["default", input])
|
||||||
|
return { location: {}, data: null }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as CatalogApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["provider", { location: { directory: "/repo" } }],
|
||||||
|
["model", { location: { directory: "/repo" } }],
|
||||||
|
["default", { location: { directory: "/repo" } }],
|
||||||
|
])
|
||||||
|
expect(result.connected).toEqual(["openai"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads current location metadata", async () => {
|
test("loads current location metadata", async () => {
|
||||||
@@ -32,6 +73,39 @@ describe("query keys", () => {
|
|||||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads agents from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
} as unknown as AgentApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads commands from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
location: {},
|
||||||
|
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as CommandApi
|
||||||
|
|
||||||
|
const result = await loadCommands("/repo", api)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
||||||
|
})
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const projects = {
|
const projects = {
|
||||||
@@ -87,4 +161,18 @@ describe("query keys", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||||
|
},
|
||||||
|
} as unknown as ReferenceApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
|
AgentListInput,
|
||||||
|
AgentListOutput,
|
||||||
|
CatalogApi,
|
||||||
|
CommandInfo,
|
||||||
|
CommandListInput,
|
||||||
|
CommandListOutput,
|
||||||
|
IntegrationListInput,
|
||||||
|
IntegrationListOutput,
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
ProjectCurrentInput,
|
ProjectCurrentInput,
|
||||||
ProjectCurrentOutput,
|
ProjectCurrentOutput,
|
||||||
ProjectListOutput,
|
ProjectListOutput,
|
||||||
QuestionRequest,
|
ReferenceListInput,
|
||||||
|
ReferenceListOutput,
|
||||||
|
ReferenceInfo,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
@@ -17,16 +27,20 @@ import { batch } from "solid-js"
|
|||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, normalizeProjectInfo } from "./utils"
|
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import { sameDirectory } from "@/utils/workspace"
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
|
ready: boolean
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
|
provider: NormalizedProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -55,6 +69,12 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
|||||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerRev = new Map<string, number>()
|
||||||
|
|
||||||
|
export function clearProviderRev(scope: ServerScope, directory: string) {
|
||||||
|
providerRev.delete(ScopedKey.from(scope, directory))
|
||||||
|
}
|
||||||
|
|
||||||
function runAll(list: Array<() => Promise<unknown>>) {
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
return Promise.allSettled(list.map((item) => item()))
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
}
|
}
|
||||||
@@ -89,8 +109,9 @@ type ProjectApi = {
|
|||||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
|
type McpApi = ServerApi["mcp"]
|
||||||
type PermissionApi = ServerApi["permission"]
|
type PermissionApi = ServerApi["permission"]
|
||||||
type QuestionApi = ServerApi["question"]
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -129,7 +150,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverAPI: {
|
serverAPI: CatalogApi & {
|
||||||
readonly location: LocationApi
|
readonly location: LocationApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly worktree: WorktreeApi
|
readonly worktree: WorktreeApi
|
||||||
@@ -143,6 +164,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||||
|
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
@@ -202,6 +224,53 @@ function warmSessions(input: {
|
|||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [scope, directory, "providers"],
|
||||||
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
const location = directory ? { location: { directory } } : undefined
|
||||||
|
const [providers, models, defaultModel] = await Promise.all([
|
||||||
|
sdk.provider.list(location),
|
||||||
|
sdk.model.list(location),
|
||||||
|
sdk.model.default(location),
|
||||||
|
])
|
||||||
|
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
type AgentListApi = {
|
||||||
|
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandListApi = {
|
||||||
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationListApi = {
|
||||||
|
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceListApi = {
|
||||||
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [scope, directory, "agents"],
|
||||||
|
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [scope, directory, "integrations"] as const,
|
||||||
|
queryFn: () =>
|
||||||
|
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||||
|
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
@@ -215,15 +284,27 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
||||||
|
queryOptions<ReferenceInfo[]>({
|
||||||
|
queryKey: [scope, directory, "references"] as const,
|
||||||
|
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||||
|
placeholderData: [],
|
||||||
|
})
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
api: {
|
api: CatalogApi & {
|
||||||
|
readonly agent: AgentListApi
|
||||||
|
readonly command: CommandListApi
|
||||||
|
readonly mcp: McpApi
|
||||||
readonly permission: PermissionApi
|
readonly permission: PermissionApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly question: QuestionApi
|
readonly reference: ReferenceListApi
|
||||||
readonly session: SessionApi
|
readonly session: SessionApi
|
||||||
|
readonly vcs: VcsApi
|
||||||
|
readonly location: LocationApi
|
||||||
}
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
@@ -233,25 +314,49 @@ export async function bootstrapDirectory(input: {
|
|||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
|
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||||
if (seededProject) input.setStore("project", seededProject)
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
|
if (seededPath) input.setStore("path", seededPath)
|
||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||||
}
|
}
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
|
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||||
|
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||||
|
providerRev.set(revKey, rev)
|
||||||
const slow = [
|
const slow = [
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
|
() =>
|
||||||
|
input.queryClient
|
||||||
|
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
||||||
|
.then((data) => input.setStore("agent", data)),
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
input.setStore("project", project.id),
|
input.setStore("project", project.id),
|
||||||
)),
|
)),
|
||||||
|
!seededPath &&
|
||||||
|
(() =>
|
||||||
|
input.queryClient
|
||||||
|
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
||||||
|
.then((data) => {
|
||||||
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
})),
|
||||||
|
input.mcp &&
|
||||||
|
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.api.permission.request
|
input.api.permission.request
|
||||||
@@ -286,41 +391,23 @@ 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.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||||
|
input.mcp &&
|
||||||
|
(() =>
|
||||||
|
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||||
|
() =>
|
||||||
|
input.queryClient
|
||||||
|
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
||||||
|
.catch((err) => {
|
||||||
|
const project = getFilename(input.directory)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
|
let providerQuerySuccess = true
|
||||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||||
store[0],
|
store[0],
|
||||||
store[1],
|
store[1],
|
||||||
@@ -16,26 +17,12 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
|||||||
]
|
]
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
const data = {
|
|
||||||
location: {
|
|
||||||
info: () => undefined,
|
|
||||||
agent: { list: () => undefined },
|
|
||||||
command: { list: () => undefined },
|
|
||||||
reference: { list: () => undefined },
|
|
||||||
provider: { list: () => undefined },
|
|
||||||
model: { list: () => undefined, default: () => undefined },
|
|
||||||
mcp: {
|
|
||||||
server: { list: () => undefined },
|
|
||||||
resource: { list: () => undefined },
|
|
||||||
},
|
|
||||||
vcs: { info: () => undefined },
|
|
||||||
},
|
|
||||||
} as unknown as Data
|
|
||||||
|
|
||||||
const queryOptionsApi = {
|
const queryOptionsApi = {
|
||||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||||
|
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||||
path: (directory: string | null) => ({
|
path: (directory: string | null) => ({
|
||||||
queryKey: [directory, "path"],
|
queryKey: [directory, "path"],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
@@ -73,7 +60,7 @@ beforeAll(async () => {
|
|||||||
return options().queryKey?.[1] === "path"
|
return options().queryKey?.[1] === "path"
|
||||||
},
|
},
|
||||||
get isSuccess() {
|
get isSuccess() {
|
||||||
return false
|
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
||||||
},
|
},
|
||||||
get isRefetchError() {
|
get isRefetchError() {
|
||||||
return false
|
return false
|
||||||
@@ -82,6 +69,7 @@ beforeAll(async () => {
|
|||||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||||
if (options().queryKey?.[1] === "lsp") return []
|
if (options().queryKey?.[1] === "lsp") return []
|
||||||
|
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -112,8 +100,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
@@ -147,8 +134,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -181,8 +167,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -213,8 +198,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,7 +214,7 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("syncs MCP only when requested for the directory", () => {
|
test("enables MCP only when requested for the directory", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
const offset = querySingles.length
|
const offset = querySingles.length
|
||||||
const mcpLoads: string[] = []
|
const mcpLoads: string[] = []
|
||||||
@@ -250,21 +234,30 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!manager) throw new Error("manager required")
|
if (!manager) throw new Error("manager required")
|
||||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
||||||
expect(querySingles.length - offset).toBe(1)
|
expect(querySingles.length - offset).toBe(6)
|
||||||
|
const query = querySingles[offset + 1]
|
||||||
|
const resourceQuery = querySingles[offset + 2]
|
||||||
|
if (!query) throw new Error("query required")
|
||||||
|
if (!resourceQuery) throw new Error("resource query required")
|
||||||
|
expect(query().enabled).toBe(false)
|
||||||
|
expect(resourceQuery().enabled).toBe(false)
|
||||||
|
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
manager.child("/project", { bootstrap: false, mcp: true })
|
manager.child("/project", { bootstrap: false, mcp: true })
|
||||||
|
expect(query().enabled).toBe(true)
|
||||||
|
expect(resourceQuery().enabled).toBe(true)
|
||||||
|
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
||||||
expect(mcpLoads).toEqual(["/project"])
|
expect(mcpLoads).toEqual(["/project"])
|
||||||
|
|
||||||
manager.disableMcp("/project")
|
manager.disableMcp("/project")
|
||||||
|
expect(query().enabled).toBe(false)
|
||||||
expect(manager.mcp("/project")).toBe(false)
|
expect(manager.mcp("/project")).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -291,8 +284,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,8 +293,11 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/project", { bootstrap: false })
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
expect(queries).toHaveLength(1)
|
expect(queries).toHaveLength(6)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
|
expect(queries[3]?.().enabled).toBe(false)
|
||||||
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
|
expect(queries[5]?.().enabled).toBe(false)
|
||||||
expect(store.path.directory).toBe("/project")
|
expect(store.path.directory).toBe("/project")
|
||||||
expect(store.provider_ready).toBe(false)
|
expect(store.provider_ready).toBe(false)
|
||||||
expect(store.lsp_ready).toBe(false)
|
expect(store.lsp_ready).toBe(false)
|
||||||
@@ -310,6 +305,9 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
manager.child("/project")
|
manager.child("/project")
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
manager.child("/project", { bootstrap: false })
|
manager.child("/project", { bootstrap: false })
|
||||||
@@ -319,8 +317,9 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not mark unsynced provider data as ready", () => {
|
test("does not mark a cancelled provider query as ready", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
providerQuerySuccess = false
|
||||||
|
|
||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
@@ -335,8 +334,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -345,6 +343,7 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/cancelled")
|
const [store] = manager.child("/cancelled")
|
||||||
expect(store.provider_ready).toBe(false)
|
expect(store.provider_ready).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
|
providerQuerySuccess = true
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -366,8 +365,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
global: { provider },
|
||||||
global: { path },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -376,9 +374,11 @@ describe("createChildStoreManager", () => {
|
|||||||
manager.child("/handshake")
|
manager.child("/handshake")
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
|
|
||||||
connected = true
|
connected = true
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { Path, VcsInfo } from "@/types"
|
import type { VcsInfo } from "@/types"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
@@ -17,9 +17,8 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
|||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { QueryOptionsApi } from "../server-sync"
|
import { QueryOptionsApi } from "../server-sync"
|
||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
|
||||||
import { normalizeAgentList, normalizeProviderList } from "./utils"
|
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -33,9 +32,8 @@ export function createChildStoreManager(input: {
|
|||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
data: Data
|
|
||||||
global: {
|
global: {
|
||||||
path: Path
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
@@ -47,6 +45,7 @@ export function createChildStoreManager(input: {
|
|||||||
const ownerPins = new WeakMap<object, Set<string>>()
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
const activeDirectories = new Set<string>()
|
const activeDirectories = new Set<string>()
|
||||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
@@ -121,6 +120,7 @@ export function createChildStoreManager(input: {
|
|||||||
iconCache.delete(key)
|
iconCache.delete(key)
|
||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
|
mcpToggles.delete(key)
|
||||||
activeDirectories.delete(key)
|
activeDirectories.delete(key)
|
||||||
activationToggles.delete(key)
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
@@ -186,49 +186,59 @@ export function createChildStoreManager(input: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
|
const pathQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.path(key),
|
||||||
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
const mcpQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.mcp(key),
|
||||||
|
enabled: input.connected() && mcpEnabled(),
|
||||||
|
}))
|
||||||
|
const mcpResourceQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.mcpResources(key),
|
||||||
|
enabled: input.connected() && mcpEnabled(),
|
||||||
|
}))
|
||||||
const lspQuery = useQuery(() => ({
|
const lspQuery = useQuery(() => ({
|
||||||
...input.queryOptions.lsp(key),
|
...input.queryOptions.lsp(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
|
const providerQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.providers(key),
|
||||||
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
const referenceQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.references(key),
|
||||||
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return (
|
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
||||||
input.data.location.provider.list({ directory }) !== undefined &&
|
|
||||||
input.data.location.model.list({ directory }) !== undefined &&
|
|
||||||
input.data.location.model.default({ directory }) !== undefined
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const provider = input.data.location.provider.list({ directory })
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
const model = input.data.location.model.list({ directory })
|
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
||||||
const defaultModel = input.data.location.model.default({ directory })
|
const provider = providerQuery.data
|
||||||
if (!provider || !model || defaultModel === undefined) return { all: new Map(), connected: [], default: {} }
|
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||||
return normalizeProviderList(provider, model, defaultModel)
|
return provider
|
||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
const location = input.data.location.info({ directory })
|
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
|
||||||
return {
|
if (pathQuery.isLoading) return EMPTY
|
||||||
state: "",
|
return pathQuery.data ?? EMPTY
|
||||||
config: "",
|
|
||||||
worktree: location?.project.directory ?? "",
|
|
||||||
directory: location?.directory ?? directory,
|
|
||||||
home: input.global.path.home,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
get agent() {
|
agent: [],
|
||||||
return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
|
command: [],
|
||||||
},
|
|
||||||
get command() {
|
|
||||||
return input.data.location.command.list({ directory }) ?? []
|
|
||||||
},
|
|
||||||
get reference() {
|
get reference() {
|
||||||
return input.data.location.reference.list({ directory }) ?? []
|
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
||||||
},
|
},
|
||||||
session: [],
|
session: [],
|
||||||
sessionTotal: 0,
|
sessionTotal: 0,
|
||||||
@@ -240,22 +250,14 @@ export function createChildStoreManager(input: {
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
return input.data.location.mcp.server.list({ directory }) !== undefined
|
return !mcpQuery.isLoading
|
||||||
},
|
},
|
||||||
get mcp() {
|
get mcp() {
|
||||||
return Object.fromEntries(
|
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||||
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
get mcp_resource() {
|
get mcp_resource() {
|
||||||
return Object.fromEntries(
|
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||||
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
|
|
||||||
`${resource.server}:${resource.uri}`,
|
|
||||||
resource,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
@@ -263,11 +265,7 @@ export function createChildStoreManager(input: {
|
|||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
},
|
},
|
||||||
get vcs() {
|
vcs: vcsStore.value,
|
||||||
const vcs = input.data.location.vcs.info({ directory })
|
|
||||||
if (!vcs) return vcsStore.value
|
|
||||||
return { branch: vcs.branch.current, default_branch: vcs.branch.default }
|
|
||||||
},
|
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
session_message: {},
|
session_message: {},
|
||||||
@@ -276,6 +274,7 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
@@ -339,6 +338,7 @@ export function createChildStoreManager(input: {
|
|||||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||||
if (mcpDirectories.has(key)) return
|
if (mcpDirectories.has(key)) return
|
||||||
mcpDirectories.add(key)
|
mcpDirectories.add(key)
|
||||||
|
mcpToggles.get(key)?.(true)
|
||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +355,7 @@ export function createChildStoreManager(input: {
|
|||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
|
mcpToggles.get(key)?.(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useServerHealth } from "@/utils/server-health"
|
|||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
import { createServerData } from "@opencode-ai/client/solid"
|
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerPermissionState } from "./permission"
|
import { createServerPermissionState } from "./permission"
|
||||||
import { createServerNotificationState } from "./notification"
|
import { createServerNotificationState } from "./notification"
|
||||||
@@ -100,15 +99,9 @@ function createServerController(
|
|||||||
) {
|
) {
|
||||||
const connKey = ServerConnection.key(conn)
|
const connKey = ServerConnection.key(conn)
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
const data = createServerData({
|
const sync = createServerSyncContext(sdk)
|
||||||
api: () => sdk.api,
|
const permission = createServerPermissionState({ sdk, sync })
|
||||||
event: sdk.event,
|
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||||
connection: sdk.connection,
|
|
||||||
directory: "",
|
|
||||||
})
|
|
||||||
const sync = createServerSyncContext(sdk, data)
|
|
||||||
const permission = createServerPermissionState({ sdk, sync, data })
|
|
||||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||||
@@ -141,7 +134,6 @@ function createServerController(
|
|||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
isLocal,
|
isLocal,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr
|
|||||||
import { useLocation } from "@solidjs/router"
|
import { useLocation } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { useServerSync } from "./server-sync"
|
||||||
import { ServerConnection, useServers } from "./servers"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { hasCustomAgent, resolveAgent } from "./local-agent"
|
|||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { useData } from "./server"
|
|
||||||
import { normalizeAgentList } from "./global-sync/utils"
|
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
@@ -63,18 +61,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() =>
|
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
|
||||||
(item) => item.mode !== "subagent" && !item.hidden,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { ServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -197,13 +197,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = async (sessionID?: string) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const session = input.data.session.get(sessionID)
|
const sync = input.sync.ensureDirSyncContext(directory)
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
if (session) return session
|
if (session) return session
|
||||||
return input.data.session
|
return sync.session
|
||||||
.sync(sessionID)
|
.sync(sessionID)
|
||||||
.then(() => input.data.session.get(sessionID))
|
.then(() => sync.session.get(sessionID))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +219,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
|||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
void lookup(sessionID).then((session) => {
|
void lookup(directory, sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (session.parentID) return
|
if (session.parentID) return
|
||||||
@@ -250,7 +251,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
|||||||
time: number,
|
time: number,
|
||||||
) => {
|
) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
void lookup(sessionID).then((session) => {
|
void lookup(directory, sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (session?.parentID) return
|
if (session?.parentID) return
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
const unsub = input.sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (
|
if (
|
||||||
event.type !== "session.execution.succeeded" &&
|
event.type !== "session.execution.succeeded" &&
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { PermissionRequest } from "@opencode-ai/client/promise"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerSDK } from "@/context/server-sdk"
|
import type { ServerSDK } from "@/context/server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
@@ -53,9 +52,9 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
return Object.values(config).some(isNonAllowRule)
|
return Object.values(config).some(isNonAllowRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||||
|
|
||||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||||
@@ -142,8 +141,10 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessions(_directory?: string) {
|
function sessions(directory?: string) {
|
||||||
return input.data.session.list()
|
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||||
|
if (!directory) return info
|
||||||
|
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
@@ -159,15 +160,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPending(permission: PermissionRequest) {
|
function isPending(permission: PermissionRequest) {
|
||||||
const pending = input.data.session.permission.list(permission.sessionID)
|
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
if (input.data.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||||
const lineage = await input.data.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||||
if (meta.disposed || !lineage) return false
|
if (meta.disposed || !lineage) return false
|
||||||
return shouldAutoRespond(permission, directory)
|
return shouldAutoRespond(permission, directory)
|
||||||
}
|
}
|
||||||
@@ -196,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
void respondPending(event.properties, e.name)
|
void respondPending(event.properties, e.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
const unsubscribe = input.sdk.event.listen((event) => {
|
||||||
if (ready()) {
|
if (ready()) {
|
||||||
handlePermission(event)
|
handlePermission(event)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
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 } from "./server-sdk"
|
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
|
|
||||||
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
|
let starts = 0
|
||||||
|
const start = () => starts++
|
||||||
|
|
||||||
|
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
||||||
|
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
||||||
|
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves current permission requests", () => {
|
test("preserves current permission requests", () => {
|
||||||
@@ -31,3 +43,57 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("current event buffering", () => {
|
||||||
|
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||||
|
directory: "/repo",
|
||||||
|
payload: adaptServerEvent({
|
||||||
|
id,
|
||||||
|
created: 1,
|
||||||
|
type: "session.text.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||||
|
} as OpenCodeEvent),
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||||
|
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||||
|
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("coalesces current tool input deltas by tool ID", () => {
|
||||||
|
const current = (eventID: string, id: string, delta: string) =>
|
||||||
|
adaptServerEvent({
|
||||||
|
id: eventID,
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
const result = coalesceServerEvents([
|
||||||
|
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||||
|
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||||
|
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||||
|
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
|
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"])
|
||||||
|
})
|
||||||
|
|
||||||
|
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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
|
||||||
import type { Event } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { type Accessor, onCleanup } from "solid-js"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection } from "./servers"
|
import { ServerConnection } from "./servers"
|
||||||
@@ -11,15 +12,85 @@ 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 CurrentDelta = Extract<
|
||||||
|
OpenCodeEvent,
|
||||||
|
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||||
|
>
|
||||||
|
|
||||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
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) {
|
||||||
|
queue.push(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
|
const output: QueuedServerEvent[] = []
|
||||||
|
events.forEach((event) => {
|
||||||
|
const current = currentDelta(event.payload.current)
|
||||||
|
if (current) {
|
||||||
|
const previous = output[output.length - 1]
|
||||||
|
const prior = currentDelta(previous?.payload.current)
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
prior &&
|
||||||
|
previous.directory === event.directory &&
|
||||||
|
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||||
|
) {
|
||||||
|
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||||
|
const data =
|
||||||
|
current.type === "session.compaction.delta"
|
||||||
|
? { ...current.data, text: fragment }
|
||||||
|
: { ...current.data, delta: fragment }
|
||||||
|
output[output.length - 1] = {
|
||||||
|
directory: event.directory,
|
||||||
|
payload: {
|
||||||
|
...event.payload,
|
||||||
|
properties: data,
|
||||||
|
current: { ...current, data } as CurrentDelta,
|
||||||
|
} as ServerEvent,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.push(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.push(event)
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
|
if (
|
||||||
|
event?.type === "session.text.delta" ||
|
||||||
|
event?.type === "session.reasoning.delta" ||
|
||||||
|
event?.type === "session.tool.input.delta" ||
|
||||||
|
event?.type === "session.compaction.delta"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaKey(event: CurrentDelta) {
|
||||||
|
if (event.type === "session.tool.input.delta")
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||||
|
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaFragment(event: CurrentDelta) {
|
||||||
|
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||||
|
if (!event.persisted) return
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
|
||||||
export type ServerConnectionStatus = ClientConnectionStatus
|
|
||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
@@ -30,51 +101,231 @@ type ServerSDKBase = {
|
|||||||
attempt: Accessor<number>
|
attempt: Accessor<number>
|
||||||
error: Accessor<string | undefined>
|
error: Accessor<string | undefined>
|
||||||
}
|
}
|
||||||
eventByDir: {
|
event: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
}
|
}
|
||||||
event: {
|
|
||||||
on: CurrentEventEmitter["on"]
|
|
||||||
listen: CurrentEventEmitter["listen"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
const abort = new AbortController()
|
||||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
|
||||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
|
||||||
|
|
||||||
const connection = createClientConnection(api, {
|
const eventFetch = (() => {
|
||||||
flushInterval: 16,
|
if (!platform.fetch || !server) return
|
||||||
pageLifecycle: true,
|
try {
|
||||||
onEvent(event) {
|
const url = new URL(server.http.url)
|
||||||
emitter.emit(event.type, event)
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||||
},
|
} catch {
|
||||||
log: {
|
return
|
||||||
info(message, data) {
|
}
|
||||||
if (message !== "event stream disconnected") return
|
})()
|
||||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
|
||||||
},
|
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||||
},
|
const emitter = createGlobalEmitter<{
|
||||||
|
[key: string]: ServerEvent
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Queued = QueuedServerEvent
|
||||||
|
const FLUSH_FRAME_MS = 16
|
||||||
|
const STREAM_YIELD_MS = 8
|
||||||
|
const CONNECT_TIMEOUT_MS = 2_000
|
||||||
|
const RECONNECT_DELAY_MS = 1_000
|
||||||
|
|
||||||
|
let queue: Queued[] = []
|
||||||
|
let buffer: Queued[] = []
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
|
||||||
|
if (queue.length === 0) return
|
||||||
|
|
||||||
|
const events = queue
|
||||||
|
queue = buffer
|
||||||
|
buffer = events
|
||||||
|
queue.length = 0
|
||||||
|
|
||||||
|
last = Date.now()
|
||||||
|
const output = coalesceServerEvents(events)
|
||||||
|
batch(() => {
|
||||||
|
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||||
|
})
|
||||||
|
|
||||||
|
buffer.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (timer) return
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(event: OpenCodeEvent) {
|
||||||
|
const directory = event.location?.directory ?? "global"
|
||||||
|
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let attempt: AbortController | undefined
|
||||||
|
let run: Promise<void> | undefined
|
||||||
|
let started = false
|
||||||
|
let generation = 0
|
||||||
|
const [connection, setConnection] = createStore<{
|
||||||
|
status: ServerConnectionStatus
|
||||||
|
attempt: number
|
||||||
|
error?: string
|
||||||
|
}>({ status: "connecting", attempt: 0 })
|
||||||
|
|
||||||
|
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
||||||
|
let connectedAt: number | undefined
|
||||||
|
|
||||||
|
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||||
|
const request = new AbortController()
|
||||||
|
const cancel = () => request.abort(signal.reason)
|
||||||
|
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
||||||
|
signal.addEventListener("abort", cancel, { once: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Open the event stream and validate its initial handshake.
|
||||||
|
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||||
|
const first = await iterator.next()
|
||||||
|
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (first.done) {
|
||||||
|
const error =
|
||||||
|
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||||
|
return { error, connectedAt }
|
||||||
|
}
|
||||||
|
if (first.value.type !== "server.connected")
|
||||||
|
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||||
|
|
||||||
|
// Publish the connected state before forwarding live events.
|
||||||
|
clearTimeout(timeout)
|
||||||
|
publish(first.value)
|
||||||
|
connectedAt = Date.now()
|
||||||
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
|
|
||||||
|
// Forward events until the stream closes or this connection is cancelled.
|
||||||
|
let yielded = Date.now()
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const event = await iterator.next()
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||||
|
publish(event.value)
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0, signal)
|
||||||
|
}
|
||||||
|
return { error: undefined, connectedAt }
|
||||||
|
} catch (error) {
|
||||||
|
return { error, connectedAt }
|
||||||
|
} finally {
|
||||||
|
request.abort()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
signal.removeEventListener("abort", cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStream(active: number) {
|
||||||
|
let retries = 0
|
||||||
|
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
||||||
|
const controller = new AbortController()
|
||||||
|
attempt = controller
|
||||||
|
const onAbort = () => controller.abort()
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
|
const result = await connect(controller.signal)
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) {
|
||||||
|
if (attempt === controller) attempt = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
||||||
|
retries += 1
|
||||||
|
const message =
|
||||||
|
result.error === undefined
|
||||||
|
? undefined
|
||||||
|
: result.error instanceof Error
|
||||||
|
? result.error.message
|
||||||
|
: String(result.error)
|
||||||
|
console.info("[global-sdk] event stream disconnected", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
attempt: retries,
|
||||||
|
error: message,
|
||||||
|
})
|
||||||
|
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
||||||
|
await wait(RECONNECT_DELAY_MS, controller.signal)
|
||||||
|
if (attempt === controller) attempt = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) return run
|
||||||
|
started = true
|
||||||
|
const active = ++generation
|
||||||
|
const previous = run
|
||||||
|
const current = (async () => {
|
||||||
|
if (previous) await previous
|
||||||
|
await runStream(active)
|
||||||
|
})().finally(() => {
|
||||||
|
if (run !== current) return
|
||||||
|
run = undefined
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
run = current
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
started = false
|
||||||
|
generation++
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
makeEventListener(window, "pagehide", stop)
|
||||||
|
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||||
|
void start()
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dirEmitter.clear()
|
stop()
|
||||||
|
abort.abort()
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
queue = []
|
||||||
|
buffer = []
|
||||||
emitter.clear()
|
emitter.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
api,
|
api,
|
||||||
connection,
|
connection: {
|
||||||
eventByDir: {
|
status: () => connection.status,
|
||||||
on: dirEmitter.on.bind(dirEmitter),
|
attempt: () => connection.attempt,
|
||||||
listen: dirEmitter.listen.bind(dirEmitter),
|
error: () => connection.error,
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
@@ -114,7 +365,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.eventByDir.on(directory, (event) => {
|
const unsub = serverSDK.event.on(directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -198,7 +198,6 @@ 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[]>,
|
||||||
@@ -281,9 +280,6 @@ export function createServerSession(
|
|||||||
...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),
|
||||||
@@ -529,9 +525,6 @@ export function createServerSession(
|
|||||||
...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),
|
||||||
@@ -1339,36 +1332,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)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
McpListInput,
|
||||||
|
McpResourceCatalogInput,
|
||||||
|
SessionApi,
|
||||||
|
SessionInfo,
|
||||||
|
SessionListInput,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import {
|
import {
|
||||||
loadActiveSessionsQuery,
|
loadActiveSessionsQuery,
|
||||||
|
loadMcpQuery,
|
||||||
|
loadMcpResourcesQuery,
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
shouldRefreshWorkspaceSessions,
|
shouldRefreshWorkspaceSessions,
|
||||||
@@ -13,6 +21,56 @@ 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"
|
||||||
|
|
||||||
|
type McpApi = ServerApi["mcp"]
|
||||||
|
|
||||||
|
describe("MCP queries", () => {
|
||||||
|
test("loads current servers for the requested location", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
const result = await queryClient.fetchQuery(
|
||||||
|
loadMcpQuery(ServerScope.local, "/project", {
|
||||||
|
list: async (input: McpListInput = {}) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||||
|
data: [
|
||||||
|
{ name: "docs", status: { status: "connected" } },
|
||||||
|
{ name: "search", status: { status: "pending" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as McpApi),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||||
|
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads and keys the current resource catalog", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
const result = await queryClient.fetchQuery(
|
||||||
|
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||||
|
resource: {
|
||||||
|
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||||
|
data: {
|
||||||
|
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||||
|
templates: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as McpApi),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||||
|
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("active session query", () => {
|
describe("active session query", () => {
|
||||||
test("loads active sessions immediately and once per server cache", async () => {
|
test("loads active sessions immediately and once per server cache", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import type { InitError } from "../pages/error"
|
||||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
|
clearProviderRev,
|
||||||
|
loadAgentsQuery,
|
||||||
|
loadCommands,
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
|
loadIntegrationsQuery,
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
|
loadProjectsQuery,
|
||||||
|
loadProvidersQuery,
|
||||||
|
loadReferencesQuery,
|
||||||
} from "./global-sync/bootstrap"
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
@@ -24,6 +32,7 @@ import { createRefreshQueue } from "./global-sync/queue"
|
|||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { pathKey, PathKey } from "@/utils/path-key"
|
import { pathKey, PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { ServerConnection } from "./servers"
|
import { ServerConnection } from "./servers"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
@@ -31,15 +40,21 @@ import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
|||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import type {
|
import type {
|
||||||
|
McpListInput,
|
||||||
|
McpListOutput,
|
||||||
|
McpResource,
|
||||||
|
McpResourceCatalogInput,
|
||||||
|
McpResourceCatalogOutput,
|
||||||
|
McpServer,
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
|
import { createCatalogSync } from "./server-sync/catalog"
|
||||||
import { createConnectionSync } from "./server-sync/connection"
|
import { createConnectionSync } from "./server-sync/connection"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
import type { Data } from "@opencode-ai/client/solid"
|
|
||||||
|
|
||||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
const type = event.current?.type ?? event.type
|
const type = event.current?.type ?? event.type
|
||||||
@@ -53,8 +68,11 @@ export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
|
ready: boolean
|
||||||
|
error?: InitError
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
|
provider: NormalizedProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -70,6 +88,16 @@ const SESSION_LIST_EVENTS = new Set([
|
|||||||
"session.usage.updated",
|
"session.usage.updated",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
type McpListApi = {
|
||||||
|
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpResourceApi = {
|
||||||
|
readonly resource: {
|
||||||
|
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||||
initialData?: undefined
|
initialData?: undefined
|
||||||
queryKey: K
|
queryKey: K
|
||||||
@@ -79,6 +107,47 @@ type SessionActiveApi = {
|
|||||||
readonly active: () => Promise<SessionActiveOutput>
|
readonly active: () => Promise<SessionActiveOutput>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loadMcpQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: McpListApi,
|
||||||
|
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||||
|
queryOptions<
|
||||||
|
Record<string, McpServer["status"]>,
|
||||||
|
Error,
|
||||||
|
Record<string, McpServer["status"]>,
|
||||||
|
readonly [ServerScope, string, "mcp"]
|
||||||
|
>({
|
||||||
|
queryKey: [scope, directory, "mcp"] as const,
|
||||||
|
queryFn: async () => {
|
||||||
|
return api
|
||||||
|
.list({ location: { directory } })
|
||||||
|
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadMcpResourcesQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: McpResourceApi,
|
||||||
|
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||||
|
queryOptions<
|
||||||
|
Record<string, McpResource>,
|
||||||
|
Error,
|
||||||
|
Record<string, McpResource>,
|
||||||
|
readonly [ServerScope, string, "mcpResources"]
|
||||||
|
>({
|
||||||
|
queryKey: [scope, directory, "mcpResources"] as const,
|
||||||
|
queryFn: async () => {
|
||||||
|
return api.resource
|
||||||
|
.catalog({ location: { directory } })
|
||||||
|
.then((result) =>
|
||||||
|
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
placeholderData: {},
|
||||||
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
@@ -125,14 +194,21 @@ export function reconcileActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
||||||
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
|
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
||||||
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||||
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||||
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||||
|
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
@@ -146,12 +222,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||||
const connected = () => serverSDK.connection.status() === "connected"
|
const connected = () => serverSDK.connection.status() === "connected"
|
||||||
const hydrateSession = (sessionID: string) => session.sync(sessionID)
|
const hydrateSessionState = async (sessionID: string) => {
|
||||||
|
await session.hydrateTransient(sessionID, async () => {
|
||||||
|
const [pending, forms] = await Promise.all([
|
||||||
|
serverSDK.api.session.inbox.list({ sessionID }),
|
||||||
|
serverSDK.api.form.list({ sessionID }),
|
||||||
|
])
|
||||||
|
return { pending, forms }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||||
|
|
||||||
const [configQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [
|
||||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||||
{ ...queryOptionsApi.path(), enabled: connected() },
|
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||||
|
{ ...queryOptionsApi.path(null), enabled: connected() },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() => ({
|
const activeSessionsQuery = useQuery(() => ({
|
||||||
@@ -160,7 +246,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
const active = await serverSDK.api.session.active()
|
const active = await serverSDK.api.session.active()
|
||||||
reconcileActiveSessionStatuses(session, active)
|
reconcileActiveSessionStatuses(session, active)
|
||||||
Object.keys(active).forEach((sessionID) => {
|
Object.keys(active).forEach((sessionID) => {
|
||||||
void session.resolve(sessionID).catch(() => undefined)
|
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
||||||
})
|
})
|
||||||
return active
|
return active
|
||||||
},
|
},
|
||||||
@@ -169,6 +255,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
|
get ready() {
|
||||||
|
return bootstrap.isSuccess
|
||||||
|
},
|
||||||
project: [],
|
project: [],
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -176,6 +265,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
if (pathQuery.isLoading) return EMPTY
|
if (pathQuery.isLoading) return EMPTY
|
||||||
return pathQuery.data ?? EMPTY
|
return pathQuery.data ?? EMPTY
|
||||||
},
|
},
|
||||||
|
get provider() {
|
||||||
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
|
if (providerQuery.isLoading) return EMPTY
|
||||||
|
return providerQuery.data ?? EMPTY
|
||||||
|
},
|
||||||
get config() {
|
get config() {
|
||||||
if (configQuery.isLoading) return {}
|
if (configQuery.isLoading) return {}
|
||||||
return configQuery.data ?? {}
|
return configQuery.data ?? {}
|
||||||
@@ -244,33 +338,51 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
if (!connected()) return
|
if (!connected()) return
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory) => {
|
onMcp: (directory, setStore) => {
|
||||||
void Promise.all([
|
void loadCommands(directory, serverSDK.api.command)
|
||||||
data.location.command.sync({ directory }),
|
.then((commands) => setStore("command", commands))
|
||||||
data.location.mcp.server.sync({ directory }),
|
.catch((err) => {
|
||||||
data.location.mcp.resource.sync({ directory }),
|
showToast({
|
||||||
]).catch((err) => {
|
variant: "error",
|
||||||
showToast({
|
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||||
variant: "error",
|
description: formatServerError(err, language.t),
|
||||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
})
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onDispose: (directory) => {
|
onDispose: (directory) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
|
clearProviderRev(serverSDK.scope, key)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
data,
|
|
||||||
global: {
|
global: {
|
||||||
get path() {
|
get provider() {
|
||||||
return globalStore.path
|
return globalStore.provider
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const catalog = createCatalogSync({
|
||||||
|
scope: serverSDK.scope,
|
||||||
|
queryClient,
|
||||||
|
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
||||||
|
load: (directory) =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
||||||
|
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
||||||
|
]).then(() => undefined),
|
||||||
|
})
|
||||||
|
const refreshVcs = (directory: string) =>
|
||||||
|
serverSDK.api.vcs
|
||||||
|
.get({ location: { directory } })
|
||||||
|
.then((result) =>
|
||||||
|
children.vcs(directory, {
|
||||||
|
branch: result.data.branch.current,
|
||||||
|
default_branch: result.data.branch.default,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => undefined)
|
||||||
const connection = createConnectionSync({
|
const connection = createConnectionSync({
|
||||||
status: serverSDK.connection.status,
|
status: serverSDK.connection.status,
|
||||||
invalidate: () => {
|
invalidate: () => {
|
||||||
@@ -281,14 +393,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
connected: (info) => {
|
connected: (info) => {
|
||||||
if (info.reconnect) void session.refreshPinned(() => Promise.resolve()).catch(() => undefined)
|
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
|
||||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
||||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||||
Object.keys(children.children)
|
Object.keys(children.children)
|
||||||
.filter(children.active)
|
.filter(children.active)
|
||||||
.forEach((directory) => {
|
.forEach((directory) => {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
void data.location.sync({ directory }).catch(() => undefined)
|
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -386,8 +498,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
children.pin(key)
|
children.pin(key)
|
||||||
const promise = Promise.resolve().then(async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
|
const initial = child[0].status === "loading"
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
data.location.sync({ directory }),
|
|
||||||
bootstrapDirectory({
|
bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -396,6 +508,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
path: globalStore.path,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
api: serverSDK.api,
|
api: serverSDK.api,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
@@ -405,6 +518,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
}),
|
}),
|
||||||
|
initial ? refreshVcs(directory) : Promise.resolve(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,7 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
@@ -514,6 +628,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
|
catalog.handleEvent({ type: eventType, directory })
|
||||||
connection.handleEvent({ type: eventType, directory })
|
connection.handleEvent({ type: eventType, directory })
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
@@ -546,10 +661,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
eventType === "agent.updated"
|
eventType === "agent.updated"
|
||||||
)
|
)
|
||||||
queue.push(key)
|
queue.push(key)
|
||||||
|
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||||
|
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||||
const [store, setStore] = existing
|
const [store, setStore] = existing
|
||||||
|
if (eventType === "agent.updated")
|
||||||
|
void queryClient
|
||||||
|
.fetchQuery(queryOptionsApi.agents(key))
|
||||||
|
.then((data) => setStore("agent", data))
|
||||||
|
.catch(() => {})
|
||||||
|
if (eventType === "command.updated")
|
||||||
|
void loadCommands(directory, serverSDK.api.command)
|
||||||
|
.then((commands) => setStore("command", commands))
|
||||||
|
.catch(() => {})
|
||||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||||
const projected = toDirectoryEvent(event)
|
const projected = toDirectoryEvent(event)
|
||||||
if (projected && eventType !== "vcs.branch.updated")
|
if (projected)
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: projected,
|
event: projected,
|
||||||
directory,
|
directory,
|
||||||
@@ -568,7 +694,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
},
|
},
|
||||||
loadReferences: () => {
|
loadReferences: () => {
|
||||||
if (!children.active(key)) return
|
if (!children.active(key)) return
|
||||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -584,6 +710,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const projectApi = {
|
const projectApi = {
|
||||||
|
loadSessions,
|
||||||
meta(directory: string, patch: ProjectMeta) {
|
meta(directory: string, patch: ProjectMeta) {
|
||||||
children.projectMeta(directory, patch)
|
children.projectMeta(directory, patch)
|
||||||
},
|
},
|
||||||
@@ -600,22 +727,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
data.location.provider.invalidate()
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
data.location.model.invalidate()
|
// appear immediately in the available provider list across all directories.
|
||||||
data.location.model.invalidateDefault()
|
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
|
||||||
void Promise.all([
|
queryClient.invalidateQueries({
|
||||||
data.location.provider.sync(),
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
data.location.model.sync(),
|
})
|
||||||
data.location.model.syncDefault(),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
set,
|
set,
|
||||||
|
get ready() {
|
||||||
|
return globalStore.ready
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return globalStore.error
|
||||||
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
refreshProviders: catalog.refreshActive,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
@@ -654,12 +788,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
platform.openExternal(attempt.data.url)
|
platform.openExternal(attempt.data.url)
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
data.location.mcp.server.invalidate({ directory: key })
|
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||||
data.location.mcp.resource.invalidate({ directory: key })
|
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||||
await Promise.all([
|
|
||||||
data.location.mcp.server.sync({ directory: key }),
|
|
||||||
data.location.mcp.resource.sync({ directory: key }),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -667,8 +797,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
|
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||||
const inner = createServerSyncContextInner(serverSDK, data)
|
const inner = createServerSyncContextInner(serverSDK)
|
||||||
return Object.assign(inner, {
|
return Object.assign(inner, {
|
||||||
ensureDirSyncContext: createRefCountMap(
|
ensureDirSyncContext: createRefCountMap(
|
||||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||||
@@ -684,3 +814,8 @@ export const useServerSync = () => {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
return server.ctx.sync
|
return server.ctx.sync
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useQueryOptions() {
|
||||||
|
const sync = useServerSync()
|
||||||
|
return sync.queryOptions
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,3 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useData = () => {
|
|
||||||
const server = useServer()
|
|
||||||
return server.ctx.data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useData } from "@/context/server"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { createEffect, type Accessor } from "solid-js"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
export function useIntegrations(directory: Accessor<string | undefined>) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
|
const query = createQuery(() => {
|
||||||
createEffect(() => {
|
|
||||||
if (serverSDK.connection.status() !== "connected") return
|
|
||||||
const value = directory()
|
const value = directory()
|
||||||
void data.location.integration.sync(value ? { directory: value } : undefined).catch(() => undefined)
|
return {
|
||||||
|
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
||||||
|
enabled: serverSDK.connection.status() === "connected",
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
|
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useData } from "@/context/server"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { normalizeProviderList } from "@/context/global-sync/utils"
|
|
||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
import { type Accessor } from "solid-js"
|
||||||
import { emptyProviderCatalog } from "./provider-catalog"
|
import { emptyProviderCatalog } from "./provider-catalog"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export const popularProviders = [
|
export const popularProviders = [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -18,41 +18,16 @@ export const popularProviders = [
|
|||||||
const popularProviderSet = new Set(popularProviders)
|
const popularProviderSet = new Set(popularProviders)
|
||||||
|
|
||||||
export function useProviders(directory: Accessor<string | undefined>) {
|
export function useProviders(directory: Accessor<string | undefined>) {
|
||||||
const data = useData()
|
const providersQuery = useQuery(() => {
|
||||||
const sdk = useServerSDK()
|
const queryOpts = useQueryOptions()
|
||||||
const location = () => {
|
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
return dir ? { directory: dir } : undefined
|
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (sdk.connection.status() !== "connected") return
|
|
||||||
const ref = location()
|
|
||||||
void Promise.all([
|
|
||||||
data.location.provider.sync(ref),
|
|
||||||
data.location.model.sync(ref),
|
|
||||||
data.location.model.syncDefault(ref),
|
|
||||||
]).catch(() => undefined)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const providers = createMemo(() => {
|
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||||
const ref = location()
|
|
||||||
const provider = data.location.provider.list(ref)
|
|
||||||
const model = data.location.model.list(ref)
|
|
||||||
const defaultModel = data.location.model.default(ref)
|
|
||||||
if (!provider || !model || defaultModel === undefined) return emptyProviderCatalog
|
|
||||||
return normalizeProviderList(provider, model, defaultModel)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => {
|
ready: () => providersQuery.isSuccess,
|
||||||
const ref = location()
|
|
||||||
return (
|
|
||||||
data.location.provider.list(ref) !== undefined &&
|
|
||||||
data.location.model.list(ref) !== undefined &&
|
|
||||||
data.location.model.default(ref) !== undefined
|
|
||||||
)
|
|
||||||
},
|
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () =>
|
popular: () =>
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { Schema } from "effect"
|
|||||||
import type { ServerConnection } from "@/context/servers"
|
import type { ServerConnection } from "@/context/servers"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
|
|
||||||
export function DirectoryDataProvider(
|
export function DirectoryDataProvider(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -27,8 +25,6 @@ export function DirectoryDataProvider(
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const slug = createMemo(() => base64Encode(directory()))
|
const slug = createMemo(() => base64Encode(directory()))
|
||||||
@@ -37,7 +33,8 @@ export function DirectoryDataProvider(
|
|||||||
return `/${slug()}/session/${sessionID}`
|
return `/${slug()}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
const navigateToSession = async (sessionID: string) => {
|
const navigateToSession = async (sessionID: string) => {
|
||||||
await data.session.lineage.resolve(sessionID).catch(() => undefined)
|
const session = serverSync.session
|
||||||
|
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
|
||||||
navigate(href(sessionID))
|
navigate(href(sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +52,6 @@ export function DirectoryDataProvider(
|
|||||||
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (serverSDK.connection.status() !== "connected") return
|
|
||||||
const ref = { directory: directory() }
|
|
||||||
void data.location.sync(ref).catch(() => undefined)
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
void Promise.allSettled([
|
|
||||||
data.session.sync(sessionID, { children: true }),
|
|
||||||
data.session.pending.sync(sessionID),
|
|
||||||
data.session.message.sync(sessionID),
|
|
||||||
data.session.permission.sync(sessionID),
|
|
||||||
data.session.form.sync(sessionID),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||||
if (prefetched.has(key)) return
|
if (prefetched.has(key)) return
|
||||||
prefetched.add(key)
|
prefetched.add(key)
|
||||||
void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {})
|
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const directory = project?.worktree ?? session.location.directory
|
const directory = project?.worktree ?? session.location.directory
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
ctx.data.session.remember(session)
|
ctx.sync.session.remember(session)
|
||||||
ctx.projects.open(directory)
|
ctx.projects.open(directory)
|
||||||
if (options?.background) {
|
if (options?.background) {
|
||||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||||
|
|||||||
@@ -11,28 +11,32 @@ export function useSessionTabAvatarState(
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||||
const serverCtx = useServerCtx(connection)
|
const serverCtx = useServerCtx(connection)
|
||||||
|
const sync = () => serverCtx()?.sync
|
||||||
const hasPermissions = createMemo(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
const ctx = serverCtx()
|
const ctx = serverCtx()
|
||||||
if (!ctx) return false
|
if (!ctx) return false
|
||||||
|
const sync = ctx.sync
|
||||||
const permission = ctx.permission
|
const permission = ctx.permission
|
||||||
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
|
const [store] = sync.child(directory(), { bootstrap: false })
|
||||||
|
return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
|
||||||
return !permission.autoResponds(item, directory())
|
return !permission.autoResponds(item, directory())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const hasQuestions = createMemo(() => {
|
const hasQuestions = createMemo(() => {
|
||||||
const data = serverCtx()?.data
|
const serverSync = sync()
|
||||||
if (!data) return false
|
if (!serverSync) return false
|
||||||
return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
|
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||||
|
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
|
||||||
})
|
})
|
||||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||||
const unread = createMemo(
|
const unread = createMemo(
|
||||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => {
|
const loading = createMemo(() => {
|
||||||
const data = serverCtx()?.data
|
const serverSync = sync()
|
||||||
if (!data) return false
|
if (!serverSync) return false
|
||||||
if (needsAttention()) return false
|
if (needsAttention()) return false
|
||||||
return data.session.status(sessionId()) === "running"
|
return serverSync.session.data.session_working(sessionId())
|
||||||
})
|
})
|
||||||
return { unread, loading }
|
return { unread, loading }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
@@ -16,6 +17,7 @@ export function createNewSessionDraftController(props: {
|
|||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}) {
|
}) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
@@ -27,6 +29,7 @@ export function createNewSessionDraftController(props: {
|
|||||||
const controls = createPromptInputController({
|
const controls = createPromptInputController({
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
|
queryOptions: serverSync.queryOptions,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls({
|
const projectControls = createPromptProjectControls({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -106,6 +107,7 @@ function ProviderTip() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||||
Persist.global("new-session.provider-tip"),
|
Persist.global("new-session.provider-tip"),
|
||||||
@@ -113,7 +115,7 @@ function ProviderTip() {
|
|||||||
)
|
)
|
||||||
const visible = createMemo(
|
const visible = createMemo(
|
||||||
() =>
|
() =>
|
||||||
providers.ready() &&
|
serverSync.child(sdk().directory)[0].provider_ready &&
|
||||||
persistedReady() &&
|
persistedReady() &&
|
||||||
providers.paid().length === 0 &&
|
providers.paid().length === 0 &&
|
||||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useData } from "@/context/server"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +47,7 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||||
const selected = createMemo(() => {
|
const selected = createMemo(() => {
|
||||||
@@ -74,17 +74,12 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
createEffect(() => {
|
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
|
||||||
const project = sync().project
|
|
||||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
|
||||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
|
||||||
})
|
|
||||||
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
|
|
||||||
const branch = createMemo(() =>
|
const branch = createMemo(() =>
|
||||||
resolveNewSessionBranch({
|
resolveNewSessionBranch({
|
||||||
worktree: value(),
|
worktree: value(),
|
||||||
local: localBranch(),
|
local: localBranch(),
|
||||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const remember = (worktree = value()) => {
|
const remember = (worktree = value()) => {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import { CommentsProvider, useComments } from "@/context/comments"
|
|||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
@@ -102,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionLineage } from "./session/session-lineage"
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
@@ -152,8 +151,8 @@ async function runPromptRollbackMutation<T, R>(input: {
|
|||||||
// workspace-scoped state (terminal, directory providers) lives below.
|
// workspace-scoped state (terminal, directory providers) lives below.
|
||||||
export function TargetSessionRouteContent() {
|
export function TargetSessionRouteContent() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
const directory = createMemo(() => data.session.lineage.peek(params.id)?.session.location.directory)
|
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory)
|
||||||
return (
|
return (
|
||||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||||
// when session content falls back to the route error boundary.
|
// when session content falls back to the route error boundary.
|
||||||
@@ -239,11 +238,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
|
|||||||
function ResolvedTargetSessionRoute() {
|
function ResolvedTargetSessionRoute() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const data = useData()
|
const sync = useServerSync()
|
||||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||||
const current = createSessionLineage(
|
const current = createSessionLineage(
|
||||||
() => params.id,
|
() => params.id,
|
||||||
() => data.session.lineage,
|
() => sync.session.lineage,
|
||||||
)
|
)
|
||||||
const directory = createMemo(() => current()?.session.location.directory)
|
const directory = createMemo(() => current()?.session.location.directory)
|
||||||
|
|
||||||
@@ -331,7 +330,6 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const data = useData()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -386,6 +384,7 @@ export default function Page() {
|
|||||||
const inputController = createPromptInputController({
|
const inputController = createPromptInputController({
|
||||||
sessionKey: controller.identity.sessionKey,
|
sessionKey: controller.identity.sessionKey,
|
||||||
sessionID: () => controller.identity.params.id,
|
sessionID: () => controller.identity.params.id,
|
||||||
|
queryOptions: serverSync.queryOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionPanelKey = createMemo(() =>
|
const sessionPanelKey = createMemo(() =>
|
||||||
@@ -573,17 +572,12 @@ export default function Page() {
|
|||||||
const project = sync().project
|
const project = sync().project
|
||||||
return !!project && project.vcs !== "git"
|
return !!project && project.vcs !== "git"
|
||||||
})
|
})
|
||||||
const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory }))
|
|
||||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
const list: ChangeMode[] = []
|
const list: ChangeMode[] = []
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
|
const vcs = sync().data.vcs
|
||||||
if (project?.vcs === "git") list.push("git")
|
if (project?.vcs === "git") list.push("git")
|
||||||
if (
|
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
||||||
project?.vcs === "git" &&
|
|
||||||
vcs()?.branch.current &&
|
|
||||||
vcs()?.branch.default &&
|
|
||||||
vcs()?.branch.current !== vcs()?.branch.default
|
|
||||||
) {
|
|
||||||
list.push("branch")
|
list.push("branch")
|
||||||
}
|
}
|
||||||
list.push("turn")
|
list.push("turn")
|
||||||
@@ -607,8 +601,8 @@ export default function Page() {
|
|||||||
serverSDK.scope,
|
serverSDK.scope,
|
||||||
"session-vcs",
|
"session-vcs",
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
vcs()?.branch.current ?? "",
|
sync().data.vcs?.branch ?? "",
|
||||||
vcs()?.branch.default ?? "",
|
sync().data.vcs?.default_branch ?? "",
|
||||||
] as const,
|
] as const,
|
||||||
)
|
)
|
||||||
const vcsQuery = createQuery(() => {
|
const vcsQuery = createQuery(() => {
|
||||||
@@ -855,7 +849,7 @@ export default function Page() {
|
|||||||
return [
|
return [
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
id,
|
id,
|
||||||
id ? data.session.status(id) : "idle",
|
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||||
id ? composer.blocked() : false,
|
id ? composer.blocked() : false,
|
||||||
] as const
|
] as const
|
||||||
},
|
},
|
||||||
@@ -1040,7 +1034,7 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => data.session.status(controller.identity.params.id ?? ""),
|
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
refreshVcs()
|
refreshVcs()
|
||||||
@@ -1652,7 +1646,7 @@ export default function Page() {
|
|||||||
target.session.remember({ ...session, revert: next })
|
target.session.remember({ ...session, revert: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
const busy = (sessionID: string) => data.session.status(sessionID) === "running"
|
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||||
const queuedFollowups = createMemo(() => {
|
const queuedFollowups = createMemo(() => {
|
||||||
const id = controller.identity.params.id
|
const id = controller.identity.params.id
|
||||||
if (!id) return emptyFollowups
|
if (!id) return emptyFollowups
|
||||||
@@ -1683,8 +1677,8 @@ export default function Page() {
|
|||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
data,
|
serverSync: serverSync,
|
||||||
session: () => data.session.get(input.sessionID),
|
session: () => sync().session.get(input.sessionID),
|
||||||
draft: item,
|
draft: item,
|
||||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1749,7 +1743,7 @@ export default function Page() {
|
|||||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||||
|
|
||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
if (data.session.get(sessionID)?.parentID) return Promise.resolve()
|
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
@@ -2134,12 +2128,9 @@ export default function Page() {
|
|||||||
const id = controller.data.parentID()
|
const id = controller.data.parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(
|
navigate(
|
||||||
sessionHref(
|
controller.identity.params.serverKey
|
||||||
controller.identity.params.serverKey
|
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
||||||
? requireServerKey(controller.identity.params.serverKey)
|
: legacySessionHref(sdk().directory, id),
|
||||||
: ServerConnection.key(serverSDK.server),
|
|
||||||
id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
setPromptRef: (el) => {
|
setPromptRef: (el) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||||
@@ -7,40 +8,43 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
|||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
|
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useData } from "@/context/server"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { normalizeAgentList } from "@/context/global-sync/utils"
|
|
||||||
|
|
||||||
export function createPromptInputController(input: {
|
export function createPromptInputController(input: {
|
||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
|
queryOptions: Pick<QueryOptionsApi, "agents">
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const data = useData()
|
const sync = useSync()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const view = layout.view(input.sessionKey)
|
const view = layout.view(input.sessionKey)
|
||||||
|
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||||
|
|
||||||
return createMemo<PromptInputControls>(() => {
|
return createMemo<PromptInputControls>(() => {
|
||||||
return {
|
return {
|
||||||
agents: {
|
agents: {
|
||||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
available: sync().data.agent,
|
||||||
options: local.agent.list().map((agent) => agent.name),
|
options: local.agent.list().map((agent) => agent.name),
|
||||||
current: local.agent.current()?.name ?? "",
|
current: local.agent.current()?.name ?? "",
|
||||||
loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
|
loading: agentsQuery.isLoading,
|
||||||
visible: local.agent.visible(),
|
visible: local.agent.visible(),
|
||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
selection: input.model ?? local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { type Accessor, createEffect, createMemo, createResource, onCleanup } fr
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { PromptInputState } from "@/components/prompt-input"
|
import type { PromptInputState } from "@/components/prompt-input"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import type { SessionComposerController } from "./session-composer-state"
|
import type { SessionComposerController } from "./session-composer-state"
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ export function createSessionComposerRegionController(input: {
|
|||||||
setDockRef: (el: HTMLDivElement) => void
|
setDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
ready: input.ready() || input.state.dock(),
|
ready: input.ready() || input.state.dock(),
|
||||||
height: 320,
|
height: 320,
|
||||||
@@ -106,7 +104,7 @@ export function createSessionComposerRegionController(input: {
|
|||||||
|
|
||||||
const parentID = createMemo(() => {
|
const parentID = createMemo(() => {
|
||||||
const id = input.sessionID()
|
const id = input.sessionID()
|
||||||
return id ? data.session.get(id)?.parentID : undefined
|
return id ? sync().session.get(id)?.parentID : undefined
|
||||||
})
|
})
|
||||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||||
const progress = useSpring(
|
const progress = useSpring(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||||
import { useData } from "@/context/server"
|
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
|
||||||
export const todoState = (input: {
|
export const todoState = (input: {
|
||||||
count: number
|
count: number
|
||||||
@@ -34,20 +34,36 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const data = useData()
|
const queryClient = useQueryClient()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
createEffect(() => {
|
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
|
||||||
if (!params.id || serverSDK.connection.status() !== "connected") return
|
const shells = createQuery(() => ({
|
||||||
void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
|
queryKey: shellKey(),
|
||||||
})
|
enabled: !!params.id && serverSDK.connection.status() === "connected",
|
||||||
|
queryFn: () =>
|
||||||
|
sdk()
|
||||||
|
.api.shell.list({ location: { directory: sdk().directory } })
|
||||||
|
.then((result) => result.data ?? []),
|
||||||
|
}))
|
||||||
|
onCleanup(
|
||||||
|
sdk().event.listen((event) => {
|
||||||
|
if (
|
||||||
|
event.details.type !== "shell.created" &&
|
||||||
|
event.details.type !== "shell.exited" &&
|
||||||
|
event.details.type !== "shell.deleted"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||||
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
|
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
|
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
||||||
return !permission.autoResponds(item, sdk().directory)
|
return !permission.autoResponds(item, sdk().directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -68,16 +84,16 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const live = createMemo(() => data.session.status(params.id ?? "") === "running" || blocked())
|
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||||
const primary = () => {
|
const primary = () => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
return !!id && !data.session.get(id)?.parentID
|
return !!id && !serverSync.session.get(id)?.parentID
|
||||||
}
|
}
|
||||||
const backgroundBlocking = createMemo(() => {
|
const backgroundBlocking = createMemo(() => {
|
||||||
if (!primary()) return []
|
if (!primary()) return []
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const assistant = data.session.message.list(id).findLast(
|
const assistant = (serverSync.session.data.session_message[id] ?? []).findLast(
|
||||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||||
)
|
)
|
||||||
if (assistant?.type !== "assistant") return []
|
if (assistant?.type !== "assistant") return []
|
||||||
@@ -100,7 +116,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const blocking = backgroundBlocking()
|
const blocking = backgroundBlocking()
|
||||||
const messages = data.session.message.list(id)
|
const messages = serverSync.session.data.session_message[id] ?? []
|
||||||
const completed = new Set(
|
const completed = new Set(
|
||||||
messages.flatMap((message) => {
|
messages.flatMap((message) => {
|
||||||
if (message.type !== "synthetic") return []
|
if (message.type !== "synthetic") return []
|
||||||
@@ -128,9 +144,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const active = data.session.list().flatMap((info) => {
|
const active = Object.values(serverSync.session.data.info).flatMap((info) => {
|
||||||
if (info?.parentID !== id) return []
|
if (info?.parentID !== id) return []
|
||||||
if (data.session.status(info.id) === "idle") return []
|
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
|
||||||
if (
|
if (
|
||||||
blocking.some(
|
blocking.some(
|
||||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||||
@@ -155,7 +171,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
||||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||||
if (
|
if (
|
||||||
blocking.some(
|
blocking.some(
|
||||||
|
|||||||
@@ -37,16 +37,14 @@ export function SessionPermissionDock(props: {
|
|||||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||||
{language.t("ui.permission.deny")}
|
{language.t("ui.permission.deny")}
|
||||||
</Button>
|
</Button>
|
||||||
<Show when={props.request.save?.length}>
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="secondary"
|
size="normal"
|
||||||
size="normal"
|
onClick={() => props.onDecide("always")}
|
||||||
onClick={() => props.onDecide("always")}
|
disabled={props.responding}
|
||||||
disabled={props.responding}
|
>
|
||||||
>
|
{language.t("ui.permission.allowAlways")}
|
||||||
{language.t("ui.permission.allowAlways")}
|
</Button>
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||||
{language.t("ui.permission.allowOnce")}
|
{language.t("ui.permission.allowOnce")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie
|
|||||||
|
|
||||||
function sessionTreeRequest<T>(
|
function sessionTreeRequest<T>(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
|
request: Record<string, T[] | undefined>,
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
include: (item: T) => boolean = () => true,
|
include: (item: T) => boolean = () => true,
|
||||||
) {
|
) {
|
||||||
@@ -28,15 +28,14 @@ function sessionTreeRequest<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
const id = ids.find((id) => request[id]?.some(include))
|
||||||
const id = ids.find((id) => list(id)?.some(include))
|
|
||||||
if (!id) return
|
if (!id) return
|
||||||
return list(id)?.find(include)
|
return request[id]?.find(include)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionPermissionRequest(
|
export function sessionPermissionRequest(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
|
request: Record<string, PermissionRequest[] | undefined>,
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
include?: (item: PermissionRequest) => boolean,
|
include?: (item: PermissionRequest) => boolean,
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +44,7 @@ export function sessionPermissionRequest(
|
|||||||
|
|
||||||
export function sessionQuestionForm(
|
export function sessionQuestionForm(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
|
request: Record<string, FormInfo[] | undefined>,
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
) {
|
) {
|
||||||
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Message, UserMessage } from "@/types"
|
|||||||
import { createMemo, type Accessor } from "solid-js"
|
import { createMemo, type Accessor } from "solid-js"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createSessionTabs } from "./helpers"
|
import { createSessionTabs } from "./helpers"
|
||||||
import {
|
import {
|
||||||
@@ -25,17 +24,16 @@ export function createSessionController(input: {
|
|||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const layout = useSessionLayout()
|
const layout = useSessionLayout()
|
||||||
const sessionID = createMemo(() => layout.params.id)
|
const sessionID = createMemo(() => layout.params.id)
|
||||||
const info = createMemo(() => {
|
const info = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
return id ? data.session.get(id) : undefined
|
return id ? sync().session.get(id) : undefined
|
||||||
})
|
})
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
return id ? data.session.get(id) : undefined
|
return id ? sync().session.get(id) : undefined
|
||||||
})
|
})
|
||||||
const status = createMemo(() => {
|
const status = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
@@ -77,7 +75,7 @@ export function createSessionController(input: {
|
|||||||
status,
|
status,
|
||||||
working: createMemo(() => {
|
working: createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
return id ? data.session.status(id) === "running" : false
|
return id ? sync().data.session_working(id) : false
|
||||||
}),
|
}),
|
||||||
revertMessageID,
|
revertMessageID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import type { SessionController } from "@/pages/session/session-controller"
|
import type { SessionController } from "@/pages/session/session-controller"
|
||||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||||
import { createTimelineProjection } from "./projection"
|
import { createTimelineProjection } from "./projection"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
|
|
||||||
const emptyMessages: Message[] = []
|
const emptyMessages: Message[] = []
|
||||||
const emptyParts: Part[] = []
|
const emptyParts: Part[] = []
|
||||||
@@ -48,10 +46,8 @@ export function createTimelineController(input: {
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const data = server.ctx.data
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -64,7 +60,7 @@ export function createTimelineController(input: {
|
|||||||
const boundary = input.session.history
|
const boundary = input.session.history
|
||||||
.messages()
|
.messages()
|
||||||
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||||
const projected = data.session.message.list(id)
|
const projected = sync().data.session_message[id] ?? []
|
||||||
if (!boundary) return projected
|
if (!boundary) return projected
|
||||||
const index = projected.findIndex((message) => message.id === boundary)
|
const index = projected.findIndex((message) => message.id === boundary)
|
||||||
return index < 0 ? projected : projected.slice(0, index)
|
return index < 0 ? projected : projected.slice(0, index)
|
||||||
@@ -150,22 +146,19 @@ export function createTimelineController(input: {
|
|||||||
if (!id || pending.unshare || !shareEnabled()) return
|
if (!id || pending.unshare || !shareEnabled()) return
|
||||||
}
|
}
|
||||||
const href = (id: string) =>
|
const href = (id: string) =>
|
||||||
sessionHref(
|
input.session.identity.params.serverKey
|
||||||
input.session.identity.params.serverKey
|
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
|
||||||
? requireServerKey(input.session.identity.params.serverKey)
|
: legacySessionHref(sdk().directory, id)
|
||||||
: ServerConnection.key(serverSDK.server),
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||||
if (input.session.identity.params.id !== id) return
|
if (input.session.identity.params.id !== id) return
|
||||||
if (parent) return navigate(href(parent))
|
if (parent) return navigate(href(parent))
|
||||||
if (next) return navigate(href(next))
|
if (next) return navigate(href(next))
|
||||||
return tabs.newDraft({
|
if (input.session.identity.params.serverKey)
|
||||||
server: input.session.identity.params.serverKey
|
return tabs.newDraft({
|
||||||
? requireServerKey(input.session.identity.params.serverKey)
|
server: requireServerKey(input.session.identity.params.serverKey),
|
||||||
: ServerConnection.key(serverSDK.server),
|
directory: sdk().directory,
|
||||||
directory: sdk().directory,
|
})
|
||||||
})
|
navigate(`/${input.session.identity.params.dir}/session`)
|
||||||
}
|
}
|
||||||
const exportSession = async (id: string) => {
|
const exportSession = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -187,7 +180,7 @@ export function createTimelineController(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
const session = data.session.get(id)
|
const session = sync().session.get(id)
|
||||||
if (!session) return false
|
if (!session) return false
|
||||||
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
||||||
const index = sessions.findIndex((item) => item.id === id)
|
const index = sessions.findIndex((item) => item.id === id)
|
||||||
@@ -210,7 +203,7 @@ export function createTimelineController(input: {
|
|||||||
|
|
||||||
function DeleteDialog(props: { sessionID: string }) {
|
function DeleteDialog(props: { sessionID: string }) {
|
||||||
const name = createMemo(
|
const name = createMemo(
|
||||||
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||||
)
|
)
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
await remove(props.sessionID)
|
await remove(props.sessionID)
|
||||||
@@ -261,7 +254,7 @@ export function createTimelineController(input: {
|
|||||||
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
||||||
([id, description]) => {
|
([id, description]) => {
|
||||||
if (!id || description || sync().data.message[id] !== undefined) return
|
if (!id || description || sync().data.message[id] !== undefined) return
|
||||||
void Promise.all([data.session.sync(id), sync().session.sync(id)])
|
void sync().session.sync(id)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
|||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useData } from "@/context/server"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { scheduleConnectedMeasure } from "./measure"
|
import { scheduleConnectedMeasure } from "./measure"
|
||||||
@@ -414,7 +414,7 @@ function MessageTimelineView(
|
|||||||
) {
|
) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const data = useData()
|
const serverSync = useServerSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||||
@@ -438,11 +438,6 @@ function MessageTimelineView(
|
|||||||
const projection = props.data.projection
|
const projection = props.data.projection
|
||||||
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
||||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||||
createEffect(() => {
|
|
||||||
const directory = sync().project?.worktree
|
|
||||||
if (!directory) return
|
|
||||||
void data.location.vcs.sync({ directory }).catch(() => undefined)
|
|
||||||
})
|
|
||||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||||
const setSummary = (open: boolean) => {
|
const setSummary = (open: boolean) => {
|
||||||
@@ -1473,8 +1468,8 @@ function MessageTimelineView(
|
|||||||
project={project()}
|
project={project()}
|
||||||
directory={sessionDirectory()}
|
directory={sessionDirectory()}
|
||||||
local={!workspaceSession()}
|
local={!workspaceSession()}
|
||||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
branch={sync().data.vcs?.branch}
|
||||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
baseBranch={serverSync.child(project().worktree)[0].vcs?.branch}
|
||||||
diffs={sessionDiffs()}
|
diffs={sessionDiffs()}
|
||||||
sessionID={id}
|
sessionID={id}
|
||||||
moveEligible={props.workspaceMoveEligible}
|
moveEligible={props.workspaceMoveEligible}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useData } from "@/context/server"
|
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
@@ -55,7 +54,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const data = useData()
|
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -306,7 +304,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!message) return
|
if (!message) return
|
||||||
const parts = sync().data.part[message.id]
|
const parts = sync().data.part[message.id]
|
||||||
|
|
||||||
if (data.session.status(sessionID) === "running") {
|
if (sync().data.session_working(sessionID)) {
|
||||||
await session.interrupt({ sessionID }).catch(() => {})
|
await session.interrupt({ sessionID }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user