From 4790a2772c0300ff610834c5628c62b4668c6bf1 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 14:19:25 -0400 Subject: [PATCH] feat(simulation): add driver controlled backend LLM (#35186) --- bun.lock | 19 + packages/core/src/effect/layer-node.ts | 2 +- packages/core/src/fs-util.ts | 4 +- packages/llm/src/protocols/openai-chat.ts | 4 +- .../specs/simulation/simulated-network-llm.md | 150 +++++++ .../specs/simulation/simulation-phases.md | 22 + packages/server/package.json | 1 + packages/server/src/routes.ts | 2 +- packages/server/src/simulation/index.ts | 14 - packages/simulation/package.json | 28 ++ packages/simulation/src/backend/control.ts | 145 +++++++ packages/simulation/src/backend/filesystem.ts | 390 ++++++++++++++++++ packages/simulation/src/backend/fs-util.ts | 89 ++++ packages/simulation/src/backend/index.ts | 41 ++ .../simulation/src/backend/llm-exchange.ts | 105 +++++ packages/simulation/src/backend/network.ts | 94 +++++ packages/simulation/src/backend/openai.ts | 89 ++++ .../src/frontend}/actions.ts | 0 .../src/frontend}/renderer.ts | 0 .../src/frontend}/server.ts | 0 .../src/frontend}/simulation.ts | 0 .../src/frontend}/trace.ts | 0 packages/simulation/tsconfig.json | 8 + packages/tui/package.json | 1 + packages/tui/src/app.tsx | 4 +- 25 files changed, 1191 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/specs/simulation/simulated-network-llm.md delete mode 100644 packages/server/src/simulation/index.ts create mode 100644 packages/simulation/package.json create mode 100644 packages/simulation/src/backend/control.ts create mode 100644 packages/simulation/src/backend/filesystem.ts create mode 100644 packages/simulation/src/backend/fs-util.ts create mode 100644 packages/simulation/src/backend/index.ts create mode 100644 packages/simulation/src/backend/llm-exchange.ts create mode 100644 packages/simulation/src/backend/network.ts create mode 100644 packages/simulation/src/backend/openai.ts rename packages/{tui/src/simulation => simulation/src/frontend}/actions.ts (100%) rename packages/{tui/src/simulation => simulation/src/frontend}/renderer.ts (100%) rename packages/{tui/src/simulation => simulation/src/frontend}/server.ts (100%) rename packages/{tui/src/simulation => simulation/src/frontend}/simulation.ts (100%) rename packages/{tui/src/simulation => simulation/src/frontend}/trace.ts (100%) create mode 100644 packages/simulation/tsconfig.json diff --git a/bun.lock b/bun.lock index 9c5bbb1591..6522800a13 100644 --- a/bun.lock +++ b/bun.lock @@ -796,6 +796,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -849,6 +850,21 @@ "vite": "catalog:", }, }, + "packages/simulation": { + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.17.13", @@ -963,6 +979,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", @@ -2019,6 +2036,8 @@ "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 9dbc3d5160..dc7aa972f0 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -227,7 +227,7 @@ export function hoist +export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] interface ParserState { diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md new file mode 100644 index 0000000000..c7631cf4cb --- /dev/null +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -0,0 +1,150 @@ +# Simulated Network And Driver-Scripted LLM + +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. + +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. + +## Why the network seam + +`LLMClient.stream` sits on a stack that ends in one platform node: + +``` +LLMClient.stream(request) + route.body.from LLMRequest -> OpenAI JSON body (real) + transport.prepare body + endpoint + auth -> HttpRequest (real) + RequestExecutor.execute status/error taxonomy (real) + HttpClient.HttpClient <- replaced by the simulated network + Framing.sse bytes -> frames (real) + protocol.stream.event frame -> OpenAIChatEvent, validated (real) + protocol.stream.step state machine -> LLMEvents (real) +``` + +Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already used by `simulationReplacements` mechanics) keeps the entire pipeline under test and gives wire-fidelity observation of what would have been sent to the provider. Failure injection (429s, malformed SSE, truncated streams) exercises real error paths that a typed `LLMClient` fake cannot reach. + +## Components + +### 1. Simulated network (`packages/simulation/src/backend/network.ts`) + +Replaces `httpClient` in `simulationReplacements`. An in-memory route table: + +- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect`. +- 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. + +### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`) + +Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `protocols/openai-chat.ts` (`https://api.openai.com/v1/chat/completions`). + +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]`. + +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. + +### 3. LLM exchange service (`packages/simulation/src/backend/llm-exchange.ts`) + +Process-global simulation service owning pending exchanges: + +``` +Exchange = { id, body, queue: Queue, deferred lifecycle } +``` + +- `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). + +### 4. Backend control WebSocket (simulation-gated) + +Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, 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): + +``` +{ "jsonrpc": "2.0", "method": "llm.request", + "params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } +``` + +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 +network.log simulated network request log +``` + +`Item` is the response vocabulary the driver speaks: + +``` +{ type: "textDelta", text } +{ type: "reasoningDelta", text } +{ type: "toolCall", id, name, input } +{ type: "raw", chunk } // escape hatch: raw OpenAIChatEvent JSON +``` + +The backend compiles items to OpenAI chunks (`delta.content`, `delta.tool_calls[].function.arguments`, `finish_reason`); `raw` passes through unmodified. Streaming granularity is the driver's choice: many small `llm.chunk` calls stream word by word; one call with many items plus `llm.finish` responds at once. + +Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not yet implemented. + +### 5. Driver topology + +A driver manages two loopback WebSocket connections: + +- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. +- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. + +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. + +### 7. Catalog and auth seeding + +The driver-facing model must be selectable in the TUI. Simulation seeds config (via the snapshot filesystem) defining a provider on the openai-chat route with `baseURL` left at the OpenAI default and a dummy `apiKey` (satisfies `Catalog.available()`). No catalog code changes. + +## End-to-end flow + +``` +driver TUI sim server (40900+) backend + control WS (40950+) + | | | + |-- ui.action (submit) ----->| | + | |-- (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 + | | | + | (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) +``` + +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. + +## 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. +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. + +## 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. +- 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 ef7aa41e3f..ebc4bd3743 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -51,6 +51,28 @@ Out of scope: Goal: make the app safe and controlled by swapping the lowest layers, not app logic. +Implementation checklist: + +- [x] Add `packages/simulation/src/backend` as the home for backend simulation layer replacements, exported from `backend/index.ts` as `simulationReplacements`; `@opencode-ai/simulation` is private/non-published and depends on logic/framework packages (`core`, `llm`, `effect`, OpenTUI), while `server` and `tui` consume it. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. +- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. +- [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. +- [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". +- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + 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_SIMULATION_ROOT/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 simulation control WebSocket (`control.ts`): JSON-RPC on `127.0.0.1:40950+`, started when the simulation module loads. 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 two sockets: TUI control (40900+) for UI, backend control (40950+) for LLM/network. +- [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). + Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. diff --git a/packages/server/package.json b/packages/server/package.json index 86e5683dcf..6c5b3cb1b5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,6 +14,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index db69ba6c7f..2157dd0b04 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -72,7 +72,7 @@ function makeRoutes( const serviceLayer = simulationEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements } = yield* Effect.promise(() => import("./simulation")) + const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements]) }), ) diff --git a/packages/server/src/simulation/index.ts b/packages/server/src/simulation/index.ts deleted file mode 100644 index 3f499a5bf8..0000000000 --- a/packages/server/src/simulation/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -/** - * Layer replacements applied when the server is built in simulation mode. - * - * Empty for now; simulation-mode implementations will populate this with - * replacement nodes/layers that swap real services for simulated ones (e.g. - * a fake filesystem). The server merges these into the app node build when - * `OPENCODE_SIMULATION` is enabled, via a dynamic import so this module is - * never loaded eagerly. - */ -export const simulationReplacements: LayerNode.Replacements = [] - -export * as Simulation from "./index" diff --git a/packages/simulation/package.json b/packages/simulation/package.json new file mode 100644 index 0000000000..93bab0540e --- /dev/null +++ b/packages/simulation/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./backend": "./src/backend/index.ts", + "./backend/*": "./src/backend/*.ts", + "./frontend": "./src/frontend/simulation.ts", + "./frontend/*": "./src/frontend/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts new file mode 100644 index 0000000000..f3fc67116c --- /dev/null +++ b/packages/simulation/src/backend/control.ts @@ -0,0 +1,145 @@ +import { Effect, Schema } from "effect" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * 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.pending` list open exchanges + * - `network.log` simulated network request log + */ + +const DefaultPort = 40950 +const MaxPortAttempts = 100 + +const ChunkItem = Schema.Union([ + Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Unknown }), + Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Unknown }), +]) + +const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(ChunkItem) }) + +const FinishParams = Schema.Struct({ + id: Schema.String, + reason: Schema.Literals(["stop", "tool-calls", "length", "content-filter"]).pipe( + Schema.withDecodingDefault(Effect.succeed("stop" as const)), + ), +}) + +const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) +const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) + +type JsonRpcRequest = { + readonly jsonrpc: "2.0" + readonly id?: string | number | null + readonly method: string + readonly params?: unknown +} + +type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> + +function parseRequest(input: string | Buffer): JsonRpcRequest { + const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown + if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request") + if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version") + if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method") + return value as JsonRpcRequest +} + +async function handle(socket: ControlSocket, request: JsonRpcRequest): 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": { + const params = await decodeChunkParams(request.params) + await Effect.runPromise( + SimulationLLMExchange.push( + params.id, + params.items.map((item) => ({ type: "item", item }) as const), + ), + ) + return { ok: true } + } + case "llm.finish": { + const params = await decodeFinishParams(request.params) + await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + return { ok: true } + } + case "llm.pending": + return { exchanges: SimulationLLMExchange.pending() } + case "network.log": + return { entries: SimulationNetwork.log() } + } + throw new Error(`Unknown simulation control method: ${request.method}`) +} + +function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> { + try { + return Bun.serve<{ unsubscribe?: () => void }>({ + hostname: "127.0.0.1", + port, + fetch(request, server) { + if (server.upgrade(request, { data: {} })) return undefined + return new Response("opencode simulation control websocket", { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.unsubscribe?.() + }, + async message(socket, message) { + let request: JsonRpcRequest | undefined + try { + request = parseRequest(message) + const result = await handle(socket, request) + if (request.id !== undefined) socket.send(JSON.stringify({ jsonrpc: "2.0", id: request.id, result })) + } catch (error) { + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: request?.id ?? null, + error: { code: -32000, message: error instanceof Error ? error.message : String(error) }, + }), + ) + } + }, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + const unavailable = message.includes("eaddrinuse") || message.includes("in use") + if (!unavailable || attempts <= 1 || port >= 65535) throw error + return serve(port + 1, attempts - 1) + } +} + +export function start() { + const server = serve() + const url = `ws://${server.hostname}:${server.port}` + process.stderr.write(`opencode simulation backend control websocket: ${url}\n`) + return { + url, + stop: () => { + server.stop(true) + }, + } +} + +export * as SimulationControl from "./control" diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts new file mode 100644 index 0000000000..01a7b00329 --- /dev/null +++ b/packages/simulation/src/backend/filesystem.ts @@ -0,0 +1,390 @@ +import { Effect, FileSystem, Layer, Option, Stream } from "effect" +import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" +import nodeFs from "fs" +import path from "path" + +/** + * In-memory simulated `FileSystem.FileSystem`. + * + * Replaces the `NodeFileSystem` platform node when the server runs in + * simulation mode. Backed by a flat map of absolute paths to entries and + * rooted at a single directory (the simulation anchor): paths that resolve + * outside the root fail with `PermissionDenied` so host filesystem escapes + * are loud. Only the operations the app actually uses are implemented; + * everything else dies with a clear defect. + * + * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten + * for the V2 platform node shape without the `just-bash` dependency. + */ + +export interface Options { + readonly root: string + readonly files?: Record +} + +interface FileEntry { + readonly type: "File" + content: Uint8Array + mode: number + mtime: Date +} + +interface DirectoryEntry { + readonly type: "Directory" + mode: number + mtime: Date +} + +type Entry = FileEntry | DirectoryEntry + +export function make(options: Options): FileSystem.FileSystem { + const root = path.resolve(options.root) + const store = new Map() + const temp = { value: 0 } + const encoder = new TextEncoder() + store.set(root, makeDirectoryEntry()) + + const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) + + const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) + + const fail = ( + tag: SystemErrorTag, + method: string, + file: string, + description?: string, + ): Effect.Effect => + Effect.fail( + systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), + ) + + const locate = (method: string, file: string): Effect.Effect => { + const resolved = path.resolve(root, file) + if (within(resolved)) return Effect.succeed(resolved) + return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") + } + + const requireEntry = (method: string, file: string): Effect.Effect => + locate(method, file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return fail("NotFound", method, file) + return Effect.succeed([resolved, entry] as const) + }), + ) + + const requireParentDirectory = ( + method: string, + resolved: string, + file: string, + ): Effect.Effect => { + const parent = store.get(path.dirname(resolved)) + if (parent?.type === "Directory") return Effect.void + return fail("NotFound", method, file, "parent directory does not exist") + } + + // Creates every missing directory between root and resolved (inclusive). + const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => + Effect.suspend(() => { + const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) + const conflict = segments.reduce>((current, segment) => { + if (typeof current !== "string") return current + const next = path.join(current, segment) + const entry = store.get(next) + if (entry && entry.type !== "Directory") + return fail("AlreadyExists", method, file, "path component is not a directory") + if (!entry) store.set(next, makeDirectoryEntry()) + return next + }, root) + return typeof conflict === "string" ? Effect.void : conflict + }) + + // Seed initial files, creating parents as needed. Entries outside the root are ignored. + for (const [file, content] of Object.entries(options.files ?? {})) { + const resolved = path.resolve(root, file) + if (!within(resolved)) continue + Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) + store.set(resolved, { + type: "File", + content: typeof content === "string" ? encoder.encode(content) : content.slice(), + mode: 0o644, + mtime: new Date(), + }) + } + + // Probe operations report NotFound outside the root instead of + // PermissionDenied: walk-up loops (project discovery, findUp, globUp) + // legitimately probe ancestor directories of the anchor and must observe + // "nothing there". Content access and mutation outside the root stay loud. + const probe = (method: string, file: string): Effect.Effect => + Effect.suspend(() => { + const resolved = path.resolve(root, file) + const entry = within(resolved) ? store.get(resolved) : undefined + if (!entry) return fail("NotFound", method, file) + return Effect.succeed(entry) + }) + + const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) + + const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) + + const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => + requireEntry("chmod", file).pipe( + Effect.map(([, entry]) => { + entry.mode = mode + }), + ) + + const realPath: FileSystem.FileSystem["realPath"] = (file) => + requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) + + const readFile: FileSystem.FileSystem["readFile"] = (file) => + requireEntry("readFile", file).pipe( + Effect.flatMap(([, entry]) => { + if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") + return Effect.succeed(entry.content.slice()) + }), + ) + + const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => + locate("writeFile", file).pipe( + Effect.flatMap((resolved) => { + const existing = store.get(resolved) + if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") + return requireParentDirectory("writeFile", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { + type: "File", + content: data.slice(), + mode: writeOptions?.mode ?? existing?.mode ?? 0o644, + mtime: new Date(), + }) + }), + ) + }), + ) + + const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => + locate("makeDirectory", file).pipe( + Effect.flatMap((resolved) => { + if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) + if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) + return requireParentDirectory("makeDirectory", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) + }), + ) + }), + ) + + const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => + requireEntry("readDirectory", file).pipe( + Effect.flatMap(([resolved, entry]) => { + if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") + const children = childrenOf(resolved) + const names = readOptions?.recursive + ? children.map((key) => path.relative(resolved, key)) + : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) + return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) + }), + ) + + const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => + locate("remove", file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) + const children = childrenOf(resolved) + if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) + return fail("Unknown", "remove", file, "directory is not empty") + for (const key of children) store.delete(key) + store.delete(resolved) + // The root itself must always exist. + if (resolved === root) store.set(root, makeDirectoryEntry()) + return Effect.void + }), + ) + + const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => + Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "rename", oldPath) + return requireParentDirectory("rename", to, newPath).pipe( + Effect.map(() => { + const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) + for (const [key] of moved) store.delete(key) + for (const key of [to, ...childrenOf(to)]) store.delete(key) + for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) + }), + ) + }), + ) + + const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => + Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "copy", fromPath) + return requireParentDirectory("copy", to, toPath).pipe( + Effect.map(() => { + for (const key of [from, ...childrenOf(from)]) { + const source = store.get(key)! + const target = key === from ? to : to + key.slice(from.length) + store.set( + target, + source.type === "File" + ? { ...source, content: source.content.slice(), mtime: new Date() } + : { ...source, mtime: new Date() }, + ) + } + }), + ) + }), + ) + + const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => + readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) + + const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => + Effect.suspend(() => { + const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") + const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) + return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) + }) + + const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => + Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => + remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ) + + // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. + const open: FileSystem.FileSystem["open"] = (file) => + requireEntry("open", file).pipe( + Effect.map(([resolved]) => { + const position = { value: 0 } + const contentOf = () => { + const current = store.get(resolved) + return current?.type === "File" ? current.content : new Uint8Array() + } + return { + [FileSystem.FileTypeId]: FileSystem.FileTypeId, + fd: FileSystem.FileDescriptor(0), + stat: Effect.suspend(() => stat(resolved)), + seek: (offset, from) => + Effect.sync(() => { + position.value = from === "start" ? Number(offset) : position.value + Number(offset) + }), + sync: Effect.void, + read: (buffer) => + Effect.sync(() => { + const chunk = contentOf().subarray(position.value, position.value + buffer.length) + buffer.set(chunk) + position.value += chunk.length + return FileSystem.Size(chunk.length) + }), + readAlloc: (size) => + Effect.sync(() => { + const chunk = contentOf().slice(position.value, position.value + Number(size)) + position.value += chunk.length + return chunk.length === 0 ? Option.none() : Option.some(chunk) + }), + truncate: () => unimplemented("File.truncate"), + write: () => unimplemented("File.write"), + writeAll: () => unimplemented("File.writeAll"), + } satisfies FileSystem.File + }), + ) + + return FileSystem.make({ + access, + chmod, + chown: () => unimplemented("chown"), + copy, + copyFile, + link: () => unimplemented("link"), + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile: () => unimplemented("makeTempFile"), + makeTempFileScoped: () => unimplemented("makeTempFileScoped"), + open, + readDirectory, + readFile, + readLink: () => unimplemented("readLink"), + realPath, + remove, + rename, + stat, + symlink: () => unimplemented("symlink"), + truncate: () => unimplemented("truncate"), + utimes: () => unimplemented("utimes"), + watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), + writeFile, + }) +} + +/** + * Lazily constructed layer so the root defaults to `process.cwd()` at + * layer-build time (the simulation anchor directory), not at import time. + * + * When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its + * `project/` contents are read from the host once at build time and seeded + * into the in-memory tree, joined onto the anchor root. + */ +export const layer = (options?: Partial) => + Layer.sync(FileSystem.FileSystem)(() => + make({ + root: options?.root ?? process.cwd(), + files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, + }), + ) + +function loadSnapshotFiles(stateDirectory: string | undefined) { + if (!stateDirectory) return {} + const project = path.join(stateDirectory, "project") + if (!nodeFs.existsSync(project)) return {} + const files: Record = {} + const walk = (dir: string) => { + for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) walk(file) + if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file)) + } + } + walk(project) + return files +} + +function makeDirectoryEntry(): Entry { + return { type: "Directory", mode: 0o755, mtime: new Date() } +} + +function withSep(dir: string) { + return dir.endsWith(path.sep) ? dir : dir + path.sep +} + +function toInfo(entry: Entry): FileSystem.File.Info { + return { + type: entry.type, + mtime: Option.some(entry.mtime), + atime: Option.some(entry.mtime), + birthtime: Option.some(entry.mtime), + dev: 0, + ino: Option.none(), + mode: entry.mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), + blksize: Option.none(), + blocks: Option.none(), + } +} + +function unimplemented(method: string) { + return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) +} + +export * as SimulationFileSystem from "./filesystem" diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts new file mode 100644 index 0000000000..e24d0c9c4b --- /dev/null +++ b/packages/simulation/src/backend/fs-util.ts @@ -0,0 +1,89 @@ +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Glob } from "@opencode-ai/core/util/glob" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import path from "path" + +/** + * Simulation replacement for `FSUtil`. + * + * The real `FSUtil` layer builds most helpers on the injected + * `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp` + * reach for node `fs/promises` and the `glob` package directly, and `resolve` + * canonicalizes through the host filesystem. This wraps the real layer and + * reroutes those through the injected `FileSystem`/lexical path resolution so + * every read observes the in-memory tree. + */ + +const layer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const base = yield* FSUtil.Service + const fs = yield* FileSystem.FileSystem + + const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { + return input + }) + + const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { + const names = yield* fs.readDirectory(dirPath) + return yield* Effect.forEach(names, (name) => + fs.stat(path.join(dirPath, name)).pipe( + Effect.map( + (info): FSUtil.DirEntry => ({ + name, + type: + info.type === "Directory" + ? "directory" + : info.type === "File" + ? "file" + : info.type === "SymbolicLink" + ? "symlink" + : "other", + }), + ), + Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), + ), + ) + }) + + const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { + const cwd = path.resolve(options?.cwd ?? process.cwd()) + const entries = yield* fs + .readDirectory(cwd, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as string[])) + const matches = yield* Effect.forEach(entries, (entry) => + fs.stat(path.join(cwd, entry)).pipe( + Effect.map((info) => ({ entry, type: info.type })), + Effect.orElseSucceed(() => undefined), + ), + ) + return matches + .filter((item) => item !== undefined) + .filter((item) => options?.include === "all" || item.type === "File") + .filter((item) => Glob.match(pattern, item.entry)) + .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) + .sort((a, b) => a.localeCompare(b)) + }) + + const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = path.resolve(start) + while (true) { + result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) + if (stop === current) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return FSUtil.Service.of({ ...base, readDirectoryEntries, resolve, glob, globUp }) + }), +).pipe(Layer.provide(FSUtil.layer)) + +export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) + +export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts new file mode 100644 index 0000000000..dd735d40dd --- /dev/null +++ b/packages/simulation/src/backend/index.ts @@ -0,0 +1,41 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { SimulationControl } from "./control" +import { SimulationFileSystem } from "./filesystem" +import { SimulationFSUtil } from "./fs-util" +import { SimulationNetwork } from "./network" +import { SimulationOpenAI } from "./openai" + +/** + * Layer replacements applied when the server is built in simulation mode. + * + * The server merges these into the app node build when `OPENCODE_SIMULATION` + * is enabled, via a dynamic import so this module is never loaded eagerly. + * + * - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real, + * empty anchor directory the runner created and chdir'd into). Everything + * under the root lives in memory; paths outside it fail loudly. + * - Network: all outbound HTTP resolves against the simulated route table; + * unknown destinations are denied. The driver-answered OpenAI endpoint is + * registered here as the first route. + * + * Loading this module also starts the backend simulation control WebSocket, + * which drivers connect to directly for LLM exchange control and network + * inspection (standalone topology; also the headless-simulation interface). + */ + +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", {})) + +SimulationControl.start() + +export const simulationReplacements: LayerNode.Replacements = [ + [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], + [FSUtil.node, SimulationFSUtil.node], + [httpClient, SimulationNetwork.layer], +] + +export * as Simulation from "./index" diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts new file mode 100644 index 0000000000..759f52866c --- /dev/null +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -0,0 +1,105 @@ +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 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) + }) + +/** + * 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 new file mode 100644 index 0000000000..7b002fcb4e --- /dev/null +++ b/packages/simulation/src/backend/network.ts @@ -0,0 +1,94 @@ +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" +import type { HttpClientRequest } from "effect/unstable/http" + +/** + * Simulated network. + * + * Replaces the `HttpClient.HttpClient` platform node in simulation mode. All + * outbound HTTP resolves against an in-memory route table; unknown + * destinations fail loudly with a transport error so no simulation run can + * 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. + */ + +export interface Route { + /** Return a response effect to claim the request, undefined to pass. */ + readonly match: ( + request: HttpClientRequest.HttpClientRequest, + url: URL, + ) => 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[], +} + +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 { + return { + match: (request, requestUrl) => { + if (request.method !== method) return undefined + if (requestUrl.origin + requestUrl.pathname !== url) return undefined + return Effect.sync(() => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }), + ), + ) + }, + } +} + +export function log(): readonly LogEntry[] { + return state.log +} + +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( + new HttpClientError({ + reason: new TransportError({ + request, + description: `Simulation denied unregistered network destination: ${request.method} ${url}`, + }), + }), + ) + }), + ), +) + +export * as SimulationNetwork from "./network" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts new file mode 100644 index 0000000000..c13e7772ef --- /dev/null +++ b/packages/simulation/src/backend/openai.ts @@ -0,0 +1,89 @@ +import { Effect, Schema, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * 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 + * 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. + */ + +const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) + +const encoder = new TextEncoder() + +// 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 { + 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") + return { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + ], + }, + }, + ], + } + return item.chunk +} + +const finishReasonWire: Record = { + stop: "stop", + "tool-calls": "tool_calls", + length: "length", + "content-filter": "content_filter", +} + +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))) + }), + ) + 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 = { + 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 }) + return HttpClientResponse.fromWeb( + request, + new Response(Stream.toReadableStream(sseBody(exchange)), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ) + }) + }, +} + +export * as SimulationOpenAI from "./openai" diff --git a/packages/tui/src/simulation/actions.ts b/packages/simulation/src/frontend/actions.ts similarity index 100% rename from packages/tui/src/simulation/actions.ts rename to packages/simulation/src/frontend/actions.ts diff --git a/packages/tui/src/simulation/renderer.ts b/packages/simulation/src/frontend/renderer.ts similarity index 100% rename from packages/tui/src/simulation/renderer.ts rename to packages/simulation/src/frontend/renderer.ts diff --git a/packages/tui/src/simulation/server.ts b/packages/simulation/src/frontend/server.ts similarity index 100% rename from packages/tui/src/simulation/server.ts rename to packages/simulation/src/frontend/server.ts diff --git a/packages/tui/src/simulation/simulation.ts b/packages/simulation/src/frontend/simulation.ts similarity index 100% rename from packages/tui/src/simulation/simulation.ts rename to packages/simulation/src/frontend/simulation.ts diff --git a/packages/tui/src/simulation/trace.ts b/packages/simulation/src/frontend/trace.ts similarity index 100% rename from packages/tui/src/simulation/trace.ts rename to packages/simulation/src/frontend/trace.ts diff --git a/packages/simulation/tsconfig.json b/packages/simulation/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/simulation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 8bf349c2da..4dade71898 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -51,6 +51,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 876faff9de..d5cf93c2c7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -200,8 +200,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig - if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") { - const { Simulation } = await import("./simulation/simulation") + if (!!process.env.OPENCODE_SIMULATION) { + const { Simulation } = await import("@opencode-ai/simulation/frontend") return Simulation.createSimulation(options) }