Compare commits

..

2 Commits

Author SHA1 Message Date
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
659 changed files with 156895 additions and 9866 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 *"],
}, },
], ],
}) })
+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,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 (
<> <>
@@ -1,15 +1,8 @@
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 { CatalogApi } 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, loadProvidersQuery } 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"
@@ -73,39 +66,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 +121,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)
})
}) })
@@ -1,22 +1,13 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import type { import type {
AgentListInput,
AgentListOutput,
CatalogApi, 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,10 +18,9 @@ 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, directoryKey, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
@@ -109,9 +99,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({
@@ -239,38 +228,6 @@ export const loadProvidersQuery = (scope: ServerScope, directory: string | null,
}), }),
}) })
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 +241,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: CatalogApi & {
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>
@@ -321,9 +266,7 @@ export async function bootstrapDirectory(input: {
}) { }) {
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 }))
} }
@@ -334,29 +277,11 @@ export async function bootstrapDirectory(input: {
providerRev.set(revKey, rev) 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,12 +316,41 @@ export async function bootstrapDirectory(input: {
) )
}), }),
), ),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) .fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
@@ -5,6 +5,7 @@ import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/con
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 }> = []
@@ -18,6 +19,20 @@ 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 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 },
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 () => ({}) }),
@@ -100,7 +115,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -134,7 +150,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -167,7 +184,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -198,7 +216,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -214,7 +233,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 +253,21 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
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(2)
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 +294,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -293,11 +304,9 @@ 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(2)
expect(queries[0]?.().enabled).toBe(false) expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false) expect(queries[1]?.().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 +314,7 @@ 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[1]?.().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 })
@@ -334,7 +341,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -365,7 +373,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path, provider },
}) })
}) })
@@ -374,11 +383,11 @@ describe("createChildStoreManager", () => {
manager.child("/handshake") manager.child("/handshake")
const queries = querySingles.slice(offset) const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false) expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false) expect(queries[1]?.().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) expect(queries[1]?.().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,
@@ -19,6 +19,8 @@ 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 { 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 } from "./utils"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
@@ -32,7 +34,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: {
path: Path
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
} }
}) { }) {
@@ -45,7 +49,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 +123,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,21 +188,7 @@ 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(),
@@ -209,11 +197,6 @@ export function createChildStoreManager(input: {
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(), 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,
@@ -230,15 +213,24 @@ export function createChildStoreManager(input: {
}, },
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 +242,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 +265,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 +278,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 +341,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 +357,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"]
+9 -1
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,7 +100,13 @@ 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({
api: () => sdk.api,
event: sdk.event,
connection: sdk.connection,
directory: "",
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync }) const permission = createServerPermissionState({ sdk, sync })
const notification = createServerNotificationState({ sdk, sync, key: connKey }) const notification = createServerNotificationState({ sdk, sync, key: connKey })
@@ -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 -1
View File
@@ -278,7 +278,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" &&
+2 -2
View File
@@ -52,7 +52,7 @@ 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 }) {
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
void respondPending(event.properties, e.name) void respondPending(event.properties, e.name)
} }
const unsubscribe = input.sdk.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
+51 -119
View File
@@ -10,14 +10,9 @@ import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
clearProviderRev, clearProviderRev,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery, loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery, loadPathQuery,
loadProjectsQuery,
loadProvidersQuery, 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"
@@ -40,21 +35,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
@@ -88,16 +77,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 +86,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 +132,15 @@ 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), 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()
@@ -237,7 +169,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queries: [ queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() }, { ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() }, { ...queryOptionsApi.providers(null), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() }, { ...queryOptionsApi.path(), enabled: connected() },
], ],
})) }))
const activeSessionsQuery = useQuery(() => ({ const activeSessionsQuery = useQuery(() => ({
@@ -338,10 +270,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) }),
@@ -357,32 +291,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
data,
global: { global: {
get path() {
return globalStore.path
},
get provider() { get provider() {
return globalStore.provider return globalStore.provider
}, },
}, },
}) })
const catalog = createCatalogSync({
scope: serverSDK.scope,
queryClient,
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
load: (directory) =>
Promise.all([
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
]).then(() => undefined),
})
const refreshVcs = (directory: string) =>
serverSDK.api.vcs
.get({ location: { directory } })
.then((result) =>
children.vcs(directory, {
branch: result.data.branch.current,
default_branch: result.data.branch.default,
}),
)
.catch(() => undefined)
const connection = createConnectionSync({ const connection = createConnectionSync({
status: serverSDK.connection.status, status: serverSDK.connection.status,
invalidate: () => { invalidate: () => {
@@ -400,7 +318,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.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 +416,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,
@@ -518,7 +436,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryClient, queryClient,
session, session,
}), }),
initial ? refreshVcs(directory) : Promise.resolve(),
]) ])
}) })
@@ -568,7 +485,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,8 +545,10 @@ 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 (eventType === "catalog.updated" || eventType === "integration.updated") {
void queryClient.invalidateQueries(queryOptionsApi.providers(directory === "global" ? null : key))
}
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
@@ -661,21 +580,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 +602,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)
}, },
}) })
}) })
@@ -719,6 +627,26 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
} }
const refreshProviders = () => {
const locations = Object.keys(children.children).filter(children.active)
if (locations.length === 0) locations.push(data.location.default().directory)
locations.filter(Boolean).forEach((directory) => {
data.location.provider.invalidate({ directory })
data.location.model.invalidate({ directory })
})
return Promise.all(
[
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
}),
...locations.filter(Boolean).flatMap((directory) => [
data.location.provider.sync({ directory }),
data.location.model.sync({ directory }),
]),
],
).then(() => undefined)
}
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: async (config: Config) => { mutationFn: async (config: Config) => {
// TODO: Restore config updates when the V2 client exposes a config API. // TODO: Restore config updates when the V2 client exposes a config API.
@@ -749,7 +677,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
refreshProviders: catalog.refreshActive, refreshProviders,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
@@ -788,8 +716,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 +729,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),
+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) ?? [],
} }
} }
@@ -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()))
@@ -52,6 +56,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
@@ -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({
+6 -4
View File
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { 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"
@@ -384,7 +384,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(() =>
@@ -2128,9 +2127,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,28 +7,26 @@ 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 { 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"
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 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 {
@@ -37,14 +34,14 @@ export function createPromptInputController(input: {
available: sync().data.agent, available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name), options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "", current: local.agent.current()?.name ?? "",
loading: 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(),
@@ -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,29 +34,13 @@ 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(sync().data.session, serverSync.session.data.form, params.id)
@@ -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>
@@ -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,6 +48,7 @@ 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 settings = useSettings() const settings = useSettings()
@@ -146,19 +149,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 {
+2 -3
View File
@@ -38,10 +38,8 @@
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"open": "10.1.2", "open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3", "uqr": "0.1.3",
"ws": "8.21.0" "ws": "8.21.0"
}, },
@@ -50,6 +48,7 @@
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
+15 -4
View File
@@ -1,7 +1,7 @@
import { $ } from "bun" import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { brotliCompressSync, constants } from "node:zlib" import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) { export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({}) if (options?.skipBuild) return compress({})
@@ -9,10 +9,8 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel }) await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries( const assets = Object.fromEntries(
await Promise.all( await Promise.all(
(await collectFiles(path.join(root, "dist"))) (await files(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map")) .filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => { .map(async (key) => {
const source = path.join(root, "dist", key) const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer()) const body = Buffer.from(await Bun.file(source).arrayBuffer())
@@ -33,3 +31,16 @@ function compress(assets: object) {
function isText(key: string) { function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key) return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
} }
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
-3
View File
@@ -12,7 +12,6 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
import { mainConfig } from "../vite.node.config" import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target" import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppArchive } from "./app-assets" import { buildAppArchive } from "./app-assets"
import { verifyArtifact } from "./verify-artifact"
const NODE_VERSION = "26.4.0" const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
@@ -92,7 +91,6 @@ for (const target of targets) {
await copyNodeAssets(assets) await copyNodeAssets(assets)
await build(mainConfig(input)) await build(mainConfig(input))
await assertTextImportsInlined("dist-node/opencode.mjs") await assertTextImportsInlined("dist-node/opencode.mjs")
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
const host = target.platform === process.platform && target.arch === process.arch const host = target.platform === process.platform && target.arch === process.arch
if (host) { if (host) {
@@ -141,7 +139,6 @@ for (const target of targets) {
2, 2,
)}\n`, )}\n`,
) )
await verifyArtifact(path.join(outdir, name))
if (host) await smoke(output) if (host) await smoke(output)
} }
+1 -14
View File
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun" import type { BunPlugin } from "bun"
import pkg from "../package.json" import pkg from "../package.json"
import { buildAppArchive } from "./app-assets" import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2" const binary = "opencode2"
@@ -77,16 +76,6 @@ const appAssetsPlugin: BunPlugin = {
} }
for (const item of targets) { for (const item of targets) {
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
setup(build) {
build.onLoad(
{ filter: /packages[/\\]simulation[/\\]src[/\\](frontend[/\\](simulation|server)|control-server)\.ts$/ },
(args) => void simulationInputs.add(args.path),
)
},
}
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}` const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
const parcelWatcherPlugin: BunPlugin = { const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding", name: "parcel-watcher-binding",
@@ -103,7 +92,7 @@ for (const item of targets) {
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/index.ts"], entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json", tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin], plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"], external: ["node-gyp"],
format: "esm", format: "esm",
minify: true, minify: true,
@@ -134,7 +123,6 @@ for (const item of targets) {
for (const log of result.logs) console.error(log) for (const log of result.logs) console.error(log)
process.exit(1) process.exit(1)
} }
verifySimulationGraph(simulationInputs)
await Bun.write( await Bun.write(
path.join(outdir, name, "package.json"), path.join(outdir, name, "package.json"),
@@ -151,7 +139,6 @@ for (const item of targets) {
2, 2,
), ),
) )
await verifyArtifact(path.join(outdir, name))
} }
function targetName(item: (typeof allTargets)[number]) { function targetName(item: (typeof allTargets)[number]) {
-13
View File
@@ -1,13 +0,0 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
export async function collectFiles(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map(async (entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? collectFiles(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
+15 -11
View File
@@ -1,10 +1,9 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { copyFile, mkdir, readFile, stat } from "node:fs/promises" import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets" import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target" import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
import { collectFiles } from "./files"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
@@ -17,6 +16,17 @@ export type NodeAsset = {
readonly source: string readonly source: string
} }
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
export async function collectNodeAssets(target: NodeTarget) { export async function collectNodeAssets(target: NodeTarget) {
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage)) const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..") const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
@@ -33,15 +43,11 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset, key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)), source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
}, },
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({ ...attentionSoundAssets.map((key) => ({
key, key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)), source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})), })),
...(await collectFiles(ptyRoot)) ...(await files(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb")) .filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({ .map((relative) => ({
key: `${target.nodePtyPackage}/${relative}`, key: `${target.nodePtyPackage}/${relative}`,
@@ -75,7 +81,5 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
export async function seaAssetMap() { export async function seaAssetMap() {
const root = path.join(dir, "dist-node", "assets") const root = path.join(dir, "dist-node", "assets")
return Object.fromEntries( return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
)
} }
-65
View File
@@ -1,65 +0,0 @@
import { stat } from "node:fs/promises"
import path from "node:path"
import { collectFiles } from "./files"
const forbidden = [
"@napi-rs/canvas",
"@fontsource/commit-mono",
"@fontsource/noto-sans",
"SimulationPng",
"frontend/png",
"Failed to register screenshot font",
"commit-mono-latin-400-normal",
"noto-sans-symbols-symbols-400-normal",
"noto-sans-math-math-400-normal",
"CommitMono-400-Regular.otf",
"NotoSansSymbols.ttf",
"src/frontend/png.ts",
"skia.darwin-",
"skia.linux-",
"skia.win32-",
]
const overlap = Math.max(...forbidden.map((value) => value.length)) - 1
export async function verifyArtifact(target: string) {
const files = await artifactFiles(target)
if (files.length === 0) throw new Error(`Artifact contains no published files: ${target}`)
for (const file of files) await scan(file)
}
export function verifySimulationGraph(inputs: Iterable<string>) {
const modules = Array.from(inputs, (input) => input.replaceAll("\\", "/"))
const required = [
"/packages/simulation/src/frontend/simulation.ts",
"/packages/simulation/src/frontend/server.ts",
"/packages/simulation/src/control-server.ts",
]
const missing = required.filter((input) => !modules.some((module) => module.endsWith(input)))
if (missing.length > 0) throw new Error(`Build graph is missing simulation bridge inputs: ${missing.join(", ")}`)
const leaked = modules.find((module) => module.includes("/packages/simulation/src/frontend/png."))
if (leaked) throw new Error(`Build graph contains Drive-only rendering input: ${leaked}`)
}
async function artifactFiles(target: string): Promise<string[]> {
if ((await stat(target)).isFile()) return [target]
return (await collectFiles(target)).map((file) => path.join(target, file))
}
async function scan(file: string) {
let trailing = ""
const reader = Bun.file(file).stream().getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) return
const text = trailing + Buffer.from(chunk.value).toString("latin1")
const leaked = forbidden.find((marker) => text.includes(marker))
if (leaked) throw new Error(`Artifact file ${file} contains forbidden simulation payload: ${leaked}`)
trailing = text.slice(-overlap)
}
}
if (import.meta.main) {
const target = process.argv[2]
if (!target) throw new Error("Usage: bun run script/verify-artifact.ts <file-or-directory>")
await verifyArtifact(target)
}
+1 -1
View File
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews), locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}), ...(previews.length > 0 ? { content: previews } : {}),
}, },
options, options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
}) })
.catch(() => undefined) .catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
-5
View File
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
} }
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm" export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [ export const attentionSoundAssets = [
@@ -1,50 +0,0 @@
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return "upgrade"
}
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
if ([match[1], match[2], match[3]].some(invalidComponent)) return
if (
match[4]
?.split(".")
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
)
return
return {
major: match[1],
core: `${match[1]}.${match[2]}.${match[3]}`,
prerelease: match[4]?.split(".") ?? [],
}
}
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
return current.prerelease.every((identifier, index) => {
const other = latest.prerelease[index]
if (identifier === other) return true
// semver compares oversized numeric prerelease identifiers after numeric coercion.
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
})
}
function invalidComponent(value: string) {
if (value.length > 1 && value.startsWith("0")) return true
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
return value > maximumComponent
}
+1 -46
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { action } from "./updater-action" import { action, decodePolicy } from "./updater"
import { decodePolicy } from "./updater"
describe("updater", () => { describe("updater", () => {
test("reads autoupdate from JSONC", () => { test("reads autoupdate from JSONC", () => {
@@ -31,48 +30,4 @@ describe("updater", () => {
test("upgrades when latest is lower (rollback)", () => { test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade") expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
}) })
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
})
test("preserves strict validity", () => {
const invalid = [
"=1.2.3",
"V1.2.3",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-01",
"1.2.3-",
"1.2.3+",
"1.2.3-alpha..1",
"1.2.3_alpha",
"9007199254740992.0.0",
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
})
}) })
+11 -2
View File
@@ -5,10 +5,12 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser" import { parse, type ParseError } from "jsonc-parser"
import path from "node:path" import path from "node:path"
import { action, type Policy } from "./updater-action" import semver from "semver"
declare const OPENCODE_CLI_NAME: string | undefined declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn" type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName = const packageName =
@@ -32,6 +34,14 @@ export function decodePolicy(text: string): Policy | undefined {
if (typeof value === "boolean" || value === "notify") return value if (typeof value === "boolean" || value === "notify") return value
} }
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -156,4 +166,3 @@ export const layer = Layer.effect(
) )
export * as Updater from "./updater" export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"
@@ -49,6 +49,7 @@ describe("acp permission behavior", () => {
send( send(
permissionAsked("ses_allow", "perm_always", { permissionAsked("ses_allow", "perm_always", {
action: "read", action: "read",
save: ["/workspace/file.ts"],
metadata: { path: "/workspace/file.ts" }, metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" }, source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}), }),
@@ -84,10 +85,10 @@ describe("acp permission behavior", () => {
}, },
options: [ options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" }, { optionId: "reject", kind: "reject_once", name: "Reject" },
], ],
}) })
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
expect(permissionRequests[1]).toMatchObject({ expect(permissionRequests[1]).toMatchObject({
sessionId: "ses_allow", sessionId: "ses_allow",
toolCall: { toolCall: {
@@ -557,6 +558,7 @@ function permissionAsked(
input: { input: {
readonly action?: string readonly action?: string
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly save?: string[]
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {}, } = {},
) { ) {
@@ -565,6 +567,7 @@ function permissionAsked(
sessionID, sessionID,
action: input.action ?? "shell", action: input.action ?? "shell",
resources: ["*"], resources: ["*"],
...(input.save ? { save: input.save } : {}),
metadata: input.metadata ?? { command: "printf hello" }, metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}), ...(input.source ? { source: input.source } : {}),
}) })
@@ -0,0 +1,371 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
config: { autoupdate: false },
run: ({ artifacts, llm, server }) =>
Effect.gen(function* () {
yield* Effect.promise(() => configureServicePort(artifacts))
yield* server.launch()
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
const root = path.resolve(import.meta.dir, "../../../..")
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
const explicitDirectory = path.join(artifacts, "explicit-model")
yield* Effect.promise(() =>
Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))),
)
/** @param {string} directory @param {string | undefined} model */
const mini = (directory, model) => [
"env",
`PWD=${directory}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
...(model ? ["--model", model] : []),
]
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
const journey = Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.promise(() =>
tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
...mini(path.join(artifacts, "files"), undefined),
]),
),
)
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
yield* Effect.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
yield* Effect.promise(() =>
waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
),
)
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
const resized = yield* Effect.promise(() => captureVisiblePane(session))
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini form complete"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
Llm.finish("tool-calls"),
)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
yield* Effect.promise(async () => {
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = yield* Effect.promise(() => capturePane(session))
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
yield* Effect.promise(() =>
tmux(["respawn-pane", "-k", "-t", session, "--", ...mini(explicitDirectory, "simulation/gpt-sim-model")]),
)
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
throw new Error("Explicit-model Mini did not exit cleanly")
yield* Effect.promise(async () => {
for (const failure of [
{
args: ["--model", "simulation/definitely-missing"],
capture: "08-unavailable-model.txt",
expected: "Model unavailable: simulation/definitely-missing",
},
{
args: ["--agent", "definitely-missing"],
capture: "09-unavailable-agent.txt",
expected: 'Agent not found: "definitely-missing"',
},
]) {
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
...failure.args,
"optimistic selection check",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: path.join(artifacts, "files"),
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
if (!stderr.includes(failure.expected))
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
}
})
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
}),
})
/** @param {string[]} args */
async function tmux(args, allowFailure = false) {
const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
child.kill("SIGKILL")
}, 5_000)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
clearTimeout(timeout)
if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
return stdout
}
/** @param {string} session */
function capturePane(session) {
return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
}
/** @param {string} session */
function captureVisiblePane(session) {
return tmux(["capture-pane", "-p", "-t", session])
}
/** @param {string} session @param {string} text @param {number} [timeout] */
async function waitForVisiblePane(session, text, timeout = 5_000) {
const deadline = Date.now() + timeout
let last = ""
while (Date.now() < deadline) {
last = await captureVisiblePane(session)
if (last.includes(text)) return last
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
}
/** @param {string} session */
async function paneAlive(session) {
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
}
/** @param {string} session */
async function paneDeadStatus(session) {
return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
}
/**
* @param {string} session
* @param {string} text
* @param {number} [timeout]
* @param {(() => Promise<void>) | undefined} [trigger]
*/
async function waitForPane(session, text, timeout = 5_000, trigger) {
const deadline = Date.now() + timeout
let last = ""
while (Date.now() < deadline) {
await trigger?.()
last = await capturePane(session)
if (last.includes(text)) return last
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
}
/** @param {string} session */
async function waitForDeadPane(session) {
for (let attempt = 0; attempt < 100; attempt++) {
if (!(await paneAlive(session))) return
await Bun.sleep(50)
}
throw new Error("Mini did not tear down after the exit sequence")
}
/**
* @param {string} file
* @param {(value: string) => boolean} accept
*/
async function waitForFile(file, accept) {
let value = ""
for (let attempt = 0; attempt < 100; attempt++) {
value = await Bun.file(file)
.text()
.catch(() => "")
if (accept(value)) return value
await Bun.sleep(50)
}
throw new Error("resize did not replay committed transcript output")
}
/** @param {string} artifacts */
async function configureServicePort(artifacts) {
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = probe.port
await probe.stop(true)
if (!port) throw new Error("Failed to allocate a Drive service port")
const file = path.join(artifacts, "files/.opencode/service-local.json")
await mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, JSON.stringify({ port }))
}
/** @param {string} artifacts */
async function serviceRegistration(artifacts) {
const directory = path.join(artifacts, "home/.local/state/opencode")
for (let attempt = 0; attempt < 200; attempt++) {
for (const name of ["service-local.json", "service.json"]) {
const value = await Bun.file(path.join(directory, name))
.json()
.catch(() => undefined)
if (isRegistration(value)) return value
}
await Bun.sleep(50)
}
throw new Error("Drive service registration was not written")
}
/** @param {unknown} value */
function isRegistration(value) {
return (
typeof value === "object" &&
value !== null &&
"url" in value &&
typeof value.url === "string" &&
"password" in value &&
typeof value.password === "string"
)
}
@@ -0,0 +1,87 @@
import { defineScript } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
setup({ config }) {
config.autoupdate = false
},
async run({ artifacts, llm, server }) {
await configureServicePort(artifacts)
llm.queue(llm.text("drive noninteractive smoke ok"))
await server.launch()
const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..")
const directory = path.join(artifacts, "files")
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
"drive smoke",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: directory,
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
},
})
/** @param {string} artifacts */
async function configureServicePort(artifacts) {
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = probe.port
await probe.stop(true)
if (!port) throw new Error("Failed to allocate a Drive service port")
const file = path.join(artifacts, "files/.opencode/service-local.json")
await mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, JSON.stringify({ port }))
}
/** @param {string} artifacts */
async function serviceRegistration(artifacts) {
const directory = path.join(artifacts, "home/.local/state/opencode")
for (let attempt = 0; attempt < 200; attempt++) {
for (const name of ["service-local.json", "service.json"]) {
const value = await Bun.file(path.join(directory, name))
.json()
.catch(() => undefined)
if (isRegistration(value)) return value
}
await Bun.sleep(50)
}
throw new Error("Drive service registration was not written")
}
/** @param {unknown} value */
function isRegistration(value) {
return (
typeof value === "object" &&
value !== null &&
"url" in value &&
typeof value.url === "string" &&
"password" in value &&
typeof value.password === "string"
)
}
+1 -8
View File
@@ -1,17 +1,10 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets" import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target" import { nodeTarget } from "../src/node/target"
test("collects each SEA asset key once", async () => { test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch)) const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key) const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length) expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
}) })
@@ -240,6 +240,8 @@ async function run(input: {
})() })()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream) spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.form, "list").mockImplementation( spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
) )
@@ -433,6 +435,8 @@ describe("runNonInteractivePrompt", () => {
expect(sdk.form.request.list).toHaveBeenCalledWith({ expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree", workspace: "wrk_1" }, location: { directory: "/work tree", workspace: "wrk_1" },
}) })
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
}) })
test("attach mode cancels only session-owned forms", async () => { test("attach mode cancels only session-owned forms", async () => {
+2 -16
View File
@@ -3,8 +3,7 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module" import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite" import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid" import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target" import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
import { verifySimulationGraph } from "./script/verify-artifact"
const dir = import.meta.dirname const dir = import.meta.dirname
@@ -49,15 +48,6 @@ function runtimeRequirePlugin(): Plugin {
} }
} }
function simulationGraphPlugin(): Plugin {
return {
name: "opencode:simulation-graph",
generateBundle() {
verifySimulationGraph(this.getModuleIds())
},
}
}
function fffNodePlugin(): Plugin { function fffNodePlugin(): Plugin {
return { return {
name: "opencode:fff-node", name: "opencode:fff-node",
@@ -222,9 +212,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)}) process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)}) process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)}) process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)}) process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)}) process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try { try {
@@ -250,7 +237,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
rawTextPlugin(), rawTextPlugin(),
runtimeRequirePlugin(), runtimeRequirePlugin(),
fffNodePlugin(), fffNodePlugin(),
simulationGraphPlugin(),
solid({ solid({
solid: { solid: {
generate: "universal", generate: "universal",
@@ -266,7 +252,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_CHANNEL: JSON.stringify(input.channel), OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined", OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined", FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
}, },
ssr: { noExternal: true }, ssr: { noExternal: true },
build: { build: {
@@ -276,6 +261,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
emptyOutDir: false, emptyOutDir: false,
minify: true, minify: true,
rollupOptions: { rollupOptions: {
external: [/^@opencode-ai\/simulation(?:\/|$)/],
output: output("opencode.mjs", nodePrelude(input)), output: output("opencode.mjs", nodePrelude(input)),
}, },
}, },
+8 -2
View File
@@ -20,6 +20,7 @@
"./promise": "./src/promise/index.ts", "./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts", "./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.ts", "./service": "./src/promise/service.ts",
"./solid": "./src/solid/index.ts",
"./effect": "./src/effect/index.ts", "./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts", "./effect/api": "./src/effect/api.ts",
"./effect/service": "./src/effect/service.ts" "./effect/service": "./src/effect/service.ts"
@@ -36,11 +37,15 @@
"@opencode-ai/protocol": "workspace:*" "@opencode-ai/protocol": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
"effect": "4.0.0-beta.101" "effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"effect": { "effect": {
"optional": true "optional": true
},
"solid-js": {
"optional": true
} }
}, },
"devDependencies": { "devDependencies": {
@@ -49,6 +54,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"effect": "catalog:" "effect": "catalog:",
"solid-js": "catalog:"
} }
} }
+73 -39
View File
@@ -32,6 +32,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command" import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty" import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference" import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree" import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs" import type { Vcs } from "@opencode-ai/schema/vcs"
@@ -1306,6 +1307,7 @@ export type Endpoint15_3Input = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> | undefined readonly save?: ReadonlyArray<string> | undefined
readonly opaque?: boolean | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly source?: Permission.Source | undefined readonly source?: Permission.Source | undefined
readonly agent?: Agent.ID | undefined readonly agent?: Agent.ID | undefined
@@ -1502,38 +1504,69 @@ export interface ShellApi<E = never> {
export type Endpoint22_0Input = { export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> } export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E> export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input,
) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint22_1Input = { readonly sessionID: Session.ID }
export type Endpoint22_1Output = ReadonlyArray<Question.Request>
export type QuestionListOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
export type Endpoint22_2Input = {
readonly sessionID: Session.ID
readonly requestID: Question.ID
readonly answers: ReadonlyArray<Question.Answer>
}
export type Endpoint22_2Output = void
export type QuestionReplyOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
export type Endpoint22_3Input = { readonly sessionID: Session.ID; readonly requestID: Question.ID }
export type Endpoint22_3Output = void
export type QuestionRejectOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
export interface QuestionApi<E = never> {
readonly request: { readonly list: QuestionRequestListOperation<E> }
readonly list: QuestionListOperation<E>
readonly reply: QuestionReplyOperation<E>
readonly reject: QuestionRejectOperation<E>
}
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export interface ReferenceApi<E = never> { export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E> readonly list: ReferenceListOperation<E>
} }
export type Endpoint23_0Input = { readonly projectID: Project.ID } export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint23_0Output = Worktree.List export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E> export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint23_1Input = { export type Endpoint24_1Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined readonly from?: AbsolutePath | undefined
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly name?: string | undefined readonly name?: string | undefined
} }
export type Endpoint23_1Output = Worktree.Info export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E> export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint23_2Input = { export type Endpoint24_2Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly force: boolean readonly force: boolean
} }
export type Endpoint23_2Output = void export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E> export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint23_3Input = { readonly projectID: Project.ID } export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint23_3Output = void export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E> export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> { export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E> readonly list: WorktreeListOperation<E>
@@ -1542,25 +1575,25 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E> readonly refresh: WorktreeRefreshOperation<E>
} }
export type Endpoint24_0Input = { export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info } export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E> export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint24_1Input = { export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> } export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E> export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint24_2Input = { export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode readonly mode: Vcs.Mode
readonly context?: number | undefined readonly context?: number | undefined
} }
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> } export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E> export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> { export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E> readonly get: VcsGetOperation<E>
@@ -1568,20 +1601,20 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E> readonly diff: VcsDiffOperation<E>
} }
export type Endpoint25_0Output = ReadonlyArray<Location.Ref> export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E> export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint25_1Input = { export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint25_1Output = void export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E> export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export interface DebugApi<E = never> { export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> } readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
} }
export type Endpoint26_0Output = export type Endpoint27_0Output =
| { readonly status: "required" | "completed" } | { readonly status: "required" | "completed" }
| { | {
readonly status: "running" readonly status: "running"
@@ -1592,36 +1625,36 @@ export type Endpoint26_0Output =
} }
} }
| { readonly status: "error"; readonly error: string } | { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E> export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export interface MigrationApi<E = never> { export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> } readonly v1: { readonly status: MigrationV1StatusOperation<E> }
} }
export type Endpoint27_0Input = { export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> } export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E> export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_1Input = { export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly providerID?: WebSearch.ID | undefined readonly providerID?: WebSearch.ID | undefined
} }
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response } export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E> export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export interface WebsearchApi<E = never> { export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E> readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E> readonly query: WebsearchQueryOperation<E>
} }
export type Endpoint28_0Input = { export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint28_0Output = ReadonlyArray<Config.Entry> export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E> export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export interface ConfigApi<E = never> { export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E> readonly get: ConfigGetOperation<E>
@@ -1650,6 +1683,7 @@ export interface AppApi<E = never> {
readonly event: EventApi<E> readonly event: EventApi<E>
readonly pty: PtyApi<E> readonly pty: PtyApi<E>
readonly shell: ShellApi<E> readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E> readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E> readonly worktree: WorktreeApi<E>
readonly vcs: VcsApi<E> readonly vcs: VcsApi<E>
+105 -60
View File
@@ -200,30 +200,38 @@ import type {
Endpoint21_5Output, Endpoint21_5Output,
Endpoint22_0Input, Endpoint22_0Input,
Endpoint22_0Output, Endpoint22_0Output,
Endpoint22_1Input,
Endpoint22_1Output,
Endpoint22_2Input,
Endpoint22_2Output,
Endpoint22_3Input,
Endpoint22_3Output,
Endpoint23_0Input, Endpoint23_0Input,
Endpoint23_0Output, Endpoint23_0Output,
Endpoint23_1Input,
Endpoint23_1Output,
Endpoint23_2Input,
Endpoint23_2Output,
Endpoint23_3Input,
Endpoint23_3Output,
Endpoint24_0Input, Endpoint24_0Input,
Endpoint24_0Output, Endpoint24_0Output,
Endpoint24_1Input, Endpoint24_1Input,
Endpoint24_1Output, Endpoint24_1Output,
Endpoint24_2Input, Endpoint24_2Input,
Endpoint24_2Output, Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output, Endpoint25_0Output,
Endpoint25_1Input, Endpoint25_1Input,
Endpoint25_1Output, Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output, Endpoint26_0Output,
Endpoint27_0Input, Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Output, Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input, Endpoint28_0Input,
Endpoint28_0Output, Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js" } from "../api/api.js"
import { ClientError } from "./client-error.js" import { ClientError } from "./client-error.js"
@@ -962,6 +970,7 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
action: input["action"], action: input["action"],
resources: input["resources"], resources: input["resources"],
save: input["save"], save: input["save"],
opaque: input["opaque"],
metadata: input["metadata"], metadata: input["metadata"],
source: input["source"], source: input["source"],
agent: input["agent"], agent: input["agent"],
@@ -1150,110 +1159,145 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
remove: Endpoint21_5(raw), remove: Endpoint21_5(raw),
}) })
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) => const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()( preserveEffect<Endpoint22_0Output>()(
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) =>
preserveEffect<Endpoint22_1Output>()(
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) =>
preserveEffect<Endpoint22_2Output>()(
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) =>
preserveEffect<Endpoint22_3Output>()(
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup22 = (raw: RawClient["server.question"]) => ({
request: { list: Endpoint22_0(raw) },
list: Endpoint22_1(raw),
reply: Endpoint22_2(raw),
reject: Endpoint22_3(raw),
})
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) }) const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) => const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint23_0Output>()( preserveEffect<Endpoint24_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)), raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) => const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint23_1Output>()( preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({ raw["worktree.create"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] }, payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) => const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint23_2Output>()( preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({ raw["worktree.remove"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] }, payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) => const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint23_3Output>()( preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)), raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({ const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint23_0(raw), list: Endpoint24_0(raw),
create: Endpoint23_1(raw), create: Endpoint24_1(raw),
remove: Endpoint23_2(raw), remove: Endpoint24_2(raw),
refresh: Endpoint23_3(raw), refresh: Endpoint24_3(raw),
}) })
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) => const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint24_0Output>()( preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) => const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint24_1Output>()( preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) => const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint24_2Output>()( preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint24_0(raw), get: Endpoint25_0(raw),
status: Endpoint24_1(raw), status: Endpoint25_1(raw),
diff: Endpoint24_2(raw), diff: Endpoint25_2(raw),
}) })
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))) preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
preserveEffect<Endpoint25_1Output>()( preserveEffect<Endpoint26_1Output>()(
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) }, location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
}) })
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () => const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError))) preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } }) const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) => const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint27_0Output>()( preserveEffect<Endpoint28_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) => const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint27_1Output>()( preserveEffect<Endpoint28_1Output>()(
raw["websearch.query"]({ raw["websearch.query"]({
query: { location: input["location"] }, query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] }, payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw), providers: Endpoint28_0(raw),
query: Endpoint27_1(raw), query: Endpoint28_1(raw),
}) })
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) => const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint28_0Output>()( preserveEffect<Endpoint29_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) }) const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]), health: adaptGroup0(raw["server.health"]),
@@ -1278,13 +1322,14 @@ const adaptClient = (raw: RawClient) => ({
event: adaptGroup19(raw["server.event"]), event: adaptGroup19(raw["server.event"]),
pty: adaptGroup20(raw["server.pty"]), pty: adaptGroup20(raw["server.pty"]),
shell: adaptGroup21(raw["server.shell"]), shell: adaptGroup21(raw["server.shell"]),
reference: adaptGroup22(raw["server.reference"]), question: adaptGroup22(raw["server.question"]),
worktree: adaptGroup23(raw["server.worktree"]), reference: adaptGroup23(raw["server.reference"]),
vcs: adaptGroup24(raw["server.vcs"]), worktree: adaptGroup24(raw["server.worktree"]),
debug: adaptGroup25(raw["server.debug"]), vcs: adaptGroup25(raw["server.vcs"]),
migration: adaptGroup26(raw["server.migration"]), debug: adaptGroup26(raw["server.debug"]),
websearch: adaptGroup27(raw["server.websearch"]), migration: adaptGroup27(raw["server.migration"]),
config: adaptGroup28(raw["server.config"]), websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
}) })
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -194,6 +194,14 @@ import type {
ShellOutputOutput, ShellOutputOutput,
ShellRemoveInput, ShellRemoveInput,
ShellRemoveOutput, ShellRemoveOutput,
QuestionRequestListInput,
QuestionRequestListOutput,
QuestionListInput,
QuestionListOutput,
QuestionReplyInput,
QuestionReplyOutput,
QuestionRejectInput,
QuestionRejectOutput,
ReferenceListInput, ReferenceListInput,
ReferenceListOutput, ReferenceListOutput,
WorktreeListInput, WorktreeListInput,
@@ -1382,6 +1390,7 @@ export function make(options: ClientOptions) {
action: input["action"], action: input["action"],
resources: input["resources"], resources: input["resources"],
save: input["save"], save: input["save"],
opaque: input["opaque"],
metadata: input["metadata"], metadata: input["metadata"],
source: input["source"], source: input["source"],
agent: input["agent"], agent: input["agent"],
@@ -1652,6 +1661,56 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
), ),
}, },
question: {
request: {
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
request<QuestionRequestListOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
request<QuestionReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
request<QuestionRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
reference: { reference: {
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) => list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
request<ReferenceListOutput>( request<ReferenceListOutput>(
+103 -1
View File
@@ -324,6 +324,12 @@ export type Pty = {
exitCode?: number exitCode?: number
} }
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionAnswer = Array<string>
export type FormMetadata1 = { [x: string]: any } export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean } export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
@@ -904,6 +910,15 @@ export type ShellDeleted = {
data: { id: string } data: { id: string }
} }
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
export type FormCancelled = { export type FormCancelled = {
id: string id: string
created: number created: number
@@ -1362,6 +1377,7 @@ export type PermissionRequest = {
action: string action: string
resources: Array<string> resources: Array<string>
save?: Array<string> save?: Array<string>
opaque?: boolean
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
source?: PermissionSource source?: PermissionSource
} }
@@ -1378,6 +1394,7 @@ export type PermissionAsked = {
action: string action: string
resources: Array<string> resources: Array<string>
save?: Array<string> save?: Array<string>
opaque?: boolean
metadata?: { [x: string]: any } metadata?: { [x: string]: any }
source?: PermissionSource source?: PermissionSource
} }
@@ -1410,6 +1427,23 @@ export type PtyUpdated = {
data: { info: Pty } data: { info: Pty }
} }
export type QuestionInfo = {
question: string
header: string
options: Array<QuestionOption>
multiple?: boolean
custom?: boolean
}
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
}
export type FormStringField1 = { export type FormStringField1 = {
key: string key: string
title?: string title?: string
@@ -1650,6 +1684,17 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer } data: { id: string; sessionID: string; answer: FormAnswer }
} }
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
}
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
export type FormField1 = export type FormField1 =
| FormStringField1 | FormStringField1
| FormNumberField1 | FormNumberField1
@@ -1835,7 +1880,6 @@ export type ConfigEntry =
} }
} }
experimental?: { experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
} }
@@ -2072,6 +2116,9 @@ export type V2Event =
| ShellCreated | ShellCreated
| ShellExited | ShellExited
| ShellDeleted | ShellDeleted
| QuestionAsked
| QuestionReplied
| QuestionRejected
| FormCreated | FormCreated
| FormReplied | FormReplied
| FormCancelled | FormCancelled
@@ -2251,6 +2298,14 @@ export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError => export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
export type QuestionNotFoundError = {
readonly _tag: "QuestionNotFoundError"
readonly requestID: string
readonly message: string
}
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type WorktreeError = { export type WorktreeError = {
readonly name: "WorktreeError" readonly name: "WorktreeError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined } readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
@@ -5177,6 +5232,7 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5186,6 +5242,7 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5195,6 +5252,7 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5204,15 +5262,27 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
}["save"] }["save"]
readonly opaque?: {
readonly id?: string | null
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["opaque"]
readonly metadata?: { readonly metadata?: {
readonly id?: string | null readonly id?: string | null
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5222,6 +5292,7 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5231,6 +5302,7 @@ export type PermissionCreateInput = {
readonly action: string readonly action: string
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null readonly agent?: string | null
@@ -5539,6 +5611,36 @@ export type ShellRemoveInput = {
export type ShellRemoveOutput = void export type ShellRemoveOutput = void
export type QuestionRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<QuestionRequest>
}
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
export type QuestionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
}
export type QuestionReplyOutput = void
export type QuestionRejectInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type QuestionRejectOutput = void
export type ReferenceListInput = { export type ReferenceListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+269
View File
@@ -0,0 +1,269 @@
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
readonly attempt: number
readonly error?: string
}
}
export type ClientConnectionOptions = {
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
}
}
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function coalesceClientEvents(events: OpenCodeEvent[]) {
return events.reduce<OpenCodeEvent[]>((output, event) => {
const current = currentDelta(event)
const previous = output[output.length - 1]
const prior = currentDelta(previous)
if (
!current ||
!prior ||
previous?.location?.directory !== event.location?.directory ||
currentDeltaKey(prior) !== currentDeltaKey(current)
) {
output.push(event)
return output
}
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
output[output.length - 1] = {
...current,
data:
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment },
} as CurrentDelta
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 createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
let api = initialApi
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
function publish(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = undefined
const events = pending
pending = []
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
}, options.flushInterval ?? 10)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
return {
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
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 }
if ("durable" in event.value)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
publish(event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let attempt = 0
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
const controller = new AbortController()
stream = controller
const cancel = () => controller.abort(abort.signal.reason)
abort.signal.addEventListener("abort", cancel)
const result = await connect(controller.signal, attempt)
abort.signal.removeEventListener("abort", cancel)
if (abort.signal.aborted || !started || generation !== active) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
attempt += 1
const message = errorMessage(result.error)
record("disconnected", attempt, message)
options.log?.info?.("event stream disconnected", { attempt, error: message })
setConnection({ status: "reconnecting", attempt, error: message })
if (options.reconnect) {
const next = await options.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
})
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
if (next) {
api = next
if (attempt === 1) continue
}
}
await wait(reconnectDelay, controller.signal)
}
}
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
})
run = current
return run
}
function stop() {
started = false
generation += 1
stream?.abort()
}
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
}
void start()
})
onCleanup(() => {
stop()
abort.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
})
return {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
internal: {
history: () => history.slice(),
},
}
}
function errorMessage(error: unknown) {
if (error === undefined) return undefined
if (error instanceof Error) return error.message
return String(error)
}
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()
}
})
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
export * from "./data"
export * from "./connection"
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "../src/promise"
import { coalesceClientEvents } from "../src/solid/connection"
describe("coalesceClientEvents", () => {
const delta = (id: string, value: string, ordinal = 0) =>
({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
}) as OpenCodeEvent
test("merges adjacent deltas for the same stream", () => {
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, value: string) =>
({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
}) as OpenCodeEvent
const result = coalesceClientEvents([
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]).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(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
})
-9
View File
@@ -53,12 +53,6 @@
"node": "./src/image/photon-wasm.node.ts", "node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts" "default": "./src/image/photon-wasm.bun.ts"
}, },
"#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": { "#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts", "workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts", "bun": "./src/util/process-lock-ffi.bun.ts",
@@ -137,10 +131,7 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1", "venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1", "which": "6.0.1",
"zod": "catalog:" "zod": "catalog:"
} }
+1 -1
View File
@@ -19,7 +19,7 @@ const result = await Bun.build({
target: "node", target: "node",
format: "esm", format: "esm",
packages: "external", packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"], external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#process-lock-ffi", "#v1-migration"],
plugins: [ plugins: [
{ {
name: "bundle-shell-scan", name: "bundle-shell-scan",
+1 -1
View File
@@ -321,7 +321,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
transport: { transport: {
id: "ai-sdk", id: "ai-sdk",
prepare: (input) => Effect.succeed(input.body), prepare: (input) => Effect.succeed(input.body),
execute: () => Effect.succeed({ frames: Stream.empty }), frames: () => Stream.empty,
}, },
defaults: { defaults: {
headers: info.headers, headers: info.headers,
-9
View File
@@ -401,15 +401,6 @@ function normalizeExperimental(
unsupportedExperimental.forEach((key) => unsupportedExperimental.forEach((key) =>
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics), unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
) )
if (own(experimental, "portable_shell_scanner")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.portable_shell_scanner,
experimental.portable_shell_scanner,
["experimental", "portable_shell_scanner"],
diagnostics,
)
if (value !== undefined) result.portable_shell_scanner = value
}
if (own(experimental, "subagent_depth")) { if (own(experimental, "subagent_depth")) {
const value = decodeEncoded( const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_depth, ConfigExperimental.Info.fields.subagent_depth,

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