mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5040e022e |
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
"@opencode-ai/schema": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
"@opencode-ai/client": minor
|
||||
---
|
||||
|
||||
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
|
||||
external directories, while the default tree-sitter path remains unchanged.
|
||||
@@ -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 }}
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
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/`.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/lab/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -173,6 +174,7 @@
|
||||
"@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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -106,7 +106,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
|
||||
Keep provider facades small and explicit:
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
@@ -124,10 +124,11 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
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(...)`.
|
||||
|
||||
@@ -153,16 +154,14 @@ packages/ai/src/
|
||||
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
|
||||
framing.ts Framing type + Framing.sse
|
||||
transport/ transport implementations
|
||||
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
|
||||
websocket-channel.ts generic sequential channel executor/driver contract
|
||||
index.ts Transport type + HttpTransport / WebSocketTransport namespaces
|
||||
http.ts HttpTransport.httpJson — POST + framing
|
||||
websocket.ts direct one-request channel executor + raw socket adapter
|
||||
websocket.ts WebSocketTransport.json + WebSocketExecutor service
|
||||
protocols/
|
||||
shared.ts ProviderShared toolkit used inside protocol impls
|
||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||
open-responses.ts provider-neutral Responses protocol baseline
|
||||
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
|
||||
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
|
||||
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||
anthropic-messages.ts
|
||||
gemini.ts
|
||||
bedrock-converse.ts
|
||||
|
||||
@@ -315,6 +315,7 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
transport: "websocket",
|
||||
headers: { "x-application": "opencode" },
|
||||
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/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`.
|
||||
|
||||
|
||||
+34
-33
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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 |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -47,19 +48,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
|
||||
## AI SDK Package Parity Matrix
|
||||
|
||||
| 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-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/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
||||
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
|
||||
| `@ai-sdk/openai-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/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||
|
||||
## Highest-Risk Gaps
|
||||
|
||||
@@ -77,24 +78,24 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 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-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`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-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`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ Examples:
|
||||
```ts
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.chat("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
|
||||
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
||||
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"),
|
||||
})
|
||||
|
||||
const openAIResponsesWebSocket = openAIResponses.with({
|
||||
id: "openai-responses-websocket",
|
||||
transport: WebSocketTransport.json,
|
||||
})
|
||||
|
||||
const openAIConfig = (input: OpenAIConfig) => ({
|
||||
endpoint: input.endpoint,
|
||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||
@@ -260,11 +266,13 @@ const openAIConfig = (input: OpenAIConfig) => ({
|
||||
|
||||
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
||||
const responses = openAIResponses.with(openAIConfig(input))
|
||||
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
|
||||
const chat = openAIChat.with(openAIConfig(input))
|
||||
|
||||
return {
|
||||
id: openAIProvider,
|
||||
responses: responses.model,
|
||||
responsesWebSocket: responsesWebSocket.model,
|
||||
chat: chat.model,
|
||||
model: responses.model,
|
||||
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
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
```
|
||||
|
||||
The package-like OpenAI Responses entrypoint has the same transport-neutral
|
||||
`model(...)` contract:
|
||||
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
||||
Responses settings while preserving the same `model(...)` contract:
|
||||
|
||||
```ts
|
||||
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,
|
||||
@@ -376,9 +387,11 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
model("claude-sonnet-4-6", { project, location: "global" })
|
||||
```
|
||||
|
||||
The client does not require a different public layer for WebSocket execution.
|
||||
Responses routes use HTTP by default, and callers may pass a channel executor per
|
||||
call. Routes without channel support simply ignore that execution capability.
|
||||
The client should not require a different public layer just because a selected
|
||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||
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
|
||||
mapping. The public API configures the Azure resource once, then selects
|
||||
@@ -484,13 +497,18 @@ generic dynamic resolver:
|
||||
|
||||
```ts
|
||||
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
|
||||
provider APIs directly. Transport selection remains execution policy: a Session
|
||||
or other caller may pass a WebSocket channel executor per call without changing
|
||||
the model constructed by this boundary.
|
||||
provider APIs directly. A direct provider-facade boundary maps metadata like
|
||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
||||
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
||||
The client runtime only executes the route carried by the resulting model.
|
||||
|
||||
## Competitive Shape
|
||||
|
||||
@@ -526,8 +544,9 @@ App boundary = explicit durable-config -> typed-provider call
|
||||
id.
|
||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||
endpoint/auth/deployment customization happens by configuring the route first.
|
||||
- No transport setting on a provider or executable model. OpenAI Responses uses
|
||||
HTTP by default and accepts an optional per-call channel executor as execution policy.
|
||||
- No transport override on an executable model or request. Direct provider
|
||||
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
|
||||
client layer with the available transport capabilities.
|
||||
- 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
|
||||
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
||||
state construction.
|
||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
|
||||
optional per-call channel execution without changing route identity.
|
||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
||||
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:
|
||||
`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
|
||||
setup happens before selecting deployment ids.
|
||||
- [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
|
||||
or three provider conversions; start with plain objects if duplication is not
|
||||
yet painful.
|
||||
- [x] Keep executable model construction transport-neutral at the Session boundary;
|
||||
Session-scoped execution policy supplies channel capability separately.
|
||||
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
|
||||
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
|
||||
by executable models, and opencode/native tests assert boundary-based route
|
||||
selection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
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"
|
||||
|
||||
/**
|
||||
@@ -213,7 +213,8 @@ const FakeEcho = {
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
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* () {
|
||||
// yield* generateOnce
|
||||
@@ -221,6 +222,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
@@ -7,4 +7,3 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenAIResponses from "./openai-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
|
||||
@@ -211,43 +211,11 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: 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(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
@@ -272,9 +240,6 @@ export const Event = Schema.StructWithRest(
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
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)],
|
||||
)
|
||||
@@ -667,9 +632,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||
// the stream, 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"])
|
||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
@@ -1001,24 +966,16 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
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 message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
return new AIError({
|
||||
module: id,
|
||||
module: state.id,
|
||||
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) => {
|
||||
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`)
|
||||
@@ -1058,11 +1015,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||
)
|
||||
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
|
||||
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
|
||||
@@ -1,23 +1,18 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.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 { OpenResponses } from "./open-responses.js"
|
||||
import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.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 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 PATH = OpenResponses.PATH
|
||||
|
||||
@@ -62,6 +57,16 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
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 = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -244,13 +249,6 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
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({
|
||||
id: ADAPTER,
|
||||
@@ -259,7 +257,36 @@ export const route = Route.make({
|
||||
protocol,
|
||||
endpoint,
|
||||
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 } } },
|
||||
})
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const SERVER_CODES = new Set([
|
||||
"overloaded_error",
|
||||
"server_error",
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
|
||||
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
|
||||
|
||||
export const id = ProviderID.make("openai")
|
||||
|
||||
export const 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
|
||||
// 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 project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly transport?: "http" | "websocket"
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
@@ -81,12 +82,17 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const responsesWebSocket = (id: string | ModelID) =>
|
||||
responsesWebSocketRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
@@ -105,6 +111,7 @@ export const configure = (input: Config = {}) => {
|
||||
id,
|
||||
model: responses,
|
||||
responses,
|
||||
responsesWebSocket,
|
||||
chat,
|
||||
image,
|
||||
configure,
|
||||
@@ -131,7 +138,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
|
||||
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"] = (
|
||||
@@ -139,5 +149,6 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import * as Option from "effect/Option"
|
||||
import { Auth } from "./auth.js"
|
||||
import { Endpoint, type EndpointPatch } from "./endpoint.js"
|
||||
import { RequestExecutor } from "./executor.js"
|
||||
import { Framing } from "./framing.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 { applyCachePolicy } from "../cache-policy.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
@@ -56,7 +58,6 @@ export interface Route<Body, Prepared = unknown> {
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: StreamOptions,
|
||||
) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
|
||||
@@ -156,7 +157,6 @@ export interface Interface {
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly http?: HttpMiddleware
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -314,29 +314,23 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
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}`
|
||||
return Stream.unwrap(
|
||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||
Effect.map((execution) => {
|
||||
const events = execution.frames.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
const stream = events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||
}),
|
||||
const events = routeInput.transport
|
||||
.frames(prepared, request, runtime)
|
||||
.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
return events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
},
|
||||
} satisfies Route<Body, Prepared>
|
||||
@@ -419,7 +413,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
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* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
|
||||
@@ -34,8 +34,44 @@ export type HttpMiddleware = (
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||
|
||||
const headerDetails = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
|
||||
const BODY_LIMIT = 16_384
|
||||
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) =>
|
||||
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({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: headerDetails(request.headers),
|
||||
url: redactUrl(request.url),
|
||||
headers: redactHeaders(request.headers, redactedNames),
|
||||
})
|
||||
|
||||
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
const responseDetails = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
redactedNames: ReadonlyArray<string | RegExp>,
|
||||
) =>
|
||||
new HttpResponseDetails({
|
||||
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 {}
|
||||
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(
|
||||
@@ -135,49 +207,52 @@ const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
),
|
||||
)
|
||||
|
||||
const providerMessage = (status: number, body: string | void) => {
|
||||
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
||||
return (
|
||||
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
||||
`Provider request failed with HTTP ${status}`
|
||||
)
|
||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||
if (body.body && body.body.length <= 500) {
|
||||
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||
}
|
||||
return `Provider request failed with HTTP ${status}`
|
||||
}
|
||||
|
||||
const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
request: requestDetails(input.request, input.redactedNames),
|
||||
response: responseDetails(input.response, input.redactedNames),
|
||||
...input.body,
|
||||
requestId: input.requestId,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
const statusError =
|
||||
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
||||
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||
(response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: classifyProviderFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, body),
|
||||
message: providerMessage(response.status, details),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
@@ -187,10 +262,10 @@ const statusError =
|
||||
})
|
||||
|
||||
// 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
|
||||
// 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: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
@@ -202,7 +277,7 @@ export const classifyHttpFailure = (input: {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
@@ -210,11 +285,11 @@ export const classifyHttpFailure = (input: {
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
@@ -244,6 +319,7 @@ const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly operation: HttpOperation
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
}) => {
|
||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||
@@ -255,8 +331,8 @@ const httpError = (input: {
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: request.url,
|
||||
http: new HttpContext({ request: requestDetails(request) }),
|
||||
url: redactUrl(request.url),
|
||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -267,7 +343,7 @@ const httpError = (input: {
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
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
|
||||
|
||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
||||
@@ -293,9 +369,10 @@ export const stream = (
|
||||
): Stream.Stream<Uint8Array, AIError> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
const response = yield* executor.execute(request, middleware)
|
||||
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 executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
if (!middleware)
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
|
||||
Effect.flatMap(statusError(request)),
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
||||
Effect.flatMap(statusError(request, redactedNames)),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
|
||||
return yield* statusError(response.request)(response)
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -16,28 +16,11 @@ export { AuthOptions } from "./auth-options.js"
|
||||
export { Endpoint } from "./endpoint.js"
|
||||
export { Framing } from "./framing.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 type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
|
||||
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
|
||||
export type { Definition as FramingDef } from "./framing.js"
|
||||
export type { Protocol as ProtocolDef } from "./protocol.js"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
HttpHandler,
|
||||
HttpMiddleware,
|
||||
Transport as TransportDef,
|
||||
TransportExecuteOptions,
|
||||
TransportExecution,
|
||||
TransportRuntime,
|
||||
WebSocketConnection,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
WebSocketConnector,
|
||||
WebSocketRequest,
|
||||
} from "./transport/index.js"
|
||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js"
|
||||
|
||||
@@ -87,10 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, _request, runtime) =>
|
||||
Effect.succeed({
|
||||
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
}),
|
||||
frames: (prepared, _request, runtime) =>
|
||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
})
|
||||
|
||||
export const sseJson = {
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import type { Effect, Scope, Stream } from "effect"
|
||||
import type { Effect, Stream } from "effect"
|
||||
import { Endpoint } from "../endpoint.js"
|
||||
import { Auth } from "../auth.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"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
}
|
||||
|
||||
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
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||
readonly execute: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: TransportExecuteOptions,
|
||||
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
|
||||
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
||||
}
|
||||
|
||||
export interface TransportPrepareInput<Body> {
|
||||
@@ -38,19 +24,8 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly middleware?: HttpMiddleware
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http.js"
|
||||
export type { HttpHandler, HttpMiddleware } from "../executor.js"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel.js"
|
||||
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
|
||||
export { WebSocketTransport } from "./websocket.js"
|
||||
export { WebSocketExecutor, 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
|
||||
}
|
||||
@@ -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 { Socket } from "effect/unstable/socket"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
import type {
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel.js"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -22,29 +15,24 @@ export interface WebSocketConnection {
|
||||
readonly close: Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
export interface WebSocketConnector {
|
||||
export interface Interface {
|
||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||
}
|
||||
|
||||
type WebSocketConstructorWithHeaders = (
|
||||
type WebSocketConstructorWithHeaders = new (
|
||||
url: string,
|
||||
options?: { readonly headers?: Headers.Headers },
|
||||
) => globalThis.WebSocket
|
||||
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
},
|
||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketConnector",
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({
|
||||
message,
|
||||
@@ -52,33 +40,9 @@ const transportError = (
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
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) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
return event.type
|
||||
@@ -99,8 +63,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
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)}`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -141,8 +101,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
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({
|
||||
try: () => {
|
||||
const url = new URL(value)
|
||||
@@ -173,31 +131,21 @@ export const toWebSocketUrl = (value: string) =>
|
||||
url: value,
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
|
||||
export const open = (input: WebSocketRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
const ws = yield* Effect.try({
|
||||
try: () =>
|
||||
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||
headers: input.headers,
|
||||
}),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
return yield* fromWebSocket(ws, input)
|
||||
})
|
||||
Effect.try({
|
||||
try: () =>
|
||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
}),
|
||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||
|
||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
||||
|
||||
export const fromWebSocket = (
|
||||
ws: globalThis.WebSocket,
|
||||
@@ -207,52 +155,16 @@ export const fromWebSocket = (
|
||||
yield* waitOpen(ws, input)
|
||||
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) => {
|
||||
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)
|
||||
if (binary) return offer(binary)
|
||||
if (binary) return Queue.offerUnsafe(messages, binary)
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -264,13 +176,12 @@ export const fromWebSocket = (
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
const onClose = (event: CloseEvent) => {
|
||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
@@ -278,7 +189,6 @@ export const fromWebSocket = (
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
phase: "close",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -295,26 +205,13 @@ export const fromWebSocket = (
|
||||
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.suspend(() => {
|
||||
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||
return Effect.fail(
|
||||
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
return Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
}),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: cleanup.pipe(
|
||||
@@ -331,57 +228,6 @@ export const fromWebSocket = (
|
||||
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
||||
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 {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
@@ -408,44 +254,33 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
...prepareInput,
|
||||
})
|
||||
return {
|
||||
url: yield* toWebSocketUrl(parts.url),
|
||||
url: yield* webSocketUrl(parts.url),
|
||||
headers: parts.headers,
|
||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, _runtime, options) => {
|
||||
const webSocket = options?.webSocket
|
||||
frames: (prepared, _request, runtime) => {
|
||||
const webSocket = runtime.webSocket
|
||||
if (!webSocket) {
|
||||
return Effect.fail(
|
||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const driver: WebSocketChannelDriver = {
|
||||
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||
}
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: { url: prepared.url, headers: prepared.headers },
|
||||
fallback: () =>
|
||||
Stream.fail(
|
||||
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "websocket",
|
||||
phase: "fallback",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
driver,
|
||||
}
|
||||
return webSocket.execute(exchange)
|
||||
const decoder = new TextDecoder()
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
yield* connection.sendText(prepared.message)
|
||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -454,13 +289,15 @@ export const jsonTransport = {
|
||||
with: json,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
direct,
|
||||
makeDirect,
|
||||
export const WebSocketExecutor = {
|
||||
Service,
|
||||
layer,
|
||||
open,
|
||||
fromWebSocket,
|
||||
messageText,
|
||||
toWebSocketUrl,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
} as const
|
||||
|
||||
@@ -106,13 +106,6 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
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>(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 { 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 OpenAI from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { dynamicResponse, systemError } from "./lib/http.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"
|
||||
|
||||
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 largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("parses response body failures at the executor seam", () =>
|
||||
@@ -77,11 +75,11 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
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(
|
||||
Effect.provide(
|
||||
@@ -154,12 +152,12 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: proxy disconnected proxy-secret",
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
message: "ECONNRESET: proxy disconnected <redacted>",
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
http: {
|
||||
request: {
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
headers: { authorization: "Bearer proxy-secret" },
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
headers: { authorization: "<redacted>" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -219,47 +217,9 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
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 })]))),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
@@ -308,15 +268,15 @@ describe("RequestExecutor", () => {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe(
|
||||
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
||||
)
|
||||
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
|
||||
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(
|
||||
Effect.provide(
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
||||
|
||||
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(
|
||||
Effect.provide(
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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 {
|
||||
CloudflareAIGateway,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
OpenAICompatibleResponses,
|
||||
OpenAIResponses,
|
||||
OpenResponses,
|
||||
OpenResponsesChannel,
|
||||
} from "@opencode-ai/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
@@ -37,7 +36,6 @@ describe("public exports", () => {
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
expect(Route.make).toBeFunction()
|
||||
expect(Protocol.make).toBeFunction()
|
||||
expect(WebSocketTransport.makeDirect).toBeFunction()
|
||||
})
|
||||
|
||||
test("provider barrels expose user-facing facades", async () => {
|
||||
@@ -45,6 +43,7 @@ describe("public exports", () => {
|
||||
|
||||
expect(OpenAI.model).toBeFunction()
|
||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
expect(
|
||||
@@ -66,10 +65,10 @@ describe("public exports", () => {
|
||||
expect(OpenAIChat.route.id).toBe("openai-chat")
|
||||
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
|
||||
expect(OpenResponses.protocol.id).toBe("open-responses")
|
||||
expect(OpenResponsesChannel.transport).toBeFunction()
|
||||
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
||||
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
||||
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
||||
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
|
||||
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
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 RequestExecutorService } from "../../src/route/executor.js"
|
||||
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket.js"
|
||||
|
||||
export type HandlerInput = {
|
||||
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 {
|
||||
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> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
||||
return Layer.mergeAll(deps, llmClientLayer)
|
||||
}
|
||||
|
||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||
|
||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies V1 overloaded provider codes", () => {
|
||||
expect(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { LLM } from "../../src/index.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({
|
||||
model: selected,
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error OpenAI reasoning effort must be a string.
|
||||
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 })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
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.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.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 { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
AIError,
|
||||
HttpOptions,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
@@ -12,23 +11,14 @@ import {
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Auth,
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelObservation,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.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 { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.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 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({
|
||||
id: "req_1",
|
||||
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* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAIResponses.route
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.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.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 })
|
||||
}),
|
||||
)
|
||||
@@ -287,60 +236,47 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("streams OpenAI Responses over WebSocket", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent: string[] = []
|
||||
const opened: Array<{
|
||||
readonly url: string
|
||||
readonly authorization: string | undefined
|
||||
readonly protocol: string | undefined
|
||||
}> = []
|
||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
let closed = false
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({
|
||||
url: input.url,
|
||||
authorization: input.headers.authorization,
|
||||
protocol: input.headers["openai-beta"],
|
||||
})
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
const deps = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
Layer.succeed(
|
||||
WebSocketExecutor.Service,
|
||||
WebSocketExecutor.Service.of({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
apiKey: "test",
|
||||
headers: { "openai-beta": "custom-protocol" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{ webSocket },
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(opened).toEqual([
|
||||
{
|
||||
url: "wss://api.openai.test/v1/responses",
|
||||
authorization: "Bearer test",
|
||||
protocol: "custom-protocol",
|
||||
},
|
||||
])
|
||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||
expect(closed).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
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", () =>
|
||||
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.
|
||||
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
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(
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
headers: { authorization: "Bearer stale" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
@@ -2610,7 +2037,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,11 +2,12 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Layer } from "effect"
|
||||
import * as path from "node:path"
|
||||
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 type { Service as ImageClientService } from "../src/image-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 WebSocketExecutorService } from "../src/route/transport/websocket.js"
|
||||
import {
|
||||
recordedEffectGroup,
|
||||
type RecordedCaseOptions as RunnerCaseOptions,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -81,10 +82,11 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
}),
|
||||
),
|
||||
)
|
||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||
return Layer.mergeAll(
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
deps,
|
||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LanguageModel,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../src/schema/index.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")))),
|
||||
).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)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { brotliCompressSync, constants } from "node:zlib"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
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 })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
.map((key) => key.replaceAll(path.sep, "/"))
|
||||
(await files(path.join(root, "dist")))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.toSorted()
|
||||
.map(async (key) => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
@@ -33,3 +31,16 @@ function compress(assets: object) {
|
||||
function isText(key: string) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact } from "./verify-artifact"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -92,7 +91,6 @@ for (const target of targets) {
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
await assertTextImportsInlined("dist-node/opencode.mjs")
|
||||
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
@@ -141,7 +139,6 @@ for (const target of targets) {
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
if (host) await smoke(output)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -77,16 +76,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
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 parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
@@ -103,7 +92,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -134,7 +123,6 @@ for (const item of targets) {
|
||||
for (const log of result.logs) console.error(log)
|
||||
process.exit(1)
|
||||
}
|
||||
verifySimulationGraph(simulationInputs)
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, name, "package.json"),
|
||||
@@ -151,7 +139,6 @@ for (const item of targets) {
|
||||
2,
|
||||
),
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -17,6 +16,17 @@ export type NodeAsset = {
|
||||
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) {
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
@@ -41,7 +51,7 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
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"))
|
||||
.map((relative) => ({
|
||||
key: `${target.nodePtyPackage}/${relative}`,
|
||||
@@ -75,7 +85,5 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
export async function seaAssetMap() {
|
||||
const root = path.join(dir, "dist-node", "assets")
|
||||
return Object.fromEntries(
|
||||
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
|
||||
)
|
||||
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||
import { verifySimulationGraph } from "./script/verify-artifact"
|
||||
|
||||
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 {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
@@ -250,7 +240,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
simulationGraphPlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
@@ -266,7 +255,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
},
|
||||
ssr: { noExternal: true },
|
||||
build: {
|
||||
@@ -276,6 +264,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
emptyOutDir: false,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
external: [/^@opencode-ai\/simulation(?:\/|$)/],
|
||||
output: output("opencode.mjs", nodePrelude(input)),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1835,7 +1835,6 @@ export type ConfigEntry =
|
||||
}
|
||||
}
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
|
||||
@@ -20,16 +20,6 @@ const result = await Bun.build({
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-shell-scan",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
|
||||
path: path.resolve("../shell-scan/src/index.ts"),
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
splitting: true,
|
||||
loader: {
|
||||
".txt": "text",
|
||||
|
||||
@@ -321,7 +321,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
transport: {
|
||||
id: "ai-sdk",
|
||||
prepare: (input) => Effect.succeed(input.body),
|
||||
execute: () => Effect.succeed({ frames: Stream.empty }),
|
||||
frames: () => Stream.empty,
|
||||
},
|
||||
defaults: {
|
||||
headers: info.headers,
|
||||
|
||||
@@ -401,15 +401,6 @@ function normalizeExperimental(
|
||||
unsupportedExperimental.forEach((key) =>
|
||||
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")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_depth,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
|
||||
@@ -12,10 +10,4 @@ export const requestExecutor = makeGlobalNode({
|
||||
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export const webSocketConstructor = makeGlobalNode({
|
||||
service: Socket.WebSocketConstructor,
|
||||
layer: NodeSocket.layerWebSocketConstructorWS,
|
||||
deps: [],
|
||||
})
|
||||
|
||||
export * as LayerNodePlatform from "./app-node-platform.js"
|
||||
|
||||
@@ -33,7 +33,6 @@ import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
@@ -92,7 +91,6 @@ const locationServiceNodes = [
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
|
||||
@@ -28,10 +28,6 @@ interface Failures extends Record<keyof Domains, unknown> {
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
|
||||
export interface Interface {
|
||||
readonly has: <Domain extends keyof Domains>(
|
||||
domain: Domain,
|
||||
name: keyof Domains[Domain] & keyof Failures[Domain],
|
||||
) => Effect.Effect<boolean>
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
@@ -78,9 +74,7 @@ const layer = Layer.effect(
|
||||
return event
|
||||
})
|
||||
|
||||
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
|
||||
|
||||
return Service.of({ has, register, trigger })
|
||||
return Service.of({ register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
@@ -202,10 +203,10 @@ export const OpenAIPlugin = define({
|
||||
if (!item) return
|
||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||
const account = chatgpt.metadata?.accountID
|
||||
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
|
||||
originator: "opencode",
|
||||
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
|
||||
})
|
||||
item.provider.headers = Provider.mergeHeaders(
|
||||
item.provider.headers,
|
||||
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||
)
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
@@ -229,6 +230,17 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { WebSearchExa } from "./exa.js"
|
||||
import { WebSearchFirecrawl } from "./firecrawl.js"
|
||||
import { WebSearchParallel } from "./parallel.js"
|
||||
import { WebSearchTavily } from "./tavily.js"
|
||||
|
||||
export const WebSearchPlugins = [
|
||||
WebSearchExa.Plugin,
|
||||
WebSearchFirecrawl.Plugin,
|
||||
WebSearchParallel.Plugin,
|
||||
WebSearchTavily.Plugin,
|
||||
] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
export * as WebSearchTavily from "./tavily.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Duration, Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
|
||||
export const endpoint = "https://api.tavily.com/search"
|
||||
|
||||
const SearchRequest = Schema.Struct({
|
||||
query: Schema.String,
|
||||
search_depth: Schema.Literal("basic"),
|
||||
chunks_per_source: Schema.Number,
|
||||
max_results: Schema.Number,
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.Struct({
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
title: Schema.String,
|
||||
url: Schema.String,
|
||||
content: Schema.String,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.tavily",
|
||||
effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("tavily", (integration) => (integration.name = "Tavily"))
|
||||
draft.method.update({
|
||||
integrationID: "tavily",
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "tavily",
|
||||
method: { type: "env", names: ["TAVILY_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "tavily",
|
||||
name: "Tavily",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("tavily")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const request = yield* HttpClientRequest.post(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.setHeaders({
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
"X-Client-Name": "opencode2",
|
||||
...(credential?.type === "key"
|
||||
? { Authorization: `Bearer ${credential.key}` }
|
||||
: { "X-Tavily-Access-Mode": "keyless" }),
|
||||
}),
|
||||
HttpClientRequest.schemaBodyJson(SearchRequest)({
|
||||
query: input.query,
|
||||
search_depth: "basic",
|
||||
chunks_per_source: 3,
|
||||
max_results: 8,
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const httpResponse = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(httpResponse)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
|
||||
}),
|
||||
)
|
||||
return response.results.map((item) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
...(item.content ? { content: item.content } : {}),
|
||||
time: {},
|
||||
}))
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -27,7 +27,6 @@ import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
@@ -307,16 +306,6 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, location))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
})
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
@@ -448,10 +437,9 @@ const layer = Layer.effect(
|
||||
return session
|
||||
}),
|
||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
yield* closeTransport(session)
|
||||
const children = yield* result.list({ parentID: sessionID })
|
||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
|
||||
@@ -3,11 +3,11 @@ export * as SessionModelRequest from "./model-request.js"
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
@@ -15,7 +15,6 @@ import { Tool } from "../tool.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
@@ -47,8 +46,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** False when Session HTTP hooks require the request to remain on HTTP. */
|
||||
readonly webSocketEligible: boolean
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
@@ -168,12 +165,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const app = yield* App.Metadata
|
||||
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
@@ -234,23 +226,12 @@ export const layer = Layer.effect(
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const webSocketEligible =
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
const http = webSocketEligible
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
@@ -282,7 +263,6 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
webSocketEligible,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
@@ -295,5 +275,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
|
||||
deps: [PluginHooks.node, App.node],
|
||||
})
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
export * as SessionModelTransport from "./model-transport.js"
|
||||
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type ChannelObservation,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelExchange,
|
||||
type WebSocketChannelExecution,
|
||||
type WebSocketChannelExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketConnector,
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { AIError, TransportReason, type TransportOperation } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
|
||||
const ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
const INBOUND_CAPACITY = 128
|
||||
const IDLE_TIMEOUT = "5 minutes"
|
||||
const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
description: "Session WebSocket lifecycle events",
|
||||
incremental: true,
|
||||
})
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
readonly affinity: string
|
||||
readonly connection: WebSocketConnection
|
||||
readonly openedAt: number
|
||||
active?: Active
|
||||
closing: boolean
|
||||
poisoned: boolean
|
||||
checkpoint?: ChannelCheckpoint
|
||||
pending?: { readonly token: object; readonly checkpoint: ChannelCheckpoint }
|
||||
reader?: Fiber.Fiber<unknown, unknown>
|
||||
}
|
||||
|
||||
interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelTransport") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "SessionModelTransport",
|
||||
method,
|
||||
reason: new TransportReason({ message, transport: "websocket", ...input }),
|
||||
})
|
||||
|
||||
const annotate = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
) => {
|
||||
if (error.reason._tag !== "Transport") return error
|
||||
return 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,
|
||||
recovery: error.reason.recovery,
|
||||
...input,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const affinity = (exchange: WebSocketChannelExchange) =>
|
||||
`${exchange.connect.url}:${Hash.sha256(JSON.stringify(Object.entries(exchange.connect.headers).sort(([a], [b]) => a.localeCompare(b))))}`
|
||||
|
||||
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 makeLayer = (connector: WebSocketConnector) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const states = new Map<SessionSchema.ID, State>()
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
const closeChannel = Effect.fn("SessionModelTransport.closeChannel")(function* (owner: State, channel: Channel) {
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
if (channel.reader) yield* Fiber.interrupt(channel.reader)
|
||||
yield* channel.connection.close
|
||||
if (channel.active)
|
||||
Queue.failCauseUnsafe(
|
||||
channel.active.queue,
|
||||
Cause.fail(
|
||||
transportError("close", "Session WebSocket closed", {
|
||||
operation: "read",
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* metric("close")
|
||||
})
|
||||
|
||||
const poison = Effect.fn("SessionModelTransport.poison")(function* (
|
||||
owner: State,
|
||||
channel: Channel,
|
||||
error: AIError,
|
||||
) {
|
||||
channel.poisoned = true
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
|
||||
yield* metric(
|
||||
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
|
||||
? "queue_overflow"
|
||||
: "protocol_failure",
|
||||
)
|
||||
yield* channel.connection.close
|
||||
})
|
||||
|
||||
const open = Effect.fn("SessionModelTransport.open")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
key: string,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* restore(
|
||||
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
|
||||
)
|
||||
if (owner.closed) {
|
||||
yield* connection.close
|
||||
return yield* transportError("open", "Session WebSocket owner closed while connecting", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
}
|
||||
const channel: Channel = {
|
||||
affinity: key,
|
||||
connection,
|
||||
openedAt: yield* Clock.currentTimeMillis,
|
||||
closing: false,
|
||||
poisoned: false,
|
||||
}
|
||||
owner.channel = channel
|
||||
channel.reader = yield* connection.messages.pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.gen(function* () {
|
||||
const active = channel.active
|
||||
if (!active)
|
||||
return yield* transportError("receive", "WebSocket data arrived without an active exchange", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("receive", "Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
})
|
||||
if (Queue.offerUnsafe(active.queue, message)) return undefined
|
||||
return yield* transportError("receive", "Session WebSocket inbound queue overflow", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
channel.closing
|
||||
? Effect.void
|
||||
: poison(
|
||||
owner,
|
||||
channel,
|
||||
annotate(error, {
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* Effect.logDebug("session websocket connected", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
})
|
||||
yield* metric("connect")
|
||||
return channel
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const fallback = (exchange: WebSocketChannelExchange): WebSocketChannelExecution => ({
|
||||
frames: exchange.fallback(),
|
||||
complete: Effect.void,
|
||||
})
|
||||
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("start", "Session WebSocket owner is closed", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
|
||||
const rotation = current
|
||||
? current.poisoned
|
||||
? "poisoned"
|
||||
: current.affinity !== key
|
||||
? "affinity"
|
||||
: now - current.openedAt >= rotateAfterMs
|
||||
? "age"
|
||||
: undefined
|
||||
: undefined
|
||||
if (current && rotation) {
|
||||
yield* Effect.logDebug("session websocket rotating", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
reason: rotation,
|
||||
})
|
||||
yield* metric("rotation", { reason: rotation })
|
||||
yield* metric("reconnect")
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
})
|
||||
if (owner.channel) yield* metric("reuse")
|
||||
const channel = owner.channel
|
||||
? owner.channel
|
||||
: yield* open(owner, exchange, key).pipe(
|
||||
Effect.catch((error) =>
|
||||
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
|
||||
? Effect.fail(error)
|
||||
: Effect.logWarning("session websocket connect failed; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("connect_failure")),
|
||||
Effect.andThen(metric("fallback")),
|
||||
Effect.andThen(Effect.succeed(undefined)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
channel.checkpoint = undefined
|
||||
}
|
||||
|
||||
const create = yield* exchange.driver.create(channel.checkpoint).pipe(
|
||||
Effect.tapError(() => closeChannel(owner, channel)),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
)
|
||||
if (sent._tag === "Failure") {
|
||||
const failure = sent.failure
|
||||
const notSent = failure.reason._tag === "Transport" && failure.reason.delivery === "not-sent"
|
||||
yield* closeChannel(owner, channel)
|
||||
if (notSent) {
|
||||
yield* metric("fallback")
|
||||
return fallback(exchange)
|
||||
}
|
||||
yield* metric("ambiguous_delivery")
|
||||
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
|
||||
}
|
||||
yield* metric("send")
|
||||
|
||||
let terminal: ChannelObservation | undefined
|
||||
const token = {}
|
||||
let staged: ChannelCheckpoint | undefined
|
||||
const frames = Stream.fromQueue(active.queue).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: IDLE_TIMEOUT,
|
||||
orElse: () =>
|
||||
Stream.fail(
|
||||
transportError("receive", "Timed out waiting for WebSocket data", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
lifecycle.delivery = "terminal"
|
||||
staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
}),
|
||||
),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
Stream.ensuring(
|
||||
Effect.gen(function* () {
|
||||
if (channel.active === active) channel.active = undefined
|
||||
const pending = yield* Queue.size(active.queue)
|
||||
yield* Queue.shutdown(active.queue)
|
||||
if (terminal && pending === 0) {
|
||||
yield* metric("terminal", { type: terminal.type })
|
||||
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
|
||||
if (terminal.type === "rejected" && terminal.recovery === "rotate-and-retry-full")
|
||||
yield* closeChannel(owner, channel)
|
||||
return
|
||||
}
|
||||
yield* metric("cancellation")
|
||||
channel.checkpoint = undefined
|
||||
channel.pending = undefined
|
||||
const error = terminal
|
||||
? transportError("receive", "WebSocket data arrived after the terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
: transportError("execute", "Session WebSocket exchange did not reach a terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const complete = Effect.sync(() => {
|
||||
if (owner.channel !== channel || channel.pending?.token !== token) return
|
||||
channel.checkpoint = channel.pending.checkpoint
|
||||
channel.pending = undefined
|
||||
})
|
||||
return { frames, complete }
|
||||
})
|
||||
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let complete = Effect.void
|
||||
return Effect.succeed({
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.tap((execution) =>
|
||||
Effect.sync(() => {
|
||||
complete = execution.complete
|
||||
}),
|
||||
),
|
||||
Effect.map((execution) => execution.frames),
|
||||
),
|
||||
),
|
||||
complete: Effect.suspend(() => complete),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const close = Effect.fn("SessionModelTransport.close")(function* (sessionID: SessionSchema.ID) {
|
||||
const owner = states.get(sessionID)
|
||||
if (!owner) return
|
||||
states.delete(sessionID)
|
||||
owner.closed = true
|
||||
if (owner.channel) yield* closeChannel(owner, owner.channel)
|
||||
})
|
||||
const closeAll = Effect.suspend(() => {
|
||||
const owners = Array.from(states.values())
|
||||
states.clear()
|
||||
return Effect.forEach(
|
||||
owners,
|
||||
(owner) => {
|
||||
owner.closed = true
|
||||
return owner.channel ? closeChannel(owner, owner.channel) : Effect.void
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() => closeAll)
|
||||
return Service.of({ bind, close, closeAll })
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.map(Socket.WebSocketConstructor, (constructor) =>
|
||||
makeLayer({
|
||||
open: (input) =>
|
||||
WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [webSocketConstructor] })
|
||||
@@ -19,7 +19,6 @@ import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
@@ -35,7 +34,7 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||
Retry: { readonly step: number }
|
||||
@@ -44,7 +43,6 @@ type CallOutcome = Data.TaggedEnum<{
|
||||
readonly error: SessionRunnerRetry.RetryableFailure["error"]
|
||||
readonly step: number
|
||||
}
|
||||
RecoverFull: { readonly step: number }
|
||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||
}>
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
@@ -110,7 +108,6 @@ const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const modelTransport = yield* SessionModelTransport.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
@@ -208,15 +205,12 @@ const layer = Layer.effect(
|
||||
let currentStep = step
|
||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||
let recoverOverflow = true
|
||||
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
const outcome = yield* callModel(
|
||||
sessionID,
|
||||
currentPromotable,
|
||||
currentStep,
|
||||
recoverOverflow,
|
||||
recoverContinuation,
|
||||
assistantMessageID,
|
||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||
@@ -238,7 +232,6 @@ const layer = Layer.effect(
|
||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
if (outcome._tag === "RecoverFull") recoverContinuation = false
|
||||
// Neither a retry nor a compaction restart re-promotes input.
|
||||
currentPromotable = undefined
|
||||
currentStep = outcome.step
|
||||
@@ -254,7 +247,6 @@ const layer = Layer.effect(
|
||||
promotable: SessionInbox.Promotable | undefined,
|
||||
step: number,
|
||||
recoverOverflow: boolean,
|
||||
recoverContinuation: boolean,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
@@ -420,13 +412,6 @@ const layer = Layer.effect(
|
||||
// escapes as a scheduled retry or fails the assistant durably.
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
|
||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!publisher.record().outputStarted
|
||||
)
|
||||
return CallOutcome.RecoverFull({ step: currentStep })
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
@@ -568,7 +553,6 @@ const layer = Layer.effect(
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||
[
|
||||
@@ -639,7 +623,6 @@ export const node = makeLocationNode({
|
||||
llmClient,
|
||||
SessionContext.node,
|
||||
SessionModelRequest.node,
|
||||
SessionModelTransport.node,
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
|
||||
@@ -18,9 +18,8 @@ export function isRetryable(error: AIError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
case "Transport":
|
||||
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||
return true
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
case "Authentication":
|
||||
|
||||
@@ -9,7 +9,6 @@ import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
type SourceToken = { raw: string; value: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
@@ -153,17 +152,7 @@ const ARITY: Record<string, number> = {
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (
|
||||
command: string,
|
||||
shell: string,
|
||||
cwd: string,
|
||||
options?: { portable?: boolean },
|
||||
) {
|
||||
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
|
||||
return yield* scanLegacy(command, shell, cwd)
|
||||
})
|
||||
|
||||
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
@@ -197,417 +186,6 @@ const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string
|
||||
)
|
||||
})
|
||||
|
||||
async function scanPortable(command: string, shell: string, cwd: string) {
|
||||
const { ShellScan } = await import("@opencode-ai/shell-scan")
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
|
||||
const carriage = powershell ? command.search(/\r(?!\n)/) : -1
|
||||
if (carriage >= 0) return { commands: [], directories: [] }
|
||||
|
||||
const parsed = result.commands.reduce(
|
||||
(output, item) => {
|
||||
const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor)
|
||||
const offset = item[ShellScan.Nested]
|
||||
? command.lastIndexOf(item.resource, output.cursor - 1)
|
||||
: index < 0
|
||||
? command.indexOf(item.resource)
|
||||
: index
|
||||
if (index >= 0) output.cursor = index + item.resource.length
|
||||
const before = command.slice(0, Math.max(0, offset))
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name) return output
|
||||
if (powershell && name === "<") return output
|
||||
if (
|
||||
powershell &&
|
||||
name === "foreach-object" &&
|
||||
item.words.some((word) => word.startsWith("{")) &&
|
||||
!/\|\s*$/.test(before)
|
||||
)
|
||||
return output
|
||||
const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource)
|
||||
const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw
|
||||
if (CWD.has(name) && (powershell || sourceHead === item.words[0])) {
|
||||
output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell))
|
||||
return output
|
||||
}
|
||||
const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words)
|
||||
output.commands.push({
|
||||
resource: powershell ? item.resource : bashResource(item.resource, before),
|
||||
save: `${save} *`,
|
||||
})
|
||||
return output
|
||||
},
|
||||
{
|
||||
commands: [] as Array<{ resource: string; save: string }>,
|
||||
directories: [] as string[],
|
||||
cursor: 0,
|
||||
},
|
||||
)
|
||||
return { commands: parsed.commands, directories: parsed.directories }
|
||||
}
|
||||
|
||||
function bashResource(resource: string, before: string) {
|
||||
if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource
|
||||
const redirect = bashRedirect(resource)
|
||||
return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim()
|
||||
}
|
||||
|
||||
function bashRedirect(resource: string) {
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1)
|
||||
else if (char === "`") index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
index = bashParenthesizedEnd(resource, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function bashParenthesizedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") continue
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === "(") level++
|
||||
if (char === ")" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function bashBacktickEnd(resource: string, start: number) {
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
if (resource[index] === "\\") index++
|
||||
else if (resource[index] === "`") return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function portableDirectoryArgs(
|
||||
command: string[],
|
||||
tokens: SourceToken[],
|
||||
powershell: boolean,
|
||||
cwd: string,
|
||||
shell: string,
|
||||
) {
|
||||
if (!powershell) {
|
||||
const start = tokens.findIndex((token) => token.value === command[0])
|
||||
if (start < 0) return []
|
||||
return directoryArgs(
|
||||
tokens.slice(start).map((token) => ({ type: "word", text: token.raw })),
|
||||
false,
|
||||
cwd,
|
||||
shell,
|
||||
)
|
||||
}
|
||||
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase())
|
||||
if (start < 0) return []
|
||||
const directories: string[] = []
|
||||
let expectsPath = false
|
||||
for (const part of tokens.slice(start + 1).map((token) => token.raw)) {
|
||||
if (expectsPath) {
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
expectsPath = false
|
||||
continue
|
||||
}
|
||||
if (part.startsWith("-")) {
|
||||
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
function sourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | "backtick" | undefined
|
||||
let substitution = 0
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "\\" && index + 1 < resource.length) {
|
||||
const next = resource[index + 1]
|
||||
if ('$`"\\\n'.includes(next)) {
|
||||
raw += resource[++index]
|
||||
if (next !== "\n") value += next
|
||||
} else value += char
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "backtick") {
|
||||
raw += char
|
||||
value += char
|
||||
if (char === "`" && resource[index - 1] !== "\\") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
raw += char
|
||||
value += char
|
||||
quote = "backtick"
|
||||
continue
|
||||
}
|
||||
if (char === "\\" && index + 1 < resource.length) {
|
||||
if (resource[index + 1] === "\n") {
|
||||
finish()
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (!raw && /\s/.test(resource[index + 1])) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
raw += char + resource[++index]
|
||||
value += resource[index]
|
||||
continue
|
||||
}
|
||||
if ((char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
const end = bashParenthesizedEnd(resource, index + 1)
|
||||
if (raw) {
|
||||
raw += resource.slice(index, end + 1)
|
||||
value += resource.slice(index, end + 1)
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (char === "$" && resource[index + 1] === "(") substitution++
|
||||
if (char === ")" && substitution > 0) substitution--
|
||||
if (substitution === 0 && /\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) {
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
if (char === "&") index++
|
||||
while (/[<>&|]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function bashSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value === words[0])
|
||||
if (start < 0) {
|
||||
const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw))
|
||||
return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
const source = tokens
|
||||
.slice(start)
|
||||
.map((token) => token.raw)
|
||||
.filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token))
|
||||
return prefix(source).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase())
|
||||
if (start < 0) return prefix(words).join(" ")
|
||||
return prefix(tokens.slice(start).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | undefined
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'" && resource[index + 1] === "'") {
|
||||
raw += resource[++index]
|
||||
value += "'"
|
||||
} else if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "`" && index + 1 < resource.length) {
|
||||
raw += resource[++index]
|
||||
value += resource[index]
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`" && index + 1 < resource.length) {
|
||||
raw += char + resource[++index]
|
||||
if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index]
|
||||
continue
|
||||
}
|
||||
if (char === "{" && !raw) {
|
||||
const end = powerShellBracedEnd(resource, index)
|
||||
raw = resource.slice(index, end + 1)
|
||||
value = raw
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (char === ">") {
|
||||
if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) {
|
||||
raw += char
|
||||
value += char
|
||||
continue
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else if (raw === "*") {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
while (/[>&\d]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function powerShellBracedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (char === "`" && quote !== "single") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (quote === "single") {
|
||||
if (char === "'" && resource[index + 1] === "'") index++
|
||||
else if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === '"') quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{") level++
|
||||
if (char === "}" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
|
||||
@@ -163,11 +163,7 @@ export const Plugin = {
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
if (!unrestricted) {
|
||||
const portable =
|
||||
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
|
||||
portable,
|
||||
})
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
|
||||
@@ -468,7 +468,7 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
||||
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
@@ -480,7 +480,7 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
||||
const http = "http" in error.reason ? error.reason.http : undefined
|
||||
expect(http?.request.url).toBe("https://api.example.com/chat")
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
|
||||
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -393,13 +393,11 @@ describe("ConfigNormalize", () => {
|
||||
enabled_providers: ["anthropic"],
|
||||
disabled_providers: ["openai"],
|
||||
experimental: {
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
||||
},
|
||||
}).encoded.experimental,
|
||||
).toEqual({
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.wor
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the workerd
|
||||
// adapter and the workerd server profile can be verified without workerd or
|
||||
// Cloudflare runtime dependencies.
|
||||
// Cloudflare runtime dependencies. The real runtime is covered by the
|
||||
// workerd-spike package, which boots inside an actual isolate.
|
||||
export const makeDurableObjectStorage = (): DurableObjectStorage => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect } from "effect"
|
||||
|
||||
interface ConnectionData {
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
export interface WebSocketServerState {
|
||||
readonly headers: Array<Record<string, string>>
|
||||
readonly messages: string[]
|
||||
opens: number
|
||||
closes: number
|
||||
pongs: number
|
||||
}
|
||||
|
||||
export interface WebSocketServerFixture {
|
||||
readonly url: string
|
||||
readonly state: WebSocketServerState
|
||||
}
|
||||
|
||||
export interface WebSocketServerOptions {
|
||||
readonly upgrade?: (request: Request) => boolean
|
||||
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
|
||||
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
|
||||
}
|
||||
|
||||
export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const state: WebSocketServerState = { headers: [], messages: [], opens: 0, closes: 0, pongs: 0 }
|
||||
let connection = 0
|
||||
const server = Bun.serve<ConnectionData>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
state.headers.push(Object.fromEntries(request.headers.entries()))
|
||||
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
|
||||
return undefined
|
||||
return new Response("WebSocket upgrade required", {
|
||||
status: 426,
|
||||
headers: { "x-upgrade-rejected": "true" },
|
||||
})
|
||||
},
|
||||
websocket: {
|
||||
open(socket) {
|
||||
state.opens++
|
||||
options.open?.(socket)
|
||||
},
|
||||
message(socket, message) {
|
||||
const text = typeof message === "string" ? message : message.toString()
|
||||
state.messages.push(text)
|
||||
options.message?.(socket, message)
|
||||
},
|
||||
close() {
|
||||
state.closes++
|
||||
},
|
||||
pong() {
|
||||
state.pongs++
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
server,
|
||||
fixture: {
|
||||
url: `${server.url.toString().replace(/^http/, "ws")}responses`,
|
||||
state,
|
||||
} satisfies WebSocketServerFixture,
|
||||
}
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
).pipe(Effect.map((item) => item.fixture))
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -27,9 +29,14 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const httpMiddlewareCount = Effect.fn(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return Number(yield* hooks.has("session", "http.request")) + Number(yield* hooks.has("session", "http.response"))
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
})
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -103,11 +110,19 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
|
||||
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
expect(yield* httpMiddlewareCount()).toBe(0)
|
||||
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
@@ -151,13 +166,13 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(provider.headers).not.toHaveProperty("originator")
|
||||
expect(yield* httpMiddlewareCount()).toBe(0)
|
||||
expect(request.headers).not.toHaveProperty("originator")
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
@@ -191,71 +190,4 @@ describe("built-in web search providers", () => {
|
||||
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Tavily with keyless and keyed Search API access", () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture(
|
||||
JSON.stringify({
|
||||
query: "effect typescript",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
score: 0.99,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchTavily.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("tavily"))).toMatchObject({
|
||||
id: "tavily",
|
||||
name: "Tavily",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["TAVILY_API_KEY"] }],
|
||||
})
|
||||
const query = {
|
||||
query: "effect typescript",
|
||||
providerID: WebSearch.ID.make("tavily"),
|
||||
}
|
||||
expect(yield* websearch.query(query)).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("tavily"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchTavily.endpoint,
|
||||
headers: { "x-client-name": "opencode2", "x-tavily-access-mode": "keyless" },
|
||||
body: {
|
||||
query: "effect typescript",
|
||||
search_depth: "basic",
|
||||
chunks_per_source: 3,
|
||||
max_results: 8,
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.headers.authorization).toBeUndefined()
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("tavily"),
|
||||
key: "tavily-secret",
|
||||
})
|
||||
yield* websearch.query(query)
|
||||
expect(requests[1]).toMatchObject({
|
||||
headers: { authorization: "Bearer tavily-secret", "x-client-name": "opencode2" },
|
||||
})
|
||||
expect(requests[1]?.headers["x-tavily-access-mode"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -128,52 +128,4 @@ describe("toSessionError", () => {
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
|
||||
test("retries transport failures only when delivery is absent or not sent", () => {
|
||||
const retryable = [
|
||||
llm(new TransportReason({ message: "http transport", transport: "http", operation: "request" })),
|
||||
llm(
|
||||
new TransportReason({
|
||||
message: "connect failed",
|
||||
transport: "websocket",
|
||||
operation: "request",
|
||||
delivery: "not-sent",
|
||||
phase: "connect",
|
||||
}),
|
||||
),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(
|
||||
new TransportReason({
|
||||
message: "send uncertain",
|
||||
transport: "websocket",
|
||||
operation: "write",
|
||||
delivery: "ambiguous",
|
||||
phase: "send",
|
||||
}),
|
||||
),
|
||||
llm(
|
||||
new TransportReason({
|
||||
message: "response interrupted",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
delivery: "accepted",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
llm(
|
||||
new TransportReason({
|
||||
message: "continuation rejected",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
delivery: "rejected",
|
||||
recovery: "retry-full",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { AIError, LLM, Message } from "@opencode-ai/ai"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelObservation,
|
||||
type WebSocketChannelExchange,
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { configure } from "@opencode-ai/ai/providers/openai"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeWebSocketServer, type WebSocketServerFixture, type WebSocketServerOptions } from "./lib/websocket-server"
|
||||
|
||||
const sessionID = Session.ID.make("ses_live_websocket")
|
||||
|
||||
const exchange = (server: WebSocketServerFixture, id: string): WebSocketChannelExchange => ({
|
||||
id,
|
||||
connect: {
|
||||
url: server.url,
|
||||
headers: Headers.fromInput({ authorization: "Bearer local-secret", "x-handshake": "visible" }),
|
||||
},
|
||||
fallback: () => Stream.die("Unexpected HTTP fallback"),
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: id, mode: "full" }),
|
||||
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
|
||||
Effect.succeed({ type: "completed", frame }),
|
||||
},
|
||||
})
|
||||
|
||||
const withServer = <A>(
|
||||
options: WebSocketServerOptions,
|
||||
effect: (server: WebSocketServerFixture) => Effect.Effect<A, unknown, SessionModelTransport.Service>,
|
||||
) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
const server = yield* makeWebSocketServer(options)
|
||||
return yield* effect(server).pipe(
|
||||
Effect.provide(
|
||||
SessionModelTransport.makeLayer({
|
||||
open: (input) =>
|
||||
WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.provide(NodeSocket.layerWebSocketConstructorWS)),
|
||||
)
|
||||
|
||||
const collect = (transport: SessionModelTransport.Interface, item: WebSocketChannelExchange) =>
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* transport.bind(sessionID).execute(item)
|
||||
return Array.from(yield* Stream.runCollect(execution.frames.pipe(Stream.onEnd(execution.complete))))
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const waitFor = (predicate: () => boolean, remaining = 100): Effect.Effect<void> => {
|
||||
if (predicate()) return Effect.void
|
||||
if (remaining === 0) return Effect.die("Timed out waiting for local WebSocket server")
|
||||
return Effect.sleep("5 millis").pipe(Effect.andThen(Effect.suspend(() => waitFor(predicate, remaining - 1))))
|
||||
}
|
||||
|
||||
describe("SessionModelTransport local WebSocket server", () => {
|
||||
test("continues a real Responses connection with only the appended input", async () => {
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
await withServer(
|
||||
{
|
||||
message: (socket, message) => {
|
||||
const request = JSON.parse(message.toString())
|
||||
requests.push(request)
|
||||
const index = requests.length
|
||||
const id = `msg_${index}`
|
||||
const text = index === 1 ? "Hello" : "Brief"
|
||||
socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id,
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
|
||||
},
|
||||
},
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(sessionID)
|
||||
const model = configure({
|
||||
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
|
||||
apiKey: "local",
|
||||
}).responses("gpt-5.2")
|
||||
const client = LLMClient.Service
|
||||
const layer = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const first = yield* client
|
||||
.use((llm) => llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }))
|
||||
.pipe(Effect.provide(layer))
|
||||
const second = yield* client
|
||||
.use((llm) =>
|
||||
llm.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Be brief")],
|
||||
}),
|
||||
{ webSocket: executor },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.provide(layer))
|
||||
|
||||
expect(first.text).toBe("Hello")
|
||||
expect(second.text).toBe("Brief")
|
||||
expect(server.state.opens).toBe(1)
|
||||
expect(requests[1]).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Be brief" }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("clears a rejected continuation and keeps one provider request per attempt", async () => {
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
await withServer(
|
||||
{
|
||||
message: (socket, message) => {
|
||||
requests.push(JSON.parse(message.toString()))
|
||||
const index = requests.length
|
||||
if (index === 2) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { code: "previous_response_not_found", message: "Missing response" },
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const id = `msg_${index}`
|
||||
const text = index === 1 ? "Hello" : "Recovered"
|
||||
socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
|
||||
},
|
||||
},
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(sessionID)
|
||||
const model = configure({
|
||||
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
|
||||
apiKey: "local",
|
||||
}).responses("gpt-5.2")
|
||||
const layer = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Continue")],
|
||||
})
|
||||
|
||||
yield* LLMClient.Service.use((llm) =>
|
||||
llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }),
|
||||
).pipe(Effect.provide(layer))
|
||||
const rejected = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.flip,
|
||||
)
|
||||
const recovered = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
|
||||
Effect.provide(layer),
|
||||
)
|
||||
|
||||
expect(rejected.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
delivery: "rejected",
|
||||
recovery: "retry-full",
|
||||
})
|
||||
expect(recovered.text).toBe("Recovered")
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[1]).toHaveProperty("previous_response_id", "resp_1")
|
||||
expect(requests[2]).not.toHaveProperty("previous_response_id")
|
||||
expect(server.state.opens).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// The browser-compatible client surface cannot originate ping frames, so the server sends one and verifies pong.
|
||||
test("reuses one real connection with handshake headers and ping/pong", async () => {
|
||||
await withServer(
|
||||
{
|
||||
open: (socket) => socket.ping("health"),
|
||||
message: (socket, message) => socket.send(`completed:${message.toString()}`),
|
||||
},
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
|
||||
expect(yield* collect(transport, exchange(server, "first"))).toEqual(["completed:first"])
|
||||
expect(yield* collect(transport, exchange(server, "second"))).toEqual(["completed:second"])
|
||||
yield* waitFor(() => server.state.pongs === 1)
|
||||
|
||||
expect(server.state.opens).toBe(1)
|
||||
expect(server.state.messages).toEqual(["first", "second"])
|
||||
expect(server.state.headers[0]).toMatchObject({
|
||||
authorization: "Bearer local-secret",
|
||||
"x-handshake": "visible",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a real active connection on cancellation", async () => {
|
||||
await withServer({ message: () => {} }, (server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport, exchange(server, "blocked")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* waitFor(() => server.state.messages.length === 1)
|
||||
|
||||
yield* Fiber.interrupt(running)
|
||||
yield* waitFor(() => server.state.closes === 1)
|
||||
|
||||
expect(server.state.messages).toEqual(["blocked"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("poisons a real connection after an unsupported binary frame", async () => {
|
||||
await withServer({ message: (socket) => socket.sendBinary(new Uint8Array([1, 2, 3])) }, (server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
|
||||
const result = yield* Effect.result(collect(transport, exchange(server, "binary")))
|
||||
yield* waitFor(() => server.state.closes === 1)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "message", delivery: "accepted" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a real connection after an oversized frame", async () => {
|
||||
await withServer({ message: (socket) => socket.send("x".repeat(16 * 1024 * 1024 + 1)) }, (server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
|
||||
const result = yield* Effect.result(collect(transport, exchange(server, "oversized")))
|
||||
yield* waitFor(() => server.state.closes === 1)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "message-too-large", delivery: "ambiguous" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Effect's browser-compatible constructor does not expose upgrade response bodies or headers.
|
||||
// The real 426 fixture therefore pins the observable contract: a not-sent connect failure and one HTTP fallback.
|
||||
test("falls back once after a real rejected upgrade", async () => {
|
||||
let fallbacks = 0
|
||||
await withServer({ upgrade: () => false }, (server) =>
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const item = exchange(server, "fallback")
|
||||
const result = yield* collect(transport, {
|
||||
...item,
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make("http")
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(["http"])
|
||||
expect(fallbacks).toBe(1)
|
||||
expect(server.state.opens).toBe(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,744 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AIError, TransportReason } from "@opencode-ai/ai"
|
||||
import type {
|
||||
ChannelObservation,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketConnection,
|
||||
WebSocketConnector,
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
|
||||
const session = Session.ID.make("ses_transport")
|
||||
const otherSession = Session.ID.make("ses_transport_other")
|
||||
const queue = <A, E = never>() => Effect.runSync(Queue.unbounded<A, E>())
|
||||
|
||||
const error = (message: string, delivery?: TransportReason["delivery"]) =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "websocket",
|
||||
reason: new TransportReason({ message, transport: "websocket", operation: "write", phase: "send", delivery }),
|
||||
})
|
||||
|
||||
const exchange = (
|
||||
id: string,
|
||||
input: {
|
||||
readonly headers?: Record<string, string>
|
||||
readonly fallback?: () => Stream.Stream<string, AIError>
|
||||
readonly rotateAfterMs?: number
|
||||
} = {},
|
||||
): WebSocketChannelExchange => ({
|
||||
id,
|
||||
connect: {
|
||||
url: "wss://provider.test/responses",
|
||||
headers: Headers.fromInput(input.headers),
|
||||
rotateAfterMs: input.rotateAfterMs,
|
||||
},
|
||||
fallback: input.fallback ?? (() => Stream.make(`fallback:${id}`)),
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: id, mode: "full" }),
|
||||
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
|
||||
Effect.succeed({ type: "completed", frame }),
|
||||
},
|
||||
})
|
||||
|
||||
const run = <A, E>(connector: WebSocketConnector, effect: Effect.Effect<A, E, SessionModelTransport.Service>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(SessionModelTransport.makeLayer(connector)), Effect.scoped))
|
||||
|
||||
const runWithTestClock = <A, E>(
|
||||
connector: WebSocketConnector,
|
||||
effect: Effect.Effect<A, E, SessionModelTransport.Service>,
|
||||
) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provide(SessionModelTransport.makeLayer(connector)),
|
||||
Effect.scoped,
|
||||
Effect.provide(TestClock.layer()),
|
||||
),
|
||||
)
|
||||
|
||||
const collect = (executor: ReturnType<SessionModelTransport.Interface["bind"]>, item: WebSocketChannelExchange) =>
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* executor.execute(item)
|
||||
return Array.from(yield* Stream.runCollect(execution.frames))
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const collectComplete = (
|
||||
executor: ReturnType<SessionModelTransport.Interface["bind"]>,
|
||||
item: WebSocketChannelExchange,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* executor.execute(item)
|
||||
return Array.from(yield* Stream.runCollect(execution.frames.pipe(Stream.onEnd(execution.complete))))
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const automatic = () => {
|
||||
const connections: Array<{
|
||||
readonly messages: Queue.Queue<string | Uint8Array, AIError>
|
||||
closed: number
|
||||
sent: string[]
|
||||
}> = []
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
|
||||
const record = { messages, closed: 0, sent: [] as string[] }
|
||||
connections.push(record)
|
||||
const connection: WebSocketConnection = {
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
record.sent.push(message)
|
||||
Queue.offerUnsafe(messages, `completed:${message}`)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => {
|
||||
record.closed++
|
||||
}).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}
|
||||
return connection
|
||||
}),
|
||||
}
|
||||
return { connector, connections }
|
||||
}
|
||||
|
||||
describe("SessionModelTransport", () => {
|
||||
test("commits checkpoints only after successful outer completion", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const checkpoints: Array<unknown> = []
|
||||
const candidate = { protocol: "test", value: { response: "one" } }
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => Queue.offerUnsafe(messages, `completed:${message}`)).pipe(Effect.asVoid),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Queue.shutdown(messages).pipe(Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
const item = (id: string): WebSocketChannelExchange => ({
|
||||
...exchange(id),
|
||||
driver: {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
checkpoints.push(checkpoint)
|
||||
return { message: id, mode: checkpoint ? "incremental" : "full" }
|
||||
}),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collectComplete(executor, item("first"))
|
||||
yield* collect(executor, item("second"))
|
||||
yield* collect(executor, item("third"))
|
||||
|
||||
expect(checkpoints).toEqual([undefined, candidate, undefined])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not carry a checkpoint across physical connection rotation", async () => {
|
||||
const fixture = automatic()
|
||||
const checkpoints: Array<unknown> = []
|
||||
const candidate = { protocol: "test", value: { response: "one" } }
|
||||
const item = (id: string, authorization: string): WebSocketChannelExchange => ({
|
||||
...exchange(id, { headers: { authorization } }),
|
||||
driver: {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
checkpoints.push(checkpoint)
|
||||
return { message: id, mode: checkpoint ? "incremental" : "full" }
|
||||
}),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collectComplete(executor, item("first", "one"))
|
||||
yield* collect(executor, item("second", "two"))
|
||||
|
||||
expect(checkpoints).toEqual([undefined, undefined])
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("clears a rejected checkpoint before the runner retries full", async () => {
|
||||
const fixture = automatic()
|
||||
const checkpoints: Array<unknown> = []
|
||||
const candidate = { protocol: "test", value: { response: "one" } }
|
||||
const item = (id: string): WebSocketChannelExchange => ({
|
||||
...exchange(id),
|
||||
driver: {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
checkpoints.push(checkpoint)
|
||||
return { message: id, mode: checkpoint ? "incremental" : "full" }
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
id === "rejected"
|
||||
? Effect.succeed({
|
||||
type: "rejected",
|
||||
recovery: "retry-full",
|
||||
error: new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
message: "missing response",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
delivery: "rejected",
|
||||
recovery: "retry-full",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
: Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collectComplete(executor, item("first"))
|
||||
yield* Effect.result(collect(executor, item("rejected")))
|
||||
yield* collect(executor, item("retry"))
|
||||
|
||||
expect(checkpoints).toEqual([undefined, candidate, undefined])
|
||||
expect(fixture.connections).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rotates after the provider rejects the connection generation", async () => {
|
||||
const fixture = automatic()
|
||||
const rejected: WebSocketChannelExchange = {
|
||||
...exchange("rejected"),
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "rejected", mode: "incremental" }),
|
||||
observe: () =>
|
||||
Effect.succeed({
|
||||
type: "rejected",
|
||||
recovery: "rotate-and-retry-full",
|
||||
error: new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
message: "connection limit",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
delivery: "rejected",
|
||||
recovery: "rotate-and-retry-full",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* Effect.result(collect(executor, rejected))
|
||||
yield* collect(executor, exchange("retry"))
|
||||
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections[0]?.closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("reuses one physical connection for sequential Session calls", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
expect(yield* collect(transport.bind(session), exchange("first"))).toEqual(["completed:first"])
|
||||
expect(yield* collect(transport.bind(session), exchange("second"))).toEqual(["completed:second"])
|
||||
expect(fixture.connections).toHaveLength(1)
|
||||
expect(fixture.connections[0]?.sent).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent calls for one Session", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const sent: string[] = []
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
sent.push(message)
|
||||
if (message === "first") {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
Queue.offerUnsafe(messages, `completed:${message}`)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Queue.shutdown(messages).pipe(Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
const first = yield* collect(executor, exchange("first")).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const second = yield* collect(executor, exchange("second")).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
expect(sent).toEqual(["first"])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(sent).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("isolates connections and permits concurrency across Sessions", async () => {
|
||||
const started = queue<string>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
let opened = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.gen(function* () {
|
||||
opened++
|
||||
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
Queue.offerUnsafe(started, message)
|
||||
yield* Deferred.await(release)
|
||||
Queue.offerUnsafe(messages, `completed:${message}`)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Queue.shutdown(messages).pipe(Effect.asVoid),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const first = yield* collect(transport.bind(session), exchange("first")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
const second = yield* collect(transport.bind(otherSession), exchange("second")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
expect(new Set([yield* Queue.take(started), yield* Queue.take(started)])).toEqual(new Set(["first", "second"]))
|
||||
expect(opened).toBe(2)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("cancels a queued call without affecting the active exchange", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const sent: string[] = []
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
sent.push(message)
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
Queue.offerUnsafe(messages, `completed:${message}`)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Queue.shutdown(messages).pipe(Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
const active = yield* collect(executor, exchange("active")).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const queued = yield* collect(executor, exchange("queued")).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(queued)
|
||||
expect(sent).toEqual(["active"])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* Fiber.join(active)).toEqual(["completed:active"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes the connection when an active exchange is interrupted", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const fiber = yield* collect(transport.bind(session), exchange("first")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes an active exchange without waiting for its Session permit", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* transport.close(session)
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("times out an idle accepted request and poisons its socket", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await runWithTestClock(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport.bind(session), exchange("idle")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "idle-timeout", delivery: "ambiguous" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a newly opened connection when request creation is interrupted", async () => {
|
||||
const opened = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Deferred.succeed(opened, undefined).pipe(
|
||||
Effect.as({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const item = exchange("first")
|
||||
const fiber = yield* collect(transport.bind(session), {
|
||||
...item,
|
||||
driver: { create: () => Effect.never, observe: item.driver.observe },
|
||||
}).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(opened)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const result = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make("http")
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual(["http"])
|
||||
expect(fallbacks).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not fall back after an ambiguous send failure", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let fallbacks = 0
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.fail(error("send failed")),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const result = yield* Effect.result(
|
||||
collect(
|
||||
transport.bind(session),
|
||||
exchange("first", {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make("http")
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", phase: "send", delivery: "ambiguous" } },
|
||||
})
|
||||
expect(fallbacks).toBe(0)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rotates when handshake affinity or connection age changes", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collect(executor, exchange("first", { headers: { authorization: "one" } }))
|
||||
yield* collect(executor, exchange("second", { headers: { authorization: "one" } }))
|
||||
yield* collect(executor, exchange("third", { headers: { authorization: "two" } }))
|
||||
yield* Effect.sleep("5 millis")
|
||||
yield* collect(executor, exchange("fourth", { headers: { authorization: "two" }, rotateAfterMs: 1 }))
|
||||
expect(fixture.connections).toHaveLength(3)
|
||||
expect(fixture.connections.slice(0, 2).map((item) => item.closed)).toEqual([1, 1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("poisons a socket that receives data while idle", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collect(executor, exchange("first"))
|
||||
const connection = fixture.connections[0]
|
||||
if (!connection) throw new Error("Expected connection")
|
||||
Queue.offerUnsafe(connection.messages, "late")
|
||||
yield* Effect.yieldNow
|
||||
yield* collect(executor, exchange("second"))
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections[0]?.closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("poisons instead of dropping data when the inbound queue overflows", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () =>
|
||||
Effect.sync(() => {
|
||||
for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const item = exchange("first")
|
||||
const result = yield* Effect.result(
|
||||
collect(transport.bind(session), {
|
||||
...item,
|
||||
driver: {
|
||||
create: item.driver.create,
|
||||
observe: (_create, frame) => Effect.sleep("1 millis").pipe(Effect.as({ type: "frame" as const, frame })),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "queue-overflow", delivery: "accepted" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("poisons unsupported binary frames after provider observation", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const closed = Deferred.makeUnsafe<void>()
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.sync(() => Queue.offerUnsafe(messages, new Uint8Array([1]))).pipe(Effect.asVoid),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Deferred.succeed(closed, undefined).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const result = yield* Effect.result(collect(transport.bind(session), exchange("first")))
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "message", delivery: "accepted" } },
|
||||
})
|
||||
yield* Deferred.await(closed)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes individual and all owned connections", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
yield* collect(transport.bind(session), exchange("first"))
|
||||
yield* collect(transport.bind(otherSession), exchange("second"))
|
||||
yield* transport.close(session)
|
||||
expect(fixture.connections.map((item) => item.closed)).toEqual([1, 0])
|
||||
yield* transport.closeAll
|
||||
expect(fixture.connections.map((item) => item.closed)).toEqual([1, 1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("closes owned connections when the Location scope ends", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
yield* collect(transport.bind(session), exchange("first"))
|
||||
expect(fixture.connections[0]?.closed).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(fixture.connections[0]?.closed).toBe(1)
|
||||
})
|
||||
|
||||
test("records metadata-only lifecycle metrics", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
yield* collect(executor, exchange("first", { headers: { authorization: "secret-one" } }))
|
||||
yield* collect(executor, exchange("second", { headers: { authorization: "secret-one" } }))
|
||||
yield* collect(executor, exchange("third", { headers: { authorization: "secret-two" } }))
|
||||
|
||||
const snapshots = yield* Metric.snapshot
|
||||
const lifecycle = snapshots.filter((item) => item.id === "opencode_session_websocket_events_total")
|
||||
const names = new Set(lifecycle.map((item) => item.attributes?.event))
|
||||
expect(Array.from(names)).toEqual(
|
||||
expect.arrayContaining(["connect", "reuse", "rotation", "reconnect", "send", "terminal"]),
|
||||
)
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -9,40 +9,21 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const closed: Session.ID[] = []
|
||||
const transport = Layer.succeed(
|
||||
SessionModelTransport.Service,
|
||||
SessionModelTransport.Service.of({
|
||||
bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
|
||||
close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
|
||||
closeAll: Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Session.node,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[SessionModelTransport.node, transport],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(import.meta.dir) })
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.remove", () => {
|
||||
it.effect("removes a session and its children", () =>
|
||||
@@ -50,13 +31,10 @@ describe("Session.remove", () => {
|
||||
const session = yield* Session.Service
|
||||
const parent = yield* session.create({ location })
|
||||
const child = yield* session.create({ parentID: parent.id })
|
||||
yield* (yield* LocationServiceMap.Service).contextEffect(location)
|
||||
closed.length = 0
|
||||
|
||||
yield* session.remove(parent.id)
|
||||
|
||||
expect((yield* session.list()).data).toEqual([])
|
||||
expect(closed).toEqual([parent.id, child.id])
|
||||
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
|
||||
}),
|
||||
|
||||
@@ -32,11 +32,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionContext } from "@opencode-ai/core/session/context"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -63,7 +60,6 @@ import {
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
||||
@@ -76,7 +72,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
@@ -137,15 +132,6 @@ const testLLM = TestLLM.layer({
|
||||
}),
|
||||
})
|
||||
const client = TestLLM.clientLayer
|
||||
const closedTransports: Session.ID[] = []
|
||||
const modelTransport = Layer.succeed(
|
||||
SessionModelTransport.Service,
|
||||
SessionModelTransport.Service.of({
|
||||
bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
|
||||
close: (sessionID) => Effect.sync(() => closedTransports.push(sessionID)),
|
||||
closeAll: Effect.void,
|
||||
}),
|
||||
)
|
||||
const model = LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
const defaultSystem = PROMPT_DEFAULT
|
||||
const replacementModel = LanguageModel.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
||||
@@ -387,7 +373,6 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Config.node, config],
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionModelTransport.node, modelTransport],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -441,8 +426,6 @@ const it = testEffect(
|
||||
ReferenceInstructions.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionContext.node,
|
||||
SessionModelRequest.node,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
@@ -462,7 +445,6 @@ const it = testEffect(
|
||||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionModelTransport.node, modelTransport],
|
||||
],
|
||||
).pipe(Layer.provideMerge(testLLM)),
|
||||
)
|
||||
@@ -509,7 +491,6 @@ const setup = Effect.gen(function* () {
|
||||
requests = (yield* TestLLM.Service).requests
|
||||
authorizations.length = 0
|
||||
executions.length = 0
|
||||
closedTransports.length = 0
|
||||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
systemUnavailable = false
|
||||
@@ -545,20 +526,6 @@ const providerUnavailable = () =>
|
||||
}),
|
||||
})
|
||||
|
||||
const continuationRejected = (recovery: "retry-full" | "rotate-and-retry-full") =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
message: "Continuation rejected",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
delivery: "rejected",
|
||||
recovery,
|
||||
}),
|
||||
})
|
||||
|
||||
const incompleteStream = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
@@ -980,48 +947,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forces HTTP and triggers active request and response hooks once", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let requestTriggers = 0
|
||||
let responseTriggers = 0
|
||||
yield* hooks.register("session", "http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
requestTriggers++
|
||||
event.request.headers.set("x-request-hook", "active")
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "http.response", (event) =>
|
||||
Effect.sync(() => {
|
||||
responseTriggers++
|
||||
event.response.headers.set("x-response-hook", "active")
|
||||
}),
|
||||
)
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const selected = yield* context.select(sessionID)
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
context: yield* context.load(selected),
|
||||
step: 1,
|
||||
})
|
||||
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
|
||||
|
||||
const response = yield* http(HttpClientRequest.post("https://provider.test/responses"), (request) => {
|
||||
expect(request.headers["x-request-hook"]).toBe("active")
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
|
||||
})
|
||||
|
||||
expect(prepared.webSocketEligible).toBe(false)
|
||||
expect(response.headers["x-response-hook"]).toBe("active")
|
||||
expect(requestTriggers).toBe(1)
|
||||
expect(responseTriggers).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes a tool renamed by a session context hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1335,7 +1260,6 @@ describe("SessionRunnerLLM", () => {
|
||||
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
expect(requests).toEqual([])
|
||||
expect(closedTransports).toEqual([sessionID])
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
@@ -4169,36 +4093,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("immediately rebuilds once after explicit continuation rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(Stream.fail(continuationRejected("retry-full")))
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "continuation-recovery"))
|
||||
|
||||
yield* runPrompt(session, "Recover continuation")
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds repeated continuation rejection to one immediate recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const failure = continuationRejected("rotate-and-retry-full")
|
||||
yield* TestLLM.push(Stream.fail(failure), Stream.fail(failure))
|
||||
|
||||
expect(yield* runPrompt(session, "Reject continuation twice").pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incomplete stream before output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "@opencode-ai/shell-scan"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../src/shell/parse.js"
|
||||
|
||||
describe("ShellParse portable parity", () => {
|
||||
test("matches tree-sitter for generated supported syntax", async () => {
|
||||
for (const [shell, command] of generated()) {
|
||||
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
|
||||
|
||||
if (scanned.kind === "opaque") {
|
||||
expect({ command, portable }).toEqual({
|
||||
command,
|
||||
portable: { commands: [{ resource: command, save: command }], directories: [] },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (shell === "pwsh" && /\r(?!\n)/.test(command)) {
|
||||
expect(portable).toEqual({ commands: [], directories: [] })
|
||||
continue
|
||||
}
|
||||
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
|
||||
expect({ command, portable }).toEqual({ command, portable: legacy })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function generated() {
|
||||
const result: Array<[shell: string, command: string]> = []
|
||||
const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"]
|
||||
const bashArgs = [
|
||||
"",
|
||||
" status",
|
||||
" plain",
|
||||
" 'two words'",
|
||||
' "two words"',
|
||||
" escaped\\ space",
|
||||
" hash#word",
|
||||
" --flag=value",
|
||||
" ./relative",
|
||||
" /tmp/absolute",
|
||||
]
|
||||
const assignments = ["", "X=value ", "X='two words' ", 'X="two words" ']
|
||||
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
|
||||
const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
|
||||
|
||||
for (const head of bashHeads)
|
||||
for (const arg of bashArgs)
|
||||
for (const assignment of assignments)
|
||||
for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect])
|
||||
for (const left of bashHeads)
|
||||
for (const right of bashHeads)
|
||||
for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`])
|
||||
for (const outer of bashHeads)
|
||||
for (const inner of bashHeads) {
|
||||
result.push(["/bin/bash", `${outer} $(${inner} nested)`])
|
||||
result.push(["/bin/bash", `${outer} "$(${inner} nested)"`])
|
||||
result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`])
|
||||
result.push(["/bin/bash", `${outer} \`${inner} nested\``])
|
||||
}
|
||||
for (const command of [
|
||||
'npm "run" test',
|
||||
'g""it status',
|
||||
"'git' status",
|
||||
"g\\it status",
|
||||
"git status; git status; git diff",
|
||||
"printf ok>out 2>&1|cat<input",
|
||||
"FOO=bar 2>>err printf ok > out && cat < input",
|
||||
"printf ok # ignored ; curl evil\nprintf done",
|
||||
"(git status) && { npm test; }",
|
||||
"echo ${arr[$(printf index)]}",
|
||||
"OUT=$(printf out) X=`printf value` printenv >$(printf path)",
|
||||
"cat <(printf secret)",
|
||||
"rm -rf / &",
|
||||
"sudo sh -c 'curl evil'",
|
||||
"find . -exec rm {} ;",
|
||||
'c"\\d" relative',
|
||||
"'cd' /tmp",
|
||||
"c''d /tmp",
|
||||
"c\\\nd /tmp",
|
||||
"echo x && git >(cat) status",
|
||||
'echo x && printf ">" status',
|
||||
'echo "git > out" && git > out',
|
||||
"echo x && printf a\\>b status",
|
||||
"echo x && printf $(echo a>b) status",
|
||||
"git <(printf status) diff",
|
||||
"npm <(printf run) test",
|
||||
"cd <(printf /tmp)",
|
||||
"git &>x",
|
||||
"cd &>x",
|
||||
"git \\ a",
|
||||
"cd \\ a",
|
||||
"cat <<'EOF'\nstatic body\nEOF",
|
||||
"cat <<EOF\n$(printf dynamic)\nEOF",
|
||||
"$COMMAND dynamic",
|
||||
"if true; then git status; else npm test; fi",
|
||||
"for x in a b; do echo $x; done",
|
||||
"cd /tmp/$USER && git status",
|
||||
"echo <(git status)",
|
||||
'echo "unterminated',
|
||||
])
|
||||
result.push(["/bin/bash", command])
|
||||
|
||||
const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
|
||||
const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
|
||||
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r", "\r\n"]
|
||||
for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
|
||||
for (const left of powershellHeads)
|
||||
for (const right of powershellHeads)
|
||||
for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
|
||||
for (const command of [
|
||||
"Get-ChildItem; Get-ChildItem; Write-Output done",
|
||||
"Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"",
|
||||
"Get-Content in.txt > out.txt 2>&1 | Out-File all.log",
|
||||
"Write-Output ok > output.txt # ignored\nGet-ChildItem",
|
||||
"Write-Output ok > output.txt # ignored\rGet-ChildItem",
|
||||
"Write-Output ok > output.txt # ignored\r\nGet-ChildItem",
|
||||
"& git status",
|
||||
". ./deploy.ps1",
|
||||
"Get-ChildItem | ForEach-Object { Remove-Item $_ }",
|
||||
"ForEach-Object { Remove-Item $_ }",
|
||||
"&Remove-Item victim",
|
||||
"< #\nRemove-Item victim",
|
||||
"Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y",
|
||||
'git "status"',
|
||||
"git st`atus",
|
||||
'npm "run" test',
|
||||
'docker "compose" up',
|
||||
"git >x",
|
||||
"git *>&1",
|
||||
"git foo2>bar",
|
||||
"git 12>bar",
|
||||
"git a`;b",
|
||||
"git & Write-Output q",
|
||||
"Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }",
|
||||
"$Command value",
|
||||
"& $Command value",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"if ($true) { Get-ChildItem } else { Remove-Item victim }",
|
||||
"Set-Location $env:TEMP; Get-ChildItem",
|
||||
'Write-Output "unterminated',
|
||||
])
|
||||
result.push(["pwsh", command])
|
||||
|
||||
let state = 0x5eed1234
|
||||
const random = (length: number) => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
||||
return state % length
|
||||
}
|
||||
for (let index = 0; index < 10_000; index++) {
|
||||
const left = bashHeads[random(bashHeads.length)]
|
||||
const right = bashHeads[random(bashHeads.length)]
|
||||
const arg = bashArgs[random(bashArgs.length)]
|
||||
const separator = bashSeparators[random(bashSeparators.length)]
|
||||
const bashForms = [
|
||||
`${left}${arg}${separator}${right} fuzz${index}`,
|
||||
`${left}${arg} $(${right} fuzz${index})`,
|
||||
`${left}${arg} # ignored\n${right} fuzz${index}`,
|
||||
`X=value ${left}${arg}${redirects[random(redirects.length)]}`,
|
||||
`${left} before\\\nafter${separator}${right} fuzz${index}`,
|
||||
]
|
||||
result.push(["/bin/bash", bashForms[index % bashForms.length]])
|
||||
|
||||
const powershellLeft = powershellHeads[random(powershellHeads.length)]
|
||||
const powershellRight = powershellHeads[random(powershellHeads.length)]
|
||||
const powershellArg = powershellArgs[random(powershellArgs.length)]
|
||||
const powershellSeparator = powershellSeparators[random(powershellSeparators.length)]
|
||||
const powershellForms = [
|
||||
`${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`,
|
||||
`${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`,
|
||||
`${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`,
|
||||
`${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`,
|
||||
]
|
||||
result.push(["pwsh", powershellForms[index % powershellForms.length]])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -18,42 +18,6 @@ describe("ShellParse", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("portable scanning never adds permission resources", async () => {
|
||||
const commands = [
|
||||
"git status && npm run test -- --watch",
|
||||
"echo $(curl evil | sed s/x/y/)",
|
||||
"cat <<'EOF'\nstatic body\nEOF",
|
||||
"cat <<EOF\n$(printf dynamic)\nEOF",
|
||||
"cd /tmp/$USER && git status",
|
||||
"$COMMAND status",
|
||||
"if true; then printf yes; else printf no; fi",
|
||||
]
|
||||
|
||||
for (const command of commands) {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(
|
||||
portable.commands.every((item) => legacy.commands.some((candidate) => candidate.resource === item.resource)),
|
||||
).toBe(true)
|
||||
expect(portable.directories.every((item) => legacy.directories.includes(item))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("portable scanning authorizes opaque heredocs without inferring directories", async () => {
|
||||
const command = "cat <<'EOF'\nstatic body\nEOF"
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(portable).toEqual({ commands: [{ resource: command, save: command }], directories: [] })
|
||||
})
|
||||
|
||||
test.each(['c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp"])(
|
||||
"portable scanning keeps source-shaped command heads under shell authorization: %s",
|
||||
async (command) => {
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(portable.commands.map((item) => item.resource)).toEqual([command])
|
||||
expect(portable.directories).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test("splits PowerShell commands case-insensitively", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan(
|
||||
|
||||
@@ -512,31 +512,6 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("does not add external-directory permission for an experimental portable heredoc", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
if (isWindows) return
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ experimental: { portable_shell_scanner: true } }),
|
||||
),
|
||||
)
|
||||
const settled = yield* withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "cat <<'EOF'\nhello\nEOF" }, "call-portable-heredoc")),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
expect(settled.content?.[0]).toMatchObject({ type: "text", text: "hello\n" })
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps non-zero exits useful", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# opencode-drive
|
||||
|
||||
## 1.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 99561ad: Restore controlled tools against the current V2 plugin API and add typed runtime control for write calls.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b524213: Render light box-drawing borders as continuous geometric primitives.
|
||||
- a24a09d: Defer recording font initialization so source-checkout scripts can start without loading a duplicate renderer.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6a8d52b: Prevent concurrent detached launchers from stealing prepared instance ownership and spawning competing daemon processes.
|
||||
- d71356f: Restore compatibility with current OpenCode V2 checkouts and packed Drive installations. Drive now uses V2's built-in simulation transport and provider shape, isolates scripted service ports and command forms, and compiles standalone scripts against the launching Drive toolchain without package installation or source-directory links.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c20d147: Control arbitrary provider-backed tool lifecycles with dynamic registration, structured progress, success, failure, cancellation, and reconnect-safe replay.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7caebeb: Expose semantic UI snapshots, exact semantic node polling, and safe semantic-node clicks for compatible OpenCode endpoints.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4e0c002: Write screenshots and recordings beneath run- and restart-scoped media directories so named outputs cannot overwrite earlier runs.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- fad9f96: Allow scripts and library drivers to intercept declared tools and control concurrent invocations by call ID at runtime.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 63d3464: Keep service and progress output out of visible TUI sessions and avoid reinstalling the OpenTUI preload package for development checkouts.
|
||||
- fd45cfe: Allow Drive runs to select a durable OpenCode database with the Effect-configured `OPENCODE_DRIVE_DB` setting while retaining `:memory:` as the default.
|
||||
- e66adc1: Preserve recorded frame timing during MP4 encoding and reduce work for dense or unchanged terminal output.
|
||||
- e7dff5f: Render diagonal quadrant block glyphs as exact terminal cell geometry in screenshots, recordings, and catalog frames.
|
||||
- 63d3464: Export recordings at 60 FPS by default and preserve the requested frame rate in generated MP4 files.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 1009394: Remove the Promise-based simulation clients. `SimulationClient`, `BackendSimulationClient`, `connectSimulation`, and `connectBackendSimulation` are gone, along with the `opencode-drive/experimental` entry point. The `opencode-drive/client` entry now exports only the canonical protocol schemas and default ports; the public API is Effect-only, as documented. The CLI drives instances through the Effect `SimulationConnector` directly.
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9deab8d: Add the browser-safe `opencode-drive/frame` entry point: canonical cell geometry, OpenTUI text-attribute bits, the geometric block/bar glyph table, and baseline placement shared by the Drive PNG renderer and downstream canvas renderers. The PNG renderer now also draws the `┃` and `╹` structural bars geometrically instead of with fonts.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8481090: Settle simulated LLM responses cleanly when OpenCode terminates an invocation during interruption. Drive now uses the negotiated `llm.pending` capability to distinguish external termination from genuine response write failures.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 58c4801: Return simulated background shells immediately, continue their handlers asynchronously, notify the session when they finish, and cancel them when Drive shuts down.
|
||||
- b5e8dfe: Make the script API Effect-only. Script setup and run callbacks, UI, LLM, filesystem, server, and TUI operations now return Effects; LLM serve handlers return Streams; and script cancellation uses Effect interruption without a Promise compatibility shim.
|
||||
- 775f799: Remove the tool handler `AbortSignal`. Foreground session interruption, transport disconnects, and Drive shutdown now surface uniformly as Effect interruption, and controller shutdown awaits handler finalizers. Detached background shell handlers remain active after launch and are interrupted during Drive shutdown.
|
||||
- 8e51796: Add deterministic shell, web fetch, and web search handlers with progress, success, failure, and interruption simulation.
|
||||
- 905f846: Add `opencode-drive script init` for generating an Effect-native starter script and show focused migration guidance when `check` finds Promise-style script callbacks.
|
||||
- d1bba54: Add first-class tool call input streaming through `Llm.toolCall` stream options.
|
||||
- 72f7aff: Expose the authenticated generated OpenCode SDK as `opencode` to drivers and scripts.
|
||||
- 37b4cd1: Give capabilities precise typed errors, validate UI predicates in canonical `ui.waitFor`, expose concrete failures through `Errors`, and keep pure response constructors exclusively under `Llm`.
|
||||
- 13ec474: Unify the Effect driver and `defineScript` around one canonical programmatic model. Both expose the generated SDK as `opencode`, the primary frontend as `tui`, additional frontends through `tuis`, and the primary UI as `ui`. Every `Tui` has the same `{ ui, close, recording }` shape and `{ recording, viewport }` options. Project setup now uses the shared `Project`, `Setup`, `SetupContext`, and `ProjectFileSystem` types. Remove duplicate script UI types, flattened frontend handles, partial settlement controls, root-level raw simulation exports, convenience CLI aliases, and the `wait` helper.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c8f5b51: Attach one best-effort normalized terminal frame to UI polling timeout errors without retaining screenshot artifacts.
|
||||
- c8f5b51: Render OpenCode's full UI symbol set with deterministic bundled fallback fonts instead of platform fonts or hand-drawn symbol exceptions.
|
||||
- c8f5b51: Preserve the managed driver's `Scope.Scope` requirement when consumed from TypeScript workspace applications.
|
||||
- 40d2241: Render the background completion arrow correctly in exported recordings.
|
||||
- 11cbbfd: Preserve the canonical OpenCode UI command shapes for optional named screenshots and key presses.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 opencode-drive
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,565 @@
|
||||
# opencode-drive
|
||||
|
||||
This project gives your agents control over OpenCode:
|
||||
|
||||
- Run it during development and let your agents see and poke at the running instance
|
||||
- Allow your agents to run it in headless mode and drive it to test things
|
||||
|
||||
## Requirements
|
||||
|
||||
OpenCode Drive requires [Bun](https://bun.sh/) 1.3.14 or newer. MP4 recording export also requires `ffmpeg` on `PATH`.
|
||||
|
||||
Install dependencies with:
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
## Skill
|
||||
|
||||
```sh
|
||||
npx skills add anomalyco/opencode --agent opencode --skill opencode-drive
|
||||
```
|
||||
|
||||
## Effect programs
|
||||
|
||||
The primary way to automate OpenCode is a default-exported, fully provided
|
||||
Effect. Drive type-checks the module contract, compiles the script and its local
|
||||
imports against the launching Drive toolchain, then validates and runs the
|
||||
export in an isolated Bun process:
|
||||
|
||||
```ts
|
||||
// drive.ts
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode-drive run ./drive.ts
|
||||
```
|
||||
|
||||
`run` accepts exactly one module path. It rejects `--command.*` flags, other
|
||||
command flags, and application arguments after `--`. Backend and UI behavior
|
||||
belongs in the Effect program.
|
||||
|
||||
`OpenCodeDriver.use` is the safe default. It owns the scope, observes backend
|
||||
failure, settles queued LLM work, closes every TUI, and exports recordings
|
||||
whether the program succeeds or fails:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(
|
||||
{
|
||||
project: {
|
||||
git: true,
|
||||
files: { "src/value.ts": "export const value = 1\n" },
|
||||
},
|
||||
},
|
||||
({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
yield* ui.submit("Read src/value.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `OpenCodeDriver.useReport` when the program also needs structured evidence.
|
||||
It returns the program value plus a schema-validated report containing branded
|
||||
artifact and recording paths, retention, and the negotiated or legacy
|
||||
compatibility of every simulation endpoint:
|
||||
|
||||
```ts
|
||||
const result = yield * OpenCodeDriver.useReport(options, program)
|
||||
yield * Effect.log(result.report)
|
||||
```
|
||||
|
||||
Drive prefers `simulation.handshake` and explicitly records legacy fallback.
|
||||
Require negotiation when protocol skew must fail before the program runs:
|
||||
|
||||
```ts
|
||||
OpenCodeDriver.use(
|
||||
{
|
||||
opencode: { compatibility: "required" },
|
||||
},
|
||||
program,
|
||||
)
|
||||
```
|
||||
|
||||
Additional TUIs share the same server and LLM controller:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use((oc) =>
|
||||
Effect.gen(function* () {
|
||||
const secondary = yield* oc.tuis.launch({
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
})
|
||||
yield* oc.ui.screenshot("primary")
|
||||
yield* secondary.ui.screenshot("secondary")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
The generated OpenCode SDK client is exposed as `opencode`; launched frontend
|
||||
processes are `tui` and `tuis`. This keeps SDK calls distinct from terminal UI
|
||||
control:
|
||||
|
||||
```ts
|
||||
const health = yield * opencode.health.get()
|
||||
const frame = yield * tui.ui.capture()
|
||||
```
|
||||
|
||||
Enable recording per TUI. Settlement finishes each timeline and exports its
|
||||
video automatically:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use({ tui: { recording: true } }, (oc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* oc.ui.screenshot("recorded-home")
|
||||
yield* Effect.log(`recording will be exported to ${oc.tui.recording?.path}`)
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Settlement errors are program failures. For example, output after a terminal
|
||||
LLM event fails the run while `use` still closes TUIs and attempts recording
|
||||
export:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.finish(), Llm.text("too late"))
|
||||
yield* ui.submit("trigger a response")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `OpenCodeDriver.make` only when the program needs explicit terminal
|
||||
settlement. It requires a scope, and `driver.settle()` must run before leaving
|
||||
that scope:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const driver = yield* OpenCodeDriver.make()
|
||||
yield* driver.ui.screenshot("home")
|
||||
yield* driver.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `opencode-drive check ./drive.ts` and `start --script` for the Effect-native
|
||||
`defineScript` workflow described below.
|
||||
|
||||
## OpenCode development
|
||||
|
||||
Run this:
|
||||
|
||||
```sh
|
||||
OPENCODE_DRIVE=1 bun run dev
|
||||
```
|
||||
|
||||
If you installed the skill file, OpenCode will be able to see and interact with the running instance.
|
||||
|
||||
## Using with agents
|
||||
|
||||
Install the skill file above and ask the agent to test various flows with the app. Start with `--record` when you want a video; `opencode-drive stop` then exports the complete session and prints its path.
|
||||
|
||||
Screenshots and videos are written beneath `<system temp>/opencode-drive/output/<run-id>/<generation-id>`, so named outputs cannot overwrite media from earlier runs or restarts. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different media root.
|
||||
|
||||
Captured frames use the official full Commit Mono v1.143 faces at 16px with bundled Noto Symbols, Symbols 2, and Math fallbacks in a fixed 10x20 cell grid. Set `OPENCODE_DRIVE_FONT` to a comma-separated list of font files (for example regular, bold, italic, and bold-italic faces) to use a different primary capture font without changing the symbol fallback or cell geometry.
|
||||
|
||||
## UI development
|
||||
|
||||
If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
|
||||
|
||||
Run it in visible mode:
|
||||
|
||||
```sh
|
||||
opencode-drive start --visible --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
Initialize first when you need to customize the isolated environment before OpenCode starts:
|
||||
|
||||
```sh
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --visible --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
`start` reuses the prepared artifacts for that name. If `init` was not run, `start` initializes them automatically.
|
||||
|
||||
Drive uses an in-memory OpenCode database by default. Set
|
||||
`OPENCODE_DRIVE_DB` when a test restarts the OpenCode service and needs sessions
|
||||
to survive the replacement process. Relative paths resolve inside the isolated
|
||||
run's OpenCode data directory:
|
||||
|
||||
```sh
|
||||
OPENCODE_DRIVE_DB=restart.sqlite \
|
||||
opencode-drive start --name restart-demo --script ./restart.ts
|
||||
```
|
||||
|
||||
Remove artifact directories left by sessions that are no longer active:
|
||||
|
||||
```sh
|
||||
opencode-drive prune
|
||||
```
|
||||
|
||||
Prune one inactive instance's artifacts by instance name, or force removal of all artifact directories:
|
||||
|
||||
```sh
|
||||
opencode-drive prune --name demo
|
||||
opencode-drive prune --force
|
||||
```
|
||||
|
||||
While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
|
||||
|
||||
View the [skills file](https://github.com/anomalyco/opencode/blob/v2/.opencode/skills/opencode-drive/SKILL.md) for more details about the CLI.
|
||||
|
||||
## Effect script API
|
||||
|
||||
Scripted runs use one fully typed, Effect-only definition. `setup` and `run`
|
||||
return Effects; Promise callbacks are not part of the API:
|
||||
|
||||
```sh
|
||||
opencode-drive script init ./drive.ts
|
||||
```
|
||||
|
||||
This creates a canonical starter without overwriting an existing file. The
|
||||
generated script is ready for `opencode-drive check ./drive.ts` and
|
||||
`start --script ./drive.ts`.
|
||||
|
||||
```ts
|
||||
import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tuiConfig: {
|
||||
theme: "system",
|
||||
},
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/example.ts": "export const value = 1\n",
|
||||
},
|
||||
},
|
||||
setup: ({ config, tuiConfig }) =>
|
||||
Effect.sync(() => {
|
||||
config.username = "Drive"
|
||||
tuiConfig.scroll_speed = 1
|
||||
}),
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* llm.send(Llm.text("The value is 1."))
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`project.files` seeds the isolated project before `setup` runs. With
|
||||
`project.git: true`, Drive creates a fresh repository and commits the complete
|
||||
pre-launch state, including files written in `setup`. A prepared repository is
|
||||
never replaced; omit `project.git` when an `init` step supplies Git history.
|
||||
Declared `config` and `tuiConfig` values are deeply merged over fixture
|
||||
`.opencode/opencode.jsonc` and `.opencode/tui.jsonc` files. Arrays replace
|
||||
instead of merging, and mutations made in `setup` take final precedence.
|
||||
|
||||
Attach arbitrary provider-backed tools at runtime with their JSON schemas, then
|
||||
take and settle native OpenCode invocations by model call ID. `attach` replaces
|
||||
the complete dynamic set atomically; it does not affect the built-in adapters
|
||||
configured through the driver or script `tools` option.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ tools, llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tools.attach({
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { answer: { type: "number" } },
|
||||
required: ["answer"],
|
||||
},
|
||||
options: { codemode: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_lookup",
|
||||
name: "lookup",
|
||||
input: { query: "meaning" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Look up the meaning")
|
||||
|
||||
const lookup = yield* tools.take("call_lookup")
|
||||
yield* lookup.progress({
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
})
|
||||
yield* lookup.finish({
|
||||
structured: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Drive owns progress sequence numbers and retries uncertain operations without
|
||||
rerunning a claimed call. `awaitCancelled()` completes when OpenCode interrupts
|
||||
the native invocation before `finish` or `fail`. Dynamic registrations survive
|
||||
the tool-only controller reconnecting; an intentional server generation change
|
||||
cancels unresolved calls and reapplies the desired set after launch.
|
||||
|
||||
Declare which built-in tools Drive should intercept with `tools`, then control
|
||||
their invocations inside `run`. Each tool controller accepts calls in arrival
|
||||
order or by the stable call ID chosen in `Llm.toolCall`:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
tools: ["shell"],
|
||||
run: ({ ui, llm, tools }) =>
|
||||
Effect.gen(function* () {
|
||||
const shells = yield* tools.control("shell")
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
input: { command: "deploy production" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Deploy production")
|
||||
const shell = yield* shells.take("call_shell")
|
||||
yield* shell.progress(`Running: ${shell.input.command}...\n`)
|
||||
yield* shell.succeed({ output: "Controlled output\n", exit: 0 })
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Use `calls.take(id)` to coordinate known parallel calls independently, or
|
||||
`calls.take()` to accept the next unclaimed invocation. A controlled call can
|
||||
emit progress and then succeed or fail exactly once. `awaitInterrupted()`
|
||||
observes OpenCode interruption or transport disconnection. Drive interrupts
|
||||
all unresolved calls when it shuts down.
|
||||
|
||||
The original `tools(registry)` callback remains available for fixed handlers
|
||||
that do not need orchestration from `run`. Foreground handler Effects are
|
||||
interrupted when OpenCode interrupts the session, the transport disconnects,
|
||||
or Drive shuts down. Detached background shell handlers continue after their
|
||||
launch response and are interrupted when Drive shuts down.
|
||||
|
||||
Only declared or registered tools are replaced. Unhandled tools continue to
|
||||
use OpenCode's real implementations. Each `progress` value replaces the
|
||||
visible tool output; send accumulated output when earlier lines should remain
|
||||
visible.
|
||||
Supported adapters are `shell`, `webfetch`, and `websearch`; each handler
|
||||
receives its canonical typed V2 input and maintains an independent call index.
|
||||
When a shell call sets `background: true`, Drive returns immediately with the
|
||||
OpenCode tool call ID as `shellID`, keeps the handler running, and injects the
|
||||
terminal `completed`, `error`, or `cancelled` result into the session
|
||||
automatically. Background handlers are cancelled when Drive shuts down.
|
||||
|
||||
Type-check every new or edited script before running it:
|
||||
|
||||
```sh
|
||||
opencode-drive check ./drive.ts
|
||||
```
|
||||
|
||||
Drive resolves its script API, Effect, Bun declarations, and `tsgo` from the
|
||||
launching installation without installing packages or modifying the script's
|
||||
directory. When it detects an old Promise-style `setup`, `run`, or `ui.waitFor`
|
||||
callback, it prints the equivalent Effect shape after the TypeScript
|
||||
diagnostics. Use `Effect.sleep(milliseconds)` for unconditional delays.
|
||||
|
||||
The `fs`, `ui`, `llm`, `tools`, `server`, and `tuis` capabilities expose
|
||||
Effect-returning operations. Compose them with `yield*`, `Effect.flatMap`, or
|
||||
other Effect operators. Scripts receive the same `Ui`, `Tui`, `Tuis`, and TUI
|
||||
options as `OpenCodeDriver`; `defineScript` does not define a second
|
||||
programmatic interface. Predicates passed to `ui.waitFor` may return a boolean
|
||||
or an Effect. Set `launch: "manual"` to launch the shared OpenCode server and
|
||||
every TUI explicitly:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
run: ({ ui, server, tuis }) =>
|
||||
Effect.gen(function* () {
|
||||
// ui is null in manual mode.
|
||||
yield* server.launch()
|
||||
const alice = yield* tuis.launch("alice")
|
||||
const bob = yield* tuis.launch("bob")
|
||||
yield* alice.ui.submit("Hello from Alice")
|
||||
yield* bob.ui.screenshot("bob-view")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Only one server may be launched per script. All TUIs share its LLM backend. TUI
|
||||
processes and compiled script artifacts are cleaned up when the script ends.
|
||||
|
||||
`yield* server.kill()` stops the server so it can be launched again later.
|
||||
`yield* tui.close()` closes a TUI, after which its name may be reused.
|
||||
|
||||
Pass `{ recording: true }` to record an individual TUI:
|
||||
|
||||
```ts
|
||||
const alice = yield * tuis.launch("alice", { recording: true })
|
||||
yield * alice.ui.submit("Hello")
|
||||
yield * alice.close()
|
||||
```
|
||||
|
||||
Recordings are exported when the script settles. Call
|
||||
`alice.recording.finish()` only when the video is needed before settlement.
|
||||
|
||||
Background title requests receive `OpenCode Drive` by default and do not
|
||||
consume `llm.queue`, `llm.send`, or `llm.serve` responses. Manual-launch
|
||||
scripts can customize them before starting the server:
|
||||
|
||||
```ts
|
||||
yield * llm.title(() => Effect.succeed("Custom title"))
|
||||
yield * server.launch()
|
||||
```
|
||||
|
||||
Use `yield* llm.send(...)` to wait for and complete the next request or `yield*
|
||||
llm.queue(...)` to declare future responses upfront. For ongoing responses,
|
||||
the handler passed to `llm.serve` returns an Effect `Stream`:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
import { Llm } from "opencode-drive"
|
||||
|
||||
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
|
||||
```
|
||||
|
||||
The backend connection, default `finish("stop")`, and cleanup are automatic.
|
||||
Cancellation is represented by Effect interruption: interrupting the script or
|
||||
the fiber running an operation interrupts its in-flight work and runs scoped
|
||||
finalizers. There is no Promise compatibility shim or separate cancellation
|
||||
API. All public script types are canonically defined in
|
||||
[`src/script/types.ts`](./src/script/types.ts), which can be provided directly
|
||||
to an authoring agent.
|
||||
|
||||
`Llm.text()` streams text in randomized chunks. It defaults to a 2 ms delay and
|
||||
a target chunk size of 15 characters, varied by plus or minus 5 per chunk:
|
||||
|
||||
```ts
|
||||
Llm.text("A deliberately slower response", { delay: 20, chunkSize: 10 })
|
||||
```
|
||||
|
||||
`Llm.reasoning()` accepts the same streaming options. Use
|
||||
`Llm.pause(milliseconds)` to add timing between any two outputs.
|
||||
|
||||
`Llm.toolCall()` emits a complete call atomically by default. Pass the same
|
||||
streaming options to expose partial JSON input while it is generated:
|
||||
|
||||
```ts
|
||||
Llm.toolCall(
|
||||
{
|
||||
index: 0,
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||
},
|
||||
{ delay: 40, chunkSize: 12 },
|
||||
)
|
||||
```
|
||||
|
||||
Finish a tool-calling response with `Llm.finish("tool-calls")`. Streamed calls
|
||||
drive OpenCode's normal tool-input start, delta, and end lifecycle; `Llm.raw()`
|
||||
remains available for provider-wire scenarios not covered by these helpers.
|
||||
|
||||
Current OpenCode simulation endpoints expose a semantic UI tree alongside
|
||||
renderer state and terminal capture. Use `ui.snapshot()` for the complete
|
||||
versioned tree or `ui.getNode()` to poll for one exact semantic match. Semantic
|
||||
nodes carry stable IDs, optional occurrence identity, role, label, hierarchy,
|
||||
component-owned state, and a transient element handle that `ui.click()` can
|
||||
resolve safely:
|
||||
|
||||
```ts
|
||||
const allow =
|
||||
yield *
|
||||
ui.getNode({
|
||||
role: "option",
|
||||
label: "Allow once",
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
yield * ui.click(allow)
|
||||
```
|
||||
|
||||
`ui.snapshot` and atomic semantic clicks are negotiated as optional
|
||||
capabilities so ordinary operations remain compatible with older OpenCode
|
||||
checkouts. Calling `ui.snapshot()`, `ui.getNode()`, or `ui.click(node)` when its
|
||||
required capability is unavailable fails locally with `UiCapabilityError`.
|
||||
|
||||
Capability errors are typed and the concrete classes are grouped under
|
||||
`Errors`. UI timeouts remain owner-fatal even when caught; recover locally
|
||||
from errors for which the script has a truthful fallback:
|
||||
|
||||
Polling timeouts from `ui.waitFor`, `ui.getElement`, and `ui.getNode` make one
|
||||
best-effort, bounded `ui.capture` request. When it succeeds, the resulting
|
||||
normalized terminal frame is available as `error.frame` without creating or
|
||||
retaining a screenshot file. RPC-level timeouts and failed diagnostic captures
|
||||
leave `error.frame` undefined.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Errors } from "opencode-drive"
|
||||
|
||||
yield *
|
||||
ui
|
||||
.getElement({ editor: true })
|
||||
.pipe(Effect.catchTag("UiElementAmbiguousError", (error) => Effect.logWarning(`Matched ${error.count} editors`)))
|
||||
|
||||
const isFileSystemError = (error: unknown) => error instanceof Errors.FileSystemError
|
||||
```
|
||||
|
||||
## Release validation
|
||||
|
||||
Before publishing a release, run the non-publishing validation command to
|
||||
check, test, and inspect the packed artifact:
|
||||
|
||||
```sh
|
||||
bun run release:validate
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Releasing opencode-drive
|
||||
|
||||
`opencode-drive` keeps its own version line. OpenCode product releases must not rewrite its version.
|
||||
|
||||
The imported baseline is `1.4.3` and the workspace package remains `private` until release setup is complete.
|
||||
Do not remove that guard or publish from this repository until both release gates are complete:
|
||||
|
||||
1. The versions of `@opencode-ai/client` and `@opencode-ai/protocol` written into the packed Drive manifest are available on npm, including the `@opencode-ai/protocol/simulation` export.
|
||||
2. npm package administration and trusted publishing move from `anomalyco/opencode-drive` to `anomalyco/opencode`.
|
||||
|
||||
The npm package is currently maintained by `jlongster`, and its trusted publisher is the old repository's
|
||||
`publish.yml`. James must add the destination release operator as an npm owner or update the trusted publisher
|
||||
himself. Keep James as an owner through the first successful release from this repository.
|
||||
|
||||
The first destination release will be `1.4.4`, which contains the pending special-key fix after `1.4.3`. Use a
|
||||
dedicated GitHub-hosted workflow named `publish-drive.yml` with Node 24, npm trusted publishing, and
|
||||
`id-token: write`. Its tag must be `opencode-drive-v1.4.4`; bare `v1.4.4` already belongs to OpenCode.
|
||||
|
||||
Before enabling that workflow:
|
||||
|
||||
1. Pack Drive and inspect the rewritten `package.json` inside the tarball.
|
||||
2. Install the tarball in a clean Bun consumer and import every public export.
|
||||
3. Run the installed `opencode-drive` binary and one scripted flow.
|
||||
4. Configure npm's trusted publisher for `anomalyco/opencode` and `publish-drive.yml`.
|
||||
5. Publish the namespaced tag and verify npm provenance points at this repository and workflow.
|
||||
|
||||
After the first successful destination release, disable the old publish workflow and archive the old repository.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bun
|
||||
import "../src/cli/index.js"
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import { OpenCodeDriver, Tool } from "../src/index.js"
|
||||
import type { Frontend, Project, Tui, Tuis, Ui } from "../src/index.js"
|
||||
import type { ScriptContext } from "../src/script/types.js"
|
||||
|
||||
type Equal<Left, Right> =
|
||||
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false
|
||||
|
||||
type Assert<Value extends true> = Value
|
||||
|
||||
export type ScriptUiIsCanonical = Assert<Equal<ScriptContext["ui"], Ui>>
|
||||
export type ScriptTuiIsCanonical = Assert<Equal<ScriptContext["tui"], Tui>>
|
||||
export type ScriptTuisAreCanonical = Assert<Equal<ScriptContext["tuis"], Tuis>>
|
||||
export type ScriptToolsAreCanonical = Assert<Equal<ScriptContext["tools"], Tool.Controls>>
|
||||
export type DriverToolsAreCanonical = Assert<Equal<OpenCodeDriver.Driver["tools"], Tool.Controls>>
|
||||
export type LaunchedTuiIsCanonical = Assert<Equal<Effect.Success<ReturnType<Tuis["launch"]>>, Tui>>
|
||||
export type ResizeIsCanonicalAction = Assert<
|
||||
Equal<
|
||||
Extract<Frontend.Action, { readonly type: "ui.resize" }>,
|
||||
{
|
||||
readonly type: "ui.resize"
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
}
|
||||
>
|
||||
>
|
||||
export type DriverProjectIsCanonical = Assert<Equal<NonNullable<OpenCodeDriver.Options["project"]>, Project>>
|
||||
|
||||
const zeroConfig = OpenCodeDriver.use(() => Effect.void)
|
||||
export type ZeroConfigUseIsRunnable = Assert<Equal<Effect.Services<typeof zeroConfig>, never>>
|
||||
|
||||
const controlledOptions: OpenCodeDriver.Options = { tools: ["shell"] }
|
||||
declare const controls: Tool.Controls
|
||||
const shellCalls = controls.control("shell")
|
||||
declare const dynamicTools: Tool.AttachParams
|
||||
const attached = controls.attach(dynamicTools)
|
||||
const dynamicCall = controls.take("call_lookup")
|
||||
declare const toolName: Tool.Name
|
||||
controls.control(toolName)
|
||||
export type ShellControlIsTyped = Assert<
|
||||
Equal<Effect.Success<typeof shellCalls>, Tool.ControlledCalls<Tool.ShellInput, Tool.ShellResult>>
|
||||
>
|
||||
export type DynamicAttachIsTyped = Assert<Equal<Effect.Success<typeof attached>, void>>
|
||||
export type DynamicCallIsTyped = Assert<Equal<Effect.Success<typeof dynamicCall>, Tool.Invocation>>
|
||||
const controlled = OpenCodeDriver.use(controlledOptions, ({ tools }) => tools.control("shell").pipe(Effect.asVoid))
|
||||
export type ControlledUseIsRunnable = Assert<Equal<Effect.Services<typeof controlled>, never>>
|
||||
@@ -0,0 +1,500 @@
|
||||
# OpenCode Driver API
|
||||
|
||||
Status: exploratory implementation, settled call sites only
|
||||
|
||||
This document records interface shapes that have been accepted during design. It intentionally omits unresolved alternatives rather than presenting them as competing proposals.
|
||||
|
||||
Internal resource ownership and desugaring are documented in [OpenCode Driver Architecture](./open-code-driver-architecture.md).
|
||||
|
||||
## Run Effect programs from the CLI
|
||||
|
||||
`opencode-drive run <module>` is the primary CLI entrypoint. The module must
|
||||
default-export an `Effect<_, _, never>`. Before importing the module, Drive
|
||||
generates and type-checks a contract entrypoint that assigns its default export
|
||||
to that fully provided Effect type. Drive then imports the module, verifies the
|
||||
value with `Effect.isEffect`, and yields it directly from the command handler.
|
||||
There is no nested runtime or detached owner.
|
||||
|
||||
```ts
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode-drive run ./drive.ts
|
||||
```
|
||||
|
||||
The command accepts no flags and no arguments after `--`. Use the driver API in
|
||||
the module for simulation control. `opencode-drive check` validates Effect-only
|
||||
`defineScript` modules, and `start --script` executes them.
|
||||
|
||||
## `use` settles one scoped driver
|
||||
|
||||
`OpenCodeDriver.use(run)` is the zero-configuration top-level interface;
|
||||
`OpenCodeDriver.use(options, run)` configures the same lifecycle. Both acquire
|
||||
the driver returned by `make`, run the program, validate queued LLM work,
|
||||
finish recordings, close TUIs, export videos, and then release the server
|
||||
and project scope.
|
||||
|
||||
`OpenCodeDriver.useReport(run)` and `useReport(options, run)` have the same lifecycle semantics and
|
||||
returns both the user value and a compact `RunReport`. The report contains
|
||||
validated artifact and recording paths, retention, and endpoint compatibility.
|
||||
Set `opencode.compatibility` to `"required"` or `"preferred"`;
|
||||
the default is `"preferred"`, which negotiates when supported and reports an
|
||||
explicit legacy profile otherwise.
|
||||
|
||||
```ts
|
||||
import { NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
const program = OpenCodeDriver.use(
|
||||
{
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/example.ts": "export const value = 1\n",
|
||||
},
|
||||
},
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 96,
|
||||
rows: 32,
|
||||
},
|
||||
recording: false,
|
||||
},
|
||||
},
|
||||
({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
)
|
||||
|
||||
NodeRuntime.runMain(program)
|
||||
```
|
||||
|
||||
`OpenCodeDriver.make(...)` remains the lower-level scoped constructor for programs that need to control settlement explicitly. Call `driver.settle()` before leaving its scope. `settle()` is terminal: it rejects new TUIs and LLM responses, validates queued work, stops TUIs, and exports recordings.
|
||||
|
||||
```ts
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const driver = yield* OpenCodeDriver.make(options)
|
||||
yield* driver.ui.submit("Hello")
|
||||
yield* driver.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Capture font size is not part of this interface. The current renderer uses a fixed 16px font in 10-by-20 cells; the terminal catalog's `OPENCODE_DRIVE_FONT_SIZE=14` environment variable is currently ignored.
|
||||
|
||||
The generated SDK client is `opencode`. The primary frontend process is `tui`,
|
||||
its UI is also available directly as `ui`, and `tuis` launches more frontend
|
||||
processes:
|
||||
|
||||
```ts
|
||||
const health = yield * driver.opencode.health.get()
|
||||
const frame = yield * driver.tui.ui.capture()
|
||||
const secondary = yield * driver.tuis.launch()
|
||||
```
|
||||
|
||||
## The driver has one primary TUI and optional additional TUIs
|
||||
|
||||
The `tui` section configures the primary frontend created by `make`. Its UI is exposed directly as `ui` for the common case.
|
||||
|
||||
Additional TUIs connect to the same server and expose their own UI:
|
||||
|
||||
```ts
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const oc = yield* OpenCodeDriver.make({
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 96,
|
||||
rows: 32,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const secondary = yield* oc.tuis.launch({
|
||||
viewport: {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
},
|
||||
recording: true,
|
||||
})
|
||||
|
||||
yield* oc.ui.submit("Prompt from the primary TUI")
|
||||
yield* secondary.ui.submit("Prompt from the secondary TUI")
|
||||
yield* oc.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`tuis.launch(options)` generates an identity. Pass a name as the first argument
|
||||
when a stable identity is useful for logs, recordings, or closing and
|
||||
relaunching the same TUI: `tuis.launch(name, options)`.
|
||||
|
||||
```text
|
||||
╭────────────────╮
|
||||
│ OpenCodeDriver ├───────────────────────╮
|
||||
╰────────┬───────╯ │
|
||||
╭────────────────╰──────────────────╮ │
|
||||
▼ ▼ │
|
||||
╭────────────────────────╮ ╭────────────────────╮ │
|
||||
│ Shared OpenCode Server │ │ Shared LLM Control │ │
|
||||
╰────────────┬───────────╯ ╰────────────────────╯ │
|
||||
╰───────────────────────────────╮ │
|
||||
▼ ▼ │
|
||||
╭────────────────╮ ╭────────────────────╮ │
|
||||
│ Primary TUI │◀───────────│ Additional TUIs │◀─────╯
|
||||
╰────────┬───────╯ ╰──────────┬─────────╯
|
||||
╰───╮ ╭──────╯
|
||||
▼ ▼
|
||||
╭────╮ ╭───────────╮
|
||||
│ ui │ │ tui.ui │
|
||||
╰────╯ ╰───────────╯
|
||||
```
|
||||
|
||||
## Common scripts destructure UI and LLM control
|
||||
|
||||
Scripts that only need the primary TUI should normally destructure the driver:
|
||||
|
||||
```ts
|
||||
const driver = yield * OpenCodeDriver.make()
|
||||
const { ui, llm } = driver
|
||||
|
||||
yield * llm.queue(Llm.text("Hello from the simulated model."))
|
||||
|
||||
yield * ui.submit("Hello")
|
||||
yield * ui.waitFor("Hello from the simulated model.")
|
||||
yield * driver.settle()
|
||||
```
|
||||
|
||||
Keep the aggregate value only when driver-wide capabilities such as `tuis` are needed:
|
||||
|
||||
```ts
|
||||
const oc = yield * OpenCodeDriver.make()
|
||||
const secondary = yield * oc.tuis.launch()
|
||||
|
||||
yield * oc.ui.screenshot("primary")
|
||||
yield * secondary.ui.screenshot("secondary")
|
||||
yield * oc.settle()
|
||||
```
|
||||
|
||||
## Runtime tool control uses statically declared adapters
|
||||
|
||||
Declare the built-in tool names Drive should intercept before OpenCode starts,
|
||||
then control each invocation through the live `tools` capability. Undeclared
|
||||
tools keep their real OpenCode implementations.
|
||||
|
||||
```ts
|
||||
const program = OpenCodeDriver.use({ tools: ["shell"] }, ({ tools, llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
const shells = yield* tools.control("shell")
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_build",
|
||||
name: "shell",
|
||||
input: { command: "bun run build" },
|
||||
}),
|
||||
Llm.toolCall({
|
||||
index: 1,
|
||||
id: "call_test",
|
||||
name: "shell",
|
||||
input: { command: "bun run test" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Build and test")
|
||||
|
||||
const build = yield* shells.take("call_build")
|
||||
const test = yield* shells.take("call_test")
|
||||
yield* test.succeed({ output: "Tests passed\n", exit: 0 })
|
||||
yield* build.succeed({ output: "Build passed\n", exit: 0 })
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`take(callID)` reserves and accepts one known invocation independently of
|
||||
arrival order. `take()` accepts the oldest unclaimed invocation. Exact-ID
|
||||
waiters take precedence over generic waiters, so parallel calls may settle in
|
||||
any deliberate order. Each call may emit serialized progress and then succeed
|
||||
or fail exactly once. `awaitInterrupted()` completes when transport or
|
||||
controller interruption wins before terminal settlement.
|
||||
|
||||
The program must take and terminally settle every intercepted invocation it
|
||||
expects. Driver scope closure fails blocked `take` operations, interrupts
|
||||
unresolved calls, and waits for transport cleanup. The callback-style
|
||||
`tools(registry)` configuration remains available
|
||||
for fixed handlers; callback-controlled tools are not also available through
|
||||
the runtime `tools.control` capability.
|
||||
|
||||
## Arbitrary tools use the provider-backed lifecycle
|
||||
|
||||
`tools.attach({ tools })` atomically replaces the complete dynamic registration
|
||||
set for the current run. Registrations use OpenCode's canonical JSON Schema,
|
||||
permission, namespace, and CodeMode options. Static `shell`, `webfetch`, and
|
||||
`websearch` adapters remain installed separately.
|
||||
|
||||
```ts
|
||||
yield *
|
||||
tools.attach({
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
options: { codemode: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const invocation = yield * tools.take("call_lookup")
|
||||
yield *
|
||||
invocation.progress({
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
})
|
||||
yield *
|
||||
invocation.finish({
|
||||
structured: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
```
|
||||
|
||||
`take(callID)` matches `context.callID`, the model call ID supplied to
|
||||
`Llm.toolCall`; `invocation.id` is the producer's transport identity. Drive
|
||||
deduplicates invocation replay after a controller reconnect and retries
|
||||
progress or terminal operations with the same producer identity and progress
|
||||
sequence. `awaitCancelled()` observes OpenCode's native interruption. There is
|
||||
no public cancel operation because cancellation flows from OpenCode to Drive.
|
||||
|
||||
Attaching a dynamic effective name that collides with a configured static
|
||||
adapter fails locally. Calling `attach({ tools: [] })` clears the dynamic set.
|
||||
Older OpenCode revisions remain compatible with static adapters and ordinary
|
||||
LLM control; dynamic attachment fails with `Tool.LifecycleError` when the six
|
||||
tool lifecycle capabilities are unavailable.
|
||||
|
||||
## LLM response description is separate from live LLM control
|
||||
|
||||
`Llm` is a pure data module. `llm` is the live capability that queues, sends, and serves responses.
|
||||
|
||||
```ts
|
||||
yield *
|
||||
llm.queue(
|
||||
Llm.reasoning("Inspecting the file"),
|
||||
Llm.pause(20),
|
||||
Llm.text("The value is 1.", {
|
||||
delay: 2,
|
||||
chunkSize: 15,
|
||||
}),
|
||||
Llm.finish("stop"),
|
||||
)
|
||||
```
|
||||
|
||||
Each constructor returns an ordinary serializable value. Raw values with the same schema remain accepted.
|
||||
|
||||
Tool calls remain atomic when options are omitted. Supplying stream options
|
||||
serializes the input to JSON and emits provider-neutral partial tool input when
|
||||
the endpoint advertises that capability. Older endpoints retain the existing
|
||||
OpenAI-compatible fallback:
|
||||
|
||||
```ts
|
||||
Llm.toolCall(
|
||||
{
|
||||
index: 0,
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||
},
|
||||
{ delay: 40, chunkSize: 12 },
|
||||
)
|
||||
```
|
||||
|
||||
The authoritative schema is a manual union of independently named variants:
|
||||
|
||||
```ts
|
||||
export const Text = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
options: Schema.optionalKey(StreamOptions),
|
||||
})
|
||||
export interface Text extends Schema.Schema.Type<typeof Text> {}
|
||||
|
||||
export const Reasoning = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
options: Schema.optionalKey(StreamOptions),
|
||||
})
|
||||
export interface Reasoning extends Schema.Schema.Type<typeof Reasoning> {}
|
||||
|
||||
export const Pause = Schema.Struct({
|
||||
type: Schema.Literal("pause"),
|
||||
milliseconds: NonNegativeMilliseconds,
|
||||
})
|
||||
export interface Pause extends Schema.Schema.Type<typeof Pause> {}
|
||||
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.Literal("finish"),
|
||||
reason: Schema.optionalKey(FinishReason),
|
||||
})
|
||||
export interface Finish extends Schema.Schema.Type<typeof Finish> {}
|
||||
|
||||
export const Output = Schema.Union([Text, Reasoning, Pause, Finish, ToolCall, Raw, Disconnect])
|
||||
export type Output = Schema.Schema.Type<typeof Output>
|
||||
```
|
||||
|
||||
Pure constructors delegate to those individual schemas:
|
||||
|
||||
```ts
|
||||
export const text = (text: string, options?: StreamOptions): Text =>
|
||||
Text.make({
|
||||
type: "text",
|
||||
text,
|
||||
...(options ? { options } : {}),
|
||||
})
|
||||
```
|
||||
|
||||
No `.cases` interface appears in userland.
|
||||
|
||||
## One `queue` call describes one future model response
|
||||
|
||||
Multiple outputs in one call are ordered events within one response:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_permission_capture",
|
||||
name: "patch",
|
||||
input: {
|
||||
patchText,
|
||||
},
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
```
|
||||
|
||||
A second call queues a response for the next model request:
|
||||
|
||||
```ts
|
||||
yield * llm.queue(Llm.text("The fixture was updated."))
|
||||
```
|
||||
|
||||
Responses without an explicit terminal output finish with `"stop"`. Title requests remain separate and do not consume this queue.
|
||||
|
||||
## `defineScript` is Effect-only
|
||||
|
||||
`defineScript` does not provide a Promise adapter. Its `setup` and `run`
|
||||
callbacks return Effects, as do operations on `fs`, `ui`, `llm`, `server`,
|
||||
and `tuis`. Compose script operations in the same runtime with
|
||||
`yield*` or Effect operators.
|
||||
|
||||
### Primary UI
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`llm.serve` accepts a handler that returns an Effect `Stream`. The registration
|
||||
itself is also an Effect:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
import { Llm } from "opencode-drive"
|
||||
|
||||
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
|
||||
```
|
||||
|
||||
Predicates passed to `ui.waitFor` may return a boolean or an Effect.
|
||||
Capability methods expose typed error channels. Concrete tagged errors are
|
||||
available from the `Errors` namespace.
|
||||
|
||||
`ui.snapshot()` returns the endpoint's versioned semantic tree. `ui.getNode()`
|
||||
polls for one exact match and fails with `UiNodeAmbiguousError` when more than
|
||||
one node matches. Semantic snapshots and identity-checked semantic clicks are
|
||||
optional during negotiation, so older OpenCode checkouts retain ordinary UI
|
||||
control while unsupported semantic operations fail locally with
|
||||
`UiCapabilityError`.
|
||||
|
||||
```ts
|
||||
const option =
|
||||
yield *
|
||||
ui.getNode({
|
||||
role: "option",
|
||||
label: "Allow once",
|
||||
selected: true,
|
||||
})
|
||||
yield * ui.click(option)
|
||||
```
|
||||
|
||||
### Additional TUI
|
||||
|
||||
```ts
|
||||
yield * server.launch()
|
||||
const alice = yield * tuis.launch("alice")
|
||||
const bob = yield * tuis.launch("bob")
|
||||
|
||||
yield * alice.ui.submit("Hello from Alice")
|
||||
yield * bob.ui.screenshot("bob-view")
|
||||
```
|
||||
|
||||
### TUI configuration
|
||||
|
||||
```ts
|
||||
export default defineScript({
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 118,
|
||||
rows: 34,
|
||||
},
|
||||
},
|
||||
run: ({ ui }) => ui.screenshot("home").pipe(Effect.asVoid),
|
||||
})
|
||||
```
|
||||
|
||||
Script cancellation uses Effect interruption. Interrupting the script or an
|
||||
operation's fiber interrupts in-flight work and runs its scoped finalizers;
|
||||
there is no `AbortSignal`, Promise cancellation convention, or compatibility
|
||||
shim.
|
||||
|
||||
## Settled interface
|
||||
|
||||
- `OpenCodeDriver.use(run)` and `use(options, run)` are the safe top-level brackets and perform typed settlement.
|
||||
- `OpenCodeDriver.make(options)` is the primary scoped constructor.
|
||||
- `opencode` is the generated OpenCode SDK client.
|
||||
- Programs that call `make` directly call terminal `driver.settle()` before leaving the scope.
|
||||
- Direct library programs run the same Effect without any export convention.
|
||||
- The `tui` section configures one primary TUI.
|
||||
- The primary TUI's UI is exposed as `ui` and `oc.ui`.
|
||||
- The common case destructures `{ ui, llm }`.
|
||||
- `oc.tuis.launch(options?)` creates an additional TUI with a generated identity.
|
||||
- `oc.tuis.launch(name, options?)` creates a TUI with a stable identity.
|
||||
- Additional TUIs expose their UI as `tui.ui`.
|
||||
- Drivers and scripts share the same `Tui`, `Tuis`, `Ui`, and option types.
|
||||
- `Llm` exposes pure constructors over manually composed Effect Schemas.
|
||||
- Raw schema-compatible LLM output objects remain accepted.
|
||||
- One `llm.queue(...)` call describes one future model response.
|
||||
@@ -0,0 +1,228 @@
|
||||
# OpenCode Driver Architecture
|
||||
|
||||
This guide describes the current Effect-native architecture. The public call
|
||||
sites are documented in [OpenCode Driver API](./open-code-driver-api.md).
|
||||
|
||||
## Domain Model
|
||||
|
||||
`OpenCodeDriver` composes these resources:
|
||||
|
||||
```text
|
||||
OpenCodeDriver
|
||||
project isolated files and configuration
|
||||
opencode generated OpenCode SDK client
|
||||
tui primary frontend process
|
||||
tuis additional frontend process factory
|
||||
ui convenience alias for tui.ui
|
||||
llm shared simulated-model control
|
||||
tools runtime control for static adapters and arbitrary tools
|
||||
```
|
||||
|
||||
The names distinguish the two kinds of client involved:
|
||||
|
||||
- `opencode` is the generated `@opencode-ai/client` SDK value.
|
||||
- `Tui` is a launched OpenCode frontend process with `ui`, `close`, and an
|
||||
optional `recording`.
|
||||
- `Tuis` launches and supervises additional frontend processes connected to
|
||||
the same server.
|
||||
- `tools.control` accepts independently controlled invocations for adapters
|
||||
declared before OpenCode starts.
|
||||
- `tools.attach` and `tools.take` control arbitrary native tools through the
|
||||
canonical provider-backed lifecycle.
|
||||
- Transport-level JSON-RPC clients remain private implementation details.
|
||||
|
||||
`defineScript` consumes these exact capabilities. It adds a branded module
|
||||
contract, restart behavior, filesystem access, and explicit manual launch. It
|
||||
does not define another UI, TUI, LLM, or project vocabulary.
|
||||
|
||||
## Ownership
|
||||
|
||||
```text
|
||||
Effect Scope
|
||||
OpenCodeProject
|
||||
artifact root
|
||||
isolated project files
|
||||
OpenCodeInstance
|
||||
server process
|
||||
TUI processes
|
||||
launch descriptors and logs
|
||||
CLI script ToolController
|
||||
controlled invocation exchanges
|
||||
OpenCodeServer
|
||||
backend simulation connection
|
||||
reconnecting tool-only backend connection
|
||||
LLM controller
|
||||
dynamic ToolProducer
|
||||
generated OpenCode SDK connection
|
||||
TUI supervisor
|
||||
primary TUI scope
|
||||
additional TUI scopes
|
||||
Library ToolController
|
||||
controlled invocation exchanges
|
||||
```
|
||||
|
||||
Library drivers create their ToolController before project preparation and
|
||||
pass that controller into `OpenCodeInstance`. CLI scripts create the controller
|
||||
inside `OpenCodeInstance`. Prepared drivers and script contexts combine the
|
||||
instance's static controller with the server's dynamic producer. The static
|
||||
controller that wrote plugin configuration remains the one exposed through
|
||||
`tools.control`.
|
||||
|
||||
`OpenCodeDriver.make(options)` requires `Scope.Scope`. It returns once the
|
||||
server, generated SDK client, primary TUI, and simulation connections are
|
||||
ready. `OpenCodeDriver.use` supplies that scope and performs terminal
|
||||
settlement even when the user program fails.
|
||||
|
||||
## Settlement
|
||||
|
||||
Settlement is one shared terminal operation. It runs in this order:
|
||||
|
||||
1. Validate that queued LLM work was consumed.
|
||||
2. Validate that native dynamic-tool invocations were settled.
|
||||
3. Shut down the LLM controller.
|
||||
4. Finish active recording timelines.
|
||||
5. Close all TUI scopes and processes.
|
||||
6. Export completed recordings.
|
||||
7. Decode the schema-validated `RunReport`.
|
||||
|
||||
`driver.settle()` is shared and idempotent. Once settlement starts, `tuis`
|
||||
rejects new launches and `llm` rejects new responses. `OpenCodeDriver.use`
|
||||
combines a user-program failure with a settlement failure rather than hiding
|
||||
either cause.
|
||||
|
||||
## Tool Control Lifecycle
|
||||
|
||||
`ToolController` installs only statically declared or callback-registered
|
||||
adapters into OpenCode's project configuration. Each runtime-controlled tool
|
||||
owns one exchange that matches incoming requests to exact-ID or FIFO waiters.
|
||||
Each accepted call owns a terminal Deferred, an interruption Deferred, and a
|
||||
one-permit Semaphore that serializes progress with terminal commitment.
|
||||
|
||||
Controller scope release closes blocked waiters, marks unresolved calls
|
||||
interrupted, aborts active HTTP transports, and waits for handler finalizers.
|
||||
Terminal commitment uses a synchronous first-writer-wins Deferred completion;
|
||||
Drive guarantees exactly-once acceptance inside the controller, not delivery
|
||||
across a transport disconnect.
|
||||
|
||||
`ToolProducer` owns a separate backend socket because LLM chunks are not
|
||||
idempotent and an LLM socket closure is terminal to `LlmController`. Dynamic
|
||||
tool progress and terminal RPCs are idempotent by producer invocation ID and
|
||||
sequence, so the tool-only connection may reconnect and replay pending
|
||||
invocations safely. One ordered event stream preserves invocation-before-
|
||||
cancellation order. The desired registration set survives reconnects and
|
||||
manual server relaunches; invocation records are scoped to one server
|
||||
generation because producer IDs may be reused by a new process.
|
||||
Settlement first clears the dynamic registration set on OpenCode, then drains
|
||||
the ordered local event stream before checking for unresolved invocations. The
|
||||
clear acts as the server-side barrier that prevents a native invocation from
|
||||
appearing after a successful settlement snapshot. Settlement is terminal for
|
||||
dynamic attachment. Reconnects remain available while the clear is in flight;
|
||||
the final connection gate drains any reconnect that landed during settlement
|
||||
before preventing further backend creation. If the server generation has
|
||||
already ended, its teardown has cleared the generation-scoped invocation
|
||||
records, so settlement does not wait for a replacement backend.
|
||||
|
||||
## TUI Lifecycle
|
||||
|
||||
`Tuis.launch(options)` generates an internal identity. `Tuis.launch(name,
|
||||
options)` uses a stable caller-supplied identity. Both return the same value:
|
||||
|
||||
```ts
|
||||
interface Tui {
|
||||
readonly ui: Ui
|
||||
readonly close: () => Effect.Effect<void>
|
||||
readonly recording?: Recording
|
||||
}
|
||||
```
|
||||
|
||||
Each TUI owns one frontend process, one negotiated UI connection, and
|
||||
optionally one recording timeline. Closing a named TUI releases its identity
|
||||
for reuse. An unexpected process exit fails the owning driver or script.
|
||||
|
||||
The primary TUI is not a special interface. `driver.tui` and values returned
|
||||
by `driver.tuis` have exactly the same `Tui` type. `driver.ui` is only
|
||||
`driver.tui.ui` exposed for the common single-TUI call site.
|
||||
|
||||
## OpenCode SDK
|
||||
|
||||
The server process writes an authenticated service registration into its
|
||||
isolated state directory. `driver/opencode.ts` discovers that registration and
|
||||
constructs the generated Effect SDK client with the project directory header.
|
||||
Passwords and registration paths remain internal. The resulting value is
|
||||
exposed as `driver.opencode` and `ScriptContext.opencode`.
|
||||
|
||||
## Canonical Protocol
|
||||
|
||||
`@opencode-ai/protocol/simulation` contains the single schema definition for OpenCode's
|
||||
handshake, frontend, and backend simulation messages. `client/protocol.ts`
|
||||
publishes those namespaces without redefining their data types.
|
||||
|
||||
```text
|
||||
Frontend protocol schemas
|
||||
-> driver/ui.ts Effect Ui capability
|
||||
-> driver/client.ts Tui and Tuis lifecycle
|
||||
-> driver/index.ts OpenCodeDriver aggregate
|
||||
-> script/types.ts exact capability reuse
|
||||
```
|
||||
|
||||
CLI `--command.ui.*` names are exhaustively checked against
|
||||
`Frontend.Capabilities`. The Promise transport under `opencode-drive/client`
|
||||
is separate from the Effect programmatic model but consumes the same protocol
|
||||
schemas.
|
||||
|
||||
## Transport Seam
|
||||
|
||||
`SimulationConnector` owns WebSocket acquisition, handshake negotiation,
|
||||
schema validation, request correlation, interruption, and connection failure.
|
||||
The driver receives the connector through an Effect service and does not
|
||||
expose it in userland.
|
||||
|
||||
The UI connection is request-response JSON-RPC. The LLM backend additionally
|
||||
receives unsolicited `llm.request` notifications. The tool-only backend keeps
|
||||
ordered `tool.invocation` and `tool.cancel` notifications on one validated
|
||||
stream and does not call `llm.attach`.
|
||||
|
||||
## Project Setup
|
||||
|
||||
Neutral project contracts live in `src/project.ts` so neither the driver nor
|
||||
scripts own the shared vocabulary:
|
||||
|
||||
```text
|
||||
Project
|
||||
Setup
|
||||
SetupContext
|
||||
ProjectFileSystem
|
||||
OpenCodeConfig
|
||||
OpenCodeTuiConfig
|
||||
```
|
||||
|
||||
Configuration is applied in this order:
|
||||
|
||||
1. Write declared project files.
|
||||
2. Read fixture `opencode.jsonc` and `tui.jsonc` values.
|
||||
3. Deep-merge `config` and `tuiConfig`; arrays replace existing arrays.
|
||||
4. Run Effect-only `setup`, which may mutate both merged objects.
|
||||
5. Write normalized JSON and optionally commit the Git baseline.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```text
|
||||
project -> Effect and Schema
|
||||
simulation -> canonical protocol and Effect RPC
|
||||
driver -> project + simulation + instance + recording
|
||||
script -> project + driver capabilities
|
||||
cli -> script + driver + Promise transport
|
||||
```
|
||||
|
||||
Lower-level modules do not import the package root or the driver/script
|
||||
barrels. `script/types.ts` may reference driver capabilities; driver modules
|
||||
must not reference script types.
|
||||
|
||||
## Public Entry Points
|
||||
|
||||
- `opencode-drive`: Effect driver, scripts, project contracts, LLM constructors.
|
||||
- `opencode-drive/driver`: complete Effect driver namespace.
|
||||
- `opencode-drive/script`: `defineScript` and script contracts.
|
||||
- `opencode-drive/client`: Promise simulation transport.
|
||||
- `opencode-drive/llm`: pure LLM output constructors and schemas.
|
||||
- `opencode-drive/recording`: recording decode, replay, and export utilities.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
|
||||
run: ({ server, tuis, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* server.launch()
|
||||
|
||||
yield* llm.serve((_request, index) => Stream.make(Llm.text(`Response for request ${index + 1}`)))
|
||||
|
||||
const [alice, bob] = yield* Effect.all(
|
||||
[tuis.launch("alice", { recording: true }), tuis.launch("bob", { recording: true })],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* Effect.all([alice.ui.submit("Reply to Alice"), bob.ui.submit("Reply to Bob")], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
yield* Effect.all(
|
||||
[alice.ui.screenshot("multiple-clients-alice-submitted"), bob.ui.screenshot("multiple-clients-bob-submitted")],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.waitFor("Response for request", { timeout: 30_000 }),
|
||||
bob.ui.waitFor("Response for request", { timeout: 30_000 }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* Effect.all(
|
||||
[alice.ui.screenshot("multiple-clients-alice-complete"), bob.ui.screenshot("multiple-clients-bob-complete")],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* server.kill()
|
||||
yield* Effect.sleep(500)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.screenshot("multiple-clients-alice-server-stopped"),
|
||||
bob.ui.screenshot("multiple-clients-bob-server-stopped"),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* server.launch()
|
||||
yield* Effect.sleep(1000)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.screenshot("multiple-clients-alice-server-relaunched"),
|
||||
bob.ui.screenshot("multiple-clients-bob-server-relaunched"),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
setup: ({ fs }) =>
|
||||
fs.writeFile(
|
||||
"src/greeting.ts",
|
||||
["export function greeting(name: string) {", " return `Welcome, ${name}!`", "}", ""].join("\n"),
|
||||
),
|
||||
|
||||
run: ({ llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
let turn = 0
|
||||
|
||||
yield* llm.title(() => Effect.succeed("Understanding the greeting"))
|
||||
yield* llm.serve(() => {
|
||||
if (turn++ === 0)
|
||||
return Stream.make(
|
||||
Llm.reasoning("I should read the implementation before explaining it."),
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_read_greeting",
|
||||
name: "read",
|
||||
input: { filePath: "src/greeting.ts" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
|
||||
return Stream.make(
|
||||
Llm.text("The function accepts a name, "),
|
||||
Llm.pause(150),
|
||||
Llm.text("places it into a welcome message, "),
|
||||
Llm.pause(150),
|
||||
Llm.text("and adds an exclamation mark."),
|
||||
Llm.pause(150),
|
||||
Llm.finish("stop"),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ui.submit("Read src/greeting.ts and explain what it does.")
|
||||
yield* ui.waitFor("adds an exclamation mark")
|
||||
}),
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user