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

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

View file

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

View file

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