diff --git a/bun.lock b/bun.lock index 58927508e4..9f3d31e66c 100644 --- a/bun.lock +++ b/bun.lock @@ -880,7 +880,7 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { - "@fontsource/adwaita-mono": "5.2.1", + "@fontsource/commit-mono": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", @@ -1765,7 +1765,7 @@ "@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=="], diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md index f09685b26a..29ccc5d4a8 100644 --- a/packages/opencode/specs/simulation/simulated-network-llm.md +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -4,7 +4,7 @@ Status: design for the Phase 2 network and LLM items in `simulation-phases.md`. ## 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. @@ -29,12 +29,12 @@ Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already ### 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`. +- `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). - 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`) @@ -42,36 +42,44 @@ Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `p On request: -1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). -2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. -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]`. +1. Parse the real OpenAI request body, which remains available to the driver for assertions. +2. Call `SimulatedProvider.Service.stream({ url, body })`. +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. -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: -``` -Exchange = { id, body, queue: Queue, deferred lifecycle } +```ts +interface SimulatedProvider { + stream(request: ProviderRequest): Stream +} ``` -- `requests()` — stream of newly opened exchanges (consumed by the control route). -- `push(id, item)` — append one response item to an open exchange. -- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. -- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). +`SimulatedProvider.layerDrive({ endpoint })` owns the Drive adapter in one Effect scope: + +- Pending provider invocations and response queues. +- 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) 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", - "params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } + "params": { "id": "inv_1", "url": "...", "body": { ...openai request body... } } } ``` Driver -> server methods: @@ -79,8 +87,9 @@ Driver -> server methods: ``` llm.attach subscribe to llm.request notifications llm.chunk { id, items: Item[] } append response items -llm.finish { id, reason?: "stop" | ... } finish the exchange -llm.pending list open exchanges +llm.finish { id, reason?: "stop" | ... } finish the invocation +llm.disconnect { id } fail the provider response stream +llm.pending list pending invocations 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: - 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. ### 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 @@ -123,14 +132,14 @@ driver TUI drive server backend + drive WS | |-- (normal app HTTP) ---->| session runner starts | | | llm.stream -> HttpClient | | | simulated network matches openai route - |<================== llm.request {ex_1} ===============| exchange ex_1 opened - |-- llm.chunk {ex_1,[...]} ============================>| SSE frames flow into the real - |-- llm.chunk {ex_1,[...]} ============================>| decode -> step -> LLMEvents -> - |-- llm.finish {ex_1} =================================>| runner publishes, TUI renders + |<================= llm.request {inv_1} ================| provider invocation inv_1 opened + |-- llm.chunk {inv_1,[...]} ===========================>| SSE frames flow into the real + |-- llm.chunk {inv_1,[...]} ===========================>| decode -> step -> LLMEvents -> + |-- llm.finish {inv_1} ================================>| runner publishes, TUI renders | | | | (if toolCall was sent: runner executes the real tool against the - | fake filesystem, then issues the next provider turn -> new exchange - | ex_2 -> driver decides the next response) + | fake filesystem, then starts the next model invocation -> inv_2 + | -> 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. @@ -138,13 +147,13 @@ The driver observes the TUI through `ui.state` while chunks stream, so mid-strea ## Implementation order 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). -3. `control.ts`: backend-hosted control WebSocket (`llm.attach|chunk|finish|pending`, `network.log`), started when the simulation module loads. +2. `simulated-provider.ts` + `openai.ts`: scoped Drive-controlled provider and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). +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). -5. Trace records for network and LLM exchange activity. +5. Trace records for network and simulated provider activity. ## Consequences - 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. diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index c8a470a449..74fd92889b 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -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. - [ ] 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). -- [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 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 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] 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 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] 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] 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`). - [ ] 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: diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 32647df3ff..f9c44d7356 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -76,14 +76,9 @@ function makeRoutes( const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements, startDriveServer } = yield* Effect.promise( - () => import("@opencode-ai/simulation/backend"), - ) - if (driveEnabled()) startDriveServer() - return AppNodeBuilder.build(applicationServices, [ - ...replacements, - ...(simulateEnabled() ? simulationReplacements : []), - ]) + const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) + const simulation = yield* simulationReplacements() + return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) }), ) : AppNodeBuilder.build(applicationServices, replacements) @@ -116,9 +111,5 @@ function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE } -function driveEnabled() { - return !!process.env.OPENCODE_DRIVE -} - export const webHandler = () => HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 624cf32ef7..1902a7a14a 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -17,7 +17,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@fontsource/adwaita-mono": "5.2.1", + "@fontsource/commit-mono": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", diff --git a/packages/simulation/src/assets.d.ts b/packages/simulation/src/assets.d.ts new file mode 100644 index 0000000000..f80c06f5d1 --- /dev/null +++ b/packages/simulation/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*.woff2" { + const path: string + export default path +} diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts deleted file mode 100644 index eaacc162c1..0000000000 --- a/packages/simulation/src/backend/control.ts +++ /dev/null @@ -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 { - 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" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 2b8bb876e6..56c2ed87c2 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,9 +1,11 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 { SimulationControl } from "./control" import { SimulationNetwork } from "./network" import { SimulationOpenAI } from "./openai" +import { SimulatedProvider } from "./simulated-provider" /** * Layer replacements applied when the server is built in simulation mode. @@ -17,17 +19,29 @@ import { SimulationOpenAI } from "./openai" * */ -SimulationNetwork.register(SimulationOpenAI.route) -// ModelsDev dies when its catalog fetch fails, so simulation answers it with -// an empty catalog; providers come from seeded config instead. -SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) +export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () { + // ModelsDev dies when its catalog fetch fails, so simulation answers it with + // an empty catalog; providers come from seeded config instead. + 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() { - return SimulationControl.start(DriveManifest.resolve().endpoints.backend) -} - -export const simulationReplacements: LayerNode.Replacements = [ - [httpClient, SimulationNetwork.layer], -] + const manifest = yield* DriveManifest.resolve() + const networkLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + 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" diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts deleted file mode 100644 index 638f80c1d0..0000000000 --- a/packages/simulation/src/backend/llm-exchange.ts +++ /dev/null @@ -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 -} - -export interface OpenedExchange { - readonly id: string - readonly url: string - readonly body: unknown -} - -const state = { - counter: 0, - exchanges: new Map(), - 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() - 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" diff --git a/packages/simulation/src/backend/network.ts b/packages/simulation/src/backend/network.ts index 7b002fcb4e..f91ec8246c 100644 --- a/packages/simulation/src/backend/network.ts +++ b/packages/simulation/src/backend/network.ts @@ -1,7 +1,8 @@ -import { Effect, Layer } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { Clock, Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse, type HttpMethod } from "effect/unstable/http" import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" import type { HttpClientRequest } from "effect/unstable/http" +import { SimulationProtocol } from "../protocol" /** * 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, * not a separate mechanism. * - * The route table is process-global module state so the control surface and - * the client layer observe the same registrations. + * Each acquired run owns its routes and request log. */ export interface Route { @@ -21,33 +21,15 @@ export interface Route { readonly match: ( request: HttpClientRequest.HttpClientRequest, url: URL, - ) => Effect.Effect | undefined + ) => Effect.Effect | undefined } -interface LogEntry { - readonly time: number - readonly method: string - readonly url: string - readonly matched: boolean -} - -const state = { - routes: [] as Route[], - log: [] as LogEntry[], -} +export type LogEntry = SimulationProtocol.Backend.NetworkLogEntry 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. */ -export function json(method: string, url: string, body: unknown): Route { +export function json(method: HttpMethod.HttpMethod, url: string, body: unknown): Route { return { match: (request, requestUrl) => { 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[] { - return state.log +export interface Run { + readonly client: HttpClient.HttpClient + readonly log: () => Effect.Effect } -function record(entry: LogEntry) { - state.log.push(entry) - if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT) -} - -export const layer = Layer.sync(HttpClient.HttpClient)(() => - HttpClient.make((request, url) => - Effect.suspend(() => { - const matched = state.routes - .map((route) => route.match(request, url)) - .find((response) => response !== undefined) - record({ time: Date.now(), method: request.method, url: url.toString(), matched: matched !== undefined }) - if (matched) return matched - return Effect.fail( +export const make = Effect.fn("SimulationNetwork.make")(function* (routes: readonly Route[] = []) { + const log = yield* Ref.make([]) + const client = HttpClient.make((request, url) => + Effect.gen(function* () { + let matched: Effect.Effect | undefined + for (const route of routes) { + matched = route.match(request, url) + if (matched) break + } + const entry = { + time: yield* Clock.currentTimeMillis, + method: request.method, + url: url.toString(), + matched: matched !== undefined, + } + yield* Ref.update(log, (entries) => [...entries, entry].slice(-LOG_LIMIT)) + if (matched) return yield* matched + return yield* Effect.fail( new HttpClientError({ reason: new TransportError({ 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" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index fb58a44724..b1a524547c 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -1,14 +1,15 @@ import { Effect, Schema, Stream } from "effect" 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 { SimulationLLMExchange } from "./llm-exchange" import { SimulationNetwork } from "./network" +import { SimulatedProvider } from "./simulated-provider" /** * Driver-answered OpenAI endpoint for the simulated network. * * 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 * of the response bytes is the real pipeline: SSE framing, the OpenAIChat * event schema, the protocol state machine, and Lifecycle grammar. @@ -17,11 +18,15 @@ import { SimulationNetwork } from "./network" const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) 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 // protocol event schema ignores unknown fields, so id/object/model are // decorative wire realism. -function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { +type ProviderItem = Exclude +type FinishReason = Extract["reason"] + +function chunkOf(item: ProviderItem): OpenAIChatEvent | unknown { if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] } if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] } if (item.type === "toolCall") @@ -43,7 +48,7 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { return item.chunk } -const finishReasonWire: Record = { +const finishReasonWire: Record = { stop: "stop", "tool-calls": "tool_calls", length: "length", @@ -54,40 +59,49 @@ function frame(payload: unknown): Uint8Array { return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) } -function sseBody(exchange: SimulationLLMExchange.Exchange): Stream.Stream { - const chunks = Stream.fromQueue(exchange.queue).pipe( - Stream.takeUntil((chunk) => chunk.type === "finish"), - Stream.map((chunk) => { - if (chunk.type === "finish") - return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[chunk.reason] }] })) - if (chunk.item.type === "raw") return frame(chunk.item.chunk) - return frame(encodeChunk(chunkOf(chunk.item))) +function sseBody( + events: Stream.Stream, +): Stream.Stream { + return events.pipe( + Stream.map((event) => { + if (event.type === "finish") + return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[event.reason] }] })) + 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"))), - // 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) => { if (request.method !== "POST") return undefined if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined return Effect.gen(function* () { - const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {} - const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body }) + const 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( request, - new Response(Stream.toReadableStream(sseBody(exchange)), { + new Response(Stream.toReadableStream(sseBody(provider.stream({ url: url.toString(), body }))), { status: 200, headers: { "content-type": "text/event-stream" }, }), ) }) }, -} +}) export * as SimulationOpenAI from "./openai" diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts new file mode 100644 index 0000000000..69f01184aa --- /dev/null +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -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()( + "SimulatedProvider.ProviderDisconnectedError", + { message: Schema.String }, +) {} + +export interface Interface { + readonly stream: (request: ProviderRequest) => Stream.Stream +} + +export class Service extends Context.Service()("@opencode/simulation/SimulatedProvider") {} + +interface ProviderInvocation extends ProviderRequest { + readonly id: string +} + +interface PendingInvocation extends ProviderInvocation { + readonly responses: Queue.Queue +} + +interface State { + readonly counter: number + readonly pending: ReadonlyMap +} + +interface Driver { + readonly requests: Stream.Stream + readonly push: ( + id: string, + items: readonly SimulationProtocol.Backend.Item[], + ) => Effect.Effect + readonly finish: ( + id: string, + reason: SimulationProtocol.Backend.FinishReason, + ) => Effect.Effect + readonly disconnect: (id: string) => Effect.Effect + readonly pending: () => Effect.Effect +} + +class InvocationNotFoundError extends Schema.TaggedErrorClass()( + "SimulatedProvider.InvocationNotFoundError", + { id: Schema.String, message: Schema.String }, +) {} + +class ControllerDisconnectedError extends Schema.TaggedErrorClass()( + "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({ counter: 0, pending: new Map() }) + const opened = yield* PubSub.unbounded() + 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(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() + const activeController = yield* Ref.make | 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, + activeController: Ref.Ref | 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 | 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" diff --git a/packages/simulation/src/control-server.ts b/packages/simulation/src/control-server.ts new file mode 100644 index 0000000000..fd4211669e --- /dev/null +++ b/packages/simulation/src/control-server.ts @@ -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 + closed?: true +} + +export type Socket = Bun.ServerWebSocket + +export function start(options: { + readonly endpoint: string + readonly label: string + readonly data: () => SocketData + readonly decode: (input: string) => Effect.Effect + readonly handle: (socket: Socket, request: RequestType) => Effect.Effect + readonly close?: (socket: Socket) => Effect.Effect +}) { + return Effect.gen(function* () { + const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256) + const closures = yield* Queue.unbounded() + 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({ + 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" diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index dbd54eb23b..19baf6a732 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,11 +1,10 @@ -import { mkdir } from "node:fs/promises" import { tmpdir } from "node:os" import { extname, join, resolve } from "node:path" import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" +import { Config, Effect, FileSystem } from "effect" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" -import { SimulationPng } from "./png" export type Action = SimulationProtocol.Frontend.Action 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 // redirects that sink to the timeline, so read the live render buffer // instead; it is also the source used by screenshots. - screen: () => - decoder.decode( - (Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(), - ), + screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes()), } } @@ -114,31 +110,29 @@ export function matches(harness: Pick, text: string) { return harness.screen().includes(text) } -export async function screenshot(harness: Harness, name?: string) { - await harness.renderOnce() - const image = SimulationPng.screenshot(harness.renderer) +export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) { const filename = name ?? `screenshot-${crypto.randomUUID()}` - if ( - !filename || - filename.includes("/") || - filename.includes("\\") || - extname(filename) - ) - throw new Error("screenshot name must not contain a path or extension") + if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename)) + return yield* Effect.fail(new Error("screenshot name must not contain a path or extension")) + yield* Effect.tryPromise(() => harness.renderOnce()) + const { SimulationPng } = yield* Effect.promise(() => import("./png")) + const image = SimulationPng.screenshot(harness.renderer) const directory = resolve( - process.env.OPENCODE_DRIVE_MEDIA_DIR ?? - join(tmpdir(), "opencode-drive", "output"), + yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe( + 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`) - await Bun.write(path, image.data) + yield* fs.writeFile(path, image.data) 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) { case "ui.type": - await harness.mockInput.typeText(action.text) + yield* Effect.tryPromise(() => harness.mockInput.typeText(action.text)) break case "ui.press": harness.mockInput.pressKey(action.key, action.modifiers) @@ -155,18 +149,23 @@ export async function execute(harness: Harness, action: Action) { ?.focus() break case "ui.click": - await harness.mockMouse.click(action.x, action.y) + yield* Effect.tryPromise(() => harness.mockMouse.click(action.x, action.y)) break case "ui.resize": - if (!Number.isSafeInteger(action.cols) || action.cols <= 0 || !Number.isSafeInteger(action.rows) || action.rows <= 0) { - throw new Error("resize cols and rows must be positive integers") + if ( + !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) SimulationRenderer.recordResize(harness.renderer, action.cols, action.rows) break } - await harness.renderOnce() + yield* Effect.tryPromise(() => harness.renderOnce()) return state(harness) -} +}) export * as SimulationActions from "./actions" diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts index 4f7b11c1a0..cecbe4cb77 100644 --- a/packages/simulation/src/frontend/png.ts +++ b/packages/simulation/src/frontend/png.ts @@ -1,22 +1,20 @@ -import { fileURLToPath } from "node:url" -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 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 CellHeight = 20 const FontSize = 16 const FontFamily = "OpenCode Mono" -for (const file of [ - "adwaita-mono-latin-400-normal.woff2", - "adwaita-mono-latin-700-normal.woff2", - "adwaita-mono-latin-400-italic.woff2", - "adwaita-mono-latin-700-italic.woff2", -]) { - GlobalFonts.registerFromPath( - fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)), - FontFamily, - ) +for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) { + const font = Buffer.from(await Bun.file(file).arrayBuffer()) + if (!GlobalFonts.register(font, FontFamily)) + throw new Error(`Failed to register screenshot font: ${file}`) } export function screenshot(renderer: CliRenderer) { @@ -54,13 +52,17 @@ export function screenshotFrame(frame: CapturedFrame) { } if (!hidden && char.codePointAt(0) !== 0x0a00) { context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) - context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` - context.fillText(char, column * CellWidth, row * CellHeight + 1) + const x = column * CellWidth + 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) { - context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) + context.fillRect(x, y + 17, cells * CellWidth, 1) } if (attributes & TextAttributes.STRIKETHROUGH) { - context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) + context.fillRect(x, y + 10, cells * CellWidth, 1) } } 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) { const [red, green, blue, alpha] = value.toInts() return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 8224a082c3..f10f0ec076 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -1,5 +1,6 @@ import type { CliRenderer, CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" +import { Effect } from "effect" import { Timeline } from "../recording" const setups = new WeakMap() @@ -16,37 +17,47 @@ export interface Viewport { readonly rows: number } -export async function create(options: CliRendererConfig, path?: string, viewport?: Viewport): Promise { +export const create = Effect.fn("SimulationRenderer.create")(function* ( + options: CliRendererConfig, + path?: string, + viewport?: Viewport, +) { const cols = viewport?.cols ?? 100 const rows = viewport?.rows ?? 40 - if (!path) { - const setup = await createTestRenderer({ - ...options, - width: cols, - height: rows, - }) - setups.set(setup.renderer, setup) - return setup.renderer - } - const recording = await Timeline.create(path, cols, rows) - const setup = await createTestRenderer({ - ...options, - width: cols, - height: rows, - stdout: recording as unknown as NodeJS.WriteStream, - bufferedOutput: "stdout", - onDestroy: () => { - void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) - options.onDestroy?.() - }, - }).catch(async (error) => { - await recording.finish().catch(() => undefined) - throw error - }) + const recording = path + ? yield* Effect.acquireRelease( + Effect.tryPromise(() => Timeline.create(path, cols, rows)), + (recording) => + Effect.tryPromise(() => recording.finish()).pipe( + Effect.catch((error) => + Effect.sync(() => process.stderr.write(`Failed to finish UI recording: ${error}\n`)), + ), + ), + ) + : undefined + const setup = yield* Effect.acquireRelease( + Effect.tryPromise(() => + createTestRenderer({ + ...options, + width: cols, + height: rows, + ...(recording + ? { + stdout: recording as unknown as NodeJS.WriteStream, + bufferedOutput: "stdout" as const, + } + : {}), + }), + ), + (setup) => + Effect.sync(() => { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + }), + ) setups.set(setup.renderer, setup) - recordings.set(setup.renderer, recording) + if (recording) recordings.set(setup.renderer, recording) return setup.renderer -} +}) export function recordResize(renderer: CliRenderer, cols: number, rows: number) { recordings.get(renderer)?.resize(cols, rows) @@ -58,8 +69,8 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined { export function finish(renderer: CliRenderer) { const recording = recordings.get(renderer) - if (!recording) throw new Error("UI recording is not available") - return recording.finish() + if (!recording) return Effect.fail(new Error("UI recording is not available")) + return Effect.tryPromise(() => recording.finish()) } export * as SimulationRenderer from "./renderer" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index ccb1bd626e..991234b5b3 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,31 +1,19 @@ +import { Effect } from "effect" +import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" +import { SimulationRenderer } from "./renderer" -export interface Server { - 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, -) { +function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { switch (request.method) { case "ui.screenshot": return SimulationActions.screenshot(harness, request.params?.name) - case "ui.state": { - return SimulationActions.state(harness) - } + case "ui.state": + return Effect.sync(() => SimulationActions.state(harness)) case "ui.matches": - return SimulationActions.matches(harness, request.params.text) + return Effect.sync(() => SimulationActions.matches(harness, request.params.text)) case "ui.recording.finish": - if (!finishRecording) throw new Error("UI recording is not available") - return finishRecording() + return SimulationRenderer.finish(harness.renderer) case "ui.type": return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) case "ui.enter": @@ -48,39 +36,22 @@ async function handle( y: request.params.y, }) 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): Server { - const url = new URL(endpoint) - const server = Bun.serve<{ readonly drive: true }>({ - hostname: url.hostname, - port: Number(url.port), - fetch(request, server) { - if (server.upgrade(request, { data: { drive: true } })) return undefined - 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))) - } - }, - }, +export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) { + return yield* SimulationControlServer.start({ + endpoint, + label: "opencode drive ui websocket", + data: () => ({ drive: true as const }), + decode: SimulationProtocol.Frontend.decodeRequestEffect, + handle: (_socket, request) => handle(harness, request), }) - return { - url: endpoint, - stop: () => { - server.stop(true) - }, - } -} +}) export * as SimulationServer from "./server" diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 591ee4e0ba..0843754ff1 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -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 { SimulationActions } from "./actions" import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" -/** - * Drive-mode renderer entry point. - * - * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal - * 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 { - const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" - const manifest = DriveManifest.resolve() +/** Drive-mode renderer and control-server acquisition. */ +export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) { + const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless" + const manifest = yield* DriveManifest.resolve() const renderer = headless - ? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport) - : await createCliRenderer(options) + ? yield* SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport) + : 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) - const server = SimulationServer.start( - SimulationActions.createHarness(renderer), - 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()) + const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui) + yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)) return renderer -} +}) export * as Drive from "./simulation" diff --git a/packages/simulation/src/manifest.ts b/packages/simulation/src/manifest.ts index 4896c2d740..fe276ee688 100644 --- a/packages/simulation/src/manifest.ts +++ b/packages/simulation/src/manifest.ts @@ -1,20 +1,54 @@ -import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { isAbsolute, join } from "node:path" +import { Config, Effect, FileSystem, Schema } from "effect" +import { PositiveInt } from "@opencode-ai/core/schema" -export interface Manifest { - readonly endpoints: { - readonly ui: string - readonly backend: string - } - readonly viewport?: { - readonly cols: number - readonly rows: number - } - readonly recording?: { - readonly timeline: string - } -} +const InstanceName = Schema.String.check( + Schema.makeFilter((value) => + /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(value) ? undefined : "a valid Drive instance name", + ), +) + +const Endpoint = Schema.String.check( + Schema.makeFilter((value) => { + if (!URL.canParse(value)) return "a loopback WebSocket endpoint with an explicit port" + const endpoint = new URL(value) + 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 {} + +export class ResolveError extends Schema.TaggedErrorClass()("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 = { endpoints: { @@ -23,48 +57,54 @@ export const defaults: Manifest = { }, } -export function resolve() { - const name = process.env.OPENCODE_DRIVE - if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name") +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest)) + +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 (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`) - const directory = - process.env.DRIVE_REGISTRY_DIR ?? - join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances") + const state = yield* Config.string("XDG_STATE_HOME").pipe( + Config.withDefault(join(homedir(), ".local", "state")), + 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`) - if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`) - - const manifest: unknown = JSON.parse(readFileSync(file, "utf8")) - if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) - validateEndpoint(manifest.endpoints.ui, "ui") - validateEndpoint(manifest.endpoints.backend, "backend") - if (manifest.viewport) validateViewport(manifest.viewport) - if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { - throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) - } - return manifest -} - -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 "ui" in value.endpoints && "backend" in value.endpoints -} - -function validateEndpoint(value: string, name: string) { - const endpoint = new URL(value) - const port = Number(endpoint.port) - if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) { - 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)}`) - } -} + const fs = yield* FileSystem.FileSystem + const contents = yield* fs.readFileString(file).pipe( + Effect.mapError( + (cause) => + new ResolveError({ + reason: cause.reason._tag === "NotFound" ? "not-found" : "read", + path: file, + message: + cause.reason._tag === "NotFound" + ? `Drive manifest not found: ${file}` + : `Failed to read Drive manifest: ${file}: ${cause.message}`, + cause, + }), + ), + ) + return yield* decode(contents).pipe( + Effect.mapError( + (cause) => + new ResolveError({ + reason: "decode", + path: file, + message: `Invalid Drive manifest: ${file}: ${cause.message}`, + cause, + }), + ), + ) +}) export * as DriveManifest from "./manifest" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 556e644a02..7ea6263b08 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -145,6 +145,7 @@ export namespace Frontend { ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) + export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request)) } export namespace Backend { @@ -188,9 +189,10 @@ export namespace Backend { ]) export type Request = Schema.Schema.Type 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 interface OpenedExchange extends Schema.Schema.Type {} + export const ProviderInvocation = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) + export interface ProviderInvocation extends Schema.Schema.Type {} export const NetworkLogEntry = Schema.Struct({ time: Schema.Number, diff --git a/packages/simulation/test/fixture/websocket.ts b/packages/simulation/test/fixture/websocket.ts new file mode 100644 index 0000000000..092b81b4fb --- /dev/null +++ b/packages/simulation/test/fixture/websocket.ts @@ -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((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()), + ) +} diff --git a/packages/simulation/test/frontend-server.test.ts b/packages/simulation/test/frontend-server.test.ts new file mode 100644 index 0000000000..80cdde4962 --- /dev/null +++ b/packages/simulation/test/frontend-server.test.ts @@ -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() + 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) +}) diff --git a/packages/simulation/test/manifest.test.ts b/packages/simulation/test/manifest.test.ts new file mode 100644 index 0000000000..7755421d6f --- /dev/null +++ b/packages/simulation/test/manifest.test.ts @@ -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") +}) diff --git a/packages/simulation/test/network.test.ts b/packages/simulation/test/network.test.ts new file mode 100644 index 0000000000..9049b5700c --- /dev/null +++ b/packages/simulation/test/network.test.ts @@ -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())), + ), + ) +}) diff --git a/packages/simulation/test/openai.test.ts b/packages/simulation/test/openai.test.ts new file mode 100644 index 0000000000..3632bdb70b --- /dev/null +++ b/packages/simulation/test/openai.test.ts @@ -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") +}) diff --git a/packages/simulation/test/png.test.ts b/packages/simulation/test/png.test.ts new file mode 100644 index 0000000000..4e420ebf16 --- /dev/null +++ b/packages/simulation/test/png.test.ts @@ -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(), + ) +}) diff --git a/packages/simulation/test/recording.test.ts b/packages/simulation/test/recording.test.ts index cbff7ac74c..00ef08b69d 100644 --- a/packages/simulation/test/recording.test.ts +++ b/packages/simulation/test/recording.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { TextRenderable } from "@opentui/core" import { createHarness, matches } from "../src/frontend/actions" import { SimulationRenderer } from "../src/frontend/renderer" +import { Effect } from "effect" import { Timeline, type Event } from "../src/recording" 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 () => { const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) const path = join(directory, "timeline.jsonl") - const renderer = await SimulationRenderer.create({}, path) try { - await SimulationRenderer.setupFor(renderer)?.renderOnce() - renderer.destroy() - expect(await SimulationRenderer.finish(renderer)).toBe(path) + await Effect.runPromise( + Effect.scoped( + 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()) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Event) - expect(events.some((event) => event.type === "output")).toBe(true) + const events = (yield* Effect.promise(() => Bun.file(path).text())) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events.some((event) => event.type === "output")).toBe(true) + }), + ), + ) } finally { - if (!renderer.isDestroyed) renderer.destroy() - await SimulationRenderer.finish(renderer) 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 () => { const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-")) const path = join(directory, "timeline.jsonl") - const renderer = await SimulationRenderer.create({}, path) try { - renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" })) - await SimulationRenderer.setupFor(renderer)?.renderOnce() + await Effect.runPromise( + 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 { - renderer.destroy() - await SimulationRenderer.finish(renderer) await rm(directory, { recursive: true, force: true }) } }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts new file mode 100644 index 0000000000..23da2d9d0d --- /dev/null +++ b/packages/simulation/test/simulated-provider.test.ts @@ -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() + 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( + body: ( + provider: SimulatedProvider.Interface, + socket: WebSocket, + messages: Queue.Queue, + ) => Effect.Effect, +) { + 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() + socket.addEventListener("message", (event) => { + Queue.offerUnsafe(messages, JSON.parse(String(event.data))) + }) + return messages + }) +} + +function attach(socket: WebSocket, messages: Queue.Queue) { + 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) { + 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 { + if (!isRecord(value)) throw new Error("Expected an object") + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0611941a9e..947fb00577 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 result = yield* Effect.scoped( Effect.gen(function* () { - const renderer = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const options = { - externalOutputMode: "passthrough", - targetFps: 60, - gatherStats: false, - exitOnCtrlC: false, - useKittyKeyboard: {}, - autoFocus: false, - openConsoleOnError: false, - useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse, - consoleOptions: { - keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], - }, - } satisfies CliRendererConfig - - if (handoff) { - handoff.renderer.useMouse = options.useMouse - return handoff.renderer - } - - if (process.env.OPENCODE_DRIVE) { - const { Drive } = await import("@opencode-ai/simulation/frontend") - return Drive.create(options) - } - - return createCliRenderer(options) - }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }), - (renderer) => - Effect.sync(() => { - destroyRenderer(renderer) + const options = { + externalOutputMode: "passthrough", + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + autoFocus: false, + openConsoleOnError: false, + useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + }, + } satisfies CliRendererConfig + const renderer = yield* Effect.gen(function* () { + if (handoff) { + handoff.renderer.useMouse = options.useMouse + return yield* Effect.acquireRelease(Effect.succeed(handoff.renderer), (renderer) => + Effect.sync(() => destroyRenderer(renderer)), + ) + } + if (process.env.OPENCODE_DRIVE) { + const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend")) + return yield* Drive.create(options) + } + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => createCliRenderer(options), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), - ) + (renderer) => Effect.sync(() => destroyRenderer(renderer)), + ) + }) win32DisableProcessedInput() const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index bad8245fcd..a6cd421ea1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,6 +1,6 @@ import { expect, mock, test } from "bun:test" 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 { Global } from "@opencode-ai/core/global" 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 }, args: {}, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), ) await ready process.emit("SIGHUP") @@ -105,7 +105,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), ) await initialTitleSet