feat(simulation): add driver controlled backend LLM (#35186)
This commit is contained in:
parent
2ff19171dc
commit
4790a2772c
25 changed files with 1191 additions and 21 deletions
145
packages/simulation/src/backend/control.ts
Normal file
145
packages/simulation/src/backend/control.ts
Normal 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"
|
||||
390
packages/simulation/src/backend/filesystem.ts
Normal file
390
packages/simulation/src/backend/filesystem.ts
Normal 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"
|
||||
89
packages/simulation/src/backend/fs-util.ts
Normal file
89
packages/simulation/src/backend/fs-util.ts
Normal 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"
|
||||
41
packages/simulation/src/backend/index.ts
Normal file
41
packages/simulation/src/backend/index.ts
Normal 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"
|
||||
105
packages/simulation/src/backend/llm-exchange.ts
Normal file
105
packages/simulation/src/backend/llm-exchange.ts
Normal 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"
|
||||
94
packages/simulation/src/backend/network.ts
Normal file
94
packages/simulation/src/backend/network.ts
Normal 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"
|
||||
89
packages/simulation/src/backend/openai.ts
Normal file
89
packages/simulation/src/backend/openai.ts
Normal 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"
|
||||
177
packages/simulation/src/frontend/actions.ts
Normal file
177
packages/simulation/src/frontend/actions.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationTrace } from "./trace"
|
||||
|
||||
export interface KeyModifiers {
|
||||
readonly ctrl?: boolean
|
||||
readonly shift?: boolean
|
||||
readonly meta?: boolean
|
||||
readonly super?: boolean
|
||||
readonly hyper?: boolean
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { readonly type: "typeText"; readonly text: string }
|
||||
| { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers }
|
||||
| { readonly type: "pressEnter" }
|
||||
| { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" }
|
||||
| { readonly type: "focus"; readonly target: number }
|
||||
| { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number }
|
||||
|
||||
export interface Element {
|
||||
readonly id: string
|
||||
readonly num: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly focusable: boolean
|
||||
readonly focused: boolean
|
||||
readonly clickable: boolean
|
||||
readonly editor: boolean
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: MockInput
|
||||
readonly mockMouse: MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
}
|
||||
|
||||
type RenderBuffer = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
getRealCharBytes(includeAnsi?: boolean): Uint8Array
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function children(renderable: Renderable) {
|
||||
return renderable.getChildren().filter((child): child is Renderable => "num" in child)
|
||||
}
|
||||
|
||||
function all(renderable: Renderable): Renderable[] {
|
||||
return [renderable, ...children(renderable).flatMap(all)]
|
||||
}
|
||||
|
||||
function mouseListeners(renderable: Renderable) {
|
||||
const general = Reflect.get(renderable, "_mouseListener")
|
||||
const specific = Reflect.get(renderable, "_mouseListeners")
|
||||
return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0)
|
||||
}
|
||||
|
||||
function hit(renderer: CliRenderer, renderable: Renderable) {
|
||||
if (renderable.width <= 0 || renderable.height <= 0) return false
|
||||
const x = Math.floor(renderable.screenX + renderable.width / 2)
|
||||
const y = Math.floor(renderable.screenY + renderable.height / 2)
|
||||
return renderer.hitTest(x, y) === renderable.num
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the harness the simulation server drives.
|
||||
*
|
||||
* When the renderer is the fake simulation renderer, its TestRendererSetup
|
||||
* provides the supported testing APIs. For the visible terminal renderer the
|
||||
* harness falls back to `requestRender` + `idle` and reading the private
|
||||
* `currentRenderBuffer`.
|
||||
*/
|
||||
export function createHarness(renderer: CliRenderer): Harness {
|
||||
const setup = SimulationRenderer.setupFor(renderer)
|
||||
return {
|
||||
renderer,
|
||||
mockInput: setup?.mockInput ?? createMockKeys(renderer),
|
||||
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
|
||||
renderOnce:
|
||||
setup?.renderOnce ??
|
||||
(async () => {
|
||||
renderer.requestRender()
|
||||
await renderer.idle()
|
||||
}),
|
||||
screen:
|
||||
setup?.captureCharFrame ??
|
||||
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
|
||||
}
|
||||
}
|
||||
|
||||
export function elements(renderer: CliRenderer): Element[] {
|
||||
return all(renderer.root)
|
||||
.filter((renderable) => renderable.visible && !renderable.isDestroyed)
|
||||
.map((renderable) => {
|
||||
const clickable = mouseListeners(renderable) && hit(renderer, renderable)
|
||||
return {
|
||||
id: renderable.id,
|
||||
num: renderable.num,
|
||||
x: renderable.screenX,
|
||||
y: renderable.screenY,
|
||||
width: renderable.width,
|
||||
height: renderable.height,
|
||||
focusable: renderable.focusable,
|
||||
focused: renderable.focused,
|
||||
clickable,
|
||||
editor: renderer.currentFocusedEditor === renderable,
|
||||
} satisfies Element
|
||||
})
|
||||
.filter((element) => element.focusable || element.clickable || element.editor)
|
||||
}
|
||||
|
||||
export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] {
|
||||
const items = elements(renderer)
|
||||
return [
|
||||
...(renderer.currentFocusedEditor
|
||||
? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[])
|
||||
: []),
|
||||
...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })),
|
||||
...items
|
||||
.filter((item) => item.clickable)
|
||||
.map((item) => ({
|
||||
type: "click" as const,
|
||||
target: item.num,
|
||||
x: Math.floor(item.x + item.width / 2),
|
||||
y: Math.floor(item.y + item.height / 2),
|
||||
})),
|
||||
{ type: "pressArrow", direction: "down" },
|
||||
{ type: "pressArrow", direction: "up" },
|
||||
]
|
||||
}
|
||||
|
||||
export function state(harness: Harness) {
|
||||
return {
|
||||
screen: harness.screen(),
|
||||
focused: {
|
||||
renderable: harness.renderer.currentFocusedRenderable?.num,
|
||||
editor: Boolean(harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: elements(harness.renderer),
|
||||
actions: actions(harness.renderer),
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(harness: Harness, action: Action) {
|
||||
SimulationTrace.add("ui.action", { action })
|
||||
switch (action.type) {
|
||||
case "typeText":
|
||||
await harness.mockInput.typeText(action.text)
|
||||
break
|
||||
case "pressKey":
|
||||
harness.mockInput.pressKey(action.key, action.modifiers)
|
||||
break
|
||||
case "pressEnter":
|
||||
harness.mockInput.pressEnter()
|
||||
break
|
||||
case "pressArrow":
|
||||
harness.mockInput.pressArrow(action.direction)
|
||||
break
|
||||
case "focus":
|
||||
all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
|
||||
break
|
||||
case "click":
|
||||
await harness.mockMouse.click(action.x, action.y)
|
||||
break
|
||||
}
|
||||
await harness.renderOnce()
|
||||
return state(harness)
|
||||
}
|
||||
|
||||
export * as SimulationActions from "./actions"
|
||||
26
packages/simulation/src/frontend/renderer.ts
Normal file
26
packages/simulation/src/frontend/renderer.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
|
||||
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||
|
||||
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
|
||||
|
||||
/**
|
||||
* Creates the fake simulation renderer: a real CliRenderer backed by an
|
||||
* in-memory screen buffer instead of a terminal. The TestRendererSetup is
|
||||
* kept module-side (keyed by renderer) so the harness can use the supported
|
||||
* testing APIs without app code carrying it around.
|
||||
*/
|
||||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const setup = await createTestRenderer({
|
||||
...options,
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
})
|
||||
setups.set(setup.renderer, setup)
|
||||
return setup.renderer
|
||||
}
|
||||
|
||||
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
|
||||
return setups.get(renderer)
|
||||
}
|
||||
|
||||
export * as SimulationRenderer from "./renderer"
|
||||
174
packages/simulation/src/frontend/server.ts
Normal file
174
packages/simulation/src/frontend/server.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { SimulationActions, type Action, type Harness } from "./actions"
|
||||
import { SimulationTrace } from "./trace"
|
||||
|
||||
const DefaultPort = 40900
|
||||
const MaxPortAttempts = 100
|
||||
|
||||
type JsonRpcRequest = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id?: string | number | null
|
||||
readonly method: string
|
||||
readonly params?: unknown
|
||||
}
|
||||
|
||||
type JsonRpcResponse = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: string | number | null
|
||||
readonly result?: unknown
|
||||
readonly error?: {
|
||||
readonly code: number
|
||||
readonly message: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
readonly url: string
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true"
|
||||
}
|
||||
|
||||
function isPortUnavailable(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function isAction(input: unknown): input is Action {
|
||||
if (typeof input !== "object" || input === null || !("type" in input)) return false
|
||||
switch (input.type) {
|
||||
case "typeText":
|
||||
return "text" in input && typeof input.text === "string"
|
||||
case "pressKey":
|
||||
return "key" in input && typeof input.key === "string"
|
||||
case "pressEnter":
|
||||
return true
|
||||
case "pressArrow":
|
||||
return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction))
|
||||
case "focus":
|
||||
return "target" in input && typeof input.target === "number"
|
||||
case "click":
|
||||
return (
|
||||
"target" in input &&
|
||||
typeof input.target === "number" &&
|
||||
"x" in input &&
|
||||
typeof input.x === "number" &&
|
||||
"y" in input &&
|
||||
typeof input.y === "number"
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function actionParam(params: unknown) {
|
||||
if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action")
|
||||
if (!isAction(params.action)) throw new Error("Invalid action")
|
||||
return params.action
|
||||
}
|
||||
|
||||
function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined {
|
||||
if (id === undefined) return undefined
|
||||
return { jsonrpc: "2.0", id, result }
|
||||
}
|
||||
|
||||
function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(harness: Harness, request: JsonRpcRequest) {
|
||||
switch (request.method) {
|
||||
case "ui.state": {
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
case "ui.action":
|
||||
return SimulationActions.execute(harness, actionParam(request.params))
|
||||
case "ui.render": {
|
||||
await harness.renderOnce()
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
case "trace.list":
|
||||
return { records: SimulationTrace.list() }
|
||||
case "trace.clear":
|
||||
SimulationTrace.clear()
|
||||
return { cleared: true }
|
||||
case "trace.export":
|
||||
return SimulationTrace.exportTrace()
|
||||
}
|
||||
throw new Error(`Unknown simulation method: ${request.method}`)
|
||||
}
|
||||
|
||||
function serve(
|
||||
harness: Harness,
|
||||
port = DefaultPort,
|
||||
attempts = MaxPortAttempts,
|
||||
): Bun.Server<{ readonly simulation: true }> {
|
||||
try {
|
||||
return Bun.serve<{ readonly simulation: true }>({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: { simulation: true } })) return undefined
|
||||
return new Response("opencode simulation websocket", { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
open() {
|
||||
SimulationTrace.add("control.connect")
|
||||
},
|
||||
close() {
|
||||
SimulationTrace.add("control.disconnect")
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: JsonRpcRequest | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(harness, request)
|
||||
const next = response(request.id, result)
|
||||
if (next) socket.send(JSON.stringify(next))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(errorResponse(request?.id, error)))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
|
||||
return serve(harness, port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function start(harness: Harness): Server | undefined {
|
||||
if (!isEnabled()) return
|
||||
const server = serve(harness)
|
||||
const url = `ws://${server.hostname}:${server.port}`
|
||||
SimulationTrace.add("control.start", { url })
|
||||
return {
|
||||
url,
|
||||
stop: () => {
|
||||
SimulationTrace.add("control.stop", { url })
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationServer from "./server"
|
||||
27
packages/simulation/src/frontend/simulation.ts
Normal file
27
packages/simulation/src/frontend/simulation.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { SimulationActions } from "./actions"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationServer } from "./server"
|
||||
|
||||
/**
|
||||
* Simulation-mode renderer entry point.
|
||||
*
|
||||
* Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the
|
||||
* normal visible renderer otherwise) and starts the simulation control
|
||||
* server against it. The server stops when the renderer is destroyed, so the
|
||||
* caller only manages the renderer lifecycle.
|
||||
*/
|
||||
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const renderer =
|
||||
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
||||
? await SimulationRenderer.create(options)
|
||||
: await createCliRenderer(options)
|
||||
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
||||
if (server) {
|
||||
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
export * as Simulation from "./simulation"
|
||||
37
packages/simulation/src/frontend/trace.ts
Normal file
37
packages/simulation/src/frontend/trace.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export type TraceRecord = {
|
||||
readonly id: number
|
||||
readonly time: string
|
||||
readonly type: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
|
||||
const records: TraceRecord[] = []
|
||||
let nextID = 0
|
||||
|
||||
export function add(type: string, data?: unknown) {
|
||||
const record = {
|
||||
id: ++nextID,
|
||||
time: new Date().toISOString(),
|
||||
type,
|
||||
...(data === undefined ? {} : { data }),
|
||||
} satisfies TraceRecord
|
||||
records.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function list() {
|
||||
return [...records]
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
records.length = 0
|
||||
nextID = 0
|
||||
}
|
||||
|
||||
export function exportTrace() {
|
||||
return {
|
||||
records: list(),
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationTrace from "./trace"
|
||||
Loading…
Add table
Add a link
Reference in a new issue