refactor(simulation): type RPC request payloads (#35734)

This commit is contained in:
James Long 2026-07-07 09:21:19 -04:00 committed by GitHub
commit b6a2912bb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 48 additions and 40 deletions

View file

@ -25,10 +25,10 @@ import { SimulationNetwork } from "./network"
type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }>
function parseRequest(input: string | Buffer) { function parseRequest(input: string | Buffer) {
return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
} }
async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise<unknown> { async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise<unknown> {
switch (request.method) { switch (request.method) {
case "llm.attach": { case "llm.attach": {
socket.data.unsubscribe?.() socket.data.unsubscribe?.()
@ -38,23 +38,22 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
return { attached: true } return { attached: true }
} }
case "llm.chunk": { case "llm.chunk": {
const params = await SimulationProtocol.Backend.decodeChunkParams(request.params)
await Effect.runPromise( await Effect.runPromise(
SimulationLLMExchange.push( SimulationLLMExchange.push(
params.id, request.params.id,
params.items.map((item) => ({ type: "item", item }) as const), request.params.items.map((item) => ({ type: "item", item }) as const),
), ),
) )
return { ok: true } return { ok: true }
} }
case "llm.finish": { case "llm.finish": {
const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) await Effect.runPromise(
await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]),
)
return { ok: true } return { ok: true }
} }
case "llm.disconnect": { case "llm.disconnect": {
const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params) await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id))
await Effect.runPromise(SimulationLLMExchange.disconnect(params.id))
return { ok: true } return { ok: true }
} }
case "llm.pending": case "llm.pending":
@ -62,7 +61,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
case "network.log": case "network.log":
return { entries: SimulationNetwork.log() } return { entries: SimulationNetwork.log() }
} }
throw new Error(`Unknown simulation control method: ${request.method}`)
} }
export function start(endpoint: string) { export function start(endpoint: string) {
@ -79,7 +77,7 @@ export function start(endpoint: string) {
socket.data.unsubscribe?.() socket.data.unsubscribe?.()
}, },
async message(socket, message) { async message(socket, message) {
let request: SimulationProtocol.JsonRpc.Request | undefined let request: SimulationProtocol.Backend.Request | undefined
try { try {
request = parseRequest(message) request = parseRequest(message)
const result = await handle(socket, request) const result = await handle(socket, request)

View file

@ -47,7 +47,7 @@ function hit(renderer: CliRenderer, renderable: Renderable) {
/** /**
* Builds the harness the simulation server drives. * Builds the harness the simulation server drives.
* *
* When the renderer is the fake simulation renderer, its TestRendererSetup * When the renderer is the headless simulation renderer, its TestRendererSetup
* provides the supported testing APIs. For the visible terminal renderer the * provides the supported testing APIs. For the visible terminal renderer the
* harness falls back to `requestRender` + `idle` and reading the private * harness falls back to `requestRender` + `idle` and reading the private
* `currentRenderBuffer`. * `currentRenderBuffer`.

View file

@ -4,7 +4,7 @@ import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testin
const setups = new WeakMap<CliRenderer, TestRendererSetup>() const setups = new WeakMap<CliRenderer, TestRendererSetup>()
/** /**
* Creates the fake simulation renderer: a real CliRenderer backed by an * Creates the headless simulation renderer: a real CliRenderer backed by an
* in-memory screen buffer instead of a terminal. The TestRendererSetup is * in-memory screen buffer instead of a terminal. The TestRendererSetup is
* kept module-side (keyed by renderer) so the harness can use the supported * kept module-side (keyed by renderer) so the harness can use the supported
* testing APIs without app code carrying it around. * testing APIs without app code carrying it around.

View file

@ -7,29 +7,20 @@ export interface Server {
readonly stop: () => void readonly stop: () => void
} }
function actionParam(params: unknown) {
return SimulationProtocol.Frontend.decodeActionParams(params).action
}
function parseRequest(input: string | Buffer) { function parseRequest(input: string | Buffer) {
return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
} }
async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) {
switch (request.method) { switch (request.method) {
case "ui.state": { case "ui.state": {
if (headless) await harness.renderOnce()
const result = SimulationActions.state(harness) const result = SimulationActions.state(harness)
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
return result return result
} }
case "ui.action": case "ui.action":
return SimulationActions.execute(harness, actionParam(request.params)) return SimulationActions.execute(harness, request.params.action)
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": case "trace.list":
return { records: SimulationTrace.list() } return { records: SimulationTrace.list() }
case "trace.clear": case "trace.clear":
@ -38,10 +29,9 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ
case "trace.export": case "trace.export":
return SimulationTrace.exportTrace() return SimulationTrace.exportTrace()
} }
throw new Error(`Unknown simulation method: ${request.method}`)
} }
export function start(harness: Harness, endpoint: string): Server { export function start(harness: Harness, endpoint: string, headless: boolean): Server {
const url = new URL(endpoint) const url = new URL(endpoint)
const server = Bun.serve<{ readonly drive: true }>({ const server = Bun.serve<{ readonly drive: true }>({
hostname: url.hostname, hostname: url.hostname,
@ -58,10 +48,10 @@ export function start(harness: Harness, endpoint: string): Server {
SimulationTrace.add("control.disconnect") SimulationTrace.add("control.disconnect")
}, },
async message(socket, message) { async message(socket, message) {
let request: SimulationProtocol.JsonRpc.Request | undefined let request: SimulationProtocol.Frontend.Request | undefined
try { try {
request = parseRequest(message) request = parseRequest(message)
const result = await handle(harness, request) const result = await handle(harness, request, headless)
const next = SimulationProtocol.JsonRpc.success(request.id, result) const next = SimulationProtocol.JsonRpc.success(request.id, result)
if (next) socket.send(JSON.stringify(next)) if (next) socket.send(JSON.stringify(next))
} catch (error) { } catch (error) {

View file

@ -7,19 +7,18 @@ import { SimulationServer } from "./server"
/** /**
* Drive-mode renderer entry point. * Drive-mode renderer entry point.
* *
* Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal
* visible renderer otherwise) and starts the UI control * visible renderer otherwise) and starts the UI control
* server against it. The server stops when the renderer is destroyed, so the * server against it. The server stops when the renderer is destroyed, so the
* caller only manages the renderer lifecycle. * caller only manages the renderer lifecycle.
*/ */
export async function create(options: CliRendererConfig): Promise<CliRenderer> { export async function create(options: CliRendererConfig): Promise<CliRenderer> {
const renderer = const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
process.env.OPENCODE_DRIVE_RENDERER === "fake" const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options)
? await SimulationRenderer.create(options)
: await createCliRenderer(options)
const server = SimulationServer.start( const server = SimulationServer.start(
SimulationActions.createHarness(renderer), SimulationActions.createHarness(renderer),
DriveManifest.resolve().endpoints.ui, DriveManifest.resolve().endpoints.ui,
headless,
) )
process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)
renderer.once("destroy", () => server.stop()) renderer.once("destroy", () => server.stop())

View file

@ -4,9 +4,12 @@ const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null])
type Json = Schema.Schema.Type<typeof Schema.Json> type Json = Schema.Schema.Type<typeof Schema.Json>
export namespace JsonRpc { export namespace JsonRpc {
export const Request = Schema.Struct({ export const RequestFields = {
jsonrpc: Schema.Literal("2.0"), jsonrpc: Schema.Literal("2.0"),
id: Schema.optional(JsonRpcID), id: Schema.optional(JsonRpcID),
}
export const Request = Schema.Struct({
...RequestFields,
method: Schema.String, method: Schema.String,
params: Schema.optional(Schema.Json), params: Schema.optional(Schema.Json),
}) })
@ -92,7 +95,16 @@ export namespace Frontend {
export const ActionParams = Schema.Struct({ action: Action }) export const ActionParams = Schema.Struct({ action: Action })
export interface ActionParams extends Schema.Schema.Type<typeof ActionParams> {} export interface ActionParams extends Schema.Schema.Type<typeof ActionParams> {}
export const decodeActionParams = Schema.decodeUnknownSync(ActionParams)
export const Request = Schema.Union([
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["ui.state", "trace.list", "trace.clear", "trace.export"]),
}),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
export const TraceRecord = Schema.Struct({ export const TraceRecord = Schema.Struct({
id: Schema.Number, id: Schema.Number,
@ -130,6 +142,18 @@ export namespace Backend {
export const DisconnectParams = Schema.Struct({ id: Schema.String }) export const DisconnectParams = Schema.Struct({ id: Schema.String })
export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {} export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {}
export const Request = Schema.Union([
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]),
}),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {} export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {}
@ -141,9 +165,6 @@ export namespace Backend {
}) })
export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {} export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {}
export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams)
export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams)
export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams)
} }
export * as SimulationProtocol from "./index" export * as SimulationProtocol from "./index"