Просмотр исходного кода

feat(simulation): add endpoint handshake protocol (#37157)

Kit Langton 3 недель назад
Родитель
Сommit
5e2e0d6965

+ 1 - 0
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.

+ 37 - 0
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<number>
+    requiredCapabilities: Array<string>
+    optionalCapabilities: Array<string>
+  }
+}
+```
+
+The response result is:
+
+```ts
+{
+  protocolVersion: 1
+  role: "ui" | "backend"
+  server: {
+    name: string
+    version: string
+  }
+  capabilities: Array<string>
+}
+```
+
+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.

+ 10 - 0
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* () {

+ 10 - 0
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":

+ 140 - 0
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<typeof ProtocolVersion>
+
+  export const Capability = Schema.NonEmptyString
+  export type Capability = Schema.Schema.Type<typeof Capability>
+
+  export const EndpointRole = Schema.Literals(["ui", "backend"])
+  export type EndpointRole = Schema.Schema.Type<typeof EndpointRole>
+
+  export const Identity = Schema.Struct({
+    name: Schema.NonEmptyString,
+    version: Schema.NonEmptyString,
+  })
+  export interface Identity extends Schema.Schema.Type<typeof Identity> {}
+
+  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<typeof Params> {}
+
+  export const Response = Schema.Struct({
+    protocolVersion: ProtocolVersion,
+    role: EndpointRole,
+    server: Identity,
+    capabilities: Schema.Array(Capability),
+  })
+  export interface Response extends Schema.Schema.Type<typeof Response> {}
+
+  export const Request = Schema.Struct({
+    ...JsonRpc.RequestFields,
+    method: Schema.Literal("simulation.handshake"),
+    params: Params,
+  })
+  export interface Request extends Schema.Schema.Type<typeof Request> {}
+
+  export interface DispatchAction {
+    readonly role: EndpointRole
+    readonly server: Identity
+    readonly capabilities: ReadonlyArray<Capability>
+  }
+
+  export class RoleMismatchError extends Schema.TaggedErrorClass<RoleMismatchError>()(
+    "SimulationHandshake.RoleMismatchError",
+    {
+      expected: EndpointRole,
+      actual: EndpointRole,
+      message: Schema.String,
+    },
+  ) {}
+
+  export class UnsupportedProtocolError extends Schema.TaggedErrorClass<UnsupportedProtocolError>()(
+    "SimulationHandshake.UnsupportedProtocolError",
+    {
+      offered: Schema.Array(Schema.Number),
+      supported: Schema.Array(ProtocolVersion),
+      message: Schema.String,
+    },
+  ) {}
+
+  export class MissingCapabilityError extends Schema.TaggedErrorClass<MissingCapabilityError>()(
+    "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<Handshake.Capability>
+
   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<typeof ResizeParams> {}
 
   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<Handshake.Capability>
+
   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<typeof DisconnectParams> {}
 
   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 }),

+ 24 - 0
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,

+ 104 - 2
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 })
+  })
+})

+ 24 - 0
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)