Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/specs/simulation/simulation-phases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/specs/simulation/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/simulation/src/backend/simulated-provider.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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* () {
Expand Down
10 changes: 10 additions & 0 deletions packages/simulation/src/frontend/server.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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":
Expand Down
140 changes: 140 additions & 0 deletions packages/simulation/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 }),
Expand All @@ -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 }),
Expand Down Expand Up @@ -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 }),
Expand Down
24 changes: 24 additions & 0 deletions packages/simulation/test/frontend-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading