simulation control endpoints

This commit is contained in:
James Long 2026-05-13 23:32:50 -04:00
parent 0ed88cfc21
commit 012fc184bc
6 changed files with 285 additions and 6 deletions

View file

@ -196,6 +196,8 @@ Implementation shape:
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
- Keep the route inaccessible unless simulation mode is explicitly enabled.
- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving.
- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot.
- Register/configure a local mock provider/model through the normal provider path.
- The mock model reads scripts from simulation control state.
- No JSON-in-prompt fallback.
@ -230,10 +232,10 @@ Keep the old useful rule: step `0` runs before tool results, step `N` runs after
Todos:
- [ ] Define simulation mode activation flag/env.
- [ ] Add simulation control state and reset semantics.
- [ ] Add gated simulation endpoints.
- [ ] Decide raw route vs typed HttpApi route. If typed, regenerate JS SDK.
- [x] Define simulation mode activation flag/env.
- [x] Add simulation control state and reset semantics.
- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot.
- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet.
- [ ] Implement mock provider/model on the normal provider path.
- [ ] Port the useful stream chunk behavior from the old branch to the current AI SDK interface.
- [ ] Make missing scripts fail with a typed simulation error.

View file

@ -64,6 +64,7 @@ import { Workspace } from "@/control-plane/workspace"
import { SimulationFileSystem } from "@/testing/simulation/filesystem"
import { SimulationNetwork } from "@/testing/simulation/network"
import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
import { Simulation } from "@/testing/simulation/service"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
@ -92,6 +93,7 @@ import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
import { disposeMiddleware } from "./lifecycle"
import { simulationRoute } from "./simulation"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { compressionLayer } from "./middleware/compression"
import { corsVaryFix } from "./middleware/cors-vary"
@ -305,6 +307,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
Workspace.layer,
Worktree.layer,
Bus.layer,
Simulation.layer,
HttpServer.layerServices,
).pipe(
Layer.provideMerge(AccountRepo.layer),
@ -316,7 +319,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
Layer.provideMerge(simulationBoundary),
)
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute, simulationRoute).pipe(
Layer.provide(simulatedServices),
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(InstanceLayer.layer),

View file

@ -0,0 +1,48 @@
import { Simulation } from "@/testing/simulation/service"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
const ok = { ok: true }
function json<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.map((result) => HttpServerResponse.jsonUnsafe(result)),
Effect.catch((error) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error: String(error) }, { status: 400 }))),
)
}
export const simulationRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const simulation = yield* Simulation.Service
yield* router.add("POST", "/experimental/simulation/reset", () =>
json(simulation.reset().pipe(Effect.as(ok))),
)
yield* router.add("POST", "/experimental/simulation/filesystem/seed", () =>
json(
Effect.gen(function* () {
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.FilesystemSeedInput)
return yield* simulation.seedFilesystem(input)
}),
),
)
yield* router.add("POST", "/experimental/simulation/network/register", () =>
json(
Effect.gen(function* () {
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.NetworkRegisterInput)
return yield* simulation.registerNetwork(input)
}),
),
)
yield* router.add("POST", "/experimental/simulation/llm/enqueue", () =>
json(Effect.succeed({ ok: false, skipped: "mock provider scripts are not implemented yet" })),
)
yield* router.add("GET", "/experimental/simulation/snapshot", () => json(simulation.snapshot()))
}),
)
export * as SimulationHttpRoute from "./simulation"

View file

@ -71,10 +71,20 @@ export interface Options {
}
interface State {
readonly initialEntries: readonly ResponseEntry[]
readonly entries: readonly ResponseEntry[]
readonly allowLoopback: boolean
}
export interface Snapshot {
readonly allowLoopback: boolean
readonly routes: ReadonlyArray<{
readonly kind: ResponseEntry["kind"]
readonly matcher: string
readonly method?: string | readonly string[]
}>
}
export class SimulationNetworkError extends Schema.TaggedErrorClass<SimulationNetworkError>()(
"SimulationNetworkError",
{
@ -86,6 +96,8 @@ export class SimulationNetworkError extends Schema.TaggedErrorClass<SimulationNe
export interface Interface {
readonly register: (entry: ResponseEntry) => Effect.Effect<void>
readonly reset: () => Effect.Effect<void>
readonly snapshot: () => Effect.Effect<Snapshot>
readonly handle: (request: RequestInfo) => Effect.Effect<Response, SimulationNetworkError>
}
@ -108,6 +120,12 @@ function matches(matcher: Matcher, request: RequestInfo) {
return matchesUrl(matcher.url, request)
}
function matcherLabel(matcher: Matcher) {
if (typeof matcher.url === "string") return matcher.url
if (matcher.url instanceof RegExp) return matcher.url.source
return "<predicate>"
}
function isLoopback(url: URL) {
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
}
@ -239,6 +257,7 @@ function toHttpClientError(request: Parameters<typeof HttpClientResponse.fromWeb
export function make(options: Options = {}) {
return Effect.gen(function* () {
const state = yield* Ref.make<State>({
initialEntries: options.entries ?? [],
entries: options.entries ?? [],
allowLoopback: options.allowLoopback ?? true,
})
@ -247,6 +266,22 @@ export function make(options: Options = {}) {
yield* Ref.update(state, (current) => ({ ...current, entries: [...current.entries, entry] }))
})
const reset = Effect.fn("SimulationNetwork.reset")(function* () {
yield* Ref.update(state, (current) => ({ ...current, entries: current.initialEntries }))
})
const snapshot = Effect.fn("SimulationNetwork.snapshot")(function* () {
const current = yield* Ref.get(state)
return {
allowLoopback: current.allowLoopback,
routes: current.entries.map((entry) => ({
kind: entry.kind,
matcher: matcherLabel(entry.matcher),
...(entry.matcher.method === undefined ? {} : { method: entry.matcher.method }),
})),
} satisfies Snapshot
})
const handle = Effect.fn("SimulationNetwork.handle")(function* (request: RequestInfo) {
const current = yield* Ref.get(state)
const entry = current.entries.find((entry) => matches(entry.matcher, request))
@ -261,7 +296,7 @@ export function make(options: Options = {}) {
})
})
return Service.of({ register, handle })
return Service.of({ register, reset, snapshot, handle })
})
}

View file

@ -0,0 +1,124 @@
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Effect, Layer, Ref, Schema } from "effect"
import path from "path"
import { SimulationNetwork } from "./network"
export const FileContent = Schema.Union([
Schema.String,
Schema.Struct({ encoding: Schema.Literal("base64"), data: Schema.String }),
])
export const FilesystemSeedInput = Schema.Struct({
files: Schema.Record(Schema.String, FileContent),
})
export const NetworkRegisterInput = Schema.Union([
Schema.Struct({
kind: Schema.Literal("json"),
url: Schema.String,
method: Schema.optional(Schema.String),
status: Schema.optional(Schema.Number),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
body: Schema.Json,
}),
Schema.Struct({
kind: Schema.Literal("text"),
url: Schema.String,
method: Schema.optional(Schema.String),
status: Schema.optional(Schema.Number),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
body: Schema.String,
}),
Schema.Struct({
kind: Schema.Literal("status"),
url: Schema.String,
method: Schema.optional(Schema.String),
status: Schema.Number,
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
])
type FilePath = string
interface State {
readonly files: readonly FilePath[]
readonly networkRegistrations: readonly string[]
}
export interface Interface {
readonly reset: () => Effect.Effect<void>
readonly seedFilesystem: (input: typeof FilesystemSeedInput.Type) => Effect.Effect<{ files: string[] }, unknown>
readonly registerNetwork: (input: typeof NetworkRegisterInput.Type) => Effect.Effect<{ registered: string }, unknown>
readonly snapshot: () => Effect.Effect<{
files: readonly string[]
networkRegistrations: readonly string[]
network: SimulationNetwork.Snapshot
}>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Simulation") {}
function fileContent(content: typeof FileContent.Type) {
if (typeof content === "string") return content
return Uint8Array.from(Buffer.from(content.data, "base64"))
}
function matcher(input: typeof NetworkRegisterInput.Type) {
return input.method ? { method: input.method, url: input.url } : input.url
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const network = yield* SimulationNetwork.Service
const state = yield* Ref.make<State>({ files: [], networkRegistrations: [] })
const reset = Effect.fn("Simulation.reset")(function* () {
yield* network.reset()
yield* Ref.set(state, { files: [], networkRegistrations: [] })
})
const seedFilesystem = Effect.fn("Simulation.seedFilesystem")(function* (input: typeof FilesystemSeedInput.Type) {
const files = Object.keys(input.files)
yield* Effect.forEach(
Object.entries(input.files),
([file, content]) => fs.writeWithDirs(path.isAbsolute(file) ? file : path.join("/opencode", file), fileContent(content)),
)
yield* Ref.update(state, (current) => ({ ...current, files: [...current.files, ...files] }))
return { files }
})
const registerNetwork = Effect.fn("Simulation.registerNetwork")(function* (input: typeof NetworkRegisterInput.Type) {
switch (input.kind) {
case "json":
yield* network.register(
SimulationNetwork.json(matcher(input), input.body, { status: input.status, headers: input.headers }),
)
break
case "text":
yield* network.register(
SimulationNetwork.text(matcher(input), input.body, { status: input.status, headers: input.headers }),
)
break
case "status":
yield* network.register(SimulationNetwork.status(matcher(input), input.status, { headers: input.headers }))
break
}
yield* Ref.update(state, (current) => ({
...current,
networkRegistrations: [...current.networkRegistrations, `${input.method ?? "*"} ${input.url}`],
}))
return { registered: input.url }
})
const snapshot = Effect.fn("Simulation.snapshot")(function* () {
const current = yield* Ref.get(state)
return { ...current, network: yield* network.snapshot() }
})
return Service.of({ reset, seedFilesystem, registerNetwork, snapshot })
}),
)
export * as Simulation from "./service"

View file

@ -0,0 +1,67 @@
import { describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
import { SimulationNetwork } from "../../../src/testing/simulation/network"
import { Simulation } from "../../../src/testing/simulation/service"
import { testEffect } from "../../lib/effect"
const fsLayer = SimulationFileSystem.layer({ root: "/opencode" })
const networkLayer = SimulationNetwork.layer({ allowLoopback: false })
const simulationLayer = Simulation.layer.pipe(Layer.provide(fsLayer), Layer.provide(networkLayer))
const it = testEffect(Layer.mergeAll(fsLayer, networkLayer, simulationLayer))
describe("Simulation", () => {
it.effect("seeds files into the simulated filesystem", () =>
Effect.gen(function* () {
const simulation = yield* Simulation.Service
const fs = yield* AppFileSystem.Service
expect(yield* simulation.seedFilesystem({ files: { "opencode.json": "{}" } })).toEqual({
files: ["opencode.json"],
})
expect(yield* fs.readFileString("/opencode/opencode.json")).toBe("{}")
}),
)
it.effect("registers network responses through control state", () =>
Effect.gen(function* () {
const simulation = yield* Simulation.Service
const http = yield* HttpClient.HttpClient
expect(
yield* simulation.registerNetwork({
kind: "json",
method: "GET",
url: "https://example.com/data",
body: { ok: true },
}),
).toEqual({ registered: "https://example.com/data" })
const response = yield* http.execute(HttpClientRequest.get("https://example.com/data"))
expect(yield* response.json).toEqual({ ok: true })
}),
)
it.effect("snapshots and resets simulation state", () =>
Effect.gen(function* () {
const simulation = yield* Simulation.Service
const http = yield* HttpClient.HttpClient
yield* simulation.seedFilesystem({ files: { "README.md": "hello" } })
yield* simulation.registerNetwork({ kind: "text", url: "https://example.com/page", body: "hello" })
const snapshot = yield* simulation.snapshot()
expect(snapshot.files).toEqual(["README.md"])
expect(snapshot.networkRegistrations).toEqual(["* https://example.com/page"])
expect(snapshot.network.routes.some((route) => route.matcher === "https://example.com/page")).toBe(true)
yield* simulation.reset()
expect((yield* simulation.snapshot()).files).toEqual([])
const exit = yield* http.execute(HttpClientRequest.get("https://example.com/page")).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})