Compare commits

..

3 Commits

Author SHA1 Message Date
Brendan Allan 3a2eeebca2 refactor(app): migrate consumers to shared data 2026-08-16 06:12:40 +00:00
Brendan Allan fbbb043d4a refactor: share server data across clients 2026-08-15 11:06:51 +00:00
Brendan Allan a9c3d484ba start on shared data impl 2026-08-14 16:42:58 +08:00
716 changed files with 157282 additions and 13583 deletions
@@ -1,8 +0,0 @@
---
"@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.
+1 -2
View File
@@ -5,5 +5,4 @@
"@opencode-ai/client": minor "@opencode-ai/client": minor
--- ---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
external directories, while the default tree-sitter path remains unchanged.
+49
View File
@@ -0,0 +1,49 @@
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 }}
+11 -1
View File
@@ -72,10 +72,20 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
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
+253
View File
@@ -0,0 +1,253 @@
---
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
```
-14
View File
@@ -12,20 +12,6 @@
- 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/`.
+678 -66
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -33,6 +33,7 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -46,9 +47,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.3", "@opentui/core": "0.5.2",
"@opentui/keymap": "0.5.3", "@opentui/keymap": "0.5.2",
"@opentui/solid": "0.5.3", "@opentui/solid": "0.5.2",
"@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",
@@ -144,10 +145,6 @@
"esbuild", "esbuild",
"node-pty", "node-pty",
"protobufjs", "protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron" "electron"
], ],
"overrides": { "overrides": {
@@ -173,6 +170,7 @@
"@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"
} }
} }
+7 -8
View File
@@ -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 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. 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.
### 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` and `chat`. - Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, 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,10 +124,11 @@ 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`. 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. 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`.
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(...)`.
@@ -153,16 +154,14 @@ 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 execution types + HttpTransport / WebSocketTransport namespaces index.ts Transport type + 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 direct one-request channel executor + raw socket adapter websocket.ts WebSocketTransport.json + WebSocketExecutor service
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
open-responses-channel.ts provider-neutral Responses WebSocket transport factory openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
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
+2 -1
View File
@@ -315,6 +315,7 @@ 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 },
}) })
@@ -331,7 +332,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`
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. 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.
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`.
+7 -6
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status # LLM Provider Parity Status
Last reviewed: 2026-08-07 Last reviewed: 2026-07-24
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,7 +16,8 @@ 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 | `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 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 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,8 +49,8 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## AI SDK Package Parity Matrix ## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps | | AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | 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` | `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-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. |
@@ -78,9 +79,9 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
These are implementation/API slices, not separate npm packages. These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose | | API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| 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`. |
+41 -18
View File
@@ -67,6 +67,7 @@ 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")
@@ -249,6 +250,11 @@ 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),
@@ -260,11 +266,13 @@ 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,
@@ -334,19 +342,22 @@ const response =
) )
``` ```
For direct provider-facade calls, Responses has one semantic model and route: For direct provider-facade calls, HTTP versus WebSocket is represented as named
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 has the same transport-neutral The package-like OpenAI Responses entrypoint instead keeps transport scoped to
`model(...)` contract: Responses settings while preserving the same `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 }) model("gpt-4o", { apiKey, transport: "websocket" })
``` ```
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints, Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
@@ -376,9 +387,11 @@ 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 does not require a different public layer for WebSocket execution. The client should not require a different public layer just because a selected
Responses routes use HTTP by default, and callers may pass a channel executor per route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
call. Routes without channel support simply ignore that execution capability. capabilities available; routes that do not need WebSocket simply never touch it.
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
@@ -484,13 +497,18 @@ generic dynamic resolver:
```ts ```ts
const model = const model =
providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID) providerID === "azure"
? 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. Transport selection remains execution policy: a Session provider APIs directly. A direct provider-facade boundary maps metadata like
or other caller may pass a WebSocket channel executor per call without changing `endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
the model constructed by this boundary. boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
The client runtime only executes the route carried by the resulting model.
## Competitive Shape ## Competitive Shape
@@ -526,8 +544,9 @@ 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 setting on a provider or executable model. OpenAI Responses uses - No transport override on an executable model or request. Direct provider
HTTP by default and accepts an optional per-call channel executor as execution policy. facades use `responses` versus `responsesWebSocket`; the package-like Responses
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
@@ -561,10 +580,12 @@ 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` accepts - [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
optional per-call channel execution without changing route identity. exposes available transport capabilities and selected routes fail with typed
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)` and `.chat(id)`. `OpenAI.configure(config).responses(id)`, `.chat(id)`, and
`.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
@@ -578,8 +599,10 @@ 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] Keep executable model construction transport-neutral at the Session boundary; - [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
Session-scoped execution policy supplies channel capability separately. executable models at the session boundary with explicit provider facade
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.
+4 -3
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai" import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers" import { OpenAI } from "@opencode-ai/ai/providers"
/** /**
@@ -213,7 +213,8 @@ 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 llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
@@ -221,6 +222,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(requestExecutorLayer, llmClientLayer))) }).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
Effect.runPromise(program) Effect.runPromise(program)
-1
View File
@@ -7,4 +7,3 @@ 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"
@@ -1,191 +0,0 @@
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
+7 -54
View File
@@ -211,43 +211,11 @@ 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,
@@ -272,9 +240,6 @@ 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)],
) )
@@ -667,9 +632,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` and `error` are hard failures. All four end // `finish` event; `response.failed` is a hard failure. All three end the stream,
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate. // so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"]) const TERMINAL_TYPES = new Set(["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 => {
@@ -1001,24 +966,16 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
return message || code || fallback return message || code || fallback
} }
export const providerFailure = (id: string, event: Event, fallback: string) => { const providerError = (state: ParserState, 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: id, module: state.id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code, status }), reason: classifyProviderFailure({ message, code }),
}) })
} }
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`)
@@ -1058,11 +1015,7 @@ 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") if (event.type === "error") return providerError(state, event, `${state.name} stream 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])
} }
@@ -1,164 +0,0 @@
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
+41 -14
View File
@@ -1,23 +1,18 @@
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 } from "../route/transport/index.js" import { HttpTransport, WebSocketTransport } 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
@@ -62,6 +57,16 @@ 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,
@@ -244,13 +249,6 @@ 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,
@@ -259,7 +257,36 @@ export const route = Route.make({
protocol, protocol,
endpoint, endpoint,
auth, auth,
transport, transport: httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
const { stream: _stream, ...message } = body
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
})
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage,
})
export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint,
auth,
transport: webSocketTransport,
defaults: { providerOptions: { openai: { store: false } } }, defaults: { providerOptions: { openai: { store: false } } },
}) })
-1
View File
@@ -67,7 +67,6 @@ 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"])
+13 -2
View File
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
export const id = ProviderID.make("openai") export const id = ProviderID.make("openai")
export const routes = [OpenAIResponses.route, OpenAIChat.route] export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, 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,6 +63,7 @@ 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
} }
@@ -81,12 +82,17 @@ 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) =>
@@ -105,6 +111,7 @@ export const configure = (input: Config = {}) => {
id, id,
model: responses, model: responses,
responses, responses,
responsesWebSocket,
chat, chat,
image, image,
configure, configure,
@@ -131,7 +138,10 @@ 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) => {
return configure(config(settings)).responses(modelID) const configured = configure(config(settings))
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"] = (
@@ -139,5 +149,6 @@ 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
+10 -15
View File
@@ -1,10 +1,12 @@
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, WebSocketChannelExecutor } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime } 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"
@@ -56,7 +58,6 @@ 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>
} }
@@ -156,7 +157,6 @@ 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,18 +314,16 @@ 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, options?: StreamOptions) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
return Stream.unwrap( const events = routeInput.transport
routeInput.transport.execute(prepared, request, runtime, options).pipe( .frames(prepared, request, runtime)
Effect.map((execution) => { .pipe(
const events = execution.frames.pipe(
Stream.mapEffect(decodeEvent(route)), Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
) )
const stream = events.pipe( return events.pipe(
Stream.mapAccumEffect( Stream.mapAccumEffect(
() => protocol.stream.initial(request), () => protocol.stream.initial(request),
protocol.stream.step, protocol.stream.step,
@@ -334,10 +332,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route), requireTerminalEvent(route),
) )
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
}),
),
)
}, },
} satisfies Route<Body, Prepared> } satisfies Route<Body, Prepared>
return route return route
@@ -419,7 +413,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, options) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}), }),
) )
@@ -457,6 +451,7 @@ 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) })
}), }),
+111 -33
View File
@@ -34,8 +34,44 @@ 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 headerDetails = (headers: Headers.Headers) => const BODY_LIMIT = 16_384
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)])) const REDACTED = "<redacted>"
// 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]))
@@ -108,22 +144,58 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
}) })
} }
const requestDetails = (request: HttpClientRequest.HttpClientRequest) => const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
new HttpRequestDetails({ new HttpRequestDetails({
method: request.method, method: request.method,
url: request.url, url: redactUrl(request.url),
headers: headerDetails(request.headers), headers: redactHeaders(request.headers, redactedNames),
}) })
const responseDetails = (response: HttpClientResponse.HttpClientResponse) => const responseDetails = (
response: HttpClientResponse.HttpClientResponse,
redactedNames: ReadonlyArray<string | RegExp>,
) =>
new HttpResponseDetails({ new HttpResponseDetails({
status: response.status, status: response.status,
headers: headerDetails(response.headers), headers: redactHeaders(response.headers, redactedNames),
}) })
const responseBody = (body: string | void) => { const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
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 {}
return { body } const redacted = redactBody(body, secrets)
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(
@@ -135,49 +207,52 @@ const decodeProviderBody = Schema.decodeUnknownOption(
), ),
) )
const providerMessage = (status: number, body: string | void) => { const providerMessage = (status: number, body: { readonly body?: string }) => {
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body)) if (body.body && body.body.length <= 500) {
return ( const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ?? return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
`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), request: requestDetails(input.request, input.redactedNames),
response: responseDetails(input.response), response: responseDetails(input.response, input.redactedNames),
...input.body, ...input.body,
requestId: input.requestId, requestId: input.requestId,
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
const statusError = const statusError =
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) => (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
(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) const details = responseBody(body, secretValues(request))
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, body), message: providerMessage(response.status, details),
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,
@@ -187,10 +262,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 HttpContext that // AI SDK's own fetch) onto the same reason types and redacted 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. // request headers are empty, and only structural body redaction applies.
export const classifyHttpFailure = (input: { export const classifyHttpFailure = (input: {
readonly message: string readonly message: string
readonly url: string readonly url: string
@@ -202,7 +277,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) const details = responseBody(input.responseBody ?? undefined, new Set<string>())
return classifyProviderFailure({ return classifyProviderFailure({
message: input.message, message: input.message,
status: input.status, status: input.status,
@@ -210,11 +285,11 @@ export const classifyHttpFailure = (input: {
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
rateLimit, rateLimit,
http: new HttpContext({ http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }), request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
response: response:
input.status === undefined input.status === undefined
? undefined ? undefined
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }), : new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
...details, ...details,
requestId: requestId(headers), requestId: requestId(headers),
rateLimit, rateLimit,
@@ -244,6 +319,7 @@ 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 }) =>
@@ -255,8 +331,8 @@ const httpError = (input: {
transport: "http", transport: "http",
operation: input.operation, operation: input.operation,
code: failure.code, code: failure.code,
url: request.url, url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request) }), http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
}), }),
}) })
@@ -267,7 +343,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 const detail = raw ? redactBody(raw, secretValues(request)) : undefined
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))
@@ -293,9 +369,10 @@ 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" })), Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
) )
}), }),
) )
@@ -306,18 +383,19 @@ 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" })), Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
Effect.flatMap(statusError(request)), Effect.flatMap(statusError(request, redactedNames)),
) )
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" }))) ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
return yield* statusError(response.request)(response) return yield* statusError(response.request, redactedNames)(response)
}) })
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
+2 -19
View File
@@ -16,28 +16,11 @@ 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, WebSocketTransport } from "./transport/index.js" export { HttpTransport, WebSocketExecutor, 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 { export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js"
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
HttpHandler,
HttpMiddleware,
Transport as TransportDef,
TransportExecuteOptions,
TransportExecution,
TransportRuntime,
WebSocketConnection,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
WebSocketConnector,
WebSocketRequest,
} from "./transport/index.js"
+2 -4
View File
@@ -87,10 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
execute: (prepared, _request, runtime) => frames: (prepared, _request, runtime) =>
Effect.succeed({ prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
}) })
export const sseJson = { export const sseJson = {
+5 -30
View File
@@ -1,33 +1,19 @@
import type { Effect, Scope, Stream } from "effect" import type { Effect, 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 { WebSocketChannelExecutor } from "./websocket-channel.js" import type { Interface as WebSocketExecutorInterface } from "./websocket.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 execute: ( readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
options?: TransportExecuteOptions,
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
} }
export interface TransportPrepareInput<Body> { export interface TransportPrepareInput<Body> {
@@ -38,19 +24,8 @@ 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 type { export { WebSocketExecutor, WebSocketTransport } from "./websocket.js"
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
export { WebSocketTransport } from "./websocket.js"
@@ -1,50 +0,0 @@
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
}
+40 -203
View File
@@ -1,15 +1,8 @@
import { Cause, Effect, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, 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
@@ -22,29 +15,24 @@ export interface WebSocketConnection {
readonly close: Effect.Effect<void, never> readonly close: Effect.Effect<void, never>
} }
export interface WebSocketConnector { export interface Interface {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError> readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
} }
type WebSocketConstructorWithHeaders = ( type WebSocketConstructorWithHeaders = new (
url: string, url: string,
options?: { readonly headers?: Headers.Headers }, options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket ) => globalThis.WebSocket
const MAX_FRAME_BYTES = 16 * 1024 * 1024 export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) => ) =>
new AIError({ new AIError({
module: "WebSocketConnector", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ reason: new TransportReason({
message, message,
@@ -52,33 +40,9 @@ 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
@@ -99,8 +63,6 @@ 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",
}), }),
) )
} }
@@ -127,8 +89,6 @@ 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",
}), }),
), ),
) )
@@ -141,8 +101,6 @@ 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",
}), }),
), ),
) )
@@ -154,7 +112,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}) })
} }
export const toWebSocketUrl = (value: string) => const webSocketUrl = (value: string) =>
Effect.try({ Effect.try({
try: () => { try: () => {
const url = new URL(value) const url = new URL(value)
@@ -173,31 +131,21 @@ export const toWebSocketUrl = (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.gen(function* () { Effect.try({
const constructor = yield* Socket.WebSocketConstructor
const ws = yield* Effect.try({
try: () => try: () =>
// Platform implementations may extend Effect's browser-compatible constructor with handshake options. new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
headers: input.headers,
}),
catch: (error) => catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url, url: input.url,
operation: "request", operation: "request",
phase: "connect",
delivery: "not-sent",
}), }),
}) }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
return yield* fromWebSocket(ws, input)
}) export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
export const fromWebSocket = ( export const fromWebSocket = (
ws: globalThis.WebSocket, ws: globalThis.WebSocket,
@@ -207,52 +155,16 @@ 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 offer(event.data) if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
const binary = binaryMessage(event.data) const binary = binaryMessage(event.data)
if (binary) return offer(binary) if (binary) return Queue.offerUnsafe(messages, 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",
}), }),
), ),
) )
@@ -264,13 +176,12 @@ 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(
@@ -278,7 +189,6 @@ export const fromWebSocket = (
url: input.url, url: input.url,
operation: "read", operation: "read",
code: String(event.code), code: String(event.code),
phase: "close",
}), }),
), ),
) )
@@ -295,26 +205,13 @@ export const fromWebSocket = (
return { return {
sendText: (message) => sendText: (message) =>
Effect.suspend(() => { Effect.try({
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
operation: "write",
phase: "send",
delivery: "not-sent",
}),
)
return Effect.try({
try: () => ws.send(message), try: () => ws.send(message),
catch: (error) => catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url, url: input.url,
operation: "write", operation: "write",
phase: "send",
delivery: "not-sent",
}), }),
})
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
close: cleanup.pipe( close: cleanup.pipe(
@@ -331,57 +228,6 @@ 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
@@ -408,44 +254,33 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
...prepareInput, ...prepareInput,
}) })
return { return {
url: yield* toWebSocketUrl(parts.url), url: yield* webSocketUrl(parts.url),
headers: parts.headers, headers: parts.headers,
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
} }
}), }),
execute: (prepared, request, _runtime, options) => { frames: (prepared, _request, runtime) => {
const webSocket = options?.webSocket const webSocket = runtime.webSocket
if (!webSocket) { if (!webSocket) {
return Effect.fail( return Stream.fail(
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", { transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url, url: prepared.url,
operation: "request", operation: "request",
code: "unavailable", code: "unavailable",
phase: "prepare",
delivery: "not-sent",
}), }),
) )
} }
const driver: WebSocketChannelDriver = { const decoder = new TextDecoder()
create: () => Effect.succeed({ message: prepared.message, mode: "full" }), return Stream.unwrap(
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }), Effect.gen(function* () {
} const connection = yield* Effect.acquireRelease(
const exchange: WebSocketChannelExchange = { webSocket.open({ url: prepared.url, headers: prepared.headers }),
id: request.id ?? "request", (connection) => connection.close,
connect: { url: prepared.url, headers: prepared.headers }, )
fallback: () => yield* connection.sendText(prepared.message)
Stream.fail( return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
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)
}, },
}) })
@@ -454,13 +289,15 @@ export const jsonTransport = {
with: json, with: json,
} as const } as const
export const WebSocketTransport = { export const WebSocketExecutor = {
json, Service,
jsonTransport, layer,
direct,
makeDirect,
open, open,
fromWebSocket, fromWebSocket,
messageText, messageText,
toWebSocketUrl, } as const
export const WebSocketTransport = {
json,
jsonTransport,
} as const } as const
-7
View File
@@ -106,13 +106,6 @@ 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>(
+30 -209
View File
@@ -1,13 +1,12 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect" import { Effect, 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, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js" import { dynamicResponse, systemError } from "./lib/http.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js" import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js" import { 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(
@@ -66,7 +65,6 @@ 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", () =>
@@ -77,11 +75,11 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: disconnected query-secret-123 header-secret-456", message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http", transport: "http",
operation: "read", operation: "read",
code: "ECONNRESET", code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1", url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
}) })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -154,12 +152,12 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: proxy disconnected proxy-secret", message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=proxy-secret", url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: { http: {
request: { request: {
url: "https://proxy.test/v1/chat?api_key=proxy-secret", url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "Bearer proxy-secret" }, headers: { authorization: "<redacted>" },
}, },
}, },
}) })
@@ -219,47 +217,9 @@ 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) =>
@@ -293,7 +253,7 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("returns complete diagnostics for rate limits", () => it.effect("returns redacted 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)
@@ -308,15 +268,15 @@ describe("RequestExecutor", () => {
requestId: "req_123", requestId: "req_123",
request: { request: {
method: "POST", method: "POST",
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1", url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "Bearer secret", "x-safe": "visible" }, headers: { authorization: "<redacted>", "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": "secret", "x-api-key": "<redacted>",
}, },
}, },
}, },
@@ -335,14 +295,14 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("preserves configured header names in diagnostics", () => it.effect("honors current redacted 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("visible") expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret") expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
}).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"]),
@@ -461,15 +421,15 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("preserves large authentication error bodies", () => it.effect("truncates 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).toBeUndefined() expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(20_000) expect(errorHttp(error)?.body).toHaveLength(16_384)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -480,15 +440,16 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("preserves response body fields", () => it.effect("redacts common secret fields in response 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(errorHttp(error)?.body).toBe( expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
) expect(errorHttp(error)?.body).not.toContain("body-secret")
expect(errorHttp(error)?.body).not.toContain("query-secret")
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -500,13 +461,16 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("preserves echoed request values in response bodies", () => it.effect("redacts echoed request secret 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).toBe("provider echoed query-secret-123 and authorization header-secret-456") expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
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([
@@ -547,146 +511,3 @@ 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)
}),
)
})
+3 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai" import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route" import { Route, Protocol } 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,7 +16,6 @@ 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"
@@ -37,7 +36,6 @@ 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 () => {
@@ -45,6 +43,7 @@ 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(
@@ -66,10 +65,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")
}) })
}) })
+6 -4
View File
@@ -1,8 +1,9 @@
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 } from "../../src/route.js" import { LLMClient, RequestExecutor, WebSocketExecutor } 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
@@ -33,7 +34,7 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
), ),
) )
export type RuntimeEnv = RequestExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error { export interface SystemError extends Error {
readonly code: string readonly code: string
@@ -43,8 +44,9 @@ 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 llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
return Layer.mergeAll(requestExecutorLayer, llmClientLayer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
return Layer.mergeAll(deps, llmClientLayer)
} }
const SSE_HEADERS = { "content-type": "text/event-stream" } as const const SSE_HEADERS = { "content-type": "text/event-stream" } as const
+2 -2
View File
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
test("classifies V1 overloaded provider codes", () => { test("classifies V1 overloaded provider codes", () => {
expect( expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map( ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
(message) => classifyProviderFailure({ message })._tag, (message) => classifyProviderFailure({ message })._tag,
), ),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"]) ).toEqual(["ProviderInternal", "ProviderInternal"])
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
@@ -1,18 +1,13 @@
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 selected = OpenAI.responses("gpt-5") const model = OpenAI.responses("gpt-5")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ LLM.request({
model: selected, model,
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,6 +80,11 @@ 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.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks") expect(error.message).toContain("HTTP 400")
}), }),
) )
}) })
@@ -1098,7 +1098,8 @@ describe("Anthropic Messages route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1275,7 +1275,8 @@ describe("OpenAI Chat route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1,10 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect" import { ConfigProvider, Effect, Layer, 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,
@@ -12,23 +11,14 @@ import {
ToolCallPart, ToolCallPart,
ToolDefinition, ToolDefinition,
ToolResultPart, ToolResultPart,
TransportReason,
Usage, Usage,
} from "../../src/index.js" } from "../../src/index.js"
import { import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
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"
@@ -41,47 +31,6 @@ 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,
@@ -267,19 +216,19 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("prepares one OpenAI Responses route for either transport", () => it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
model: OpenAIResponses.route model: OpenAIResponses.webSocketRoute
.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") expect(prepared.route).toBe("openai-responses-websocket")
expect(prepared.protocol).toBe("openai-responses") expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "http-json" }) expect(prepared.metadata).toEqual({ transport: "websocket-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 })
}), }),
) )
@@ -287,32 +236,26 @@ 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<{ const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
readonly url: string
readonly authorization: string | undefined
readonly protocol: string | undefined
}> = []
let closed = false let closed = false
const deps = Layer.succeed( const deps = Layer.mergeAll(
Layer.succeed(
RequestExecutor.Service, RequestExecutor.Service,
RequestExecutor.Service.of({ RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"), execute: () => Effect.die("unexpected HTTP request"),
}), }),
) ),
const webSocket = WebSocketTransport.makeDirect({ Layer.succeed(
WebSocketExecutor.Service,
WebSocketExecutor.Service.of({
open: (input) => open: (input) =>
Effect.succeed({ Effect.succeed({
sendText: (message) => sendText: (message) =>
Effect.sync(() => { Effect.sync(() => {
opened.push({ opened.push({ url: input.url, authorization: input.headers.authorization })
url: input.url,
authorization: input.headers.authorization,
protocol: input.headers["openai-beta"],
})
sent.push(message) sent.push(message)
}), }),
messages: Stream.fromArray([ messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }), ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }), ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]), ]),
@@ -320,27 +263,20 @@ describe("OpenAI Responses route", () => {
closed = true closed = true
}), }),
}), }),
}) }),
),
)
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLM.request({ LLM.request({
model: OpenAI.configure({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
baseURL: "https://api.openai.test/v1/", "gpt-4.1-mini",
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([ expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
{
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({
@@ -352,524 +288,15 @@ 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* WebSocketTransport.fromWebSocket( const error = yield* WebSocketExecutor.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" })
}), }),
) )
@@ -902,7 +329,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/", baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -2610,7 +2037,8 @@ describe("OpenAI Responses route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
}) })
+7 -5
View File
@@ -2,11 +2,12 @@ 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 } from "../src/route.js" import { LLMClient, RequestExecutor, WebSocketExecutor } 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,
@@ -16,7 +17,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 | LLMClientService | ImageClientService type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & { type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions readonly options?: HttpRecorder.RecorderOptions
@@ -81,10 +82,11 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}), }),
), ),
) )
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll( return Layer.mergeAll(
requestExecutor, deps,
LLMClient.layer.pipe(Layer.provide(requestExecutor)), LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)), ImageClient.layer.pipe(Layer.provide(deps)),
) )
}, },
}) })
-23
View File
@@ -11,7 +11,6 @@ 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"
@@ -109,25 +108,3 @@ 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: [], always: ["git *"],
}, },
], ],
}) })
@@ -16,7 +16,6 @@ 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"
@@ -362,7 +361,6 @@ 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()
@@ -386,10 +384,7 @@ function ProviderConnection(props: {
const provider = createMemo(() => ({ const provider = createMemo(() => ({
id: props.provider, id: props.provider,
name: name:
providers.all().get(props.provider)?.name ?? providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
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,6 +10,7 @@ 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"
@@ -42,6 +43,7 @@ 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>({
@@ -117,7 +119,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(serverSync.data.provider.all.keys()), existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
+5 -1
View File
@@ -11,6 +11,9 @@ 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
@@ -27,6 +30,7 @@ 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()
@@ -73,7 +77,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(`/${dir}/session/${forked.id}`) navigate(sessionHref(ServerConnection.key(serverSDK.server), 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,5 +1,6 @@
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
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"
@@ -14,12 +15,13 @@ const statusLabels = {
} as const } as const
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const data = useData()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync().data.mcp ?? {}) (data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
.map(([name, status]) => ({ name, status: status.status })) .map((server) => ({ name: server.name, status: server.status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
}} }}
> >
{(i) => { {(i) => {
const mcpStatus = () => sync().data.mcp[i.name] const mcpStatus = () =>
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,12 +1,13 @@
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, createMemo, createResource, createSignal } from "solid-js" import { type Component, For, Show, createEffect, 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"
@@ -77,30 +78,41 @@ 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()
const [serverMcp] = createResource( createEffect(() => {
serverSDK, if (serverSDK.connection.status() !== "connected") return
(sdk) => const ref = { directory: directorySDK().directory }
sdk.api.mcp void Promise.all([
.list() data.location.mcp.server.sync(),
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) data.location.skill.sync(),
.catch(() => ({})), data.location.mcp.server.sync(ref),
{ initialValue: {} }, data.location.skill.sync(ref),
) ]).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 Object.keys(sync().data.mcp ?? {}) return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
.map((server) => server.name)
.filter((name) => !shared.has(name)) .filter((name) => !shared.has(name))
.sort() .sort()
}) })
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" const mcpEnabled = (name: string) =>
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(() => {
@@ -108,23 +120,12 @@ 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] = createResource( const serverSkills = createMemo(() => data.location.skill.list() ?? [])
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.latest.map(skillKey)) const shared = new Set(serverSkills().map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item))) return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
(item) => !shared.has(skillKey(item)),
)
}) })
const mcpRows = (items: string[]) => ( const mcpRows = (items: string[]) => (
@@ -205,7 +206,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.latest.length}>{skillRows(serverSkills.latest)}</SharedSection> <SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div> </div>
</TabsV2.Content> </TabsV2.Content>
@@ -23,6 +23,7 @@ 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"
@@ -82,6 +83,7 @@ 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()
@@ -113,8 +115,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path] return [...result, path]
}, []) }, [])
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
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"),
) )
@@ -246,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
) )
const resources = createMemo(() => const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({ (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).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}`,
@@ -292,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
]) ])
const slashCommands = createMemo(() => [ const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({ ...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
id: `custom.${item.name}`, id: `custom.${item.name}`,
trigger: item.name, trigger: item.name,
title: item.name, title: item.name,
+6 -4
View File
@@ -27,6 +27,7 @@ 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"
@@ -115,6 +116,7 @@ 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()
@@ -250,8 +252,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths return paths
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
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"),
) )
@@ -585,7 +587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
const mcpResourceList = createMemo(() => const mcpResourceList = createMemo(() =>
Object.values(sync().data.mcp_resource).map( (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map(
(resource): AtOption => ({ (resource): AtOption => ({
type: "resource", type: "resource",
name: resource.name, name: resource.name,
@@ -702,7 +704,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "builtin" as const, type: "builtin" as const,
})) }))
const custom = sync().data.command.map((cmd) => ({ const custom = (data.location.command.list({ directory: sdk().directory }) ?? []).map((cmd) => ({
id: `custom.${cmd.name}`, id: `custom.${cmd.name}`,
trigger: cmd.name, trigger: cmd.name,
title: cmd.name, title: cmd.name,
@@ -286,6 +286,20 @@ 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,5 +1,6 @@
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"
@@ -7,6 +8,7 @@ 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"
@@ -37,7 +39,7 @@ export type FollowupDraft = {
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: DirectorySDK["api"]["session"]
serverSync: ServerSync data: Data
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
@@ -54,17 +56,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.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) input.data.session.setStatus(input.draft.sessionID, "running")
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) input.data.session.setStatus(input.draft.sessionID, "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.sync.data.command.find((item) => item.name === cmd)) { if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
setBusy() setBusy()
try { try {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
@@ -228,6 +230,7 @@ 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
@@ -281,6 +284,7 @@ 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[]) => {
@@ -329,6 +333,7 @@ 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
@@ -503,11 +508,13 @@ 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 = submissionSync.data.command.find((c) => c.name === commandName) const customCommand = submissionData.location.command
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
submissionServerSync.session.set("session_status", session.id, { type: "busy" }) submissionData.session.setStatus(session.id, "running")
void submissionSDK.api.session void submissionSDK.api.session
.command({ .command({
sessionID: session.id, sessionID: session.id,
@@ -524,7 +531,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
), ),
}) })
.catch((err) => { .catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "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")),
@@ -552,14 +559,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({ void sendFollowupDraft({
api: submissionSDK.api.session, api: submissionSDK.api.session,
sync: submissionSync, sync: submissionSync,
serverSync: submissionServerSync, data: submissionData,
session: () => session, session: () => session,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => { }).catch((err) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "idle")
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1,9 +1,7 @@
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 { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
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"
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const queryClient = useQueryClient()
const location = () => { const location = () => {
const directory = options.directory() const directory = options.directory()
return directory ? { directory } : undefined return directory ? { directory } : undefined
@@ -118,10 +115,17 @@ export function createProviderConnectionController(options: {
} }
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const directory = options.directory() const ref = location()
await queryClient data.location.integration.invalidate(ref)
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null)) data.location.provider.invalidate(ref)
.catch(() => undefined) data.location.model.invalidate(ref)
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,6 +9,7 @@ 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"
@@ -44,6 +45,7 @@ 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()
@@ -62,7 +64,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 ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.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 { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
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 serverSync = useServerSync() const data = useData()
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 || serverSync.session.data.session_working(props.sessionID) const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
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,6 +1,7 @@
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"
@@ -96,12 +97,13 @@ 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 ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
() => { () => {
@@ -3,6 +3,7 @@ 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"
@@ -19,6 +20,7 @@ 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])
@@ -37,7 +39,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 = sync().data.vcs?.branch const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
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()?.sync.session.get(route.sessionId)?.location.directory if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined return undefined
}) })
@@ -1,10 +1,11 @@
import { Component, For, createMemo, createResource } from "solid-js" import { Component, For, createEffect, createMemo } 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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
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"
@@ -22,6 +23,7 @@ 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]) => ({
@@ -47,9 +49,11 @@ export const SettingsExtensionsV2: Component = () => {
}) })
}) })
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), { createEffect(() => {
initialValue: [], if (serverSdk.connection.status() !== "connected") return
void data.location.skill.sync().catch(() => undefined)
}) })
const skills = () => data.location.skill.list() ?? []
return ( return (
<> <>
@@ -2,7 +2,6 @@ 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,
@@ -34,7 +33,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()?.sync.session.lineage.peek(id)?.session.location.directory return serverCtx()?.data.session.lineage.peek(id)?.session.location.directory
}) })
return { return {
@@ -14,6 +14,7 @@ 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"
@@ -49,6 +50,7 @@ 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({
@@ -71,13 +73,19 @@ 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 { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } return {
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,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session), context.data.session.list(),
) )
} }
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 { useServer } from "@/context/server" import { useData, 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,6 +103,7 @@ 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()
@@ -119,8 +120,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
dialogRun += 1 dialogRun += 1
}) })
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
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 -7
View File
@@ -14,7 +14,8 @@ import {
hasServiceNeedingAttention, hasServiceNeedingAttention,
serverStatusDotClass, serverStatusDotClass,
} from "./status-popover-indicator" } from "./status-popover-indicator"
import { useServer } from "@/context/server" import { useData, 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 })))
@@ -23,17 +24,20 @@ 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 ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: (sync().data.lsp ?? []).map((item) => item.status),
}), }),
) )
@@ -87,17 +91,20 @@ 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 ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: (sync().data.lsp ?? []).map((item) => item.status),
}), }),
) )
@@ -84,14 +84,18 @@ 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?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx?.data.session.get(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 }) => ctx.sync.session.resolve(id).catch(() => undefined), ({ id, ctx }) =>
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())
@@ -103,13 +107,13 @@ function SessionTabEntry(props: {
const ctx = props.serverCtx const ctx = props.serverCtx
if (!value || !ctx) return if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title }) ctx.data.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.sync.session.remember({ ...current, title: value.title }) if (current && currentCtx) currentCtx.data.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,
@@ -126,9 +130,13 @@ function SessionTabEntry(props: {
prefetched = true prefetched = true
createRoot((dispose) => { createRoot((dispose) => {
try { try {
void ctx.sync void Promise.allSettled([
.ensureDirSyncContext(value.location.directory) ctx.data.session.sync(value.id, { children: true }),
.session.sync(value.id) ctx.data.session.pending.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,15 +1,7 @@
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 { import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
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"
@@ -18,44 +10,11 @@ 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 () => {
@@ -73,39 +32,6 @@ 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 = {
@@ -161,18 +87,4 @@ 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)
})
}) })
+40 -127
View File
@@ -1,22 +1,12 @@
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,
ReferenceListInput, QuestionRequest,
ReferenceListOutput,
ReferenceInfo,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
@@ -27,20 +17,16 @@ 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, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import { cmp, normalizeProjectInfo } 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 { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import type { ServerScope } from "@/utils/server-scope"
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"
@@ -69,12 +55,6 @@ 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()))
} }
@@ -109,9 +89,8 @@ 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 VcsApi = ServerApi["vcs"] type QuestionApi = ServerApi["question"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
queryOptions({ queryOptions({
@@ -150,7 +129,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & { serverAPI: {
readonly location: LocationApi readonly location: LocationApi
readonly project: ProjectApi readonly project: ProjectApi
readonly worktree: WorktreeApi readonly worktree: WorktreeApi
@@ -164,7 +143,6 @@ 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
@@ -224,53 +202,6 @@ 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"],
@@ -284,27 +215,15 @@ 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: CatalogApi & { api: {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
readonly permission: PermissionApi readonly permission: PermissionApi
readonly project: ProjectApi readonly project: ProjectApi
readonly reference: ReferenceListApi readonly question: QuestionApi
readonly session: SessionApi readonly session: SessionApi
readonly vcs: VcsApi
readonly location: LocationApi
} }
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
@@ -314,49 +233,25 @@ 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
@@ -391,23 +286,41 @@ export async function bootstrapDirectory(input: {
) )
}), }),
), ),
() => 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 retry(() =>
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) input.api.question.request
.catch((err) => { .list({ location: { directory: input.directory } })
const project = getFilename(input.directory) .then((result) => result.data)
showToast({ .then((questions) => {
variant: "error", const ids = questions.map((question) => question.sessionID)
title: input.translate("toast.project.reloadFailed.title", { project }), const grouped = groupBySession(
description: formatServerError(err, input.translate), 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)),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -1,14 +1,13 @@
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],
@@ -17,12 +16,26 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
] ]
const child = () => createStore({} as State) const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const path = { state: "", config: "", worktree: "", directory: "", home: "" }
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 () => ({
@@ -60,7 +73,7 @@ beforeAll(async () => {
return options().queryKey?.[1] === "path" return options().queryKey?.[1] === "path"
}, },
get isSuccess() { get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess return false
}, },
get isRefetchError() { get isRefetchError() {
return false return false
@@ -69,7 +82,6 @@ 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
}, },
} }
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -167,7 +181,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -198,7 +213,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -214,7 +230,7 @@ describe("createChildStoreManager", () => {
} }
}) })
test("enables MCP only when requested for the directory", () => { test("syncs 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[] = []
@@ -234,30 +250,21 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
try { try {
if (!manager) throw new Error("manager required") if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false }) const [, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(6) expect(querySingles.length - offset).toBe(1)
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()
@@ -284,7 +291,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -293,11 +301,8 @@ 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(6) expect(queries).toHaveLength(1)
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)
@@ -305,9 +310,6 @@ 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 })
@@ -317,9 +319,8 @@ describe("createChildStoreManager", () => {
} }
}) })
test("does not mark a cancelled provider query as ready", () => { test("does not mark unsynced provider data 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({
@@ -334,7 +335,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -343,7 +345,6 @@ 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()
} }
}) })
@@ -365,7 +366,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -374,11 +376,9 @@ 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 { VcsInfo } from "@/types" import type { Path, VcsInfo } from "@/types"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@@ -17,8 +17,9 @@ 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
@@ -32,8 +33,9 @@ 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: {
provider: NormalizedProviderListResponse path: Path
} }
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -45,7 +47,6 @@ 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>()
@@ -120,7 +121,6 @@ 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,59 +186,49 @@ 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 instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) return (
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 EMPTY = { all: new Map(), connected: [], default: {} } const provider = input.data.location.provider.list({ directory })
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY const model = input.data.location.model.list({ directory })
const provider = providerQuery.data const defaultModel = input.data.location.model.default({ directory })
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider if (!provider || !model || defaultModel === undefined) return { all: new Map(), connected: [], default: {} }
return provider return normalizeProviderList(provider, model, defaultModel)
}, },
config: {}, config: {},
get path() { get path() {
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" } const location = input.data.location.info({ directory })
if (pathQuery.isLoading) return EMPTY return {
return pathQuery.data ?? EMPTY state: "",
config: "",
worktree: location?.project.directory ?? "",
directory: location?.directory ?? directory,
home: input.global.path.home,
}
}, },
status: "loading" as const, status: "loading" as const,
agent: [], get agent() {
command: [], return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
},
get command() {
return input.data.location.command.list({ directory }) ?? []
},
get reference() { get reference() {
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) return input.data.location.reference.list({ directory }) ?? []
}, },
session: [], session: [],
sessionTotal: 0, sessionTotal: 0,
@@ -250,14 +240,22 @@ export function createChildStoreManager(input: {
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {}, permission: {},
question: {},
get mcp_ready() { get mcp_ready() {
return !mcpQuery.isLoading return input.data.location.mcp.server.list({ directory }) !== undefined
}, },
get mcp() { get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) return Object.fromEntries(
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
)
}, },
get mcp_resource() { get mcp_resource() {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) return Object.fromEntries(
(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
@@ -265,7 +263,11 @@ export function createChildStoreManager(input: {
get lsp() { get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? []) return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
}, },
vcs: vcsStore.value, get vcs() {
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: {},
@@ -274,7 +276,6 @@ 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) => {
@@ -338,7 +339,6 @@ 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,7 +355,6 @@ 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, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, 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,6 +45,19 @@ 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",
@@ -62,6 +75,7 @@ const baseState = (input: Partial<State> = {}) =>
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {}, permission: {},
question: {},
mcp: {}, mcp: {},
lsp: [], lsp: [],
vcs: undefined, vcs: undefined,
@@ -206,6 +220,7 @@ 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" } },
}), }),
) )
@@ -226,6 +241,7 @@ 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()
}) })
@@ -266,6 +282,7 @@ 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" } },
}), }),
) )
@@ -289,6 +306,7 @@ 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()
} }
}) })
@@ -307,6 +325,7 @@ 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" } },
}), }),
) )
@@ -330,6 +349,7 @@ 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])
}) })
@@ -466,11 +486,12 @@ describe("applyDirectoryEvent", () => {
expect(store.part[messageID]).toBeUndefined() expect(store.part[messageID]).toBeUndefined()
}) })
test("tracks permission request lifecycles", () => { test("tracks permission and question 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)] },
}), }),
) )
@@ -503,6 +524,36 @@ 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,7 +2,13 @@ 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 { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise" import type {
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"
@@ -21,6 +27,9 @@ 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: {
@@ -77,6 +86,7 @@ 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)
@@ -428,6 +438,43 @@ 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, SessionStatus } from "@opencode-ai/client/promise" import type { FormInfo, PermissionRequest, QuestionRequest, 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,6 +33,7 @@ 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>
} = { } = {
@@ -43,6 +44,7 @@ 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" },
} }
@@ -56,6 +58,7 @@ 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()
}) })
@@ -69,6 +72,7 @@ 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>
} = { } = {
@@ -79,6 +83,7 @@ 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, SessionStatus } from "@opencode-ai/client/promise" import type { FormInfo, PermissionRequest, QuestionRequest, 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,6 +13,7 @@ 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>
} }
@@ -37,6 +38,7 @@ 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,6 +2,7 @@ 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,
@@ -49,6 +50,9 @@ 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"]
+11 -3
View File
@@ -7,6 +7,7 @@ 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"
@@ -99,9 +100,15 @@ function createServerController(
) { ) {
const connKey = ServerConnection.key(conn) const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope) const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk) const data = createServerData({
const permission = createServerPermissionState({ sdk, sync }) api: () => sdk.api,
const notification = createServerNotificationState({ sdk, sync, key: connKey }) event: sdk.event,
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 })
@@ -134,6 +141,7 @@ 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,
-1
View File
@@ -3,7 +3,6 @@ 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"
+8 -1
View File
@@ -11,6 +11,8 @@ 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"
@@ -61,13 +63,18 @@ 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(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() =>
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)))
+9 -10
View File
@@ -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 { ServerSync } from "./server-sync" import type { Data } from "@opencode-ai/client/solid"
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; sync: ServerSync; key: ServerConnection.Key }) { export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
@@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
}) })
} }
const lookup = async (directory: string, sessionID?: string) => { const lookup = async (sessionID?: string) => {
if (!sessionID) return undefined if (!sessionID) return undefined
const sync = input.sync.ensureDirSyncContext(directory) const session = input.data.session.get(sessionID)
const session = sync.session.get(sessionID)
if (session) return session if (session) return session
return sync.session return input.data.session
.sync(sessionID) .sync(sessionID)
.then(() => sync.session.get(sessionID)) .then(() => input.data.session.get(sessionID))
.catch(() => undefined) .catch(() => undefined)
} }
@@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
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(directory, sessionID).then((session) => { void lookup(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
@@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
time: number, time: number,
) => { ) => {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => { void lookup(sessionID).then((session) => {
if (meta.disposed) return if (meta.disposed) return
if (session?.parentID) return if (session?.parentID) return
@@ -278,7 +277,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
}) })
} }
const unsub = input.sdk.event.listen((e) => { const unsub = input.sdk.eventByDir.listen((e) => {
const event = e.details const event = e.details
if ( if (
event.type !== "session.execution.succeeded" && event.type !== "session.execution.succeeded" &&
+9 -10
View File
@@ -5,6 +5,7 @@ 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"
@@ -52,9 +53,9 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule) return Object.values(config).some(isNonAllowRule)
} }
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0] type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
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"]),
@@ -141,10 +142,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
}) })
} }
function sessions(directory?: string) { function sessions(_directory?: string) {
const info = Object.values(input.sync.session.data.info).filter((session) => !!session) return input.data.session.list()
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) {
@@ -160,15 +159,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
} }
function isPending(permission: PermissionRequest) { function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID] const pending = input.data.session.permission.list(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.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) if (input.data.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) const lineage = await input.data.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)
} }
@@ -197,7 +196,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.event.listen((event) => { const unsubscribe = input.sdk.eventByDir.listen((event) => {
if (ready()) { if (ready()) {
handlePermission(event) handlePermission(event)
return return
+1 -67
View File
@@ -1,18 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { adaptServerEvent } 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", () => {
@@ -43,57 +31,3 @@ 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"])
})
})
+32 -283
View File
@@ -1,9 +1,8 @@
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 { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, onCleanup } from "solid-js"
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"
@@ -12,85 +11,15 @@ 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 }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting" type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
scope: ServerScope scope: ServerScope
@@ -101,231 +30,51 @@ type ServerSDKBase = {
attempt: Accessor<number> attempt: Accessor<number>
error: Accessor<string | undefined> error: Accessor<string | undefined>
} }
event: { eventByDir: {
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 abort = new AbortController() const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const eventFetch = (() => { const connection = createClientConnection(api, {
if (!platform.fetch || !server) return flushInterval: 16,
try { pageLifecycle: true,
const url = new URL(server.http.url) onEvent(event) {
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" emitter.emit(event.type, event)
if (url.protocol === "http:" && !loopback) return platform.fetch dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
} catch { },
return log: {
} 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(() => {
stop() dirEmitter.clear()
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,
status: () => connection.status, eventByDir: {
attempt: () => connection.attempt, on: dirEmitter.on.bind(dirEmitter),
error: () => connection.error, listen: dirEmitter.listen.bind(dirEmitter),
}, },
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
@@ -365,7 +114,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.eventByDir.on(directory, (event) => {
emitter.emit(event.type, event) emitter.emit(event.type, event)
}) })
onCleanup(unsub) onCleanup(unsub)
+38 -1
View File
@@ -10,7 +10,7 @@ import type {
SessionMessageInfo, SessionMessageInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, 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,6 +198,7 @@ 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[]>,
@@ -280,6 +281,9 @@ 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),
@@ -525,6 +529,9 @@ 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),
@@ -1332,6 +1339,36 @@ 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 -59
View File
@@ -1,18 +1,10 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
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,
@@ -21,56 +13,6 @@ 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
+38 -173
View File
@@ -4,20 +4,12 @@ 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"
@@ -32,7 +24,6 @@ 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"
@@ -40,21 +31,15 @@ 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
@@ -68,11 +53,8 @@ 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"
@@ -88,16 +70,6 @@ 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
@@ -107,47 +79,6 @@ 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,
@@ -194,21 +125,14 @@ export function reconcileActiveSessionStatuses(
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope), globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree), path: () => loadPathQuery(scope, null, serverAPI.location),
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) { export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const owner = getOwner() const owner = getOwner()
@@ -222,22 +146,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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 hydrateSessionState = async (sessionID: string) => { const hydrateSession = (sessionID: string) => session.sync(sessionID)
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, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() }, { ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() }, { ...queryOptionsApi.path(), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
], ],
})) }))
const activeSessionsQuery = useQuery(() => ({ const activeSessionsQuery = useQuery(() => ({
@@ -246,7 +160,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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 Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined) void session.resolve(sessionID).catch(() => undefined)
}) })
return active return active
}, },
@@ -255,9 +169,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})) }))
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() {
@@ -265,11 +176,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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 ?? {}
@@ -338,10 +244,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (!connected()) return if (!connected()) return
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onMcp: (directory, setStore) => { onMcp: (directory) => {
void loadCommands(directory, serverSDK.api.command) void Promise.all([
.then((commands) => setStore("command", commands)) data.location.command.sync({ directory }),
.catch((err) => { data.location.mcp.server.sync({ directory }),
data.location.mcp.resource.sync({ directory }),
]).catch((err) => {
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
@@ -353,36 +261,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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 provider() { get path() {
return globalStore.provider return globalStore.path
}, },
}, },
}) })
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: () => {
@@ -393,14 +281,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
}, },
connected: (info) => { connected: (info) => {
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined) if (info.reconnect) void session.refreshPinned(() => Promise.resolve()).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)
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory) void data.location.sync({ directory }).catch(() => undefined)
}) })
}, },
}) })
@@ -498,8 +386,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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,
@@ -508,7 +396,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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],
@@ -518,7 +405,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryClient, queryClient,
session, session,
}), }),
initial ? refreshVcs(directory) : Promise.resolve(),
]) ])
}) })
@@ -568,7 +454,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return event return event
} }
const unsub = serverSDK.event.listen((e) => { const unsub = serverSDK.eventByDir.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
@@ -628,7 +514,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.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") {
@@ -661,21 +546,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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) if (projected && eventType !== "vcs.branch.updated")
applyDirectoryEvent({ applyDirectoryEvent({
event: projected, event: projected,
directory, directory,
@@ -694,7 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
loadReferences: () => { loadReferences: () => {
if (!children.active(key)) return if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key)) void data.location.reference.sync({ directory: key }).catch(() => undefined)
}, },
}) })
}) })
@@ -710,7 +584,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
const projectApi = { const projectApi = {
loadSessions,
meta(directory: string, patch: ProjectMeta) { meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch) children.projectMeta(directory, patch)
}, },
@@ -727,29 +600,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers data.location.provider.invalidate()
// appear immediately in the available provider list across all directories. data.location.model.invalidate()
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) data.location.model.invalidateDefault()
queryClient.invalidateQueries({ void Promise.all([
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", data.location.provider.sync(),
}) 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,
@@ -788,8 +654,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
platform.openExternal(attempt.data.url) platform.openExternal(attempt.data.url)
}, },
refresh: async () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) data.location.mcp.server.invalidate({ directory: key })
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) data.location.mcp.resource.invalidate({ directory: key })
await Promise.all([
data.location.mcp.server.sync({ directory: key }),
data.location.mcp.resource.sync({ directory: key }),
])
}, },
}) })
}, },
@@ -797,8 +667,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
} }
export function createServerSyncContext(serverSDK: ServerSDK) { export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
const inner = createServerSyncContextInner(serverSDK) const inner = createServerSyncContextInner(serverSDK, data)
return Object.assign(inner, { return Object.assign(inner, {
ensureDirSyncContext: createRefCountMap( ensureDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, serverSDK), (dir) => createDirSyncContext(dir, inner, serverSDK),
@@ -814,8 +684,3 @@ 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
}
+5
View File
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
}, },
}) })
export const useData = () => {
const server = useServer()
return server.ctx.data
}
+8 -11
View File
@@ -1,21 +1,18 @@
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { pathKey } from "@/utils/path-key" import { createEffect, type Accessor } from "solid-js"
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 serverSync = useServerSync() const data = useData()
const query = createQuery(() => {
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
const value = directory() const value = directory()
return { void data.location.integration.sync(value ? { directory: value } : undefined).catch(() => undefined)
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
enabled: serverSDK.connection.status() === "connected",
}
}) })
return { return {
list: () => (query.isSuccess || query.isRefetchError ? query.data : []), list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
} }
} }
+34 -9
View File
@@ -1,9 +1,9 @@
import { useQueryOptions } from "@/context/server-sync" import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProviderList } from "@/context/global-sync/utils"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import { type Accessor } from "solid-js" import { createEffect, createMemo, 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,16 +18,41 @@ 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 providersQuery = useQuery(() => { const data = useData()
const queryOpts = useQueryOptions() const sdk = useServerSDK()
const location = () => {
const dir = directory() const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null) return dir ? { directory: dir } : undefined
}
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 = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data) const providers = createMemo(() => {
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: () => providersQuery.isSuccess, ready: () => {
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: () =>
+20 -2
View File
@@ -12,6 +12,8 @@ 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<{
@@ -25,6 +27,8 @@ 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()))
@@ -33,8 +37,7 @@ export function DirectoryDataProvider(
return `/${slug()}/session/${sessionID}` return `/${slug()}/session/${sessionID}`
} }
const navigateToSession = async (sessionID: string) => { const navigateToSession = async (sessionID: string) => {
const session = serverSync.session await data.session.lineage.resolve(sessionID).catch(() => undefined)
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
navigate(href(sessionID)) navigate(href(sessionID))
} }
@@ -52,6 +55,21 @@ 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.sync.session.sync(record.session.id)).catch(() => {}) void untrack(() => ctx.data.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.sync.session.remember(session) ctx.data.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,32 +11,28 @@ 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
const [store] = sync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
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 serverSync = sync() const data = serverCtx()?.data
if (!serverSync) return false if (!data) return false
const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
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 serverSync = sync() const data = serverCtx()?.data
if (!serverSync) return false if (!data) return false
if (needsAttention()) return false if (needsAttention()) return false
return serverSync.session.data.session_working(sessionId()) return data.session.status(sessionId()) === "running"
}) })
return { unread, loading } return { unread, loading }
} }
@@ -4,7 +4,6 @@ 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"
@@ -17,7 +16,6 @@ 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()
@@ -29,7 +27,6 @@ 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,7 +17,6 @@ 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"
@@ -107,7 +106,6 @@ 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"),
@@ -115,7 +113,7 @@ function ProviderTip() {
) )
const visible = createMemo( const visible = createMemo(
() => () =>
serverSync.child(sdk().directory)[0].provider_ready && providers.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 { createMemo } from "solid-js" import { createEffect, 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 { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
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 serverSync = useServerSync() const data = useData()
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,12 +74,17 @@ export function createNewSessionWorkspaceController(input: {
}), }),
) )
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch) createEffect(() => {
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) => serverSync.child(worktree)[0].vcs?.branch, worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}), }),
) )
const remember = (worktree = value()) => { const remember = (worktree = value()) => {
+27 -18
View File
@@ -47,6 +47,7 @@ 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"
@@ -101,7 +102,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 { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { 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"
@@ -151,8 +152,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 serverSync = useServerSync() const data = useData()
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory) const directory = createMemo(() => data.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.
@@ -238,11 +239,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 sync = useServerSync() const data = useData()
const serverKey = createMemo(() => requireServerKey(params.serverKey)) const serverKey = createMemo(() => requireServerKey(params.serverKey))
const current = createSessionLineage( const current = createSessionLineage(
() => params.id, () => params.id,
() => sync.session.lineage, () => data.session.lineage,
) )
const directory = createMemo(() => current()?.session.location.directory) const directory = createMemo(() => current()?.session.location.directory)
@@ -330,6 +331,7 @@ 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()
@@ -384,7 +386,6 @@ 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(() =>
@@ -572,12 +573,17 @@ 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 (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { if (
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")
@@ -601,8 +607,8 @@ export default function Page() {
serverSDK.scope, serverSDK.scope,
"session-vcs", "session-vcs",
sdk().directory, sdk().directory,
sync().data.vcs?.branch ?? "", vcs()?.branch.current ?? "",
sync().data.vcs?.default_branch ?? "", vcs()?.branch.default ?? "",
] as const, ] as const,
) )
const vcsQuery = createQuery(() => { const vcsQuery = createQuery(() => {
@@ -849,7 +855,7 @@ export default function Page() {
return [ return [
sdk().directory, sdk().directory,
id, id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", id ? data.session.status(id) : "idle",
id ? composer.blocked() : false, id ? composer.blocked() : false,
] as const ] as const
}, },
@@ -1034,7 +1040,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type, () => data.session.status(controller.identity.params.id ?? ""),
(next, prev) => { (next, prev) => {
if (next !== "idle" || prev === undefined || prev === "idle") return if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs() refreshVcs()
@@ -1646,7 +1652,7 @@ export default function Page() {
target.session.remember({ ...session, revert: next }) target.session.remember({ ...session, revert: next })
} }
const busy = (sessionID: string) => sync().data.session_working(sessionID) const busy = (sessionID: string) => data.session.status(sessionID) === "running"
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
@@ -1677,8 +1683,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(),
serverSync: serverSync, data,
session: () => sync().session.get(input.sessionID), session: () => data.session.get(input.sessionID),
draft: item, draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory, optimisticBusy: item.sessionDirectory === sdk().directory,
}).catch((err) => { }).catch((err) => {
@@ -1743,7 +1749,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 (sync().session.get(sessionID)?.parentID) return Promise.resolve() if (data.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()
@@ -2128,9 +2134,12 @@ 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,5 +1,4 @@
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"
@@ -8,43 +7,40 @@ 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 { pathKey } from "@/utils/path-key" import { useData } from "@/context/server"
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 sync = useSync() const data = useData()
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: sync().data.agent, available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
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: agentsQuery.isLoading, loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
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() && agentsQuery.isLoading) || !providers.ready(), loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
}, },
session: { session: {
id: input.sessionID(), id: input.sessionID(),
@@ -4,6 +4,7 @@ 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"
@@ -40,6 +41,7 @@ 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,
@@ -104,7 +106,7 @@ export function createSessionComposerRegionController(input: {
const parentID = createMemo(() => { const parentID = createMemo(() => {
const id = input.sessionID() const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined return id ? data.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 { createQuery, useQueryClient } from "@tanstack/solid-query" import { useData } from "@/context/server"
export const todoState = (input: { export const todoState = (input: {
count: number count: number
@@ -34,36 +34,20 @@ 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 queryClient = useQueryClient() const data = useData()
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const createEffect(() => {
const shells = createQuery(() => ({ if (!params.id || serverSDK.connection.status() !== "connected") return
queryKey: shellKey(), void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
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(sync().data.session, serverSync.session.data.form, params.id) return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
}) })
const permissionRequest = createMemo((): PermissionRequest | undefined => { const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => { return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory) return !permission.autoResponds(item, sdk().directory)
}) })
}) })
@@ -84,16 +68,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(() => sync().data.session_working(params.id ?? "") || blocked()) const live = createMemo(() => data.session.status(params.id ?? "") === "running" || blocked())
const primary = () => { const primary = () => {
const id = params.id const id = params.id
return !!id && !serverSync.session.get(id)?.parentID return !!id && !data.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 = (serverSync.session.data.session_message[id] ?? []).findLast( const assistant = data.session.message.list(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 []
@@ -116,7 +100,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 = serverSync.session.data.session_message[id] ?? [] const messages = data.session.message.list(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 []
@@ -144,9 +128,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
] ]
}) })
}) })
const active = Object.values(serverSync.session.data.info).flatMap((info) => { const active = data.session.list().flatMap((info) => {
if (info?.parentID !== id) return [] if (info?.parentID !== id) return []
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return [] if (data.session.status(info.id) === "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)),
@@ -171,7 +155,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
] ]
}) })
}) })
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => { const running = data.shell.list({ directory: sdk().directory }).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,6 +37,7 @@ 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"
@@ -45,6 +46,7 @@ export function SessionPermissionDock(props: {
> >
{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>, request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
sessionID?: string, sessionID?: string,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
@@ -28,14 +28,15 @@ function sessionTreeRequest<T>(
} }
} }
const id = ids.find((id) => request[id]?.some(include)) const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return if (!id) return
return request[id]?.find(include) return list(id)?.find(include)
} }
export function sessionPermissionRequest( export function sessionPermissionRequest(
session: SessionInfo[], session: SessionInfo[],
request: Record<string, PermissionRequest[] | undefined>, request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
sessionID?: string, sessionID?: string,
include?: (item: PermissionRequest) => boolean, include?: (item: PermissionRequest) => boolean,
) { ) {
@@ -44,7 +45,7 @@ export function sessionPermissionRequest(
export function sessionQuestionForm( export function sessionQuestionForm(
session: SessionInfo[], session: SessionInfo[],
request: Record<string, FormInfo[] | undefined>, request: Record<string, FormInfo[] | undefined> | ((sessionID: 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,6 +2,7 @@ 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 {
@@ -24,16 +25,17 @@ 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 ? sync().session.get(id) : undefined return id ? data.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 ? sync().session.get(id) : undefined return id ? data.session.get(id) : undefined
}) })
const status = createMemo(() => { const status = createMemo(() => {
const id = sessionID() const id = sessionID()
@@ -75,7 +77,7 @@ export function createSessionController(input: {
status, status,
working: createMemo(() => { working: createMemo(() => {
const id = sessionID() const id = sessionID()
return id ? sync().data.session_working(id) : false return id ? data.session.status(id) === "running" : false
}), }),
revertMessageID, revertMessageID,
}, },
@@ -15,13 +15,15 @@ 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 { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { 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[] = []
@@ -46,8 +48,10 @@ 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()
@@ -60,7 +64,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 = sync().data.session_message[id] ?? [] const projected = data.session.message.list(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)
@@ -146,19 +150,22 @@ 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))
if (input.session.identity.params.serverKey)
return tabs.newDraft({ return tabs.newDraft({
server: requireServerKey(input.session.identity.params.serverKey), server: input.session.identity.params.serverKey
? 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 {
@@ -180,7 +187,7 @@ export function createTimelineController(input: {
} }
} }
const remove = async (id: string) => { const remove = async (id: string) => {
const session = sync().session.get(id) const session = data.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)
@@ -203,7 +210,7 @@ export function createTimelineController(input: {
function DeleteDialog(props: { sessionID: string }) { function DeleteDialog(props: { sessionID: string }) {
const name = createMemo( const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), () => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
) )
const confirm = async () => { const confirm = async () => {
await remove(props.sessionID) await remove(props.sessionID)
@@ -254,7 +261,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 sync().session.sync(id) void Promise.all([data.session.sync(id), 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 { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
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 serverSync = useServerSync() const data = useData()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom) const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
@@ -438,6 +438,11 @@ 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) => {
@@ -1468,8 +1473,8 @@ function MessageTimelineView(
project={project()} project={project()}
directory={sessionDirectory()} directory={sessionDirectory()}
local={!workspaceSession()} local={!workspaceSession()}
branch={sync().data.vcs?.branch} branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={serverSync.child(project().worktree)[0].vcs?.branch} baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()} diffs={sessionDiffs()}
sessionID={id} sessionID={id}
moveEligible={props.workspaceMoveEligible} moveEligible={props.workspaceMoveEligible}
@@ -10,6 +10,7 @@ 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"
@@ -54,6 +55,7 @@ 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()
@@ -304,7 +306,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 (sync().data.session_working(sessionID)) { if (data.session.status(sessionID) === "running") {
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