refactor(simulation): scope Drive lifecycle with Effect (#36908)

This commit is contained in:
Kit Langton 2026-07-14 17:16:50 -04:00 committed by GitHub
commit 947566f611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1313 additions and 600 deletions

View file

@ -880,7 +880,7 @@
"name": "@opencode-ai/simulation", "name": "@opencode-ai/simulation",
"version": "1.17.13", "version": "1.17.13",
"dependencies": { "dependencies": {
"@fontsource/adwaita-mono": "5.2.1", "@fontsource/commit-mono": "5.2.5",
"@napi-rs/canvas": "1.0.2", "@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
@ -1765,7 +1765,7 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="], "@fontsource/commit-mono": ["@fontsource/commit-mono@5.2.5", "", {}, "sha512-htX8yQWtiPt5L1Hzh4sirvfUJT2+KYiquDB/Q2sY2tWQYplpBUOD5zHnIM3k36Hnm4V+JIIqA/wmwupSQ68WjA=="],
"@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="], "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="],

View file

@ -4,7 +4,7 @@ Status: design for the Phase 2 network and LLM items in `simulation-phases.md`.
## Summary ## Summary
Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not a separate fake: it is one registered route in that network (`api.openai.com`), answered by the **external driver** over the existing control WebSocket. When the app issues a provider request, the backend forwards it to the driver and the driver streams response chunks back. There is no enqueueing and no scripted-response store; the driver is the model. Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not replaced: an OpenAI route intercepts the real provider request and delegates its response to a **simulated model provider** controlled by the external driver. There is no server-side response script or replay adapter; the driver decides what the provider returns.
Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions. Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions.
@ -29,12 +29,12 @@ Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already
### 1. Simulated network (`packages/simulation/src/backend/network.ts`) ### 1. Simulated network (`packages/simulation/src/backend/network.ts`)
Replaces `httpClient` in `simulationReplacements`. An in-memory route table: Replaces `httpClient` in `simulationReplacements`. Each acquired network run owns its route table and bounded request log:
- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect<HttpClientResponse>`. - `make(routes)` constructs one isolated client and log; routes are ordinary matchers supplied at acquisition.
- Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default). - Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default).
- Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it. - Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it.
- Every request/response summary is traced. - Every request summary is timestamped through Effect `Clock` and retained only for that run.
### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`) ### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`)
@ -42,36 +42,44 @@ Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `p
On request: On request:
1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). 1. Parse the real OpenAI request body, which remains available to the driver for assertions.
2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. 2. Call `SimulatedProvider.Service.stream({ url, body })`.
3. Return `HttpClientResponse` with `content-type: text/event-stream` whose body stream reads from the queue, encoding each item as an SSE `data:` frame, terminated by `[DONE]`. 3. Encode the returned provider response events as SSE `data:` frames and terminate a finished response with `[DONE]`.
Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime. Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime.
The response stream is interruptible like a real HTTP response: if the runner cancels (user interrupt), the exchange closes and the driver is notified. The response stream is interruptible like a real HTTP response. If the runner cancels, the provider invocation is removed and later driver commands for its id fail.
### 3. LLM exchange service (`packages/simulation/src/backend/llm-exchange.ts`) ### 3. Simulated provider (`packages/simulation/src/backend/simulated-provider.ts`)
Process-global simulation service owning pending exchanges: The OpenAI route sees one Effect service:
``` ```ts
Exchange = { id, body, queue: Queue<Item | Error | Done>, deferred lifecycle } interface SimulatedProvider {
stream(request: ProviderRequest): Stream<ProviderResponseEvent, ProviderDisconnectedError>
}
``` ```
- `requests()` — stream of newly opened exchanges (consumed by the control route). `SimulatedProvider.layerDrive({ endpoint })` owns the Drive adapter in one Effect scope:
- `push(id, item)` — append one response item to an open exchange.
- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. - Pending provider invocations and response queues.
- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). - Late controller attachment and pending-invocation replay.
- The backend control WebSocket and its request fibers.
- Stream interruption, explicit disconnect, finish, and scope cleanup.
Invocation ids, queues, controller attachment, and WebSocket commands remain private to `layerDrive`. The OpenAI route only sees a provider request producing a response stream.
### 4. Backend control WebSocket (simulation-gated) ### 4. Backend control WebSocket (simulation-gated)
Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all.
Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): The backend and frontend control sockets share one scoped Effect adapter. It owns the Bun server, a bounded sequential message queue, its worker fiber, schema-based JSON decoding, and shutdown ordering.
Server -> driver notification (after `llm.attach`; pending invocations are replayed on attach so late-attaching drivers miss nothing):
``` ```
{ "jsonrpc": "2.0", "method": "llm.request", { "jsonrpc": "2.0", "method": "llm.request",
"params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } "params": { "id": "inv_1", "url": "...", "body": { ...openai request body... } } }
``` ```
Driver -> server methods: Driver -> server methods:
@ -79,8 +87,9 @@ Driver -> server methods:
``` ```
llm.attach subscribe to llm.request notifications llm.attach subscribe to llm.request notifications
llm.chunk { id, items: Item[] } append response items llm.chunk { id, items: Item[] } append response items
llm.finish { id, reason?: "stop" | ... } finish the exchange llm.finish { id, reason?: "stop" | ... } finish the invocation
llm.pending list open exchanges llm.disconnect { id } fail the provider response stream
llm.pending list pending invocations
network.log simulated network request log network.log simulated network request log
``` ```
@ -102,13 +111,13 @@ Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not ye
A driver manages two loopback WebSocket connections: A driver manages two loopback WebSocket connections:
- TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace. - TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace.
- Backend control server (manifest `endpoints.backend`) — LLM exchanges, network log. - Backend control server (manifest `endpoints.backend`) — simulated provider invocations. The network request log remains run-local diagnostic state.
Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins.
### 6. Pacing and the clock ### 6. Pacing and the clock
No server-side pacing by default: the driver controls timing by when it sends chunks, which is the point of driver-in-the-loop. A convenience `llm.chunk` option `{ delayMs }` may sleep via `Effect.sleep` between items server-side; because that uses the fiber `Clock`, scoping a controllable clock to the exchange stream (`Stream.provideService(Clock.Clock, simClock)`) remains available for deterministic replay without touching app time. Defer until replay work needs it. No server-side pacing exists. The driver controls timing by deciding when to send chunks.
### 7. Catalog and auth seeding ### 7. Catalog and auth seeding
@ -123,14 +132,14 @@ driver TUI drive server backend + drive WS
| |-- (normal app HTTP) ---->| session runner starts | |-- (normal app HTTP) ---->| session runner starts
| | | llm.stream -> HttpClient | | | llm.stream -> HttpClient
| | | simulated network matches openai route | | | simulated network matches openai route
|<================== llm.request {ex_1} ===============| exchange ex_1 opened |<================= llm.request {inv_1} ================| provider invocation inv_1 opened
|-- llm.chunk {ex_1,[...]} ============================>| SSE frames flow into the real |-- llm.chunk {inv_1,[...]} ===========================>| SSE frames flow into the real
|-- llm.chunk {ex_1,[...]} ============================>| decode -> step -> LLMEvents -> |-- llm.chunk {inv_1,[...]} ===========================>| decode -> step -> LLMEvents ->
|-- llm.finish {ex_1} =================================>| runner publishes, TUI renders |-- llm.finish {inv_1} ================================>| runner publishes, TUI renders
| | | | | |
| (if toolCall was sent: runner executes the real tool against the | (if toolCall was sent: runner executes the real tool against the
| fake filesystem, then issues the next provider turn -> new exchange | fake filesystem, then starts the next model invocation -> inv_2
| ex_2 -> driver decides the next response) | -> driver decides the next provider response)
``` ```
The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet. The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet.
@ -138,13 +147,13 @@ The driver observes the TUI through `ui.state` while chunks stream, so mid-strea
## Implementation order ## Implementation order
1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`. 1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`.
2. `llm-exchange.ts` + `openai.ts`: exchange service and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). 2. `simulated-provider.ts` + `openai.ts`: scoped Drive-controlled provider and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption).
3. `control.ts`: backend-hosted control WebSocket (`llm.attach|chunk|finish|pending`, `network.log`), started when the simulation module loads. 3. `SimulatedProvider.layerDrive`: backend-hosted control WebSocket (`llm.attach|chunk|finish|disconnect|pending`), acquired only when `OPENCODE_DRIVE` is set.
4. Config seeding for the sim provider; end-to-end verification via `packages/server/script/e2e-sim.ts` (headless) and `packages/tui/script/sim-llm-driver.ts` (TUI + backend sockets). 4. Config seeding for the sim provider; end-to-end verification via `packages/server/script/e2e-sim.ts` (headless) and `packages/tui/script/sim-llm-driver.ts` (TUI + backend sockets).
5. Trace records for network and LLM exchange activity. 5. Trace records for network and simulated provider activity.
## Consequences ## Consequences
- No enqueue/script store to keep consistent; the driver is the single source of model behavior. - No enqueue/script store to keep consistent; the driver is the single source of model behavior.
- Deterministic tests write drivers (respond to `llm.request` programmatically) instead of pre-baked scripts; replay (Phase 4) records exchanges and replays them as an automatic driver. - Deterministic tests write drivers that respond to `llm.request` programmatically instead of adding a second provider implementation.
- Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed. - Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed.

View file

@ -64,14 +64,16 @@ Implementation checklist:
- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATE=1` + `OPENCODE_SIMULATE_STATE` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. - [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATE=1` + `OPENCODE_SIMULATE_STATE` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run.
- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATE_STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). - [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATE_STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`).
- [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). - [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory).
- [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). - [x] Add run-local simulated network (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves outbound HTTP against routes supplied at acquisition, denies unknown destinations loudly, and keeps an isolated bounded request log timestamped through Effect `Clock` (design: `simulated-network-llm.md`).
- [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. - [x] Add a simulated model provider behind the OpenAI route (`simulated-provider.ts` + `openai.ts`): real provider requests call `SimulatedProvider.Service.stream`; the Drive adapter streams response events back as schema-checked OpenAI Chat SSE consumed by the real protocol pipeline.
- [x] Add backend-hosted drive control WebSocket (`control.ts`): JSON-RPC at the named manifest's backend endpoint, started when `OPENCODE_DRIVE` is set. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage the manifest's UI endpoint for UI control and backend endpoint for LLM/network control. - [x] Scope the backend Drive control WebSocket, pending provider invocations, queues, and request fibers to `SimulatedProvider.layerDrive`. JSON-RPC remains at the named manifest's backend endpoint: `llm.attach` replays pending invocations; `llm.chunk`, `llm.finish`, `llm.disconnect`, and `llm.pending` control them; `llm.request` reports provider-native requests.
- [x] Scope the frontend Drive control WebSocket, request queue, renderer, and optional recording timeline to the TUI Effect scope. Server shutdown and request interruption precede renderer destruction; timeline finalization runs last and remains explicitly finishable through `ui.recording.finish`.
- [x] Decode Drive manifests through Effect `Config`, `FileSystem`, and `Schema`, with typed config, not-found, read, and decode failures.
- [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). - [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route).
- [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. - [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals.
- [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). - [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`).
- [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns). - [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns).
- [ ] Trace filesystem, process, and LLM exchange activity (network requests are traced in the backend network log ring buffer; LLM exchange trace records moved out with the frontend proxy and need re-adding on the backend control server). - [ ] Trace filesystem, process, and simulated provider activity (network requests are traced in the backend network log ring buffer; provider trace records still need adding on the backend control server).
Scope: Scope:

View file

@ -76,14 +76,9 @@ function makeRoutes<AuthError, AuthServices>(
const serviceLayer = simulateEnabled() const serviceLayer = simulateEnabled()
? Layer.unwrap( ? Layer.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const { simulationReplacements, startDriveServer } = yield* Effect.promise( const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
() => import("@opencode-ai/simulation/backend"), const simulation = yield* simulationReplacements()
) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
if (driveEnabled()) startDriveServer()
return AppNodeBuilder.build(applicationServices, [
...replacements,
...(simulateEnabled() ? simulationReplacements : []),
])
}), }),
) )
: AppNodeBuilder.build(applicationServices, replacements) : AppNodeBuilder.build(applicationServices, replacements)
@ -116,9 +111,5 @@ function simulateEnabled() {
return !!process.env.OPENCODE_SIMULATE return !!process.env.OPENCODE_SIMULATE
} }
function driveEnabled() {
return !!process.env.OPENCODE_DRIVE
}
export const webHandler = () => export const webHandler = () =>
HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices)))

View file

@ -17,7 +17,7 @@
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"dependencies": { "dependencies": {
"@fontsource/adwaita-mono": "5.2.1", "@fontsource/commit-mono": "5.2.5",
"@napi-rs/canvas": "1.0.2", "@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",

4
packages/simulation/src/assets.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.woff2" {
const path: string
export default path
}

View file

@ -1,97 +0,0 @@
import { Effect } from "effect"
import { SimulationProtocol } from "../protocol"
import { SimulationLLMExchange } from "./llm-exchange"
/**
* Backend-hosted simulation control WebSocket.
*
* JSON-RPC 2.0 over a loopback WebSocket, mirroring the protocol of the TUI
* simulation server. Drivers connect directly (standalone topology; no
* frontend proxy) to answer LLM exchanges and inspect the simulated network.
* This is also the headless-simulation interface: it works with no TUI at
* all.
*
* Methods:
* - `llm.attach` -> subscribe; pending and future exchanges arrive
* as `llm.request` notifications
* - `llm.chunk` { id, items } append response items to an exchange
* - `llm.finish` { id, reason? } finish an exchange
* - `llm.disconnect` { id } abruptly terminate an exchange without a finish
* - `llm.pending` list open exchanges
*/
type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }>
function parseRequest(input: string | Buffer) {
return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
}
async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise<unknown> {
switch (request.method) {
case "llm.attach": {
socket.data.unsubscribe?.()
socket.data.unsubscribe = SimulationLLMExchange.subscribe((exchange) => {
socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: exchange }))
})
return { attached: true }
}
case "llm.chunk": {
await Effect.runPromise(
SimulationLLMExchange.push(
request.params.id,
request.params.items.map((item) => ({ type: "item", item }) as const),
),
)
return { ok: true }
}
case "llm.finish": {
await Effect.runPromise(
SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]),
)
return { ok: true }
}
case "llm.disconnect": {
await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id))
return { ok: true }
}
case "llm.pending":
return { exchanges: SimulationLLMExchange.pending() }
}
}
export function start(endpoint: string) {
const url = new URL(endpoint)
const server = Bun.serve<{ unsubscribe?: () => void }>({
hostname: url.hostname,
port: Number(url.port),
fetch(request, server) {
if (server.upgrade(request, { data: {} })) return undefined
return new Response("opencode drive backend websocket", { status: 426 })
},
websocket: {
close(socket) {
socket.data.unsubscribe?.()
},
async message(socket, message) {
let request: SimulationProtocol.Backend.Request | undefined
try {
request = parseRequest(message)
const result = await handle(socket, request)
const response = SimulationProtocol.JsonRpc.success(request.id, result)
if (response) socket.send(JSON.stringify(response))
} catch (error) {
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
}
},
},
})
process.stderr.write(`opencode drive backend websocket: ${endpoint}\n`)
return {
url: endpoint,
stop: () => {
server.stop(true)
},
}
}
export * as SimulationControl from "./control"

View file

@ -1,9 +1,11 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { Config, Effect, Layer } from "effect"
import { HttpClient } from "effect/unstable/http"
import { DriveManifest } from "../manifest" import { DriveManifest } from "../manifest"
import { SimulationControl } from "./control"
import { SimulationNetwork } from "./network" import { SimulationNetwork } from "./network"
import { SimulationOpenAI } from "./openai" import { SimulationOpenAI } from "./openai"
import { SimulatedProvider } from "./simulated-provider"
/** /**
* Layer replacements applied when the server is built in simulation mode. * Layer replacements applied when the server is built in simulation mode.
@ -17,17 +19,29 @@ import { SimulationOpenAI } from "./openai"
* *
*/ */
SimulationNetwork.register(SimulationOpenAI.route) export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () {
// ModelsDev dies when its catalog fetch fails, so simulation answers it with // ModelsDev dies when its catalog fetch fails, so simulation answers it with
// an empty catalog; providers come from seeded config instead. // an empty catalog; providers come from seeded config instead.
SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {})
const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined))
if (!drive) return [[httpClient, SimulationNetwork.layer([models])]] satisfies LayerNode.Replacements
export function startDriveServer() { const manifest = yield* DriveManifest.resolve()
return SimulationControl.start(DriveManifest.resolve().endpoints.backend) const networkLayer = Layer.effect(
} HttpClient.HttpClient,
Effect.gen(function* () {
export const simulationReplacements: LayerNode.Replacements = [ const provider = yield* SimulatedProvider.Service
[httpClient, SimulationNetwork.layer], const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models])
] return network.client
}),
).pipe(
Layer.provide(
SimulatedProvider.layerDrive({
endpoint: manifest.endpoints.backend,
}),
),
)
return [[httpClient, networkLayer]] satisfies LayerNode.Replacements
})
export * as Simulation from "./index" export * as Simulation from "./index"

View file

@ -1,119 +0,0 @@
import { Effect, Queue } from "effect"
/**
* Pending driver-answered LLM exchanges.
*
* When the simulated network receives a provider request it opens an
* exchange: the parsed request body plus a queue of response chunks. The
* simulation control WebSocket notifies the external driver, and the driver
* pushes chunks back until it finishes the exchange. The driver is the
* model; nothing is scripted or enqueued server-side.
*
* Process-global by design (plain module state, like the network route
* table): the simulated network and the control server must observe the same
* exchanges regardless of which layer instance touched them.
*/
/** One response item the driver sends back. Compiled to provider wire chunks by the endpoint. */
export type Item =
| { readonly type: "textDelta"; readonly text: string }
| { readonly type: "reasoningDelta"; readonly text: string }
| {
readonly type: "toolCall"
readonly index: number
readonly id: string
readonly name: string
readonly input: unknown
}
| { readonly type: "raw"; readonly chunk: unknown }
export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter"
export type Chunk =
| { readonly type: "item"; readonly item: Item }
| { readonly type: "finish"; readonly reason: FinishReason }
export interface Exchange {
readonly id: string
readonly url: string
readonly body: unknown
readonly queue: Queue.Queue<Chunk>
}
export interface OpenedExchange {
readonly id: string
readonly url: string
readonly body: unknown
}
const state = {
counter: 0,
exchanges: new Map<string, Exchange>(),
listeners: new Set<(exchange: OpenedExchange) => void>(),
}
export class ExchangeNotFoundError extends Error {
constructor(id: string) {
super(`Simulation LLM exchange not found or already finished: ${id}`)
}
}
/** Opens an exchange and notifies listeners. Called by the simulated provider endpoint. */
export const open = (input: { readonly url: string; readonly body: unknown }) =>
Effect.gen(function* () {
const id = `ex_${++state.counter}`
const queue = yield* Queue.unbounded<Chunk>()
const exchange: Exchange = { id, url: input.url, body: input.body, queue }
state.exchanges.set(id, exchange)
for (const listener of state.listeners) listener({ id, url: input.url, body: input.body })
return exchange
})
/** Closes an exchange without consuming remaining chunks (response interrupted or finished). */
export const close = (id: string) =>
Effect.suspend(() => {
const exchange = state.exchanges.get(id)
state.exchanges.delete(id)
if (!exchange) return Effect.void
return Queue.shutdown(exchange.queue).pipe(Effect.asVoid)
})
/** Appends response chunks to an open exchange. Driver-facing. */
export const push = (id: string, chunks: readonly Chunk[]) =>
Effect.gen(function* () {
const exchange = state.exchanges.get(id)
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
yield* Queue.offerAll(exchange.queue, chunks)
})
/** Abruptly ends the provider body without a finish chunk or SSE sentinel. */
export const disconnect = (id: string) =>
Effect.gen(function* () {
const exchange = state.exchanges.get(id)
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
yield* Queue.shutdown(exchange.queue)
})
/**
* Registers a listener for newly opened exchanges and immediately replays
* currently-pending ones, so a late-attaching driver observes requests that
* arrived before it connected. Returns an unsubscribe function.
*/
export function subscribe(listener: (exchange: OpenedExchange) => void) {
state.listeners.add(listener)
for (const exchange of pending()) listener(exchange)
return () => {
state.listeners.delete(listener)
}
}
/** Snapshot of currently open exchanges, for control-surface inspection. */
export function pending(): OpenedExchange[] {
return [...state.exchanges.values()].map((exchange) => ({
id: exchange.id,
url: exchange.url,
body: exchange.body,
}))
}
export * as SimulationLLMExchange from "./llm-exchange"

View file

@ -1,7 +1,8 @@
import { Effect, Layer } from "effect" import { Clock, Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError"
import type { HttpClientRequest } from "effect/unstable/http" import type { HttpClientRequest } from "effect/unstable/http"
import { SimulationProtocol } from "../protocol"
/** /**
* Simulated network. * Simulated network.
@ -12,8 +13,7 @@ import type { HttpClientRequest } from "effect/unstable/http"
* silently reach the real network. The scripted LLM is one registered route, * silently reach the real network. The scripted LLM is one registered route,
* not a separate mechanism. * not a separate mechanism.
* *
* The route table is process-global module state so the control surface and * Each acquired run owns its routes and request log.
* the client layer observe the same registrations.
*/ */
export interface Route { export interface Route {
@ -21,33 +21,15 @@ export interface Route {
readonly match: ( readonly match: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
url: URL, url: URL,
) => Effect.Effect<HttpClientResponse.HttpClientResponse> | undefined ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError> | undefined
} }
interface LogEntry { export type LogEntry = SimulationProtocol.Backend.NetworkLogEntry
readonly time: number
readonly method: string
readonly url: string
readonly matched: boolean
}
const state = {
routes: [] as Route[],
log: [] as LogEntry[],
}
const LOG_LIMIT = 1000 const LOG_LIMIT = 1000
export function register(route: Route) {
state.routes.push(route)
return () => {
const index = state.routes.indexOf(route)
if (index >= 0) state.routes.splice(index, 1)
}
}
/** Static JSON route: exact method + origin/path match answered with a fixed body. */ /** Static JSON route: exact method + origin/path match answered with a fixed body. */
export function json(method: string, url: string, body: unknown): Route { export function json(method: HttpMethod.HttpMethod, url: string, body: unknown): Route {
return { return {
match: (request, requestUrl) => { match: (request, requestUrl) => {
if (request.method !== method) return undefined if (request.method !== method) return undefined
@ -62,24 +44,29 @@ export function json(method: string, url: string, body: unknown): Route {
} }
} }
export function log(): readonly LogEntry[] { export interface Run {
return state.log readonly client: HttpClient.HttpClient
readonly log: () => Effect.Effect<readonly LogEntry[]>
} }
function record(entry: LogEntry) { export const make = Effect.fn("SimulationNetwork.make")(function* (routes: readonly Route[] = []) {
state.log.push(entry) const log = yield* Ref.make<readonly LogEntry[]>([])
if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT) const client = HttpClient.make((request, url) =>
} Effect.gen(function* () {
let matched: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError> | undefined
export const layer = Layer.sync(HttpClient.HttpClient)(() => for (const route of routes) {
HttpClient.make((request, url) => matched = route.match(request, url)
Effect.suspend(() => { if (matched) break
const matched = state.routes }
.map((route) => route.match(request, url)) const entry = {
.find((response) => response !== undefined) time: yield* Clock.currentTimeMillis,
record({ time: Date.now(), method: request.method, url: url.toString(), matched: matched !== undefined }) method: request.method,
if (matched) return matched url: url.toString(),
return Effect.fail( matched: matched !== undefined,
}
yield* Ref.update(log, (entries) => [...entries, entry].slice(-LOG_LIMIT))
if (matched) return yield* matched
return yield* Effect.fail(
new HttpClientError({ new HttpClientError({
reason: new TransportError({ reason: new TransportError({
request, request,
@ -88,7 +75,11 @@ export const layer = Layer.sync(HttpClient.HttpClient)(() =>
}), }),
) )
}), }),
), )
) return { client, log: () => Ref.get(log) } satisfies Run
})
export const layer = (routes: readonly Route[] = []) =>
Layer.effect(HttpClient.HttpClient, make(routes).pipe(Effect.map((run) => run.client)))
export * as SimulationNetwork from "./network" export * as SimulationNetwork from "./network"

View file

@ -1,14 +1,15 @@
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientResponse } from "effect/unstable/http" import { HttpClientResponse } from "effect/unstable/http"
import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError"
import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat" import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
import { SimulationLLMExchange } from "./llm-exchange"
import { SimulationNetwork } from "./network" import { SimulationNetwork } from "./network"
import { SimulatedProvider } from "./simulated-provider"
/** /**
* Driver-answered OpenAI endpoint for the simulated network. * Driver-answered OpenAI endpoint for the simulated network.
* *
* Claims `POST {DEFAULT_BASE_URL}{PATH}` (the real openai-chat route * Claims `POST {DEFAULT_BASE_URL}{PATH}` (the real openai-chat route
* endpoint), opens an LLM exchange, and streams the driver's chunks back as * endpoint), invokes the simulated provider, and streams the driver's events back as
* an OpenAI Chat SSE response terminated by `[DONE]`. Everything downstream * an OpenAI Chat SSE response terminated by `[DONE]`. Everything downstream
* of the response bytes is the real pipeline: SSE framing, the OpenAIChat * of the response bytes is the real pipeline: SSE framing, the OpenAIChat
* event schema, the protocol state machine, and Lifecycle grammar. * event schema, the protocol state machine, and Lifecycle grammar.
@ -17,11 +18,15 @@ import { SimulationNetwork } from "./network"
const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent)
const encoder = new TextEncoder() const encoder = new TextEncoder()
const decodeBody = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))
// The simulated model id is echoed back only in non-schema fields; the // The simulated model id is echoed back only in non-schema fields; the
// protocol event schema ignores unknown fields, so id/object/model are // protocol event schema ignores unknown fields, so id/object/model are
// decorative wire realism. // decorative wire realism.
function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { type ProviderItem = Exclude<SimulatedProvider.ProviderResponseEvent, { readonly type: "finish" }>
type FinishReason = Extract<SimulatedProvider.ProviderResponseEvent, { readonly type: "finish" }>["reason"]
function chunkOf(item: ProviderItem): OpenAIChatEvent | unknown {
if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] } if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] }
if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] } if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] }
if (item.type === "toolCall") if (item.type === "toolCall")
@ -43,7 +48,7 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown {
return item.chunk return item.chunk
} }
const finishReasonWire: Record<SimulationLLMExchange.FinishReason, string> = { const finishReasonWire: Record<FinishReason, string> = {
stop: "stop", stop: "stop",
"tool-calls": "tool_calls", "tool-calls": "tool_calls",
length: "length", length: "length",
@ -54,40 +59,49 @@ function frame(payload: unknown): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
} }
function sseBody(exchange: SimulationLLMExchange.Exchange): Stream.Stream<Uint8Array> { function sseBody(
const chunks = Stream.fromQueue(exchange.queue).pipe( events: Stream.Stream<SimulatedProvider.ProviderResponseEvent, SimulatedProvider.ProviderDisconnectedError>,
Stream.takeUntil((chunk) => chunk.type === "finish"), ): Stream.Stream<Uint8Array, SimulatedProvider.ProviderDisconnectedError> {
Stream.map((chunk) => { return events.pipe(
if (chunk.type === "finish") Stream.map((event) => {
return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[chunk.reason] }] })) if (event.type === "finish")
if (chunk.item.type === "raw") return frame(chunk.item.chunk) return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[event.reason] }] }))
return frame(encodeChunk(chunkOf(chunk.item))) if (event.type === "raw") return frame(event.chunk)
return frame(encodeChunk(chunkOf(event)))
}), }),
)
return chunks.pipe(
Stream.concat(Stream.make(encoder.encode("data: [DONE]\n\n"))), Stream.concat(Stream.make(encoder.encode("data: [DONE]\n\n"))),
// Close the exchange when the response body ends or is interrupted, so
// late driver pushes fail with ExchangeNotFoundError instead of leaking.
Stream.ensuring(SimulationLLMExchange.close(exchange.id)),
) )
} }
export const route: SimulationNetwork.Route = { export const route = (provider: SimulatedProvider.Interface): SimulationNetwork.Route => ({
match: (request, url) => { match: (request, url) => {
if (request.method !== "POST") return undefined if (request.method !== "POST") return undefined
if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined
return Effect.gen(function* () { return Effect.gen(function* () {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {} const body =
const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body }) request.body._tag === "Uint8Array"
? yield* decodeBody(new TextDecoder().decode(request.body.body)).pipe(
Effect.mapError(
(cause) =>
new HttpClientError({
reason: new TransportError({
request,
cause,
description: "Simulation received an invalid OpenAI request body",
}),
}),
),
)
: {}
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(Stream.toReadableStream(sseBody(exchange)), { new Response(Stream.toReadableStream(sseBody(provider.stream({ url: url.toString(), body }))), {
status: 200, status: 200,
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
}), }),
) )
}) })
}, },
} })
export * as SimulationOpenAI from "./openai" export * as SimulationOpenAI from "./openai"

View file

@ -0,0 +1,270 @@
import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect"
import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol"
export interface ProviderRequest {
readonly url: string
readonly body: unknown
}
export type ProviderResponseEvent =
| SimulationProtocol.Backend.Item
| { readonly type: "finish"; readonly reason: SimulationProtocol.Backend.FinishReason }
export class ProviderDisconnectedError extends Schema.TaggedErrorClass<ProviderDisconnectedError>()(
"SimulatedProvider.ProviderDisconnectedError",
{ message: Schema.String },
) {}
export interface Interface {
readonly stream: (request: ProviderRequest) => Stream.Stream<ProviderResponseEvent, ProviderDisconnectedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/simulation/SimulatedProvider") {}
interface ProviderInvocation extends ProviderRequest {
readonly id: string
}
interface PendingInvocation extends ProviderInvocation {
readonly responses: Queue.Queue<ProviderResponseEvent, ProviderDisconnectedError | Cause.Done>
}
interface State {
readonly counter: number
readonly pending: ReadonlyMap<string, PendingInvocation>
}
interface Driver {
readonly requests: Stream.Stream<ProviderInvocation>
readonly push: (
id: string,
items: readonly SimulationProtocol.Backend.Item[],
) => Effect.Effect<void, InvocationNotFoundError>
readonly finish: (
id: string,
reason: SimulationProtocol.Backend.FinishReason,
) => Effect.Effect<void, InvocationNotFoundError>
readonly disconnect: (id: string) => Effect.Effect<void, InvocationNotFoundError>
readonly pending: () => Effect.Effect<readonly ProviderInvocation[]>
}
class InvocationNotFoundError extends Schema.TaggedErrorClass<InvocationNotFoundError>()(
"SimulatedProvider.InvocationNotFoundError",
{ id: Schema.String, message: Schema.String },
) {}
class ControllerDisconnectedError extends Schema.TaggedErrorClass<ControllerDisconnectedError>()(
"SimulatedProvider.ControllerDisconnectedError",
{ message: Schema.String },
) {}
type ControlSocket = SimulationControlServer.Socket
export const layerDrive = (options: { readonly endpoint: string }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* Ref.make<State>({ counter: 0, pending: new Map() })
const opened = yield* PubSub.unbounded<ProviderInvocation>()
const lock = yield* Semaphore.make(1)
const close = (invocation: PendingInvocation) =>
Effect.gen(function* () {
yield* Queue.shutdown(invocation.responses)
yield* lock.withPermit(
Ref.update(state, (current) =>
current.pending.get(invocation.id) === invocation ? remove(current, invocation.id) : current,
),
)
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const current = yield* Ref.get(state)
yield* Effect.forEach(current.pending.values(), (invocation) => Queue.shutdown(invocation.responses), {
discard: true,
})
yield* PubSub.shutdown(opened)
}),
)
const open = (request: ProviderRequest) =>
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
const id = `inv_${current.counter + 1}`
const responses = yield* Queue.bounded<ProviderResponseEvent, ProviderDisconnectedError | Cause.Done>(256)
const invocation: PendingInvocation = { id, ...request, responses }
yield* Ref.set(state, {
counter: current.counter + 1,
pending: new Map(current.pending).set(id, invocation),
})
yield* PubSub.publish(opened, { id, ...request })
return invocation
}),
)
const requireInvocation = (id: string) =>
Effect.gen(function* () {
const current = yield* Ref.get(state)
const invocation = current.pending.get(id)
if (invocation) return invocation
return yield* Effect.fail(
new InvocationNotFoundError({
id,
message: `Simulated provider invocation not found or already finished: ${id}`,
}),
)
})
const remove = (current: State, id: string) => {
const pending = new Map(current.pending)
pending.delete(id)
return { ...current, pending }
}
const driver: Driver = {
requests: Stream.unwrap(
lock.withPermit(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(opened)
const current = yield* Ref.get(state)
const pending = Array.from(current.pending.values(), ({ id, url, body }) => ({ id, url, body }))
return Stream.concat(Stream.fromIterable(pending), Stream.fromEffectRepeat(PubSub.take(subscription)))
}),
),
),
push: (id, items) =>
Effect.gen(function* () {
const invocation = yield* lock.withPermit(requireInvocation(id))
yield* Queue.offerAll(invocation.responses, items)
}),
finish: (id, reason) =>
Effect.gen(function* () {
const invocation = yield* lock.withPermit(
Effect.gen(function* () {
const invocation = yield* requireInvocation(id)
const current = yield* Ref.get(state)
yield* Ref.set(state, remove(current, id))
return invocation
}),
)
yield* Queue.offer(invocation.responses, { type: "finish", reason })
yield* Queue.end(invocation.responses)
}),
disconnect: (id) =>
Effect.gen(function* () {
const invocation = yield* lock.withPermit(
Effect.gen(function* () {
const invocation = yield* requireInvocation(id)
const current = yield* Ref.get(state)
yield* Ref.set(state, remove(current, id))
return invocation
}),
)
yield* Queue.fail(
invocation.responses,
new ProviderDisconnectedError({ message: "Simulated model provider disconnected" }),
)
}),
pending: () =>
lock.withPermit(
Ref.get(state).pipe(
Effect.map((current) => Array.from(current.pending.values(), ({ id, url, body }) => ({ id, url, body }))),
),
),
}
const fibers = yield* FiberSet.make<void, unknown>()
const activeController = yield* Ref.make<Fiber.Fiber<void> | undefined>(undefined)
const controllerLock = yield* Semaphore.make(1)
yield* SimulationControlServer.start({
endpoint: options.endpoint,
label: "opencode drive backend websocket",
data: () => ({}),
decode: SimulationProtocol.Backend.decodeRequestEffect,
handle: (socket, request) => handle(driver, fibers, activeController, controllerLock, socket, request),
close: (socket) => releaseController(activeController, controllerLock, socket),
})
yield* Effect.sync(() => process.stderr.write(`opencode drive backend websocket: ${options.endpoint}\n`))
return Service.of({
stream: (request) =>
Stream.unwrap(
Effect.acquireRelease(open(request), close).pipe(
Effect.map((invocation) =>
Stream.fromQueue(invocation.responses).pipe(Stream.takeUntil((event) => event.type === "finish")),
),
),
),
})
}),
)
function handle(
driver: Driver,
fibers: FiberSet.FiberSet<void, unknown>,
activeController: Ref.Ref<Fiber.Fiber<void> | undefined>,
controllerLock: Semaphore.Semaphore,
socket: ControlSocket,
request: SimulationProtocol.Backend.Request,
) {
switch (request.method) {
case "llm.attach":
return controllerLock.withPermit(
Effect.gen(function* () {
if (socket.data.closed)
return yield* Effect.fail(
new ControllerDisconnectedError({ message: "Drive controller disconnected before attachment" }),
)
const previous = yield* Ref.get(activeController)
if (previous) yield* Fiber.interrupt(previous)
const attachment = yield* FiberSet.run(
fibers,
driver.requests.pipe(
Stream.runForEach((invocation) =>
Effect.sync(() => {
socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation }))
}),
),
),
)
if (socket.data.closed) {
yield* Fiber.interrupt(attachment)
return yield* Effect.fail(
new ControllerDisconnectedError({ message: "Drive controller disconnected during attachment" }),
)
}
socket.data.attachment = attachment
yield* Ref.set(activeController, attachment)
return { attached: true }
}),
)
case "llm.chunk":
return driver.push(request.params.id, request.params.items).pipe(Effect.as({ ok: true }))
case "llm.finish":
return driver.finish(request.params.id, request.params.reason).pipe(Effect.as({ ok: true }))
case "llm.disconnect":
return driver.disconnect(request.params.id).pipe(Effect.as({ ok: true }))
case "llm.pending":
return driver.pending().pipe(Effect.map((invocations) => ({ invocations })))
}
}
function releaseController(
activeController: Ref.Ref<Fiber.Fiber<void> | undefined>,
controllerLock: Semaphore.Semaphore,
socket: ControlSocket,
) {
return controllerLock.withPermit(
Effect.gen(function* () {
const attachment = socket.data.attachment
if (!attachment) return
yield* Fiber.interrupt(attachment)
yield* Ref.update(activeController, (active) => (active === attachment ? undefined : active))
}),
)
}
export * as SimulatedProvider from "./simulated-provider"

View file

@ -0,0 +1,91 @@
import { Effect, Fiber, Queue, Stream } from "effect"
import { SimulationProtocol } from "./protocol"
export interface Server {
readonly url: string
}
interface Request {
readonly id?: string | number | null
}
export interface SocketData {
readonly drive?: true
attachment?: Fiber.Fiber<void>
closed?: true
}
export type Socket = Bun.ServerWebSocket<SocketData>
export function start<RequestType extends Request, Error, Services>(options: {
readonly endpoint: string
readonly label: string
readonly data: () => SocketData
readonly decode: (input: string) => Effect.Effect<RequestType, Error>
readonly handle: (socket: Socket, request: RequestType) => Effect.Effect<unknown, unknown, Services>
readonly close?: (socket: Socket) => Effect.Effect<void, never, Services>
}) {
return Effect.gen(function* () {
const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256)
const closures = yield* Queue.unbounded<Socket>()
yield* Stream.fromQueue(messages).pipe(
Stream.runForEach((message) =>
options.decode(message.input).pipe(
Effect.flatMap((request) =>
options.handle(message.socket, request).pipe(
Effect.matchEffect({
onFailure: (error) => send(message.socket, SimulationProtocol.JsonRpc.failure(request.id, error)),
onSuccess: (result) => send(message.socket, SimulationProtocol.JsonRpc.success(request.id, result)),
}),
),
),
Effect.catch((error) => send(message.socket, SimulationProtocol.JsonRpc.failure(undefined, error))),
),
),
Effect.forkScoped,
)
yield* Stream.fromQueue(closures).pipe(
Stream.runForEach((socket) => options.close?.(socket) ?? Effect.void),
Effect.forkScoped,
)
const url = yield* Effect.try({ try: () => new URL(options.endpoint), catch: (cause) => cause })
yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve<SocketData>({
hostname: url.hostname,
port: Number(url.port),
fetch(request, server) {
if (server.upgrade(request, { data: options.data() })) return undefined
return new Response(options.label, { status: 426 })
},
websocket: {
close(socket) {
socket.data.closed = true
Queue.offerUnsafe(closures, socket)
},
message(socket, message) {
const input = typeof message === "string" ? message : message.toString()
if (Queue.offerUnsafe(messages, { socket, input })) return
socket.send(
JSON.stringify(
SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")),
),
)
},
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
return { url: options.endpoint } satisfies Server
})
}
function send(socket: Socket, response: SimulationProtocol.JsonRpc.Response | undefined) {
if (!response) return Effect.void
return Effect.sync(() => {
socket.send(JSON.stringify(response))
})
}
export * as SimulationControlServer from "./control-server"

View file

@ -1,11 +1,10 @@
import { mkdir } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { extname, join, resolve } from "node:path" import { extname, join, resolve } from "node:path"
import type { CliRenderer, Renderable } from "@opentui/core" import type { CliRenderer, Renderable } from "@opentui/core"
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
import { Config, Effect, FileSystem } from "effect"
import type { SimulationProtocol } from "../protocol" import type { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer" import { SimulationRenderer } from "./renderer"
import { SimulationPng } from "./png"
export type Action = SimulationProtocol.Frontend.Action export type Action = SimulationProtocol.Frontend.Action
export type Element = SimulationProtocol.Frontend.Element export type Element = SimulationProtocol.Frontend.Element
@ -72,10 +71,7 @@ export function createHarness(renderer: CliRenderer): Harness {
// captureCharFrame follows the test renderer's output sink. Recording // captureCharFrame follows the test renderer's output sink. Recording
// redirects that sink to the timeline, so read the live render buffer // redirects that sink to the timeline, so read the live render buffer
// instead; it is also the source used by screenshots. // instead; it is also the source used by screenshots.
screen: () => screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes()),
decoder.decode(
(Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(),
),
} }
} }
@ -114,31 +110,29 @@ export function matches(harness: Pick<Harness, "screen">, text: string) {
return harness.screen().includes(text) return harness.screen().includes(text)
} }
export async function screenshot(harness: Harness, name?: string) { export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
await harness.renderOnce()
const image = SimulationPng.screenshot(harness.renderer)
const filename = name ?? `screenshot-${crypto.randomUUID()}` const filename = name ?? `screenshot-${crypto.randomUUID()}`
if ( if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
!filename || return yield* Effect.fail(new Error("screenshot name must not contain a path or extension"))
filename.includes("/") || yield* Effect.tryPromise(() => harness.renderOnce())
filename.includes("\\") || const { SimulationPng } = yield* Effect.promise(() => import("./png"))
extname(filename) const image = SimulationPng.screenshot(harness.renderer)
)
throw new Error("screenshot name must not contain a path or extension")
const directory = resolve( const directory = resolve(
process.env.OPENCODE_DRIVE_MEDIA_DIR ?? yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe(
join(tmpdir(), "opencode-drive", "output"), Config.withDefault(join(tmpdir(), "opencode-drive", "output")),
),
) )
await mkdir(directory, { recursive: true }) const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(directory, { recursive: true })
const path = join(directory, `${filename}.png`) const path = join(directory, `${filename}.png`)
await Bun.write(path, image.data) yield* fs.writeFile(path, image.data)
return path return path
} })
export async function execute(harness: Harness, action: Action) { export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
switch (action.type) { switch (action.type) {
case "ui.type": case "ui.type":
await harness.mockInput.typeText(action.text) yield* Effect.tryPromise(() => harness.mockInput.typeText(action.text))
break break
case "ui.press": case "ui.press":
harness.mockInput.pressKey(action.key, action.modifiers) harness.mockInput.pressKey(action.key, action.modifiers)
@ -155,18 +149,23 @@ export async function execute(harness: Harness, action: Action) {
?.focus() ?.focus()
break break
case "ui.click": case "ui.click":
await harness.mockMouse.click(action.x, action.y) yield* Effect.tryPromise(() => harness.mockMouse.click(action.x, action.y))
break break
case "ui.resize": case "ui.resize":
if (!Number.isSafeInteger(action.cols) || action.cols <= 0 || !Number.isSafeInteger(action.rows) || action.rows <= 0) { if (
throw new Error("resize cols and rows must be positive integers") !Number.isSafeInteger(action.cols) ||
action.cols <= 0 ||
!Number.isSafeInteger(action.rows) ||
action.rows <= 0
) {
return yield* Effect.fail(new Error("resize cols and rows must be positive integers"))
} }
harness.resize(action.cols, action.rows) harness.resize(action.cols, action.rows)
SimulationRenderer.recordResize(harness.renderer, action.cols, action.rows) SimulationRenderer.recordResize(harness.renderer, action.cols, action.rows)
break break
} }
await harness.renderOnce() yield* Effect.tryPromise(() => harness.renderOnce())
return state(harness) return state(harness)
} })
export * as SimulationActions from "./actions" export * as SimulationActions from "./actions"

View file

@ -1,22 +1,20 @@
import { fileURLToPath } from "node:url" /// <reference path="../assets.d.ts" />
import { GlobalFonts, createCanvas } from "@napi-rs/canvas" import { GlobalFonts, createCanvas, type SKRSContext2D } from "@napi-rs/canvas"
import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core" import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core"
import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-normal.woff2" with { type: "file" }
import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" }
import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" }
import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" }
const CellWidth = 10 const CellWidth = 10
const CellHeight = 20 const CellHeight = 20
const FontSize = 16 const FontSize = 16
const FontFamily = "OpenCode Mono" const FontFamily = "OpenCode Mono"
for (const file of [ for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) {
"adwaita-mono-latin-400-normal.woff2", const font = Buffer.from(await Bun.file(file).arrayBuffer())
"adwaita-mono-latin-700-normal.woff2", if (!GlobalFonts.register(font, FontFamily))
"adwaita-mono-latin-400-italic.woff2", throw new Error(`Failed to register screenshot font: ${file}`)
"adwaita-mono-latin-700-italic.woff2",
]) {
GlobalFonts.registerFromPath(
fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)),
FontFamily,
)
} }
export function screenshot(renderer: CliRenderer) { export function screenshot(renderer: CliRenderer) {
@ -54,13 +52,17 @@ export function screenshotFrame(frame: CapturedFrame) {
} }
if (!hidden && char.codePointAt(0) !== 0x0a00) { if (!hidden && char.codePointAt(0) !== 0x0a00) {
context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1)
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` const x = column * CellWidth
context.fillText(char, column * CellWidth, row * CellHeight + 1) const y = row * CellHeight
if (!drawBlockElement(context, char, x, y, cells)) {
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"`
context.fillText(char, x, y + 1)
}
if (attributes & TextAttributes.UNDERLINE) { if (attributes & TextAttributes.UNDERLINE) {
context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) context.fillRect(x, y + 17, cells * CellWidth, 1)
} }
if (attributes & TextAttributes.STRIKETHROUGH) { if (attributes & TextAttributes.STRIKETHROUGH) {
context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) context.fillRect(x, y + 10, cells * CellWidth, 1)
} }
} }
column += cells column += cells
@ -83,6 +85,15 @@ export function screenshotFrame(frame: CapturedFrame) {
} }
} }
function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: number, cells: number) {
const width = cells * CellWidth
if (char === "█") context.fillRect(x, y, width, CellHeight)
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
else return false
return true
}
function color(value: RGBA, opacity = 1) { function color(value: RGBA, opacity = 1) {
const [red, green, blue, alpha] = value.toInts() const [red, green, blue, alpha] = value.toInts()
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`

View file

@ -1,5 +1,6 @@
import type { CliRenderer, CliRendererConfig } from "@opentui/core" import type { CliRenderer, CliRendererConfig } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { Effect } from "effect"
import { Timeline } from "../recording" import { Timeline } from "../recording"
const setups = new WeakMap<CliRenderer, TestRendererSetup>() const setups = new WeakMap<CliRenderer, TestRendererSetup>()
@ -16,37 +17,47 @@ export interface Viewport {
readonly rows: number readonly rows: number
} }
export async function create(options: CliRendererConfig, path?: string, viewport?: Viewport): Promise<CliRenderer> { export const create = Effect.fn("SimulationRenderer.create")(function* (
options: CliRendererConfig,
path?: string,
viewport?: Viewport,
) {
const cols = viewport?.cols ?? 100 const cols = viewport?.cols ?? 100
const rows = viewport?.rows ?? 40 const rows = viewport?.rows ?? 40
if (!path) { const recording = path
const setup = await createTestRenderer({ ? yield* Effect.acquireRelease(
...options, Effect.tryPromise(() => Timeline.create(path, cols, rows)),
width: cols, (recording) =>
height: rows, Effect.tryPromise(() => recording.finish()).pipe(
}) Effect.catch((error) =>
setups.set(setup.renderer, setup) Effect.sync(() => process.stderr.write(`Failed to finish UI recording: ${error}\n`)),
return setup.renderer ),
} ),
const recording = await Timeline.create(path, cols, rows) )
const setup = await createTestRenderer({ : undefined
...options, const setup = yield* Effect.acquireRelease(
width: cols, Effect.tryPromise(() =>
height: rows, createTestRenderer({
stdout: recording as unknown as NodeJS.WriteStream, ...options,
bufferedOutput: "stdout", width: cols,
onDestroy: () => { height: rows,
void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) ...(recording
options.onDestroy?.() ? {
}, stdout: recording as unknown as NodeJS.WriteStream,
}).catch(async (error) => { bufferedOutput: "stdout" as const,
await recording.finish().catch(() => undefined) }
throw error : {}),
}) }),
),
(setup) =>
Effect.sync(() => {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
}),
)
setups.set(setup.renderer, setup) setups.set(setup.renderer, setup)
recordings.set(setup.renderer, recording) if (recording) recordings.set(setup.renderer, recording)
return setup.renderer return setup.renderer
} })
export function recordResize(renderer: CliRenderer, cols: number, rows: number) { export function recordResize(renderer: CliRenderer, cols: number, rows: number) {
recordings.get(renderer)?.resize(cols, rows) recordings.get(renderer)?.resize(cols, rows)
@ -58,8 +69,8 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
export function finish(renderer: CliRenderer) { export function finish(renderer: CliRenderer) {
const recording = recordings.get(renderer) const recording = recordings.get(renderer)
if (!recording) throw new Error("UI recording is not available") if (!recording) return Effect.fail(new Error("UI recording is not available"))
return recording.finish() return Effect.tryPromise(() => recording.finish())
} }
export * as SimulationRenderer from "./renderer" export * as SimulationRenderer from "./renderer"

View file

@ -1,31 +1,19 @@
import { Effect } from "effect"
import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol" import { SimulationProtocol } from "../protocol"
import { SimulationActions, type Harness } from "./actions" import { SimulationActions, type Harness } from "./actions"
import { SimulationRenderer } from "./renderer"
export interface Server { function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
readonly url: string
readonly stop: () => void
}
function parseRequest(input: string | Buffer) {
return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
}
async function handle(
harness: Harness,
request: SimulationProtocol.Frontend.Request,
finishRecording?: () => Promise<string>,
) {
switch (request.method) { switch (request.method) {
case "ui.screenshot": case "ui.screenshot":
return SimulationActions.screenshot(harness, request.params?.name) return SimulationActions.screenshot(harness, request.params?.name)
case "ui.state": { case "ui.state":
return SimulationActions.state(harness) return Effect.sync(() => SimulationActions.state(harness))
}
case "ui.matches": case "ui.matches":
return SimulationActions.matches(harness, request.params.text) return Effect.sync(() => SimulationActions.matches(harness, request.params.text))
case "ui.recording.finish": case "ui.recording.finish":
if (!finishRecording) throw new Error("UI recording is not available") return SimulationRenderer.finish(harness.renderer)
return finishRecording()
case "ui.type": case "ui.type":
return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text })
case "ui.enter": case "ui.enter":
@ -48,39 +36,22 @@ async function handle(
y: request.params.y, y: request.params.y,
}) })
case "ui.resize": case "ui.resize":
return SimulationActions.execute(harness, { type: "ui.resize", cols: request.params.cols, rows: request.params.rows }) return SimulationActions.execute(harness, {
type: "ui.resize",
cols: request.params.cols,
rows: request.params.rows,
})
} }
} }
export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise<string>): Server { export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) {
const url = new URL(endpoint) return yield* SimulationControlServer.start({
const server = Bun.serve<{ readonly drive: true }>({ endpoint,
hostname: url.hostname, label: "opencode drive ui websocket",
port: Number(url.port), data: () => ({ drive: true as const }),
fetch(request, server) { decode: SimulationProtocol.Frontend.decodeRequestEffect,
if (server.upgrade(request, { data: { drive: true } })) return undefined handle: (_socket, request) => handle(harness, request),
return new Response("opencode drive ui websocket", { status: 426 })
},
websocket: {
async message(socket, message) {
let request: SimulationProtocol.Frontend.Request | undefined
try {
request = parseRequest(message)
const result = await handle(harness, request, finishRecording)
const next = SimulationProtocol.JsonRpc.success(request.id, result)
if (next) socket.send(JSON.stringify(next))
} catch (error) {
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
}
},
},
}) })
return { })
url: endpoint,
stop: () => {
server.stop(true)
},
}
}
export * as SimulationServer from "./server" export * as SimulationServer from "./server"

View file

@ -1,32 +1,27 @@
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
import { Config, Effect } from "effect"
import { DriveManifest } from "../manifest" import { DriveManifest } from "../manifest"
import { SimulationActions } from "./actions" import { SimulationActions } from "./actions"
import { SimulationRenderer } from "./renderer" import { SimulationRenderer } from "./renderer"
import { SimulationServer } from "./server" import { SimulationServer } from "./server"
/** /** Drive-mode renderer and control-server acquisition. */
* Drive-mode renderer entry point. export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) {
* const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
* Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal const manifest = yield* DriveManifest.resolve()
* visible renderer otherwise) and starts the UI control
* server against it. The server stops when the renderer is destroyed, so the
* caller only manages the renderer lifecycle.
*/
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
const manifest = DriveManifest.resolve()
const renderer = headless const renderer = headless
? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport) ? yield* SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
: await createCliRenderer(options) : yield* Effect.acquireRelease(
Effect.tryPromise(() => createCliRenderer(options)),
(renderer) =>
Effect.sync(() => {
if (!renderer.isDestroyed) renderer.destroy()
}),
)
if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows) if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
const server = SimulationServer.start( const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui)
SimulationActions.createHarness(renderer), yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`))
manifest.endpoints.ui,
headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined,
)
process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)
renderer.once("destroy", () => server.stop())
return renderer return renderer
} })
export * as Drive from "./simulation" export * as Drive from "./simulation"

View file

@ -1,20 +1,54 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os" import { homedir } from "node:os"
import { isAbsolute, join } from "node:path" import { isAbsolute, join } from "node:path"
import { Config, Effect, FileSystem, Schema } from "effect"
import { PositiveInt } from "@opencode-ai/core/schema"
export interface Manifest { const InstanceName = Schema.String.check(
readonly endpoints: { Schema.makeFilter((value) =>
readonly ui: string /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(value) ? undefined : "a valid Drive instance name",
readonly backend: string ),
} )
readonly viewport?: {
readonly cols: number const Endpoint = Schema.String.check(
readonly rows: number Schema.makeFilter((value) => {
} if (!URL.canParse(value)) return "a loopback WebSocket endpoint with an explicit port"
readonly recording?: { const endpoint = new URL(value)
readonly timeline: string const port = Number(endpoint.port)
} return endpoint.protocol === "ws:" && endpoint.hostname === "127.0.0.1" && Number.isInteger(port) && port >= 1
} ? undefined
: "a loopback WebSocket endpoint with an explicit port"
}),
)
const AbsolutePath = Schema.String.check(
Schema.makeFilter((value) => (isAbsolute(value) ? undefined : "an absolute path")),
)
export const Manifest = Schema.Struct({
endpoints: Schema.Struct({
ui: Endpoint,
backend: Endpoint,
}),
viewport: Schema.optionalKey(
Schema.Struct({
cols: PositiveInt,
rows: PositiveInt,
}),
),
recording: Schema.optionalKey(
Schema.Struct({
timeline: AbsolutePath,
}),
),
})
export interface Manifest extends Schema.Schema.Type<typeof Manifest> {}
export class ResolveError extends Schema.TaggedErrorClass<ResolveError>()("DriveManifest.ResolveError", {
reason: Schema.Literals(["config", "not-found", "read", "decode"]),
path: Schema.optionalKey(Schema.String),
message: Schema.String,
cause: Schema.Defect(),
}) {}
export const defaults: Manifest = { export const defaults: Manifest = {
endpoints: { endpoints: {
@ -23,48 +57,54 @@ export const defaults: Manifest = {
}, },
} }
export function resolve() { const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))
const name = process.env.OPENCODE_DRIVE
if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name") const configError = (cause: unknown) =>
new ResolveError({
reason: "config",
message: `Invalid Drive configuration: ${String(cause)}`,
cause,
})
export const resolve = Effect.fn("DriveManifest.resolve")(function* () {
const name = yield* Config.schema(InstanceName, "OPENCODE_DRIVE").pipe(Effect.mapError(configError))
if (name === "1") return defaults if (name === "1") return defaults
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`)
const directory = const state = yield* Config.string("XDG_STATE_HOME").pipe(
process.env.DRIVE_REGISTRY_DIR ?? Config.withDefault(join(homedir(), ".local", "state")),
join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances") Effect.mapError(configError),
)
const directory = yield* Config.string("DRIVE_REGISTRY_DIR").pipe(
Config.withDefault(join(state, "opencode-drive", "instances")),
Effect.mapError(configError),
)
const file = join(directory, `${name}.json`) const file = join(directory, `${name}.json`)
if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`) const fs = yield* FileSystem.FileSystem
const contents = yield* fs.readFileString(file).pipe(
const manifest: unknown = JSON.parse(readFileSync(file, "utf8")) Effect.mapError(
if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) (cause) =>
validateEndpoint(manifest.endpoints.ui, "ui") new ResolveError({
validateEndpoint(manifest.endpoints.backend, "backend") reason: cause.reason._tag === "NotFound" ? "not-found" : "read",
if (manifest.viewport) validateViewport(manifest.viewport) path: file,
if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { message:
throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) cause.reason._tag === "NotFound"
} ? `Drive manifest not found: ${file}`
return manifest : `Failed to read Drive manifest: ${file}: ${cause.message}`,
} cause,
}),
function isManifest(value: unknown): value is Manifest { ),
if (typeof value !== "object" || value === null || !("endpoints" in value)) return false )
if (typeof value.endpoints !== "object" || value.endpoints === null) return false return yield* decode(contents).pipe(
return "ui" in value.endpoints && "backend" in value.endpoints Effect.mapError(
} (cause) =>
new ResolveError({
function validateEndpoint(value: string, name: string) { reason: "decode",
const endpoint = new URL(value) path: file,
const port = Number(endpoint.port) message: `Invalid Drive manifest: ${file}: ${cause.message}`,
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) { cause,
throw new Error(`Invalid drive ${name} endpoint: ${value}`) }),
} ),
} )
})
function validateViewport(value: Manifest["viewport"]) {
if (!value) return
if (!Number.isSafeInteger(value.cols) || value.cols <= 0 || !Number.isSafeInteger(value.rows) || value.rows <= 0) {
throw new Error(`Invalid drive viewport: ${JSON.stringify(value)}`)
}
}
export * as DriveManifest from "./manifest" export * as DriveManifest from "./manifest"

View file

@ -145,6 +145,7 @@ export namespace Frontend {
]) ])
export type Request = Schema.Schema.Type<typeof Request> export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request) export const decodeRequest = Schema.decodeUnknownSync(Request)
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
} }
export namespace Backend { export namespace Backend {
@ -188,9 +189,10 @@ export namespace Backend {
]) ])
export type Request = Schema.Schema.Type<typeof Request> export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request) export const decodeRequest = Schema.decodeUnknownSync(Request)
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export const ProviderInvocation = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {} export interface ProviderInvocation extends Schema.Schema.Type<typeof ProviderInvocation> {}
export const NetworkLogEntry = Schema.Struct({ export const NetworkLogEntry = Schema.Struct({
time: Schema.Number, time: Schema.Number,

View file

@ -0,0 +1,26 @@
import { Effect } from "effect"
export function availableEndpoint() {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const endpoint = `ws://127.0.0.1:${server.port}`
server.stop(true)
return endpoint
}
export function connect(endpoint: string) {
return Effect.acquireRelease(
Effect.callback<WebSocket, Error>((resume) => {
const socket = new WebSocket(endpoint)
const open = () => resume(Effect.succeed(socket))
const error = () => resume(Effect.fail(new Error(`Failed to connect to ${endpoint}`)))
socket.addEventListener("open", open, { once: true })
socket.addEventListener("error", error, { once: true })
return Effect.sync(() => {
socket.removeEventListener("open", open)
socket.removeEventListener("error", error)
socket.close()
})
}),
(socket) => Effect.sync(() => socket.close()),
)
}

View file

@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { Effect, FileSystem, Queue } from "effect"
import { SimulationActions } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer"
import { SimulationServer } from "../src/frontend/server"
import { availableEndpoint, connect } from "./fixture/websocket"
test("scopes the frontend control server and reports malformed JSON", async () => {
const endpoint = availableEndpoint()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
yield* SimulationServer.start(SimulationActions.createHarness(renderer), endpoint)
const socket = yield* connect(endpoint)
const messages = yield* Queue.unbounded<unknown>()
socket.addEventListener("message", (event) => {
Queue.offerUnsafe(messages, JSON.parse(String(event.data)))
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" }))
expect(yield* Queue.take(messages)).toMatchObject({
id: 1,
result: { focused: { editor: false }, elements: [] },
})
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({
id: null,
error: { code: -32000 },
})
}),
).pipe(Effect.provide(FileSystem.layerNoop({}))),
)
const url = new URL(endpoint)
const rebound = Bun.serve({ hostname: url.hostname, port: Number(url.port), fetch: () => new Response() })
await rebound.stop(true)
})

View file

@ -0,0 +1,74 @@
import { expect, test } from "bun:test"
import { ConfigProvider, Effect, FileSystem, Layer } from "effect"
import { DriveManifest } from "../src/manifest"
test("loads and validates a Drive manifest through Effect services", async () => {
const manifest = await Effect.runPromise(
DriveManifest.resolve().pipe(
Effect.provide(
Layer.merge(
FileSystem.layerNoop({
readFileString: () =>
Effect.succeed(
JSON.stringify({
endpoints: {
ui: "ws://127.0.0.1:41000",
backend: "ws://127.0.0.1:41050",
},
viewport: { cols: 120, rows: 50 },
recording: { timeline: "/tmp/drive/timeline.jsonl" },
}),
),
}),
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_DRIVE: "test-instance",
DRIVE_REGISTRY_DIR: "/tmp/drive",
}),
),
),
),
),
)
expect(manifest).toEqual({
endpoints: {
ui: "ws://127.0.0.1:41000",
backend: "ws://127.0.0.1:41050",
},
viewport: { cols: 120, rows: 50 },
recording: { timeline: "/tmp/drive/timeline.jsonl" },
})
})
test("reports schema-invalid manifests as typed decode failures", async () => {
const error = await Effect.runPromise(
DriveManifest.resolve().pipe(
Effect.flip,
Effect.provide(
Layer.merge(
FileSystem.layerNoop({
readFileString: () =>
Effect.succeed(
JSON.stringify({
endpoints: {
ui: "https://example.com",
backend: "ws://127.0.0.1:41050",
},
}),
),
}),
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_DRIVE: "test-instance",
DRIVE_REGISTRY_DIR: "/tmp/drive",
}),
),
),
),
),
)
expect(error).toBeInstanceOf(DriveManifest.ResolveError)
expect(error.reason).toBe("decode")
})

View file

@ -0,0 +1,31 @@
import { expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest } from "effect/unstable/http"
import { SimulationNetwork } from "../src/backend/network"
test("keeps routes and request logs local to each network", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(1_234)
const first = yield* SimulationNetwork.make([
SimulationNetwork.json("GET", "https://example.test/value", { source: "first" }),
])
const second = yield* SimulationNetwork.make()
const request = HttpClientRequest.get("https://example.test/value")
const response = yield* first.client.execute(request)
expect(yield* response.text).toBe('{"source":"first"}')
expect(Exit.isFailure(yield* second.client.execute(request).pipe(Effect.exit))).toBe(true)
expect(yield* first.log()).toEqual([
{ time: 1_234, method: "GET", url: "https://example.test/value", matched: true },
])
expect(yield* second.log()).toEqual([
{ time: 1_234, method: "GET", url: "https://example.test/value", matched: false },
])
}).pipe(Effect.provide(TestClock.layer())),
),
)
})

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
import { DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
import { Effect, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { HttpClientError } from "effect/unstable/http/HttpClientError"
import { SimulationOpenAI } from "../src/backend/openai"
import { SimulatedProvider } from "../src/backend/simulated-provider"
test("encodes every simulated provider event as OpenAI SSE", async () => {
const provider: SimulatedProvider.Interface = {
stream: () =>
Stream.make(
{ type: "textDelta", text: "Hello " },
{ type: "textDelta", text: "from Drive" },
{ type: "finish", reason: "stop" },
),
}
const url = new URL(DEFAULT_BASE_URL + PATH)
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe({ model: "gpt-5" }))
const matched = SimulationOpenAI.route(provider).match(request, url)
if (!matched) throw new Error("The simulated OpenAI route did not match")
const body = await Effect.runPromise(matched.pipe(Effect.flatMap((response) => response.text)))
expect(body).toBe(
[
'data: {"choices":[{"delta":{"content":"Hello "}}]}',
'data: {"choices":[{"delta":{"content":"from Drive"}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n"),
)
})
test("rejects malformed intercepted OpenAI JSON as an HTTP client error", async () => {
const provider: SimulatedProvider.Interface = { stream: () => Stream.empty }
const url = new URL(DEFAULT_BASE_URL + PATH)
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyText("{"))
const matched = SimulationOpenAI.route(provider).match(request, url)
if (!matched) throw new Error("The simulated OpenAI route did not match")
const error = await Effect.runPromise(matched.pipe(Effect.flip))
expect(error).toBeInstanceOf(HttpClientError)
expect(error.reason._tag).toBe("TransportError")
})

View file

@ -0,0 +1,61 @@
import { expect, test } from "bun:test"
import { createCanvas, loadImage } from "@napi-rs/canvas"
import { RGBA, TextAttributes, type CapturedFrame } from "@opentui/core"
import { SimulationPng } from "../src/frontend/png"
test("renders captured frames with bundled fonts", () => {
const frame: CapturedFrame = {
cols: 4,
rows: 1,
cursor: [0, 0],
lines: [
{
spans: [
{
text: "Test",
width: 4,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: TextAttributes.BOLD | TextAttributes.ITALIC,
},
],
},
],
}
const image = SimulationPng.screenshotFrame(frame)
expect(image.width).toBe(40)
expect(image.height).toBe(20)
expect(image.data.subarray(1, 4).toString()).toBe("PNG")
})
test("fills adjacent block elements without glyph gaps", async () => {
const image = SimulationPng.screenshotFrame({
cols: 2,
rows: 1,
cursor: [0, 0],
lines: [
{
spans: [
{
text: "▀▀",
width: 2,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: 0,
},
],
},
],
})
const canvas = createCanvas(image.width, image.height)
const context = canvas.getContext("2d")
context.drawImage(await loadImage(image.data), 0, 0)
expect([...context.getImageData(0, 5, image.width, 1).data]).toEqual(
Array.from({ length: image.width }, () => [255, 255, 255, 255]).flat(),
)
expect([...context.getImageData(0, 15, image.width, 1).data]).toEqual(
Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(),
)
})

View file

@ -5,6 +5,7 @@ import { join } from "node:path"
import { TextRenderable } from "@opentui/core" import { TextRenderable } from "@opentui/core"
import { createHarness, matches } from "../src/frontend/actions" import { createHarness, matches } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer" import { SimulationRenderer } from "../src/frontend/renderer"
import { Effect } from "effect"
import { Timeline, type Event } from "../src/recording" import { Timeline, type Event } from "../src/recording"
test("streams ANSI chunks into a versioned JSONL timeline", async () => { test("streams ANSI chunks into a versioned JSONL timeline", async () => {
@ -39,21 +40,25 @@ test("streams ANSI chunks into a versioned JSONL timeline", async () => {
test("captures native renderer output and finishes on destroy", async () => { test("captures native renderer output and finishes on destroy", async () => {
const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-"))
const path = join(directory, "timeline.jsonl") const path = join(directory, "timeline.jsonl")
const renderer = await SimulationRenderer.create({}, path)
try { try {
await SimulationRenderer.setupFor(renderer)?.renderOnce() await Effect.runPromise(
renderer.destroy() Effect.scoped(
expect(await SimulationRenderer.finish(renderer)).toBe(path) Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({}, path)
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
renderer.destroy()
expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
const events = (await Bun.file(path).text()) const events = (yield* Effect.promise(() => Bun.file(path).text()))
.trim() .trim()
.split("\n") .split("\n")
.map((line) => JSON.parse(line) as Event) .map((line) => JSON.parse(line) as Event)
expect(events.some((event) => event.type === "output")).toBe(true) expect(events.some((event) => event.type === "output")).toBe(true)
}),
),
)
} finally { } finally {
if (!renderer.isDestroyed) renderer.destroy()
await SimulationRenderer.finish(renderer)
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}) })
@ -61,16 +66,20 @@ test("captures native renderer output and finishes on destroy", async () => {
test("matches live screen text while recording", async () => { test("matches live screen text while recording", async () => {
const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-")) const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-"))
const path = join(directory, "timeline.jsonl") const path = join(directory, "timeline.jsonl")
const renderer = await SimulationRenderer.create({}, path)
try { try {
renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" })) await Effect.runPromise(
await SimulationRenderer.setupFor(renderer)?.renderOnce() Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({}, path)
renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" }))
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
expect(matches(createHarness(renderer), "recorded screen text")).toBe(true) expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
}),
),
)
} finally { } finally {
renderer.destroy()
await SimulationRenderer.finish(renderer)
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}) })

View file

@ -0,0 +1,230 @@
import { expect, test } from "bun:test"
import { Deferred, Effect, Fiber, Queue, Stream } from "effect"
import type { Scope } from "effect/Scope"
import { SimulatedProvider } from "../src/backend/simulated-provider"
import { availableEndpoint, connect } from "./fixture/websocket"
test("streams a Drive-controlled provider response and removes the finished invocation", async () => {
await runProvider((provider, socket, messages) =>
Effect.gen(function* () {
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } })
yield* attach(socket, messages)
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
const opened = yield* takeInvocation(messages)
expect(opened).toMatchObject({
method: "llm.request",
params: {
url: "https://api.openai.com/v1/chat/completions",
body: { model: "gpt-5" },
},
})
const params = requireRecord(opened.params)
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
expect(response.pollUnsafe()).toBeUndefined()
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "llm.chunk",
params: { id: params.id, items: [{ type: "textDelta", text: "Hello from Drive" }] },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "llm.finish",
params: { id: params.id, reason: "stop" },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
expect(Array.from(yield* Fiber.join(response))).toEqual([
{ type: "textDelta", text: "Hello from Drive" },
{ type: "finish", reason: "stop" },
])
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 4, method: "llm.pending" }))
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { invocations: [] } })
}),
)
})
test("replays an invocation to a controller that attaches after it opens", async () => {
await runProvider((provider, socket, messages) =>
Effect.gen(function* () {
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
const received = [requireRecord(yield* Queue.take(messages)), requireRecord(yield* Queue.take(messages))]
expect(received).toContainEqual(expect.objectContaining({ id: 1, result: { attached: true } }))
const opened = received.find((message) => message.method === "llm.request")
if (!opened) throw new Error("The pending invocation was not replayed")
const params = requireRecord(opened.params)
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
socket.send(
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
}),
)
})
test("replaces the previous attached controller", async () => {
const endpoint = availableEndpoint()
await Effect.runPromise(
Effect.gen(function* () {
const provider = yield* SimulatedProvider.Service
const first = yield* connect(endpoint)
const second = yield* connect(endpoint)
const firstMessages = yield* messagesFrom(first)
const secondMessages = yield* messagesFrom(second)
yield* attach(first, firstMessages)
yield* attach(second, secondMessages)
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
const opened = yield* takeInvocation(secondMessages)
expect(yield* Queue.size(firstMessages)).toBe(0)
const params = requireRecord(opened.params)
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
second.send(
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }),
)
expect(yield* Queue.take(secondMessages)).toMatchObject({ id: 2, result: { ok: true } })
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
)
})
test("removes an invocation when its response stream is interrupted", async () => {
await runProvider((provider, socket, messages) =>
Effect.gen(function* () {
yield* attach(socket, messages)
const response = yield* provider.stream(request).pipe(Stream.runDrain, Effect.forkScoped)
yield* takeInvocation(messages)
yield* Fiber.interrupt(response)
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.pending" }))
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { invocations: [] } })
}),
)
})
test("releases a backpressured response when its consumer is interrupted", async () => {
await runProvider((provider, socket, messages) =>
Effect.gen(function* () {
yield* attach(socket, messages)
const started = yield* Deferred.make<void>()
const response = yield* provider.stream(request).pipe(
Stream.runForEach(() => Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never))),
Effect.forkScoped,
)
const opened = yield* takeInvocation(messages)
const params = requireRecord(opened.params)
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "llm.chunk",
params: {
id: params.id,
items: Array.from({ length: 300 }, (_, index) => ({ type: "textDelta", text: String(index) })),
},
}),
)
const result = yield* Queue.take(messages).pipe(Effect.forkScoped)
yield* Deferred.await(started)
expect(result.pollUnsafe()).toBeUndefined()
yield* Fiber.interrupt(response)
expect(yield* Fiber.join(result)).toMatchObject({ id: 2 })
}),
)
})
test("fails the provider stream when Drive disconnects the invocation", async () => {
await runProvider((provider, socket, messages) =>
Effect.gen(function* () {
yield* attach(socket, messages)
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.flip, Effect.forkScoped)
const opened = yield* takeInvocation(messages)
const params = requireRecord(opened.params)
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.disconnect", params: { id: params.id } }))
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
expect(yield* Fiber.join(response)).toBeInstanceOf(SimulatedProvider.ProviderDisconnectedError)
}),
)
})
const request: SimulatedProvider.ProviderRequest = {
url: "https://api.openai.com/v1/chat/completions",
body: { model: "gpt-5", messages: [{ role: "user", content: "Hello" }] },
}
function runProvider<E>(
body: (
provider: SimulatedProvider.Interface,
socket: WebSocket,
messages: Queue.Queue<unknown>,
) => Effect.Effect<void, E, Scope>,
) {
const endpoint = availableEndpoint()
return Effect.runPromise(
Effect.gen(function* () {
const provider = yield* SimulatedProvider.Service
const socket = yield* connect(endpoint)
const messages = yield* messagesFrom(socket)
yield* body(provider, socket, messages)
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
)
}
function messagesFrom(socket: WebSocket) {
return Effect.gen(function* () {
const messages = yield* Queue.unbounded<unknown>()
socket.addEventListener("message", (event) => {
Queue.offerUnsafe(messages, JSON.parse(String(event.data)))
})
return messages
})
}
function attach(socket: WebSocket, messages: Queue.Queue<unknown>) {
return Effect.gen(function* () {
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
})
}
function takeInvocation(messages: Queue.Queue<unknown>) {
return Queue.take(messages).pipe(
Effect.map((message) => {
const opened = requireRecord(message)
if (opened.method !== "llm.request") throw new Error("Expected an llm.request notification")
return opened
}),
)
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("Expected an object")
return value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View file

@ -197,42 +197,38 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const result = yield* Effect.scoped( const result = yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease( const options = {
Effect.tryPromise({ externalOutputMode: "passthrough",
try: async () => { targetFps: 60,
const options = { gatherStats: false,
externalOutputMode: "passthrough", exitOnCtrlC: false,
targetFps: 60, useKittyKeyboard: {},
gatherStats: false, autoFocus: false,
exitOnCtrlC: false, openConsoleOnError: false,
useKittyKeyboard: {}, useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
autoFocus: false, consoleOptions: {
openConsoleOnError: false, keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse, },
consoleOptions: { } satisfies CliRendererConfig
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], const renderer = yield* Effect.gen(function* () {
}, if (handoff) {
} satisfies CliRendererConfig handoff.renderer.useMouse = options.useMouse
return yield* Effect.acquireRelease(Effect.succeed(handoff.renderer), (renderer) =>
if (handoff) { Effect.sync(() => destroyRenderer(renderer)),
handoff.renderer.useMouse = options.useMouse )
return handoff.renderer }
} if (process.env.OPENCODE_DRIVE) {
const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
if (process.env.OPENCODE_DRIVE) { return yield* Drive.create(options)
const { Drive } = await import("@opencode-ai/simulation/frontend") }
return Drive.create(options) return yield* Effect.acquireRelease(
} Effect.tryPromise({
try: () => createCliRenderer(options),
return createCliRenderer(options) catch: (error) => (error instanceof Error ? error : new Error(String(error))),
},
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
(renderer) =>
Effect.sync(() => {
destroyRenderer(renderer)
}), }),
) (renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
})
win32DisableProcessedInput() win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>() const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>

View file

@ -1,6 +1,6 @@
import { expect, mock, test } from "bun:test" import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing" import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect" import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
@ -33,7 +33,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
packages: { resolve: async () => undefined }, packages: { resolve: async () => undefined },
args: {}, args: {},
log: () => {}, log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
) )
await ready await ready
process.emit("SIGHUP") process.emit("SIGHUP")
@ -105,7 +105,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
packages: { resolve: async () => undefined }, packages: { resolve: async () => undefined },
args: { sessionID: "dummy" }, args: { sessionID: "dummy" },
log: () => {}, log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
) )
await initialTitleSet await initialTitleSet