diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index 74fd92889b..ba1da75dba 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -17,6 +17,7 @@ Implementation checklist: - [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. - [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. - [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint. +- [x] Add `simulation.handshake` protocol, role, identity, version, and capability negotiation to both control endpoints. - [x] Expose `ui.state`, `ui.action`, `ui.render`. - [x] Expose `trace.list`, `trace.clear`, `trace.export`. - [x] Wire visible V1/full-TUI renderer path through the same action protocol. diff --git a/packages/opencode/specs/simulation/simulation.md b/packages/opencode/specs/simulation/simulation.md index 7682fad0c4..32924fdabd 100644 --- a/packages/opencode/specs/simulation/simulation.md +++ b/packages/opencode/specs/simulation/simulation.md @@ -69,6 +69,7 @@ This is important because the frontend has direct access to the renderer, screen Protocol: - JSON-RPC 2.0 over WebSocket. +- Clients negotiate protocol version, endpoint role, and capabilities with `simulation.handshake` before using endpoint methods. - Loopback only. - `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints. - The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints. @@ -77,6 +78,42 @@ Protocol: The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. +The canonical handshake request is: + +```ts +{ + jsonrpc: "2.0" + id: string | number | null + method: "simulation.handshake" + params: { + client: { + name: string + version: string + } + expectedRole: "ui" | "backend" + offeredVersions: Array + requiredCapabilities: Array + optionalCapabilities: Array + } +} +``` + +The response result is: + +```ts +{ + protocolVersion: 1 + role: "ui" | "backend" + server: { + name: string + version: string + } + capabilities: Array +} +``` + +Capabilities are open strings. Each endpoint advertises only methods and notifications it actually implements. A role mismatch, no supported offered protocol version, or a missing required capability fails the request; unsupported optional capabilities do not. + Initial method groups: - `ui.state`: return screen, elements, focus, and generated possible actions. diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index 69f01184aa..98ef54fe42 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -1,3 +1,4 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" @@ -211,6 +212,15 @@ function handle( request: SimulationProtocol.Backend.Request, ) { switch (request.method) { + case "simulation.handshake": + return SimulationProtocol.Handshake.dispatch( + { + role: "backend", + server: { name: "opencode", version: InstallationVersion }, + capabilities: SimulationProtocol.Backend.Capabilities, + }, + request.params, + ) case "llm.attach": return controllerLock.withPermit( Effect.gen(function* () { diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 533ddeb3c6..f26bb2eaf8 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,3 +1,4 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" @@ -6,6 +7,15 @@ import { SimulationRenderer } from "./renderer" function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { switch (request.method) { + case "simulation.handshake": + return SimulationProtocol.Handshake.dispatch( + { + role: "ui", + server: { name: "opencode", version: InstallationVersion }, + capabilities: SimulationProtocol.Frontend.Capabilities, + }, + request.params, + ) case "ui.capture": return SimulationActions.capture(harness) case "ui.screenshot": diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 55f08733bb..9a15098333 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -48,7 +48,136 @@ export namespace JsonRpc { } } +export namespace Handshake { + export const ProtocolVersion = Schema.Literal(1) + export type ProtocolVersion = Schema.Schema.Type + + export const Capability = Schema.NonEmptyString + export type Capability = Schema.Schema.Type + + export const EndpointRole = Schema.Literals(["ui", "backend"]) + export type EndpointRole = Schema.Schema.Type + + export const Identity = Schema.Struct({ + name: Schema.NonEmptyString, + version: Schema.NonEmptyString, + }) + export interface Identity extends Schema.Schema.Type {} + + export const Params = Schema.Struct({ + client: Identity, + expectedRole: EndpointRole, + offeredVersions: Schema.Array( + Schema.Int.check(Schema.isGreaterThan(0)), + ).check(Schema.isMinLength(1), Schema.isUnique()), + requiredCapabilities: Schema.Array(Capability).check(Schema.isUnique()), + optionalCapabilities: Schema.Array(Capability).check(Schema.isUnique()), + }) + export interface Params extends Schema.Schema.Type {} + + export const Response = Schema.Struct({ + protocolVersion: ProtocolVersion, + role: EndpointRole, + server: Identity, + capabilities: Schema.Array(Capability), + }) + export interface Response extends Schema.Schema.Type {} + + export const Request = Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literal("simulation.handshake"), + params: Params, + }) + export interface Request extends Schema.Schema.Type {} + + export interface DispatchAction { + readonly role: EndpointRole + readonly server: Identity + readonly capabilities: ReadonlyArray + } + + export class RoleMismatchError extends Schema.TaggedErrorClass()( + "SimulationHandshake.RoleMismatchError", + { + expected: EndpointRole, + actual: EndpointRole, + message: Schema.String, + }, + ) {} + + export class UnsupportedProtocolError extends Schema.TaggedErrorClass()( + "SimulationHandshake.UnsupportedProtocolError", + { + offered: Schema.Array(Schema.Number), + supported: Schema.Array(ProtocolVersion), + message: Schema.String, + }, + ) {} + + export class MissingCapabilityError extends Schema.TaggedErrorClass()( + "SimulationHandshake.MissingCapabilityError", + { + missing: Schema.Array(Capability), + message: Schema.String, + }, + ) {} + + export function dispatch(action: DispatchAction, params: Params) { + return Effect.gen(function* () { + if (params.expectedRole !== action.role) { + return yield* Effect.fail( + new RoleMismatchError({ + expected: params.expectedRole, + actual: action.role, + message: `Expected simulation endpoint role ${params.expectedRole}, received ${action.role}`, + }), + ) + } + if (!params.offeredVersions.includes(1)) { + return yield* Effect.fail( + new UnsupportedProtocolError({ + offered: params.offeredVersions, + supported: [1], + message: "No mutually supported simulation protocol version", + }), + ) + } + const installed = new Set(action.capabilities) + const missing = params.requiredCapabilities.filter((capability) => !installed.has(capability)) + if (missing.length > 0) { + return yield* Effect.fail( + new MissingCapabilityError({ + missing, + message: `Simulation endpoint is missing required capabilities: ${missing.join(", ")}`, + }), + ) + } + return { + protocolVersion: 1, + role: action.role, + server: action.server, + capabilities: Array.from(installed), + } satisfies Response + }) + } +} + export namespace Frontend { + export const Capabilities = [ + "ui.type", + "ui.press", + "ui.enter", + "ui.arrow", + "ui.focus", + "ui.click", + "ui.resize", + "ui.matches", + "ui.screenshot", + "ui.state", + "ui.capture", + "ui.recording.finish", + ] as const satisfies ReadonlyArray + export const KeyModifiers = Schema.Struct({ ctrl: Schema.optional(Schema.Boolean), shift: Schema.optional(Schema.Boolean), @@ -149,6 +278,7 @@ export namespace Frontend { export interface ResizeParams extends Schema.Schema.Type {} export const Request = Schema.Union([ + Handshake.Request, Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), @@ -173,6 +303,15 @@ export namespace Frontend { } export namespace Backend { + export const Capabilities = [ + "llm.attach", + "llm.chunk", + "llm.finish", + "llm.disconnect", + "llm.pending", + "llm.request", + ] as const satisfies ReadonlyArray + export const Item = Schema.Union([ Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), @@ -203,6 +342,7 @@ export namespace Backend { export interface DisconnectParams extends Schema.Schema.Type {} export const Request = Schema.Union([ + Handshake.Request, 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 }), diff --git a/packages/simulation/test/frontend-server.test.ts b/packages/simulation/test/frontend-server.test.ts index 451675d2e2..849adfb6f7 100644 --- a/packages/simulation/test/frontend-server.test.ts +++ b/packages/simulation/test/frontend-server.test.ts @@ -19,6 +19,30 @@ test("scopes the frontend control server and reports malformed JSON", async () = Queue.offerUnsafe(messages, JSON.parse(String(event.data))) }) + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "simulation.handshake", + params: { + client: { name: "test", version: "test" }, + expectedRole: "ui", + offeredVersions: [1], + requiredCapabilities: ["ui.state"], + optionalCapabilities: [], + }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 0, + result: { + protocolVersion: 1, + role: "ui", + server: { name: "opencode", version: expect.any(String) }, + capabilities: expect.arrayContaining(["ui.state", "ui.capture"]), + }, + }) + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" })) expect(yield* Queue.take(messages)).toMatchObject({ id: 1, diff --git a/packages/simulation/test/protocol.test.ts b/packages/simulation/test/protocol.test.ts index 5d0ea4c970..bd516bea99 100644 --- a/packages/simulation/test/protocol.test.ts +++ b/packages/simulation/test/protocol.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from "bun:test" -import { Frontend } from "../src/protocol" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Backend, Frontend, Handshake } from "../src/protocol" test("decodes ui.matches text params", () => { expect( @@ -19,3 +20,104 @@ test("decodes ui.matches text params", () => { }), ).toThrow() }) + +const params: Handshake.Params = { + client: { name: "opencode-drive", version: "test" }, + expectedRole: "ui", + offeredVersions: [1], + requiredCapabilities: ["ui.state"], + optionalCapabilities: ["ui.capture", "future.capability"], +} + +const ui: Handshake.DispatchAction = { + role: "ui", + server: { name: "opencode", version: "test" }, + capabilities: Frontend.Capabilities, +} + +describe("simulation.handshake", () => { + test("decodes through both endpoint request protocols", () => { + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "simulation.handshake" as const, + params, + } + expect(Frontend.decodeRequest(request)).toEqual(request) + expect(Backend.decodeRequest({ ...request, params: { ...params, expectedRole: "backend" } })).toMatchObject({ + method: "simulation.handshake", + params: { expectedRole: "backend" }, + }) + }) + + test("rejects invalid version and capability declarations", () => { + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "simulation.handshake" as const, + params, + } + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, offeredVersions: [] }, + }), + ).toThrow() + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, requiredCapabilities: ["ui.state", "ui.state"] }, + }), + ).toThrow() + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, optionalCapabilities: [""] }, + }), + ).toThrow() + }) + + test("selects the protocol and advertises only installed capabilities", async () => { + await expect(Effect.runPromise(Handshake.dispatch(ui, params))).resolves.toEqual({ + protocolVersion: 1, + role: "ui", + server: { name: "opencode", version: "test" }, + capabilities: [...Frontend.Capabilities], + }) + }) + + test("rejects a role mismatch", async () => { + await expect( + Effect.runPromise(Handshake.dispatch(ui, { ...params, expectedRole: "backend" })), + ).rejects.toMatchObject({ _tag: "SimulationHandshake.RoleMismatchError", expected: "backend", actual: "ui" }) + }) + + test("rejects unsupported protocol versions", async () => { + await expect(Effect.runPromise(Handshake.dispatch(ui, { ...params, offeredVersions: [2] }))).rejects.toMatchObject({ + _tag: "SimulationHandshake.UnsupportedProtocolError", + offered: [2], + supported: [1], + }) + }) + + test("rejects a missing required capability but ignores missing optional capabilities", async () => { + await expect( + Effect.runPromise( + Handshake.dispatch(ui, { + ...params, + requiredCapabilities: ["ui.state", "ui.future"], + }), + ), + ).rejects.toMatchObject({ _tag: "SimulationHandshake.MissingCapabilityError", missing: ["ui.future"] }) + + await expect( + Effect.runPromise( + Handshake.dispatch(ui, { + ...params, + requiredCapabilities: [], + optionalCapabilities: ["ui.future"], + }), + ), + ).resolves.toMatchObject({ capabilities: Frontend.Capabilities }) + }) +}) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index 23da2d9d0d..650fd2d172 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -7,6 +7,30 @@ import { availableEndpoint, connect } from "./fixture/websocket" test("streams a Drive-controlled provider response and removes the finished invocation", async () => { await runProvider((provider, socket, messages) => Effect.gen(function* () { + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "simulation.handshake", + params: { + client: { name: "test", version: "test" }, + expectedRole: "backend", + offeredVersions: [1], + requiredCapabilities: ["llm.attach", "llm.request"], + optionalCapabilities: [], + }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 0, + result: { + protocolVersion: 1, + role: "backend", + server: { name: "opencode", version: expect.any(String) }, + capabilities: expect.arrayContaining(["llm.attach", "llm.request"]), + }, + }) + socket.send("{") expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } }) yield* attach(socket, messages)