mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 04:19:51 +00:00
simulation control endpoints
This commit is contained in:
parent
0ed88cfc21
commit
012fc184bc
6 changed files with 285 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
124
packages/opencode/src/testing/simulation/service.ts
Normal file
124
packages/opencode/src/testing/simulation/service.ts
Normal 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"
|
||||
67
packages/opencode/test/testing/simulation/service.test.ts
Normal file
67
packages/opencode/test/testing/simulation/service.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue