mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce2379697 |
+277
-125
@@ -4,6 +4,10 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null])
|
||||
const decodeJson = Schema.decodeUnknownSync(Schema.Json)
|
||||
|
||||
// Generated schema unions lose mapped tuple types even though both come from the same operation tuple.
|
||||
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion, typescript-eslint/no-unnecessary-type-parameters
|
||||
const decoded = <Type>(value: unknown) => value as Type
|
||||
|
||||
export namespace JsonRpc {
|
||||
export const RequestFields = {
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
@@ -22,13 +26,33 @@ export namespace JsonRpc {
|
||||
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<typeof Response> {}
|
||||
export type Response =
|
||||
| {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: string | number | null
|
||||
readonly result: Schema.Schema.Type<typeof Schema.Json>
|
||||
readonly error?: never
|
||||
}
|
||||
| {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: string | number | null
|
||||
readonly error: Schema.Schema.Type<typeof ErrorObject>
|
||||
readonly result?: never
|
||||
}
|
||||
export const Response = decoded<Schema.Decoder<Response>>(
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: JsonRpcID,
|
||||
result: Schema.optionalKey(Schema.Json),
|
||||
error: Schema.optionalKey(ErrorObject),
|
||||
}).check(
|
||||
Schema.makeFilter((response) =>
|
||||
"result" in response === "error" in response
|
||||
? "JSON-RPC responses must contain exactly one of result or error"
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
|
||||
@@ -49,6 +73,161 @@ export namespace JsonRpc {
|
||||
}
|
||||
}
|
||||
|
||||
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
|
||||
"SimulationRequestError",
|
||||
{
|
||||
method: Schema.String,
|
||||
code: Schema.Number,
|
||||
message: Schema.String,
|
||||
data: Schema.optionalKey(Schema.Json),
|
||||
},
|
||||
) {}
|
||||
|
||||
const request = <
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
|
||||
Success extends Schema.Top = typeof Schema.Void,
|
||||
>(
|
||||
tag: Tag,
|
||||
options?: {
|
||||
readonly payload?: Payload
|
||||
readonly success?: Success
|
||||
},
|
||||
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
|
||||
|
||||
function operation<const Tag extends string, Success extends Schema.Decoder<unknown>>(method: Tag, success: Success) {
|
||||
return {
|
||||
method,
|
||||
success,
|
||||
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method) }),
|
||||
rpc: request(method, { success }),
|
||||
}
|
||||
}
|
||||
|
||||
function operationWithPayload<
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Decoder<unknown>,
|
||||
Success extends Schema.Decoder<unknown>,
|
||||
>(method: Tag, payload: Payload, success: Success) {
|
||||
return {
|
||||
method,
|
||||
payload,
|
||||
success,
|
||||
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method), params: payload }),
|
||||
rpc: request(method, { payload, success }),
|
||||
}
|
||||
}
|
||||
|
||||
function operationWithRpcPayload<
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Decoder<unknown>,
|
||||
RpcPayload extends Schema.Decoder<unknown>,
|
||||
Success extends Schema.Decoder<unknown>,
|
||||
>(method: Tag, payload: Payload, rpcPayload: RpcPayload, success: Success) {
|
||||
return {
|
||||
method,
|
||||
payload,
|
||||
success,
|
||||
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method), params: payload }),
|
||||
rpc: request(method, { payload: rpcPayload, success }),
|
||||
}
|
||||
}
|
||||
|
||||
function notification<const Method extends string, Payload extends Schema.Decoder<unknown>>(
|
||||
method: Method,
|
||||
payload: Payload,
|
||||
) {
|
||||
return {
|
||||
method,
|
||||
payload,
|
||||
schema: Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
method: Schema.Literal(method),
|
||||
params: payload,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type OperationRequest<Operation> = Operation extends {
|
||||
readonly method: infer Method extends string
|
||||
readonly payload: infer Payload extends Schema.Top
|
||||
}
|
||||
? Omit<JsonRpc.Request, "method" | "params"> & {
|
||||
readonly method: Method
|
||||
readonly params: Schema.Schema.Type<Payload>
|
||||
}
|
||||
: Operation extends { readonly method: infer Method extends string }
|
||||
? Omit<JsonRpc.Request, "method" | "params"> & { readonly method: Method }
|
||||
: never
|
||||
|
||||
type EndpointRequest<Operations extends ReadonlyArray<{ readonly method: string }>> =
|
||||
| Handshake.Request
|
||||
| OperationRequest<Operations[number]>
|
||||
|
||||
type EndpointNotification<Notifications extends ReadonlyArray<{ readonly schema: Schema.Top }>> = Schema.Schema.Type<
|
||||
Notifications[number]["schema"]
|
||||
>
|
||||
|
||||
function endpoint<
|
||||
const Operations extends ReadonlyArray<{
|
||||
readonly method: string
|
||||
readonly success: Schema.Top
|
||||
readonly rpc: Rpc.Any
|
||||
readonly request: Schema.Decoder<unknown>
|
||||
readonly payload?: Schema.Decoder<unknown>
|
||||
}>,
|
||||
const Notifications extends ReadonlyArray<{
|
||||
readonly method: string
|
||||
readonly payload: Schema.Decoder<unknown>
|
||||
readonly schema: Schema.Decoder<unknown>
|
||||
}>,
|
||||
const Capabilities extends ReadonlyArray<Handshake.Capability>,
|
||||
>(
|
||||
operations: Operations,
|
||||
notifications: Notifications,
|
||||
features: ReadonlyArray<Handshake.Capability>,
|
||||
Capabilities: Capabilities,
|
||||
) {
|
||||
const requests: ReadonlyArray<Schema.Decoder<unknown>> = [
|
||||
Handshake.Request,
|
||||
...operations.map((operation) => operation.request),
|
||||
]
|
||||
const Request = Schema.Union(requests)
|
||||
const Notification =
|
||||
notifications.length === 0 ? Schema.Never : Schema.Union(notifications.map((notification) => notification.schema))
|
||||
const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
|
||||
const decodeNotification = Schema.decodeUnknownSync(Notification)
|
||||
const decodeNotificationEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Notification))
|
||||
const handshake = request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response })
|
||||
const rpcs = RpcGroup.make(handshake, ...operations.map((operation) => operation.rpc)) as RpcGroup.RpcGroup<
|
||||
typeof handshake | Operations[number]["rpc"]
|
||||
>
|
||||
const derived = [
|
||||
...operations.map((operation) => operation.method),
|
||||
...notifications.map((notification) => notification.method),
|
||||
...features,
|
||||
]
|
||||
if (
|
||||
new Set(Capabilities).size !== Capabilities.length ||
|
||||
derived.length !== Capabilities.length ||
|
||||
derived.some((capability) => !Capabilities.includes(capability))
|
||||
)
|
||||
throw new Error("Simulation capabilities must exactly match endpoint operations")
|
||||
return {
|
||||
Capabilities,
|
||||
Request: decoded<Schema.Decoder<EndpointRequest<Operations>>>(Request),
|
||||
Notification: decoded<Schema.Decoder<EndpointNotification<Notifications>>>(Notification),
|
||||
decodeRequest: (input: unknown) => decoded<EndpointRequest<Operations>>(decodeRequest(input)),
|
||||
decodeRequestEffect: (input: string) =>
|
||||
decodeRequestEffect(input).pipe(Effect.map(decoded<EndpointRequest<Operations>>)),
|
||||
decodeNotification: (input: unknown) => decoded<EndpointNotification<Notifications>>(decodeNotification(input)),
|
||||
decodeNotificationEffect: (input: string) =>
|
||||
decodeNotificationEffect(input).pipe(Effect.map(decoded<EndpointNotification<Notifications>>)),
|
||||
rpcs,
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Handshake {
|
||||
export const ProtocolVersion = Schema.Literal(1)
|
||||
export type ProtocolVersion = Schema.Schema.Type<typeof ProtocolVersion>
|
||||
@@ -81,7 +260,7 @@ export namespace Handshake {
|
||||
protocolVersion: ProtocolVersion,
|
||||
role: EndpointRole,
|
||||
server: Identity,
|
||||
capabilities: Schema.Array(Capability),
|
||||
capabilities: Schema.Array(Capability).check(Schema.isUnique()),
|
||||
})
|
||||
export interface Response extends Schema.Schema.Type<typeof Response> {}
|
||||
|
||||
@@ -165,23 +344,6 @@ export namespace Handshake {
|
||||
}
|
||||
|
||||
export namespace Frontend {
|
||||
export const Capabilities = [
|
||||
"ui.type",
|
||||
"ui.press",
|
||||
"ui.enter",
|
||||
"ui.arrow",
|
||||
"ui.focus",
|
||||
"ui.click",
|
||||
"ui.click.semantic",
|
||||
"ui.resize",
|
||||
"ui.matches",
|
||||
"ui.state",
|
||||
"ui.snapshot",
|
||||
"ui.capture",
|
||||
"ui.recording.finish",
|
||||
] as const satisfies ReadonlyArray<Handshake.Capability>
|
||||
export type Capability = (typeof Capabilities)[number]
|
||||
|
||||
export const KeyModifiers = Schema.Struct({
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
@@ -337,44 +499,51 @@ export namespace Frontend {
|
||||
|
||||
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
|
||||
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 }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.resize"), params: ResizeParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]),
|
||||
}),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }),
|
||||
])
|
||||
export type Request = Schema.Schema.Type<typeof Request>
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
|
||||
const FrontendOperations = [
|
||||
operation("ui.state", Frontend.State),
|
||||
operation("ui.snapshot", Frontend.SemanticSnapshot),
|
||||
operation("ui.capture", Frontend.CapturedFrame),
|
||||
operationWithPayload("ui.matches", Frontend.MatchesParams, Frontend.Matches),
|
||||
operation("ui.recording.finish", Frontend.RecordingFinish),
|
||||
operationWithPayload("ui.type", Frontend.TypeParams, Frontend.State),
|
||||
operationWithPayload("ui.press", Frontend.PressParams, Frontend.State),
|
||||
operation("ui.enter", Frontend.State),
|
||||
operationWithPayload("ui.arrow", Frontend.ArrowParams, Frontend.State),
|
||||
operationWithPayload("ui.focus", Frontend.FocusParams, Frontend.State),
|
||||
operationWithPayload("ui.click", Frontend.ClickParams, Frontend.State),
|
||||
operationWithPayload("ui.resize", Frontend.ResizeParams, Frontend.State),
|
||||
] as const
|
||||
const FrontendCapabilities = [
|
||||
"ui.type",
|
||||
"ui.press",
|
||||
"ui.enter",
|
||||
"ui.arrow",
|
||||
"ui.focus",
|
||||
"ui.click",
|
||||
"ui.click.semantic",
|
||||
"ui.resize",
|
||||
"ui.matches",
|
||||
"ui.state",
|
||||
"ui.snapshot",
|
||||
"ui.capture",
|
||||
"ui.recording.finish",
|
||||
] as const
|
||||
const FrontendEndpoint = endpoint(FrontendOperations, [], ["ui.click.semantic"], FrontendCapabilities)
|
||||
type FrontendRequest = EndpointRequest<typeof FrontendOperations>
|
||||
|
||||
export namespace Frontend {
|
||||
export const Capabilities: typeof FrontendCapabilities = FrontendEndpoint.Capabilities
|
||||
export type Capability = (typeof Capabilities)[number]
|
||||
export const Request: Schema.Decoder<FrontendRequest> = FrontendEndpoint.Request
|
||||
export type Request = FrontendRequest
|
||||
export const decodeRequest: (input: unknown) => Request = FrontendEndpoint.decodeRequest
|
||||
export const decodeRequestEffect: (input: string) => Effect.Effect<Request, Schema.SchemaError> =
|
||||
FrontendEndpoint.decodeRequestEffect
|
||||
}
|
||||
|
||||
export namespace Backend {
|
||||
export const Capabilities = [
|
||||
"llm.attach",
|
||||
"llm.chunk",
|
||||
"llm.finish",
|
||||
"llm.disconnect",
|
||||
"llm.pending",
|
||||
"llm.request",
|
||||
"llm.tool-input-delta",
|
||||
"tool.attach",
|
||||
"tool.update",
|
||||
"tool.finish",
|
||||
"tool.fail",
|
||||
"tool.invocation",
|
||||
"tool.cancel",
|
||||
] 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 }),
|
||||
@@ -533,24 +702,6 @@ export namespace Backend {
|
||||
export const DisconnectParams = Schema.Struct({ id: Schema.String })
|
||||
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 }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.attach"), params: ToolAttachParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.update"), params: ToolUpdateParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.finish"), params: ToolFinishParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.fail"), params: ToolFailParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["llm.attach", "llm.pending"]),
|
||||
}),
|
||||
])
|
||||
export type Request = Schema.Schema.Type<typeof Request>
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
|
||||
|
||||
export const ProviderInvocation = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
|
||||
export interface ProviderInvocation extends Schema.Schema.Type<typeof ProviderInvocation> {}
|
||||
|
||||
@@ -566,53 +717,54 @@ export namespace Backend {
|
||||
export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {}
|
||||
}
|
||||
|
||||
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
|
||||
"SimulationRequestError",
|
||||
{
|
||||
method: Schema.String,
|
||||
code: Schema.Number,
|
||||
message: Schema.String,
|
||||
data: Schema.optionalKey(Schema.Json),
|
||||
},
|
||||
) {}
|
||||
const BackendOperations = [
|
||||
operation("llm.attach", Backend.Attached),
|
||||
operation("llm.pending", Backend.Pending),
|
||||
operationWithPayload("llm.chunk", Backend.ChunkParams, Backend.Ok),
|
||||
operationWithRpcPayload("llm.finish", Backend.FinishParams, Backend.FinishPayload, Backend.Ok),
|
||||
operationWithPayload("llm.disconnect", Backend.DisconnectParams, Backend.Ok),
|
||||
operationWithPayload("tool.attach", Backend.ToolAttachParams, Backend.Attached),
|
||||
operationWithPayload("tool.update", Backend.ToolUpdateParams, Backend.Ok),
|
||||
operationWithPayload("tool.finish", Backend.ToolFinishParams, Backend.Ok),
|
||||
operationWithPayload("tool.fail", Backend.ToolFailParams, Backend.Ok),
|
||||
] as const
|
||||
const BackendNotifications = [
|
||||
notification("llm.request", Backend.ProviderInvocation),
|
||||
notification("tool.invocation", Backend.ToolInvocation),
|
||||
notification("tool.cancel", Backend.ToolCancellation),
|
||||
] as const
|
||||
const BackendCapabilities = [
|
||||
"llm.attach",
|
||||
"llm.chunk",
|
||||
"llm.finish",
|
||||
"llm.disconnect",
|
||||
"llm.pending",
|
||||
"llm.request",
|
||||
"llm.tool-input-delta",
|
||||
"tool.attach",
|
||||
"tool.update",
|
||||
"tool.finish",
|
||||
"tool.fail",
|
||||
"tool.invocation",
|
||||
"tool.cancel",
|
||||
] as const
|
||||
const BackendEndpoint = endpoint(BackendOperations, BackendNotifications, ["llm.tool-input-delta"], BackendCapabilities)
|
||||
type BackendRequest = EndpointRequest<typeof BackendOperations>
|
||||
type BackendNotification = EndpointNotification<typeof BackendNotifications>
|
||||
|
||||
const request = <
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
|
||||
Success extends Schema.Top = typeof Schema.Void,
|
||||
>(
|
||||
tag: Tag,
|
||||
options?: {
|
||||
readonly payload?: Payload
|
||||
readonly success?: Success
|
||||
},
|
||||
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
|
||||
export namespace Backend {
|
||||
export const Capabilities: typeof BackendCapabilities = BackendEndpoint.Capabilities
|
||||
export const Request: Schema.Decoder<BackendRequest> = BackendEndpoint.Request
|
||||
export type Request = BackendRequest
|
||||
export const decodeRequest: (input: unknown) => Request = BackendEndpoint.decodeRequest
|
||||
export const decodeRequestEffect: (input: string) => Effect.Effect<Request, Schema.SchemaError> =
|
||||
BackendEndpoint.decodeRequestEffect
|
||||
export const Notification: Schema.Decoder<BackendNotification> = BackendEndpoint.Notification
|
||||
export type Notification = BackendNotification
|
||||
export const decodeNotification: (input: unknown) => Notification = BackendEndpoint.decodeNotification
|
||||
export const decodeNotificationEffect: (input: string) => Effect.Effect<Notification, Schema.SchemaError> =
|
||||
BackendEndpoint.decodeNotificationEffect
|
||||
}
|
||||
|
||||
export const UiRpcs = RpcGroup.make(
|
||||
request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response }),
|
||||
request("ui.state", { success: Frontend.State }),
|
||||
request("ui.snapshot", { success: Frontend.SemanticSnapshot }),
|
||||
request("ui.capture", { success: Frontend.CapturedFrame }),
|
||||
request("ui.matches", { payload: Frontend.MatchesParams, success: Frontend.Matches }),
|
||||
request("ui.recording.finish", { success: Frontend.RecordingFinish }),
|
||||
request("ui.type", { payload: Frontend.TypeParams, success: Frontend.State }),
|
||||
request("ui.press", { payload: Frontend.PressParams, success: Frontend.State }),
|
||||
request("ui.enter", { success: Frontend.State }),
|
||||
request("ui.arrow", { payload: Frontend.ArrowParams, success: Frontend.State }),
|
||||
request("ui.focus", { payload: Frontend.FocusParams, success: Frontend.State }),
|
||||
request("ui.click", { payload: Frontend.ClickParams, success: Frontend.State }),
|
||||
request("ui.resize", { payload: Frontend.ResizeParams, success: Frontend.State }),
|
||||
)
|
||||
|
||||
export const BackendRpcs = RpcGroup.make(
|
||||
request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response }),
|
||||
request("llm.attach", { success: Backend.Attached }),
|
||||
request("llm.pending", { success: Backend.Pending }),
|
||||
request("llm.chunk", { payload: Backend.ChunkParams, success: Backend.Ok }),
|
||||
request("llm.finish", { payload: Backend.FinishPayload, success: Backend.Ok }),
|
||||
request("llm.disconnect", { payload: Backend.DisconnectParams, success: Backend.Ok }),
|
||||
request("tool.attach", { payload: Backend.ToolAttachParams, success: Backend.Attached }),
|
||||
request("tool.update", { payload: Backend.ToolUpdateParams, success: Backend.Ok }),
|
||||
request("tool.finish", { payload: Backend.ToolFinishParams, success: Backend.Ok }),
|
||||
request("tool.fail", { payload: Backend.ToolFailParams, success: Backend.Ok }),
|
||||
)
|
||||
export const UiRpcs = FrontendEndpoint.rpcs
|
||||
export const BackendRpcs = BackendEndpoint.rpcs
|
||||
|
||||
@@ -1,6 +1,110 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Backend, Frontend, Handshake } from "../src/protocol"
|
||||
import { Backend, BackendRpcs, Frontend, Handshake, JsonRpc, UiRpcs } from "../src/protocol"
|
||||
|
||||
const uiCapability: Frontend.Capability = "ui.state"
|
||||
// @ts-expect-error capability literals must remain narrow for consumers
|
||||
const invalidUiCapability: Frontend.Capability = "ui.future"
|
||||
const successResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1, result: null }
|
||||
// @ts-expect-error responses require one outcome
|
||||
const missingResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1 }
|
||||
// @ts-expect-error responses cannot contain both outcomes
|
||||
const invalidResponse: Schema.Schema.Type<typeof JsonRpc.Response> = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: null,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
}
|
||||
void [uiCapability, invalidUiCapability, successResponse, missingResponse, invalidResponse]
|
||||
|
||||
test("preserves capability order and keeps request capabilities aligned with RPC groups", () => {
|
||||
expect(Frontend.Capabilities).toEqual([
|
||||
"ui.type",
|
||||
"ui.press",
|
||||
"ui.enter",
|
||||
"ui.arrow",
|
||||
"ui.focus",
|
||||
"ui.click",
|
||||
"ui.click.semantic",
|
||||
"ui.resize",
|
||||
"ui.matches",
|
||||
"ui.state",
|
||||
"ui.snapshot",
|
||||
"ui.capture",
|
||||
"ui.recording.finish",
|
||||
])
|
||||
expect(Backend.Capabilities).toEqual([
|
||||
"llm.attach",
|
||||
"llm.chunk",
|
||||
"llm.finish",
|
||||
"llm.disconnect",
|
||||
"llm.pending",
|
||||
"llm.request",
|
||||
"llm.tool-input-delta",
|
||||
"tool.attach",
|
||||
"tool.update",
|
||||
"tool.finish",
|
||||
"tool.fail",
|
||||
"tool.invocation",
|
||||
"tool.cancel",
|
||||
])
|
||||
expect(new Set<string>(Frontend.Capabilities.filter((capability) => capability !== "ui.click.semantic"))).toEqual(
|
||||
new Set(Array.from(UiRpcs.requests.keys()).filter((method) => method !== "simulation.handshake")),
|
||||
)
|
||||
expect(
|
||||
new Set<string>(
|
||||
Backend.Capabilities.filter(
|
||||
(capability) => !["llm.request", "llm.tool-input-delta", "tool.invocation", "tool.cancel"].includes(capability),
|
||||
),
|
||||
),
|
||||
).toEqual(new Set(Array.from(BackendRpcs.requests.keys()).filter((method) => method !== "simulation.handshake")))
|
||||
})
|
||||
|
||||
test("normalizes an omitted finish reason", () => {
|
||||
expect(Backend.decodeRequest({ jsonrpc: "2.0", id: 1, method: "llm.finish", params: { id: "inv_1" } })).toMatchObject(
|
||||
{ params: { id: "inv_1", reason: "stop" } },
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes typed backend notifications", () => {
|
||||
expect(
|
||||
Backend.decodeNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "interrupted" },
|
||||
}),
|
||||
).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "interrupted" },
|
||||
})
|
||||
expect(() =>
|
||||
Backend.decodeNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "unknown" },
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("requires exactly one JSON-RPC response outcome", () => {
|
||||
const decode = Schema.decodeUnknownSync(JsonRpc.Response)
|
||||
expect(decode({ jsonrpc: "2.0", id: 1, result: null })).toEqual({ jsonrpc: "2.0", id: 1, result: null })
|
||||
expect(decode({ jsonrpc: "2.0", id: 1, error: { code: -32600, message: "Invalid request" } })).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
})
|
||||
expect(() => decode({ jsonrpc: "2.0", id: 1 })).toThrow()
|
||||
expect(() =>
|
||||
decode({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: null,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("decodes ui.matches text params", () => {
|
||||
expect(
|
||||
|
||||
@@ -83,7 +83,7 @@ test("streams a Drive-controlled provider response and removes the finished invo
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "llm.finish",
|
||||
params: { id: params.id, reason: "stop" },
|
||||
params: { id: params.id },
|
||||
}),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# TUI UI Experiments
|
||||
|
||||
- Before implementing a visual behavior as an experiment, add a fixture-driven story under `src/feature-plugins/system/storybook` that renders the real production component.
|
||||
- Put the current treatment and meaningfully different variants in the story. Expose replay and tuning controls in `StoryFooter`, including reset when values are adjustable.
|
||||
- Let the user choose or tune a variant in the story before selecting production defaults.
|
||||
- After selection, register the behavior in `src/component/dialog-experiments.tsx` and gate it with `config.data.experimental?.<id> === true`; experiments must not change default behavior.
|
||||
- Treat tuning-only stories as local scaffolding and remove them and their registration before committing. Commit a story only when the user explicitly wants it retained as a reusable regression fixture.
|
||||
- Use OpenCode Drive with a simulated LLM for deterministic turn/session behavior. Do not invoke a real model only to verify TUI behavior.
|
||||
- Run the story with `OPENCODE_STORY=<story-id> bun run dev:live` and exercise relevant wide and narrow terminal sizes.
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, on, onMount, Show } from "solid-js"
|
||||
import { tint } from "../theme/color"
|
||||
import { createAnimatable, tween } from "../ui/animation"
|
||||
|
||||
export type AssistantSummaryFlash = {
|
||||
trigger: number
|
||||
duration: number
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export function AssistantSummary(props: {
|
||||
agent: string
|
||||
model: string
|
||||
duration?: string
|
||||
interrupted?: boolean
|
||||
agentColor: RGBA
|
||||
subduedColor: RGBA
|
||||
flashColor: RGBA
|
||||
animations: boolean
|
||||
flash?: AssistantSummaryFlash
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const flash = createAnimatable(
|
||||
{ level: 0 },
|
||||
{
|
||||
enabled: () => props.animations,
|
||||
transition: tween({ duration: props.flash?.duration ?? 0.32 }),
|
||||
},
|
||||
)
|
||||
const run = () => {
|
||||
if (!props.flash || !props.animations || props.flash.trigger === 0) return
|
||||
flash.jump({ level: props.flash.intensity })
|
||||
flash.animate({ level: 0 })
|
||||
}
|
||||
onMount(run)
|
||||
createEffect(on(() => props.flash?.trigger, run, { defer: true }))
|
||||
const color = (resting: RGBA) => tint(resting, props.flashColor, flash.value().level)
|
||||
|
||||
return (
|
||||
<text>
|
||||
<span style={{ fg: color(props.agentColor) }}>{props.agent}</span>
|
||||
<Show when={dimensions().width >= 28}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · {props.model}</span>
|
||||
</Show>
|
||||
<Show when={props.duration && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · {props.duration}</span>
|
||||
</Show>
|
||||
<Show when={props.interrupted}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -19,11 +19,6 @@ export const experiments: Experiment[] = [
|
||||
title: "Remember tab scroll",
|
||||
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
|
||||
},
|
||||
{
|
||||
id: "turn_summary_flash",
|
||||
title: "Turn summary flash",
|
||||
description: "Brighten the agent, model, and duration when a turn completes, then fade to their resting colors.",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
|
||||
@@ -98,7 +98,6 @@ import {
|
||||
type SessionRow,
|
||||
} from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { AssistantSummary } from "../../component/assistant-summary"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
@@ -1335,7 +1334,7 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
<Show when={props.message(row().messageID)}>
|
||||
{(message) => (
|
||||
<Show when={message().type === "assistant"}>
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} flash={row().flash} />
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1795,10 +1794,11 @@ function SessionGroupView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true }) {
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
@@ -1818,21 +1818,20 @@ function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
|
||||
<AssistantSummary
|
||||
agent={Locale.titlecase(props.message.agent)}
|
||||
model={model()}
|
||||
duration={duration() ? Locale.duration(duration()) : undefined}
|
||||
interrupted={interrupted()}
|
||||
agentColor={props.message.error ? theme.text.subdued : local.agent.color(props.message.agent)}
|
||||
subduedColor={theme.text.subdued}
|
||||
flashColor={theme.text.default}
|
||||
animations={ctx.config.animations ?? true}
|
||||
flash={
|
||||
props.flash && ctx.config.experimental?.turn_summary_flash === true
|
||||
? { trigger: 1, duration: 0.8, intensity: 0.7 }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<Show when={dimensions().width >= 28}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
||||
</Show>
|
||||
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ export type SessionRow =
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string; flash?: true }
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
@@ -176,13 +176,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
}),
|
||||
)
|
||||
|
||||
const appendFooter = (messageID: string, flash?: true) =>
|
||||
const appendFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
const index = queuedStart(draft)
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID, ...(flash ? { flash } : {}) })
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -268,12 +268,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID, true)
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
data.on("session.step.failed", (event) => {
|
||||
if (event.data.sessionID !== sessionID()) return
|
||||
appendFooter(event.data.assistantMessageID, true)
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user