feat(simulation): add driver controlled backend LLM (#35186)

This commit is contained in:
James Long 2026-07-03 14:19:25 -04:00 committed by GitHub
commit 4790a2772c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1191 additions and 21 deletions

View file

@ -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"],

View file

@ -227,7 +227,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
if (existing && existing.implementation !== node.implementation) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))

View file

@ -50,7 +50,9 @@ export namespace FSUtil {
export const use = serviceUse(Service)
const layer = Layer.effect(
// Exported so simulation can wrap this layer and override the methods that
// bypass the injected FileSystem (readDirectoryEntries, glob, globUp).
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem

View file

@ -153,11 +153,11 @@ const OpenAIChatChoice = Schema.Struct({
finish_reason: optionalNull(Schema.String),
})
const OpenAIChatEvent = Schema.Struct({
export const OpenAIChatEvent = Schema.Struct({
choices: Schema.Array(OpenAIChatChoice),
usage: optionalNull(OpenAIChatUsage),
})
type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface ParserState {

View file

@ -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<HttpClientResponse>`.
- 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<Item | Error | Done>, 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.

View file

@ -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(...)`.

View file

@ -14,6 +14,7 @@
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},

View file

@ -72,7 +72,7 @@ function makeRoutes<AuthError, AuthServices>(
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])
}),
)

View file

@ -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"

View file

@ -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:"
}
}

View file

@ -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<unknown> {
switch (request.method) {
case "llm.attach": {
socket.data.unsubscribe?.()
socket.data.unsubscribe = SimulationLLMExchange.subscribe((exchange) => {
socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: exchange }))
})
return { attached: true }
}
case "llm.chunk": {
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"

View file

@ -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<string, string | Uint8Array>
}
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<string, Entry>()
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<never, PlatformError> =>
Effect.fail(
systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }),
)
const locate = (method: string, file: string): Effect.Effect<string, PlatformError> => {
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<readonly [string, Entry], PlatformError> =>
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<void, PlatformError> => {
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<void, PlatformError> =>
Effect.suspend(() => {
const segments = path.relative(root, resolved).split(path.sep).filter(Boolean)
const conflict = segments.reduce<string | Effect.Effect<never, PlatformError>>((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<Entry, PlatformError> =>
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<Options>) =>
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<string, Uint8Array> = {}
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"

View file

@ -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"

View file

@ -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"

View file

@ -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<Chunk>
}
export interface OpenedExchange {
readonly id: string
readonly url: string
readonly body: unknown
}
const state = {
counter: 0,
exchanges: new Map<string, Exchange>(),
listeners: new Set<(exchange: OpenedExchange) => void>(),
}
export class ExchangeNotFoundError extends Error {
constructor(id: string) {
super(`Simulation LLM exchange not found or already finished: ${id}`)
}
}
/** Opens an exchange and notifies listeners. Called by the simulated provider endpoint. */
export const open = (input: { readonly url: string; readonly body: unknown }) =>
Effect.gen(function* () {
const id = `ex_${++state.counter}`
const queue = yield* Queue.unbounded<Chunk>()
const exchange: Exchange = { id, url: input.url, body: input.body, queue }
state.exchanges.set(id, exchange)
for (const listener of state.listeners) listener({ id, url: input.url, body: input.body })
return exchange
})
/** Closes an exchange without consuming remaining chunks (response interrupted or finished). */
export const close = (id: string) =>
Effect.suspend(() => {
const exchange = state.exchanges.get(id)
state.exchanges.delete(id)
if (!exchange) return Effect.void
return Queue.shutdown(exchange.queue).pipe(Effect.asVoid)
})
/** Appends response chunks to an open exchange. Driver-facing. */
export const push = (id: string, chunks: readonly Chunk[]) =>
Effect.gen(function* () {
const exchange = state.exchanges.get(id)
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
yield* Queue.offerAll(exchange.queue, chunks)
})
/**
* Registers a listener for newly opened exchanges and immediately replays
* currently-pending ones, so a late-attaching driver observes requests that
* arrived before it connected. Returns an unsubscribe function.
*/
export function subscribe(listener: (exchange: OpenedExchange) => void) {
state.listeners.add(listener)
for (const exchange of pending()) listener(exchange)
return () => {
state.listeners.delete(listener)
}
}
/** Snapshot of currently open exchanges, for control-surface inspection. */
export function pending(): OpenedExchange[] {
return [...state.exchanges.values()].map((exchange) => ({
id: exchange.id,
url: exchange.url,
body: exchange.body,
}))
}
export * as SimulationLLMExchange from "./llm-exchange"

View file

@ -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<HttpClientResponse.HttpClientResponse> | 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"

View file

@ -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<SimulationLLMExchange.FinishReason, string> = {
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<Uint8Array> {
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"

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View file

@ -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:",

View file

@ -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)
}