diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 93bab0540e..29613ff28a 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -9,7 +9,8 @@ "./backend": "./src/backend/index.ts", "./backend/*": "./src/backend/*.ts", "./frontend": "./src/frontend/simulation.ts", - "./frontend/*": "./src/frontend/*.ts" + "./frontend/*": "./src/frontend/*.ts", + "./protocol": "./src/protocol/index.ts" }, "scripts": { "typecheck": "tsgo --noEmit" diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index f3fc67116c..10bded13af 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect" +import { Effect } from "effect" +import { SimulationProtocol } from "../protocol" import { SimulationLLMExchange } from "./llm-exchange" import { SimulationNetwork } from "./network" @@ -23,43 +24,13 @@ import { SimulationNetwork } from "./network" 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 +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(socket: ControlSocket, request: JsonRpcRequest): Promise { +async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { switch (request.method) { case "llm.attach": { socket.data.unsubscribe?.() @@ -69,7 +40,7 @@ async function handle(socket: ControlSocket, request: JsonRpcRequest): Promise void @@ -36,63 +19,15 @@ function isPortUnavailable(error: unknown) { 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 + return SimulationProtocol.Frontend.decodeActionParams(params).action } -function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined { - if (id === undefined) return undefined - return { jsonrpc: "2.0", id, result } +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -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) { +async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { switch (request.method) { case "ui.state": { const result = SimulationActions.state(harness) @@ -139,14 +74,14 @@ function serve( SimulationTrace.add("control.disconnect") }, async message(socket, message) { - let request: JsonRpcRequest | undefined + let request: SimulationProtocol.JsonRpc.Request | undefined try { request = parseRequest(message) const result = await handle(harness, request) - const next = response(request.id, result) + const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { - socket.send(JSON.stringify(errorResponse(request?.id, error))) + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) } }, }, diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts new file mode 100644 index 0000000000..6ff63c9bb3 --- /dev/null +++ b/packages/simulation/src/protocol/index.ts @@ -0,0 +1,145 @@ +import { Effect, Schema } from "effect" + +const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) +type Json = Schema.Schema.Type + +export namespace JsonRpc { + export const Request = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.optional(JsonRpcID), + method: Schema.String, + params: Schema.optional(Schema.Json), + }) + export interface Request extends Schema.Schema.Type {} + + export const ErrorObject = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optional(Schema.Json), + }) + + export const Response = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: JsonRpcID, + result: Schema.optional(Schema.Json), + error: Schema.optional(ErrorObject), + }) + export interface Response extends Schema.Schema.Type {} + + export const decodeRequest = Schema.decodeUnknownSync(Request) + + export function success(id: Request["id"], result: unknown): Response | undefined { + if (id === undefined) return undefined + return { jsonrpc: "2.0", id, result: result as Json } + } + + export function failure(id: Request["id"], error: unknown): Response { + return { + jsonrpc: "2.0", + id: id ?? null, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + } + } +} + +export namespace Frontend { + export const KeyModifiers = Schema.Struct({ + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), + }) + export interface KeyModifiers extends Schema.Schema.Type {} + + export const Action = Schema.Union([ + Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("pressEnter") }), + Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + ]) + export type Action = Schema.Schema.Type + + export const Element = Schema.Struct({ + id: Schema.String, + num: Schema.Number, + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + focusable: Schema.Boolean, + focused: Schema.Boolean, + clickable: Schema.Boolean, + editor: Schema.Boolean, + }) + export interface Element extends Schema.Schema.Type {} + + export const State = Schema.Struct({ + screen: Schema.String, + focused: Schema.Struct({ + renderable: Schema.optional(Schema.Number), + editor: Schema.Boolean, + }), + elements: Schema.Array(Element), + actions: Schema.Array(Action), + }) + export interface State extends Schema.Schema.Type {} + + export const ActionParams = Schema.Struct({ action: Action }) + export interface ActionParams extends Schema.Schema.Type {} + export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + + export const TraceRecord = Schema.Struct({ + id: Schema.Number, + time: Schema.String, + type: Schema.String, + data: Schema.optional(Schema.Json), + }) + export interface TraceRecord extends Schema.Schema.Type {} + + export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) + export interface TraceList extends Schema.Schema.Type {} +} + +export namespace Backend { + export const Item = 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.Json }), + Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), + ]) + export type Item = Schema.Schema.Type + + export const FinishReason = Schema.Literals(["stop", "tool-calls", "length", "content-filter"]) + export type FinishReason = Schema.Schema.Type + + export const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(Item) }) + export interface ChunkParams extends Schema.Schema.Type {} + + export const FinishParams = Schema.Struct({ + id: Schema.String, + reason: FinishReason.pipe(Schema.withDecodingDefault(Effect.succeed("stop" as const))), + }) + export interface FinishParams extends Schema.Schema.Type {} + + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) + export interface OpenedExchange extends Schema.Schema.Type {} + + export const NetworkLogEntry = Schema.Struct({ + time: Schema.Number, + method: Schema.String, + url: Schema.String, + matched: Schema.Boolean, + }) + export interface NetworkLogEntry extends Schema.Schema.Type {} + + export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) + export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) +} + +export * as SimulationProtocol from "./index"