Compare commits

...
112 changed files with 6255 additions and 533 deletions
+1
View File
@@ -46,6 +46,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles
- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
+1
View File
@@ -183,6 +183,7 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-YAKhbMPKeXAZLYgJzLRQi/fwIMjPOkVIvOWkG3mCgTE=",
"aarch64-linux": "sha256-UnIhoAleUQXKP8E0pVG0OyEpugmKEhfh6fEvpkpN2QI=",
"aarch64-darwin": "sha256-m68nJKpcVrX9R6EZqVvPKgRmZEXilJGcISMaQpN/k+Q=",
"x86_64-darwin": "sha256-DEjeoEn10K5YdJJCWtLw7LtthXChUXc0Vniccl/6KIc="
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
}
}
+9 -6
View File
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
@@ -169,17 +170,19 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
const routeDefaults = request.model.route.defaults
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
const messages = normalizeToolHistory(request.messages)
const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages })
const routeDefaults = normalized.model.route.defaults
const modelDefaults = normalized.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation)
return LLMRequest.update(normalized, {
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
modelDefaults?.providerOptions,
request.providerOptions,
normalized.providerOptions,
),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
})
}
+74
View File
@@ -0,0 +1,74 @@
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
const EMPTY_TOOL_OUTPUT = "(no tool output)"
const MISSING_TOOL_RESULT = "Tool result missing"
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
const pending = new Map<string, ToolCallPart>()
const appendMissingResults = () => {
if (pending.size === 0) return
normalized.push(missingToolResults(pending.values()))
pending.clear()
}
for (const message of messages) {
if (message.role === "user" || message.role === "assistant") appendMissingResults()
if (message.role === "tool") {
const tool = normalizeToolMessage(message, pending)
if (tool) normalized.push(tool)
continue
}
normalized.push(message)
if (message.role !== "assistant") continue
for (const part of message.content) {
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
}
}
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
? messages
: normalized
}
function missingToolResults(calls: Iterable<ToolCallPart>) {
return new Message({
role: "tool",
content: [...calls].map((call) =>
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
),
})
}
function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPart>): Message | undefined {
const content = message.content.map((part) => {
if (part.type !== "tool-result" || part.providerExecuted === true) return part
const call = pending.get(part.id)
if (call) pending.delete(part.id)
return normalizeToolResult(part, call?.name ?? part.name)
})
if (content.length === 0) return undefined
if (content.every((part, index) => part === message.content[index])) return message
return new Message({
id: message.id,
role: message.role,
content,
metadata: message.metadata,
native: message.native,
})
}
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
const named = part.name === name ? part : { ...part, name }
if (named.result.type === "text" && named.result.value === "")
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type === "error" && named.result.value === "")
return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type !== "content") return named
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (value.length === named.result.value.length) return named
return { ...named, result: { type: "content", value } }
}
+20
View File
@@ -106,6 +106,26 @@ describe("request option precedence", () => {
}),
)
it.effect("normalizes tool history before protocol lowering", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
messages: [
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
Message.user("Continue."),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] },
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing" },
{ role: "user", content: "Continue." },
])
}),
)
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
LLMClient.generate(
LLM.request({
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js"
import { normalizeToolHistory } from "../src/tool-history.js"
const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} })
const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") =>
Message.tool(ToolResultPart.make({ id, name, result: value, resultType }))
describe("tool history normalization", () => {
test("fills missing local results before the next step", () => {
const normalized = normalizeToolHistory([
Message.assistant([toolCall("first"), toolCall("second")]),
toolResult("first", "done", "wrong", "text"),
Message.user("Continue."),
Message.assistant(toolCall("trailing")),
])
expect(normalized.map((message) => message.role)).toEqual([
"assistant",
"tool",
"tool",
"user",
"assistant",
])
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
expect(normalized[2]?.content).toEqual([
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
])
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
})
test("normalizes empty results without changing whitespace or media", () => {
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
const normalized = normalizeToolHistory([
Message.assistant([
toolCall("text"),
toolCall("content"),
toolCall("error"),
toolCall("mixed"),
toolCall("whitespace"),
]),
toolResult("text", "", "text", "text"),
toolResult("content", [], "content", "content"),
toolResult("error", "", "error", "error"),
toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"),
toolResult("whitespace", " ", "whitespace", "text"),
])
expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
])
})
test("leaves unmatched and provider-executed history unchanged", () => {
const hostedCall = ToolCallPart.make({
id: "hosted",
name: "web_search",
input: {},
providerExecuted: true,
})
const hostedResult = ToolResultPart.make({
id: "hosted",
name: "web_search",
result: "",
resultType: "text",
providerExecuted: true,
})
const hosted = Message.assistant([hostedCall, hostedResult])
const orphan = toolResult("orphan", "ignored", "orphan", "text")
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
})
})
@@ -65,8 +65,8 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
state: { status: "active" },
features: { server: true },
})),
},
})
@@ -83,7 +83,12 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
json: {
location: { directory },
data: [
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
@@ -5,10 +5,20 @@ import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
@@ -1,4 +1,5 @@
import { EOL } from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
@@ -7,7 +8,7 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -19,7 +20,7 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
@@ -48,11 +49,15 @@ export function format(
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.status})`)
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
+25 -7
View File
@@ -6,18 +6,23 @@ test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
status: "active",
tui: true,
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", package: "broken-plugin" },
status: "failed",
error: "broken",
tui: false,
state: { status: "failed", error: "broken" },
features: { server: true },
},
{
id: "local.dual",
source: { type: "local", path: "/tmp/local/index.ts" },
state: { status: "active" },
features: { server: true, tui: true },
},
],
[
@@ -28,6 +33,7 @@ test("formats server and TUI plugins in sections without builtins", () => {
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
@@ -35,12 +41,24 @@ test("formats server and TUI plugins in sections without builtins", () => {
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
format(
[
{
id: "opencode.agent",
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
],
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
+2 -1
View File
@@ -55,6 +55,7 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"solid-js": "catalog:",
"zod": "catalog:"
}
}
+2
View File
@@ -1,5 +1,7 @@
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
export type { RpcApi, RpcClient } from "./rpc.js"
export type * from "./api/api.js"
export type WebSearchApi<E = never> = WebsearchApi<E>
+14
View File
@@ -1573,6 +1573,19 @@ export interface SkillApi<E = never> {
readonly list: SkillListOperation<E>
}
export type RpcCallInput = {
readonly rpcID: string
readonly method: string
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly input?: unknown | undefined
}
export type RpcCallOutput = { readonly output?: unknown }
export type RpcCallOperation<E = never> = (input: RpcCallInput) => Effect.Effect<RpcCallOutput, E>
export interface RpcApi<E = never> {
readonly call: RpcCallOperation<E>
}
export type EventSubscribeOutput = OpenCodeEvent
export type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>
@@ -2073,6 +2086,7 @@ export interface AppApi<E = never> {
readonly file: FileApi<E>
readonly command: CommandApi<E>
readonly skill: SkillApi<E>
readonly rpc: RpcApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
+58
View File
@@ -0,0 +1,58 @@
export * as OpenCode from "./client.js"
import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
import { ClientError, OpenCode } from "./generated/index.js"
import { RpcClientRuntime } from "./rpc.js"
import type { RpcCallOptions } from "../promise/rpc.js"
const CurrentHeaders = Context.Reference<RpcCallOptions["headers"]>("@opencode-ai/client/effect/rpc/headers", {
defaultValue: () => undefined,
})
export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) {
const httpClient = yield* HttpClient.HttpClient
const raw = yield* OpenCode.make(options).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
),
),
)
const context = yield* Effect.context()
const native = raw.event.subscribe()
// Async iterators throw a squashed cause; retain the native typed failures and defects intact.
class EventFailure {
constructor(readonly cause: Cause.Cause<Stream.Error<typeof native>>) {}
}
const shared = SharedEvents.make((signal) =>
Stream.toAsyncIterableWith(
native.pipe(
Stream.interruptWhen(RpcClientRuntime.aborted(signal)),
Stream.catchCause((cause) => Stream.fail(new EventFailure(cause))),
),
context,
),
)
const subscribe = () =>
Stream.fromAsyncIterable(shared.subscribe(), (error) => error).pipe(
Stream.catch((error) =>
Stream.failCause(error instanceof EventFailure ? error.cause : Cause.fail(new ClientError({ cause: error }))),
),
)
return {
...raw,
event: { ...raw.event, subscribe },
rpc: Object.assign(
RpcClientRuntime.make(
(input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)),
subscribe,
),
raw.rpc,
),
}
})
@@ -185,6 +185,8 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1166,6 +1168,17 @@ const EndpointSkillList = (raw: RawClient["server.skill"]) => (input?: SkillList
const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSkillList(raw) })
const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) =>
preserveEffect<RpcCallOutput>()(
raw["rpc.call"]({
params: { rpcID: input["rpcID"], method: input["method"] },
query: { location: input["location"] },
payload: { input: input["input"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupRpc = (raw: RawClient["server.rpc"]) => ({ call: EndpointRpcCall(raw) })
const EndpointEventSubscribe = (raw: RawClient["server.event"]) => () =>
preserveStream<EventSubscribeOutput>()(
Stream.unwrap(
@@ -1564,6 +1577,7 @@ const adaptClient = (raw: RawClient) => ({
file: adaptGroupFile(raw["server.fs"]),
command: adaptGroupCommand(raw["server.command"]),
skill: adaptGroupSkill(raw["server.skill"]),
rpc: adaptGroupRpc(raw["server.rpc"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
+5 -1
View File
@@ -1,8 +1,10 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
import type { OpenCode } from "./client.js"
export * from "./generated/index"
export { OpenCode } from "./client.js"
export type {
AgentApi,
AppApi,
@@ -15,6 +17,8 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcClient,
WebSearchApi,
SessionApi,
SkillApi,
@@ -48,4 +52,4 @@ export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
export type OpenCodeClient = Effect.Success<ReturnType<typeof OpenCode.make>>
+94
View File
@@ -0,0 +1,94 @@
export * as RpcClientRuntime from "./rpc.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Effect, Schema, Stream } from "effect"
import type { RpcArguments, RpcCallOptions } from "../promise/rpc.js"
import { RpcRuntime } from "../rpc-runtime.js"
import type { RpcCallInput, RpcCallOutput } from "./api/api.js"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
type DecodeError<S> = S extends Schema.Top ? Schema.SchemaError : never
export type RpcClient<
D extends Rpc.Definition,
E = never,
Options = RpcCallOptions,
EventError = E,
> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Effect.Effect<
Rpc.Output<D["methods"][Name]["output"]>,
Rpc.MethodError<D["methods"][Name]> | DecodeError<D["methods"][Name]["output"]> | E
>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
) => Stream.Stream<Rpc.EventPayload<D, Name>, DecodeError<D["events"][Name]["schema"]> | EventError>
}
}
export interface RpcApi<E = never, Options = RpcCallOptions, EventError = E> {
<D extends Rpc.Definition>(definition: D): RpcClient<D, E, Options, EventError>
}
export function make<CallError, EventError>(
call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect<RpcCallOutput, CallError>,
subscribe: () => Stream.Stream<OpenCodeEvent, EventError>,
): RpcApi<Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError> {
return <D extends Rpc.Definition>(definition: D) => {
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input?: unknown, options?: RpcCallOptions) => {
const result = Effect.gen(function* () {
const response = yield* call(
{
rpcID: definition.id,
method: name,
input,
location: options?.location,
},
options,
)
return yield* RpcRuntime.read(method.output, response.output)
}).pipe(Effect.catch((error) => RpcRuntime.readError(method, error)))
const signal = options?.signal
if (!signal) return result
return Effect.suspend(() =>
signal.aborted
? Effect.interrupt
: Effect.raceFirst(result, Effect.andThen(aborted(signal), Effect.interrupt)),
)
},
]),
)
// SAFETY: Every runtime key comes from this definition, and each value is decoded through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(methods, {
events: {
subscribe: (name: keyof D["events"] & string) => {
const type = RpcRuntime.eventType(definition, name)
if (!Object.hasOwn(definition.events, name)) return Stream.fail(new Error(`Unknown RPC event: ${type}`))
const schema = definition.events[name]
return subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === type),
Stream.mapEffect((event) => RpcRuntime.event(definition, name, schema, event)),
)
},
},
}) as RpcClient<D, Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError>
}
}
export function aborted(signal: AbortSignal) {
return Effect.callback<void>((resume) => {
if (signal.aborted) return resume(Effect.void)
const abort = () => resume(Effect.void)
signal.addEventListener("abort", abort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", abort))
})
}
+5 -1
View File
@@ -1,4 +1,8 @@
type Client = ReturnType<typeof import("./generated/client.js").make>
import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
+18
View File
@@ -0,0 +1,18 @@
export * as OpenCode from "./client.js"
import { SharedEvents } from "../shared-events.js"
import { OpenCode } from "./generated/index.js"
import type { ClientOptions } from "./generated/client.js"
import { makeRpc } from "./rpc.js"
export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
event: events,
}
}
@@ -181,6 +181,8 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1594,6 +1596,21 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
rpc: {
call: (input: RpcCallInput, requestOptions?: RequestOptions) =>
request<RpcCallOutput>(
{
method: "POST",
path: `/api/rpc/${encodeURIComponent(input.rpcID)}/${encodeURIComponent(input.method)}`,
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 500, 401],
empty: false,
},
requestOptions,
),
},
event: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventSubscribeOutput> =>
sse<EventSubscribeOutput>(
+46 -3
View File
@@ -16,6 +16,10 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -333,6 +337,8 @@ export type SkillInfo = {
content: string
}
export type RpcOutput = { output?: any }
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
@@ -442,9 +448,7 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; state: PluginState }
export type SessionMessageLocationSwitched = {
id: string
@@ -459,6 +463,15 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type V2EventRpc = {
id: string
created: number
metadata?: { [x: string]: any } | undefined
type: `${"rpc."}${string}`
location: LocationRef
data: { [x: string]: any }
}
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: any } | undefined
@@ -2315,6 +2328,7 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| V2EventRpc
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
@@ -2481,6 +2495,24 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcError = (value: unknown): value is RpcError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcError"
export type RpcInternalError = {
readonly _tag: "RpcInternalError"
readonly type: "rpc.internal" | "rpc.invalid_output"
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcInternalError = (value: unknown): value is RpcInternalError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcInternalError"
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
@@ -5669,6 +5701,17 @@ export type SkillListOutput = {
data: Array<SkillInfo>
}
export type RpcCallInput = {
readonly rpcID: { readonly rpcID: string; readonly method: string }["rpcID"]
readonly method: { readonly rpcID: string; readonly method: string }["method"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly input?: { readonly input: JsonValue }["input"]
}
export type RpcCallOutput = RpcOutput
export type EventSubscribeOutput = V2Event
export type PtyListInput = {
+8 -1
View File
@@ -1,4 +1,7 @@
import type { OpenCode } from "./client.js"
export * from "./generated/index.js"
export { OpenCode } from "./client.js"
export type {
AgentApi,
CatalogApi,
@@ -10,9 +13,13 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcCallOptions,
RpcClient,
RpcEventPayload,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js"
export type OpenCodeClient = ReturnType<typeof import("./generated/client.js").make>
export type OpenCodeClient = ReturnType<typeof OpenCode.make>
+147
View File
@@ -0,0 +1,147 @@
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { make, RequestOptions } from "./generated/client.js"
import { isRpcError, isRpcInternalError } from "./generated/types.js"
import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js"
type RpcEvent = Extract<EventSubscribeOutput, { type: `rpc.${string}` }>
export interface RpcCallOptions extends RequestOptions {
readonly location?: LocationGetInput["location"]
}
export type RpcArguments<Input, Options> = unknown extends Input
? [input: Input, options?: Options]
: undefined extends Input
? [input?: Input, options?: Options]
: [input: Input, options?: Options]
export type RpcClient<D extends Rpc.PortableDefinition, Options = RpcCallOptions> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
options?: Pick<RequestOptions, "signal">,
) => AsyncIterable<RpcEventPayload<D, Name>>
readonly on: <Name extends keyof D["events"] & string>(
name: Name,
handler: (event: RpcEventPayload<D, Name>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => () => void
}
}
type RpcEventPayloadFor<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string,
> = Omit<RpcEvent, "type" | "data"> & {
type: `rpc.${D["id"]}.${Name}`
data: Rpc.EventData<D["events"][Name]["schema"]>
}
export type RpcEventPayload<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string = keyof D["events"] & string,
> = { [K in Name]: RpcEventPayloadFor<D, K> }[Name]
export interface RpcApi<Options = RpcCallOptions> {
<D extends Rpc.PortableDefinition>(definition: D): RpcClient<D, Options>
}
export function makeRpc(
raw: ReturnType<typeof make>,
events: { subscribe(options?: Pick<RequestOptions, "signal">): AsyncIterable<EventSubscribeOutput> },
): RpcApi {
return (definition) => {
const subscribe = (
name: string,
options?: Pick<RequestOptions, "signal">,
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => {
if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.id}.${name}`)
const type = eventType(definition, name)
return {
[Symbol.asyncIterator]() {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const iterator = (async function* () {
try {
for await (const published of events.subscribe({ signal })) {
if (signal.aborted) return
if (published.type !== type) continue
// SAFETY: The exact RPC type was selected above; Promise contracts require no client-side transform.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
yield published as RpcEventPayload<Rpc.PortableDefinition>
}
} catch (error) {
if (!signal.aborted) throw error
} finally {
controller.abort()
}
})()
return {
next: () => iterator.next(),
return: () => {
// Interrupt a pending source read before closing the generator.
controller.abort()
return iterator.return()
},
}
},
}
}
// SAFETY: Every runtime key comes from this definition's method and event maps, which define RpcClient's mapped keys.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(
Object.fromEntries(
Object.keys(definition.methods).map((name) => [
name,
async (input: unknown, options?: RpcCallOptions) => {
try {
const result = await raw.rpc.call(
{
rpcID: definition.id,
method: name,
// SAFETY: The method schema defines the accepted input; this assertion bridges it to the generic JSON transport.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
input: input as RpcCallInput["input"],
location: options?.location,
},
{ signal: options?.signal, headers: options?.headers },
)
return result.output
} catch (error) {
if (!isRpcError(error) && !isRpcInternalError(error)) throw error
throw error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data }
}
},
]),
),
{
events: {
subscribe,
on: (
name: string,
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const source = subscribe(name, { signal })
void (async () => {
for await (const event of source) await handler(event)
})().catch((error: unknown) => console.error(error))
return () => controller.abort()
},
},
},
) as RpcClient<typeof definition>
}
}
function eventType(definition: Rpc.PortableDefinition, name: string) {
return `rpc.${definition.id}.${name}` as const
}
+60
View File
@@ -0,0 +1,60 @@
export * as RpcRuntime from "./rpc-runtime.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import { Effect, Schema } from "effect"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
export function read(schema: Rpc.Method["output"], value: unknown) {
// Standard Schema results have already been parsed by the server.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
export function readError(method: Rpc.Method, error: unknown): Effect.Effect<never, unknown> {
if (!(error instanceof RpcError) && !(error instanceof RpcInternalError)) return Effect.fail(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.fail(
error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data },
)
}
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) =>
Effect.fail(
data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data },
),
),
)
}
export const event = Effect.fn("Client.Rpc.event")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
schema: Rpc.EventDefinition,
event: RpcEvent,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const data = yield* read(schema.schema, event.data)
// SAFETY: The event type was selected by the caller and data was decoded with this event's schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...event,
type: eventType(definition, name),
data,
} as Rpc.EventPayload<D, Name>
})
export function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["id"]}.${Name}` {
return `rpc.${definition.id}.${name}`
}
+130
View File
@@ -0,0 +1,130 @@
export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => void
finish: (completion: Completion) => void
}
type Connection = {
controller: AbortController
subscribers: Set<Subscriber>
connected?: A
}
let current: Connection | undefined
function stop(connection: Connection) {
connection.connected = undefined
connection.controller.abort()
if (current === connection) current = undefined
}
async function run(connection: Connection) {
let iterator: AsyncIterator<A> | undefined
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
if (item.value.type === "server.connected") connection.connected = item.value
connection.subscribers.forEach((subscriber) => subscriber.push(item.value))
}
} catch (error) {
completion = { error }
} finally {
stop(connection)
try {
await iterator?.return?.()
} catch (error) {
if (!("error" in completion)) completion = { error }
}
connection.subscribers.forEach((subscriber) => subscriber.finish(completion))
}
}
return {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
let started = false
let completion: Completion | undefined
let connection: Connection | undefined
const queued: A[] = []
function finish(result: Completion) {
completion = result
options?.signal?.removeEventListener("abort", abort)
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
pending.splice(0).forEach((request) => {
if ("error" in result) request.reject(result.error)
else request.resolve({ done: true, value: undefined })
})
}
function abort() {
queued.splice(0)
finish({})
}
const subscriber: Subscriber = {
finish,
push(value) {
if (completion) return
const request = pending.shift()
if (request) {
request.resolve({ done: false, value })
return
}
queued.push(value)
},
}
function start() {
if (completion) return
const fresh = !current
connection = current ?? {
controller: new AbortController(),
subscribers: new Set<Subscriber>(),
}
current = connection
connection.subscribers.add(subscriber)
if (connection.connected) subscriber.push(connection.connected)
if (fresh) void run(connection)
}
return {
next(): Promise<IteratorResult<A>> {
const value = queued.shift()
if (value) return Promise.resolve({ done: false, value })
if (completion) {
if ("error" in completion) return Promise.reject(completion.error)
return Promise.resolve({ done: true, value: undefined })
}
if (options?.signal?.aborted) {
abort()
return Promise.resolve({ done: true, value: undefined })
}
const request = Promise.withResolvers<IteratorResult<A>>()
pending.push(request)
if (!started) {
started = true
options?.signal?.addEventListener("abort", abort, { once: true })
start()
}
return request.promise
},
return(): Promise<IteratorResult<A>> {
queued.splice(0)
finish({})
return Promise.resolve({ done: true, value: undefined })
},
}
},
}
},
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
if ("durable" in event.value && event.value.durable)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
+2 -1
View File
@@ -51,6 +51,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export type CreateDataInput = {
readonly api: () => OpenCodeClient
@@ -58,7 +59,7 @@ export type CreateDataInput = {
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
handler: (event: OpenCodeEventMap[Type]) => void,
) => () => void
readonly listen: (handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) => () => void
}
+2
View File
@@ -45,6 +45,7 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
sessionID: "ses_test",
key: "review-notes",
})
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
void [
effectSession,
@@ -54,6 +55,7 @@ void [
promiseList,
promisePut,
promiseRemove,
emptyRpcOutput,
exactVersion,
compatibleVersion,
]
+34 -21
View File
@@ -14,34 +14,34 @@ describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
expect(within(root.all, effect)).toEqual([])
expect(within(root.all, schema)).toEqual([])
expect(within(root.all, protocol)).toEqual([])
expect(within(root.all, core)).toEqual([])
expect(within(root.all, server)).toEqual([])
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
expect(within(network.eager, effect).length).toBeGreaterThan(0)
expect(within(network.eager, schema).length).toBeGreaterThan(0)
expect(within(network.eager, protocol).length).toBeGreaterThan(0)
expect(within(network.all, core)).toEqual([])
expect(within(network.all, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService, effect)).toEqual([])
expect(within(promiseService, schema)).toEqual([])
expect(within(promiseService, protocol)).toEqual([])
expect(within(promiseService, core)).toEqual([])
expect(within(promiseService, server)).toEqual([])
expect(within(promiseService.all, effect)).toEqual([])
expect(within(promiseService.all, schema)).toEqual([])
expect(within(promiseService.all, protocol)).toEqual([])
expect(within(promiseService.all, core)).toEqual([])
expect(within(promiseService.all, server)).toEqual([])
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
expect(within(effectService, effect).length).toBeGreaterThan(0)
expect(within(effectService, protocol).length).toBeGreaterThan(0)
expect(within(effectService, core)).toEqual([])
expect(within(effectService, server)).toEqual([])
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
expect(within(effectService.all, core)).toEqual([])
expect(within(effectService.all, server)).toEqual([])
})
})
@@ -70,8 +70,21 @@ async function bundleInputs(specifier: string, target: "browser" | "bun") {
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
const metadata: {
inputs: Record<string, { imports: Array<{ path: string; kind: string; external?: boolean }> }>
} = await Bun.file(metafile).json()
const inputs = new Map(Object.entries(metadata.inputs).map(([file, input]) => [resolve(directory, file), input]))
const eager = new Set<string>()
const visit = (file: string) => {
if (eager.has(file)) return
eager.add(file)
inputs
.get(file)
?.imports.filter((input) => !input.external && input.kind !== "dynamic-import")
.forEach((input) => visit(resolve(directory, input.path)))
}
visit(entrypoint)
return { all: Array.from(inputs.keys()), eager: Array.from(eager) }
} finally {
await rm(temporary, { recursive: true, force: true })
}
+46
View File
@@ -24,6 +24,7 @@ test("exposes every standard HTTP API group", () => {
"file",
"command",
"skill",
"rpc",
"event",
"pty",
"experimental",
@@ -677,6 +678,51 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
})
})
test("native event signals cancel only their listener and close transport after the last listener", async () => {
const opened = Promise.withResolvers<Request>()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
opened.resolve(request)
return new Response(
new ReadableStream({
start(controller) {
request.signal.addEventListener("abort", () => controller.error(request.signal.reason), { once: true })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const first = new AbortController()
const second = new AbortController()
const one = client.event.subscribe({ signal: first.signal })[Symbol.asyncIterator]().next()
const two = client.event.subscribe({ signal: second.signal })[Symbol.asyncIterator]().next()
const request = await opened.promise
expect(request.headers.get("authorization")).toBe("Bearer events")
first.abort()
expect((await one).done).toBe(true)
expect(request.signal.aborted).toBe(false)
second.abort()
expect((await two).done).toBe(true)
expect(request.signal.aborted).toBe(true)
})
test("native pre-aborted event signals do not open a transport", async () => {
let requests = 0
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => {
requests++
return new Response(null)
},
})
expect((await client.event.subscribe({ signal: AbortSignal.abort() })[Symbol.asyncIterator]().next()).done).toBe(true)
expect(requests).toBe(0)
})
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
+495
View File
@@ -0,0 +1,495 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
id: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
id: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})
+361
View File
@@ -0,0 +1,361 @@
import { afterEach, expect, test } from "bun:test"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Rpc } from "@opencode-ai/schema/rpc"
import { z } from "zod"
import { OpenCode } from "../src/promise/index"
const cleanup = new Set<() => void>()
afterEach(() => {
cleanup.forEach((close) => close())
cleanup.clear()
})
const Echo = Rpc.define({
id: "acme/jobs",
methods: {
echo: {
input: z.string(),
output: z.string(),
errors: { rejected: z.object({ reason: z.string() }) },
},
raw: { input: z.unknown(), output: z.unknown() },
ping: { input: z.undefined(), output: z.undefined() },
},
events: {
updated: { schema: z.object({ count: z.number() }) },
},
})
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
id: "evt_rpc",
created: 10,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { source: "test" },
data,
})
function http(fetch: (request: Request) => Response | Promise<Response>) {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
cleanup.add(() => server.stop(true))
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
}
function events() {
const requests: Request[] = []
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
const cancelled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
let stopped = false
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const abort = () => {
if (stopped) return
stopped = true
controller.error(request.signal.reason)
cancelled.resolve()
}
request.signal.addEventListener("abort", abort, { once: true })
cleanup.add(abort)
opened.resolve(controller)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
},
cancel() {
stopped = true
cancelled.resolve()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
return {
client,
requests,
cancelled: cancelled.promise,
async send(value: unknown) {
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
},
async end() {
stopped = true
return (await opened.promise).close()
},
async fail(error: Error) {
stopped = true
return (await opened.promise).error(error)
},
}
}
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
const client = http(async (request) => {
const body = await request.json()
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
return Response.json({ output: body.input })
})
expect(typeof client.rpc).toBe("function")
expect(typeof client.rpc.call).toBe("function")
expect(
await client.rpc(Echo).echo("hello", {
location: { directory: "/project with spaces", workspace: "wrk_test" },
headers: { authorization: "Bearer override", "x-call": "call" },
}),
).toBe("hello")
const url = new URL(requests[0].url)
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
expect(requests[0].body).toEqual({ input: "hello" })
expect(requests[0].method).toBe("POST")
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
expect(requests[0].headers.get("x-base")).toBe("base")
expect(requests[0].headers.get("x-call")).toBe("call")
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
expect(new URL(requests[1].url).search).toBe("")
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
})
test("no-input RPC methods and absent output use empty wrappers", async () => {
const client = http(async (request) => {
expect(await request.json()).toEqual({})
return Response.json({})
})
expect(await client.rpc(Echo).ping()).toBeUndefined()
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
})
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
const calls = { input: 0, output: 0 }
const input: StandardSchemaV1<string, number> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.input++
return { value: Number(value) }
},
},
}
const output: StandardSchemaV1<number, string> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.output++
return { value: String(value) }
},
},
}
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
return { issues: [{ message: "Expected count" }] }
return { value: { text: String(value.count) } }
},
},
}
const definition = Rpc.define({
id: "standard",
methods: { count: { input, output } },
events: { counted: { schema: eventOutput } },
})
const client = http(async (request) => {
expect(await request.json()).toEqual({ input: "41" })
return Response.json({ output: "42" })
})
expect(await client.rpc(definition).count("41")).toBe("42")
const source = events()
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
const next = iterator.next()
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
expect((await next).value?.data).toEqual({ text: "42" })
await iterator.return?.()
expect(calls).toEqual({ input: 0, output: 0 })
})
test("RPC method signals cancel an in-flight HTTP request", async () => {
const received = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const client = http(() => {
received.resolve()
return response.promise
})
const controller = new AbortController()
const result = client
.rpc(Echo)
.echo("hello", { signal: controller.signal })
.catch((error: unknown) => error)
await received.promise
controller.abort()
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
response.resolve(Response.json({ output: "late" }))
})
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
let requests = 0
const client = http(() => {
requests++
return Response.json({ output: "hello" })
})
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
expect(requests).toBe(0)
})
test("RPC declared HTTP failures propagate", async () => {
await expect(
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
.rpc(Echo)
.echo("hello"),
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
})
test("RPC method failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
const client = http(() => Response.json(response, { status: 400 }))
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
})
test("RPC transport failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
type: "rpc.internal",
message: "Failed",
})
})
test("native events and multiple RPC clients share one lazy source across locations", async () => {
const source = events()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const firstNext = first.next()
const secondNext = second.next()
const otherNext = other.next()
expect(await native.next()).toEqual({ done: false, value: connected })
expect(source.requests).toHaveLength(1)
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
expect(await late.next()).toEqual({ done: false, value: connected })
await Promise.all([native.return?.(), late.return?.()])
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
expect((await otherNext).value).toMatchObject({
type: "rpc.other.updated",
location: { directory: "/other" },
data: { count: 9 },
})
await other.return?.()
await source.send(rpcEvent({ count: 42 }))
const expected = {
id: "evt_rpc",
created: 10,
type: `rpc.${Echo.id}.updated`,
location: { directory: "/first" },
metadata: { source: "test" },
data: { count: 42 },
}
expect(await firstNext).toEqual({ done: false, value: expected })
expect(await secondNext).toEqual({ done: false, value: expected })
const next = first.next()
await source.send(rpcEvent({ count: 43 }, "/second"))
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
await Promise.all([first.return?.(), second.return?.()])
await source.cancelled
expect(source.requests[0].signal.aborted).toBe(true)
expect(source.requests).toHaveLength(1)
})
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
const source = events()
const controller = new AbortController()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
const second = secondEvents[Symbol.asyncIterator]()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const firstNext = first.next()
const secondNext = second.next()
await native.next()
expect((await first.return?.())?.done).toBe(true)
expect((await firstNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
controller.abort()
expect((await secondNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
const nativeNext = native.next()
const event = rpcEvent({ count: 42 })
await source.send(event)
expect(await nativeNext).toEqual({ done: false, value: event })
await native.return?.()
await source.cancelled
})
test("RPC callback subscriptions unsubscribe independently", async () => {
const source = events()
const received = Promise.withResolvers<unknown>()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
await native.next()
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
await source.send(rpcEvent({ count: 42 }))
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
unsubscribe()
unsubscribe()
expect(source.requests[0].signal.aborted).toBe(false)
await native.return?.()
await source.cancelled
})
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
const source = events()
const client = source.client.rpc(Echo)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failed: number[] = []
cleanup.add(release.resolve)
cleanup.add(
client.events.on("updated", async (event) => {
failed.push(event.data.count)
started.resolve()
await release.promise
throw new Error("Expected async RPC callback failure")
}),
)
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
const first = healthy.next()
await source.send(rpcEvent({ count: 1 }))
await started.promise
expect((await first).value.data.count).toBe(1)
const second = healthy.next()
await source.send(rpcEvent({ count: 2 }))
expect((await second).value.data.count).toBe(2)
expect(failed).toEqual([1])
release.resolve()
await healthy.return?.()
await source.cancelled
expect(failed).toEqual([1])
})
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
const source = events()
const broad: Rpc.PortableDefinition = Echo
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
const iterator = aborted[Symbol.asyncIterator]()
expect((await iterator.next()).done).toBe(true)
expect(source.requests).toHaveLength(0)
})
+321
View File
@@ -0,0 +1,321 @@
import { expect, test } from "bun:test"
import { SharedEvents } from "../src/shared-events"
type Event = { readonly type: string; readonly value?: number }
function source(cleanup?: Promise<void>) {
const connections: {
signal: AbortSignal
push: (event: Event) => void
close: () => void
fail: (error: unknown) => void
closing: Promise<void>
closed: Promise<void>
}[] = []
const opened: ReturnType<typeof Promise.withResolvers<void>>[] = []
return {
connections,
async at(index: number) {
if (!connections[index]) await (opened[index] ??= Promise.withResolvers<void>()).promise
return connections[index]
},
connect(signal: AbortSignal): AsyncIterable<Event> {
let controller!: ReadableStreamDefaultController<Event>
let ended = false
const closing = Promise.withResolvers<void>()
const closed = Promise.withResolvers<void>()
const stream = new ReadableStream<Event>({
start(value) {
controller = value
},
})
const close = () => {
if (ended) return
ended = true
controller.close()
}
signal.addEventListener("abort", close, { once: true })
connections.push({
signal,
push: (event) => controller.enqueue(event),
close,
fail(error) {
ended = true
controller.error(error)
},
closing: closing.promise,
closed: closed.promise,
})
opened[connections.length - 1]?.resolve()
return (async function* () {
try {
yield* stream
} finally {
signal.removeEventListener("abort", close)
closing.resolve()
await cleanup
closed.resolve()
}
})()
},
}
}
test("creation, subscription, and idle iterators are lazy", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const iterable = shared.subscribe()
const idle = iterable[Symbol.asyncIterator]()
expect(events.connections).toHaveLength(0)
expect(await idle.return!()).toEqual({ done: true, value: undefined })
expect(await idle.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
const active = iterable[Symbol.asyncIterator]()
const next = active.next()
expect(events.connections).toHaveLength(1)
events.connections[0].push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await active.return!()
await events.connections[0].closed
})
test("pre-aborted subscribers do not open a source", async () => {
const events = source()
const controller = new AbortController()
const iterator = SharedEvents.make(events.connect).subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
controller.abort()
expect(await iterator.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
})
test("multiple consumers share one source and receive live native and RPC events", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [
{ type: "server.connected" },
{ type: "session.updated" },
{ type: "rpc.example.updated", value: 1 },
]) {
const reads = [first.next(), second.next()]
events.connections[0].push(event)
expect(await Promise.all(reads)).toEqual([
{ done: false, value: event },
{ done: false, value: event },
])
}
expect(events.connections).toHaveLength(1)
await first.return!()
expect(events.connections[0].signal.aborted).toBe(false)
const next = second.next()
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect((await next).value).toEqual({ type: "rpc.example.updated", value: 2 })
await second.return!()
await events.connections[0].closed
})
test("a paused consumer does not block other subscribers", async () => {
const events = source()
const shared = SharedEvents.make((signal) => events.connect(signal))
const paused = shared.subscribe()[Symbol.asyncIterator]()
const active = shared.subscribe()[Symbol.asyncIterator]()
const connected = [paused.next(), active.next()]
const connection = await events.at(0)
connection.push({ type: "server.connected" })
await Promise.all(connected)
for (const event of [
{ type: "session.updated", value: 1 },
{ type: "session.updated", value: 2 },
]) {
const next = active.next()
connection.push(event)
expect(await within(next)).toEqual({ done: false, value: event })
}
expect(await paused.next()).toEqual({ done: false, value: { type: "session.updated", value: 1 } })
expect(await paused.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
await paused.return!()
await active.return!()
await connection.closed
})
test("late consumers receive the latest connection marker but no business event replay", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const idle = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [
{ type: "server.connected", value: 1 },
{ type: "server.connected", value: 2 },
{ type: "rpc.example.updated", value: 3 },
]) {
const next = first.next()
events.connections[0].push(event)
await next
}
expect(await idle.next()).toEqual({ done: false, value: { type: "server.connected", value: 2 } })
const next = idle.next()
events.connections[0].push({ type: "rpc.example.updated", value: 4 })
expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value: 4 } })
expect(events.connections).toHaveLength(1)
await first.return!()
await idle.return!()
await events.connections[0].closed
})
test("abort removes only its subscriber; last return closes the native source and resolves pending reads", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const controller = new AbortController()
const first = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
const secondReads = [second.next(), second.next()]
controller.abort()
expect(await firstRead).toEqual({ done: true, value: undefined })
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(events.connections[0].signal.aborted).toBe(false)
await second.return!()
expect(await Promise.all(secondReads)).toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
expect(await second.next()).toEqual({ done: true, value: undefined })
})
test("breaking a native for-await loop closes the last source", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const consumed = (async () => {
for await (const event of shared.subscribe()) {
expect(event.type).toBe("server.connected")
break
}
})()
events.connections[0].push({ type: "server.connected" })
await consumed
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
})
test("rapid resubscription opens a replacement while old cleanup finishes", async () => {
const cleanup = Promise.withResolvers<void>()
const events = source(cleanup.promise)
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
events.connections[0].push({ type: "server.connected", value: 1 })
await firstRead
await first.return!()
await events.connections[0].closing
const second = shared.subscribe()[Symbol.asyncIterator]()
const third = shared.subscribe()[Symbol.asyncIterator]()
const secondRead = second.next()
const thirdRead = third.next()
const controller = new AbortController()
const cancelled = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const cancelledRead = cancelled.next()
controller.abort()
expect(await cancelledRead).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(2)
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 2 })
expect(await Promise.all([secondRead, thirdRead])).toEqual([
{ done: false, value: { type: "server.connected", value: 2 } },
{ done: false, value: { type: "server.connected", value: 2 } },
])
cleanup.resolve()
await events.connections[0].closed
await second.return!()
await third.return!()
await replacement.closed
})
test("source EOF finishes all consumers and permits a fresh subscription without retry", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = [first.next(), second.next()]
events.connections[0].push({ type: "server.connected", value: 1 })
await Promise.all(reads)
const nextReads = [first.next(), second.next()]
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect(await Promise.all(nextReads)).toEqual([
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
])
events.connections[0].close()
await events.connections[0].closed
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(await second.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 3 })
expect(await next).toEqual({ done: false, value: { type: "server.connected", value: 3 } })
await fresh.return!()
await replacement.closed
})
test("source failures preserve error identity for every consumer and permit a new subscription", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const failure = { reason: "actual source failure" }
const reads = Promise.allSettled([first.next(), second.next()])
events.connections[0].fail(failure)
expect(await reads).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
])
await expect(first.next()).rejects.toBe(failure)
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await fresh.return!()
await replacement.closed
})
test("synchronous source creation failures reject subscribers without automatic retry", async () => {
const failure = new Error("connect failed")
const attempts: AbortSignal[] = []
const shared = SharedEvents.make<Event>((signal) => {
attempts.push(signal)
throw failure
})
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(1)
expect(attempts[0].aborted).toBe(true)
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
async function within<Value>(promise: Promise<Value>) {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => timeout.reject(new Error("active subscriber was blocked")), 1_000)
try {
return await Promise.race([promise, timeout.promise])
} finally {
clearTimeout(timer)
}
}
+16 -5
View File
@@ -41,7 +41,7 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin files can live outside config roots, where the
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
@@ -55,9 +55,6 @@ export const layer = Layer.effect(
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
@@ -144,8 +141,22 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
return { ...operation, target }
}),
)
const resolved = yield* Effect.forEach(configured, (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Option.some(operation)
if (yield* fs.isFile(operation.target)) {
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
+2
View File
@@ -30,6 +30,7 @@ import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
@@ -62,6 +63,7 @@ const nodes = [
Agent.node,
Command.node,
Reference.node,
Rpc.node,
WebSearch.node,
Integration.node,
Catalog.node,
+12 -8
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -19,6 +19,7 @@ import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { WebSearch } from "./websearch.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
@@ -30,14 +31,17 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -80,7 +84,7 @@ const layer = Layer.effect(
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
@@ -122,9 +126,8 @@ const layer = Layer.effect(
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
if (!previous) continue
@@ -175,8 +178,8 @@ function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
state: { status: "active" },
features: { server: true, ...plugin.features },
}
}
@@ -195,6 +198,7 @@ export const node = makeLocationNode({
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
+18 -1
View File
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import type { Event } from "@opencode-ai/schema/event"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -20,6 +21,7 @@ import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
import { Rpc } from "../rpc.js"
import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
@@ -32,6 +34,12 @@ import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type RpcEvent = Event.Payload & {
readonly type: `rpc.${string}`
readonly location: Location.Ref
readonly data: Readonly<Record<string, unknown>>
}
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
const app = yield* App.Metadata
const agents = yield* Agent.Service
@@ -44,6 +52,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const rpc = yield* Rpc.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
@@ -75,6 +84,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
app,
location: locationInfo(),
options: {},
rpc: Object.assign(rpc.client, { register: rpc.register }),
agent: {
get: (input) => {
const ref = locationRef(input)
@@ -191,7 +201,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
transform: commands.transform,
},
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
subscribe: () =>
bus
.subscribe()
.pipe(
Stream.filter(
(event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
),
),
},
experimental: {
terminal: {
+35 -7
View File
@@ -4,6 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -14,18 +15,15 @@ const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
@@ -38,9 +36,11 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
const local = path.isAbsolute(operation.target)
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -49,9 +49,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
tui: plugin.tui,
features,
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
@@ -60,3 +71,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
names.has(`${name}.${extension}`),
)
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+5 -21
View File
@@ -1,18 +1,11 @@
export * as PluginSourceDirectory from "./source-directory.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Option, Predicate, Schema } from "effect"
import { Effect, Option } from "effect"
import path from "path"
export const names = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
fs: FSUtil.Interface,
directory: string,
@@ -29,30 +22,21 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* packageEntry(fs, entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
function packageEntry(fs: FSUtil.Interface, directory: string) {
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
(entry) =>
fs
.isFile(entry)
+6 -4
View File
@@ -25,7 +25,10 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -58,9 +61,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
state: { status: "failed", error: plugin.error },
features: { server: true },
})
continue
}
+275
View File
@@ -0,0 +1,275 @@
export * as Rpc from "./rpc.js"
export { define } from "@opencode-ai/schema/rpc"
export type { Definition, EventPayload, Failure } from "@opencode-ai/schema/rpc"
import type { RpcClient, RpcDomain, RpcHandlers } from "@opencode-ai/plugin/effect/rpc"
import type { Rpc } from "@opencode-ai/schema/rpc"
import { Event } from "@opencode-ai/schema/event"
import type { Tool } from "@opencode-ai/schema/tool"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { optional, statics } from "./schema.js"
export interface Interface {
readonly register: RpcDomain["register"]
readonly client: <D extends Rpc.Definition>(definition: D) => RpcClient<D, Rpc.SystemError, never, unknown>
readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect<unknown, Rpc.Failure>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Rpc") {}
class DeclaredError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
const callContext = {
error: (type: string, message: string, data?: unknown) => new DeclaredError(type, message, data),
}
const registrations = new Map<
string,
Array<{
readonly definition: Rpc.Definition
readonly handlers: Readonly<Record<string, Function>>
}>
>()
const definitions = new WeakMap<
Rpc.Definition,
ReadonlyMap<string, { readonly event: Rpc.EventDefinition; readonly definition: Event.Definition }>
>()
const eventsFor = (definition: Rpc.Definition) => {
const existing = definitions.get(definition)
if (existing) return existing
const events = new Map(
Object.entries(definition.events).map(([name, event]) => [
name,
{ event, definition: eventDefinition(definition, name) },
]),
)
definitions.set(definition, events)
return events
}
const register = Effect.fn("Rpc.register")(function* <const D extends Rpc.Definition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) {
const entry = { definition, handlers }
const dispose = Effect.sync(() => {
const remaining = (registrations.get(definition.id) ?? []).filter((candidate) => candidate !== entry)
if (remaining.length === 0) {
registrations.delete(definition.id)
return
}
registrations.set(definition.id, remaining)
})
yield* Effect.acquireRelease(
Effect.sync(() =>
registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry]),
),
() => dispose,
)
const events = eventsFor(definition)
return {
dispose,
events: {
emit: Effect.fn("Rpc.emit")(function* (...args: Rpc.EventInput<D>) {
const registered = events.get(args[0])
if (!registered)
return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.id}.${args[0]}`))
const event = registered.event
// SAFETY: The public event-schema contract guarantees an object encoded/output type.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
const data = (yield* encode(event.schema, args[1])) as Readonly<Record<string, unknown>>
return yield* bus
.publish(registered.definition, data, {
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
})
.pipe(Effect.asVoid)
}),
},
}
})
const call = Effect.fn("Rpc.call")(function* (rpcID: string, name: string, input: unknown) {
const entry = registrations.get(rpcID)?.at(-1)
if (!entry)
return yield* Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`))
if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name))
return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${rpcID}.${name}`))
const method = entry.definition.methods[name]
const handler = entry.handlers[name]
const parsed = yield* parse(method.input, input).pipe(
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
)
const result = yield* Effect.suspend(() => {
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
})
const client = <D extends Rpc.Definition>(definition: D): RpcClient<D, Rpc.SystemError, never, unknown> => {
const events = eventsFor(definition)
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input: unknown) =>
call(definition.id, name, input).pipe(
Effect.catch((error) => decodeError(method, error)),
Effect.flatMap((value) => read(method.output, value).pipe(Effect.catch((cause) => Effect.die(cause)))),
),
]),
)
// SAFETY: Every runtime key comes from this definition, and each method delegates through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...methods,
events: {
subscribe: <Name extends keyof D["events"] & string>(name: Name) => {
const registered = events.get(name)
if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.id}.${name}`))
return bus.subscribe(registered.definition).pipe(
Stream.provideService(Location.Service, location),
Stream.mapEffect((payload) => logicalEvent(definition, name, payload, ref)),
)
},
},
} as RpcClient<D, Rpc.SystemError, never, unknown>
}
return Service.of({ register, call, client })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Location.node] })
const fields = {
id: Event.ID,
created: Schema.Finite,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
location: optional(Location.Ref),
}
const EventData = Schema.Record(Schema.String, Schema.Unknown)
const jsonSchemas = new WeakMap<JsonSchema.JsonSchema, Schema.Codec<unknown>>()
function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["id"]}.${Name}` {
return `rpc.${definition.id}.${name}`
}
function eventDefinition(definition: Rpc.Definition, name: string): Event.Definition {
const type = eventType(definition, name)
const data = EventData
return Schema.Struct({ ...fields, type: Schema.Literal(type), data }).pipe(
statics(() => ({ type, durability: "ephemeral" as const, durable: undefined, data })),
) satisfies Event.EphemeralDefinition<string, typeof data>
}
function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
if (Schema.isSchema(schema)) return Schema.decodeUnknownEffect(schema)(value)
if (isStandardSchema(schema)) {
return Effect.gen(function* () {
const parsed = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (cause) => cause })
const result =
parsed instanceof Promise ? yield* Effect.tryPromise({ try: () => parsed, catch: (cause) => cause }) : parsed
if (result.issues) return yield* Effect.fail(new Error(result.issues.map((issue) => issue.message).join("\n")))
return result.value
})
}
return Effect.try({
try: () => {
const existing = jsonSchemas.get(schema)
if (existing) return existing
const codec = Schema.make<Schema.Codec<unknown>>(
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
)
jsonSchemas.set(schema, codec)
return codec
},
catch: (cause) => cause,
}).pipe(Effect.flatMap((codec) => Schema.decodeUnknownEffect(codec)(value)))
}
function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
}
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
if (!(error instanceof DeclaredError)) return Effect.die(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
}
return encode(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function failure(type: string, message: string, data?: unknown): Rpc.Failure {
return data === undefined ? { type, message } : { type, message, data }
}
function errorMessage(error: unknown, fallback: string) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
return fallback
}
function isStandardSchema(schema: Tool.ValueSchema): schema is Extract<Tool.ValueSchema, StandardSchemaV1> {
return "~standard" in schema
}
function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
// Standard Schema results were already parsed by the publisher; don't apply transforms twice.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
const logicalEvent = Effect.fn("Rpc.logicalEvent")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
payload: Event.Payload,
ref: Location.Ref,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const event = definition.events[name]
const data = yield* read(event.schema, payload.data)
// SAFETY: The private Bus definition owns the envelope and location.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...payload,
type: eventType(definition, name),
data,
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
} as Rpc.EventPayload<D, Name>
})
+42 -57
View File
@@ -104,7 +104,7 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded from config" },
},
],
@@ -121,17 +121,17 @@ describe("PluginSupervisor config", () => {
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"),
},
status: "active",
tui: true,
state: { status: "active" },
features: { server: true, tui: true },
})
}),
),
)
it.live("disables configured plugins by exported ID", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
return withLocation(
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
@@ -145,7 +145,7 @@ describe("PluginSupervisor config", () => {
})
it.live("does not disable configured plugins by package target", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
return withLocation(
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
@@ -162,7 +162,7 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect"),
options: { description: "Effect plugin from config" },
},
],
@@ -191,9 +191,9 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded after invalid plugins" },
},
],
@@ -207,13 +207,13 @@ describe("PluginSupervisor config", () => {
})
expect(output).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
@@ -233,35 +233,23 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin package entrypoints in order", () =>
it.live("loads conventional auto-discovered plugin entrypoints", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("package-exports")
expect(ids).toContain("package-module")
expect(ids).toContain("package-main")
expect(ids).toContain("package-index")
expect(ids).toContain("package-index-ts")
expect(ids).toContain("package-index-js")
expect(ids).not.toContain("package-custom-entry")
}),
false,
async (directory) => {
await Promise.all([
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
writeDiscoveredPackage(
directory,
"module",
{ exports: "./missing.js", module: "./entry.js" },
{ "entry.js": "package-module" },
),
writeDiscoveredPackage(
directory,
"main",
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
{ "entry.js": "package-main" },
),
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
writeDiscoveredPackage(directory, "ts", { "index.ts": "package-index-ts" }),
writeDiscoveredPackage(directory, "js", { "index.js": "package-index-js" }),
writeDiscoveredPackage(directory, "custom", { "entry.ts": "package-custom-entry" }),
])
},
),
@@ -282,21 +270,11 @@ describe("PluginSupervisor config", () => {
async (directory) => {
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
await writeDiscoveredPackage(
directory,
"contained",
{ exports: "../../escape.js" },
{ "index.js": "contained-fallback" },
)
await writeDiscoveredPackage(
directory,
"symlink",
{ exports: "./entry.js" },
{ "index.js": "symlink-fallback" },
)
await writeDiscoveredPackage(directory, "contained", { "index.js": "contained-fallback" })
await writeDiscoveredPackage(directory, "symlink", { "index.js": "symlink-fallback" })
await fs.symlink(
path.join(directory, ".opencode", "escape.js"),
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
path.join(directory, ".opencode", "plugins", "symlink", "index.ts"),
)
},
),
@@ -307,7 +285,7 @@ describe("PluginSupervisor config", () => {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
@@ -365,15 +343,15 @@ describe("PluginSupervisor config", () => {
),
)
it.live("reloads a configured plugin when its source file changes", () =>
it.live("reloads a configured plugin when its entrypoint changes", () =>
withLocation(
{ plugins: ["-*", "./external/mutable.ts"] },
{ plugins: ["-*", "./external"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const file = path.join(location.directory, "external", "mutable.ts")
const file = path.join(location.directory, "external", "index.ts")
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
@@ -395,11 +373,22 @@ describe("PluginSupervisor config", () => {
// configured-entrypoint watch can observe the edit.
const external = path.join(directory, "external")
await fs.mkdir(external, { recursive: true })
await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
await fs.writeFile(path.join(external, "index.ts"), mutablePlugin("first"))
},
),
)
it.live("skips configured local files", () =>
withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).not.toContain("config-promise-plugin")
}),
),
)
it.live("applies explicit removals after auto-discovery", () =>
withLocation(
{ plugins: ["-*"] },
@@ -419,8 +408,8 @@ describe("PluginSupervisor config", () => {
yield* withLocation(
{
plugins: [
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source"),
],
},
Effect.gen(function* () {
@@ -448,7 +437,7 @@ describe("PluginSupervisor config", () => {
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source"), "-opencode.variant"],
},
Effect.gen(function* () {
yield* ready()
@@ -592,13 +581,9 @@ function discoveredPlugin(id: string) {
async function writeDiscoveredPackage(
directory: string,
name: string,
manifest: Record<string, unknown> | undefined,
files: Record<string, string>,
) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all([
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
])
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
}
+6 -7
View File
@@ -347,7 +347,7 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() =>
fs.writeFile(
file,
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect")] }),
),
)
yield* Fiber.join(updated)
@@ -561,21 +561,20 @@ describe("LocationServiceMap", () => {
fs.writeFile(
file,
JSON.stringify({
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing")],
}),
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
if ((yield* registry.list()).some((plugin) => plugin.state.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") },
state: { status: "failed", error: expect.stringContaining("plugin failed") },
features: { server: true },
},
])
+53 -17
View File
@@ -268,9 +268,8 @@ describe("Plugin", () => {
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
},
],
)
@@ -331,7 +330,27 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
expect(yield* plugins.list()).toEqual([
{ id: active, source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
])
}),
)
it.effect("reports activated and discovered plugin features", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("rpc-plugin"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true, rpc: true },
},
])
}),
)
@@ -361,13 +380,17 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
state: { status: "failed", error: expect.stringContaining("materialization failed") },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
@@ -375,8 +398,18 @@ describe("Plugin", () => {
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
])
}),
)
@@ -413,7 +446,12 @@ describe("Plugin", () => {
])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("partial-tools"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
@@ -457,9 +495,8 @@ describe("Plugin", () => {
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
@@ -497,9 +534,8 @@ describe("Plugin", () => {
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
+2
View File
@@ -22,6 +22,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
import { Rpc } from "@opencode-ai/core/rpc"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -79,6 +80,7 @@ export const PluginTestLayer = LayerNode.compile(
Permission.node,
PluginHooks.node,
Reference.node,
Rpc.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
@@ -0,0 +1 @@
export { default } from "../config-effect-plugin"
@@ -2,7 +2,6 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
@@ -0,0 +1 @@
export { default } from "../config-promise-plugin"
@@ -0,0 +1 @@
export default { id: "config-promise-plugin.tui", setup() {} }
@@ -0,0 +1 @@
export { default } from "../failing-plugin"
@@ -0,0 +1 @@
export { default } from "../invalid-plugin"
@@ -0,0 +1 @@
export { default } from "../variant-source-plugin"
+8
View File
@@ -29,6 +29,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
}),
options: {},
rpc:
overrides.rpc ??
Object.assign(
() => {
throw new Error("unused rpc.client")
},
{ register: () => Effect.die("unused rpc.register") },
),
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
+7 -2
View File
@@ -17,7 +17,11 @@ test("loads cached plugin packages without requesting a refresh", async () => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
@@ -25,5 +29,6 @@ test("loads cached plugin packages without requesting a refresh", async () => {
)
expect(plugin.id).toBe("config-effect-plugin")
expect(calls).toEqual([{ subpaths: ["server", ""] }])
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
})
@@ -0,0 +1,106 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { Rpc } from "@opencode-ai/core/rpc"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { PluginTestLayer } from "./fixture"
import { Effect, Exit, Schema } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginTestLayer)
const Echo = Rpc.define({
id: "shared-echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
fail: {
input: Schema.String,
output: Schema.String,
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
},
},
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const events: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type !== "rpc.shared-echo.updated") return
expect(event.location).toEqual({ directory: location.directory })
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
events.push(event.data.text)
}),
)
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
echo: (value) => Effect.succeed(`${value}!`),
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
})
yield* registration.events.emit("updated", { text: "ready" })
}).pipe(Effect.orDie),
},
{
id: "consumer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
}).pipe(Effect.orDie),
},
])
expect(events).toEqual(["ready"])
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
yield* plugins.activate([])
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
yield* unsubscribe
}),
)
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("original"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.asVoid, Effect.orDie),
},
])
yield* plugins.activate([
{
id: "implementer",
version: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("replacement"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
},
])
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
@@ -0,0 +1,291 @@
import { describe, expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Logger } from "effect"
import { z } from "zod"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("Promise plugin RPC", () => {
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-calls",
methods: {
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
ping: { input: z.undefined(), output: z.null() },
errorShapedOutput: {
input: z.undefined(),
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
},
returned: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
thrown: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
defect: { input: z.undefined(), output: z.null() },
},
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-calls-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {
standard: async (input) => {
expect(input).toBe(42)
return input + 1
},
ping: async () => null,
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
returned: async (_input, context) =>
context.error("rejected", "returned failure", { attempts: "1" }),
thrown: async (_input, context) => {
throw context.error("rejected", "thrown failure", { attempts: "2" })
},
defect: async () => {
throw new Error("handler defect")
},
})
const client = ctx.rpc(service)
expect(await client.standard("42")).toBe("43")
expect(await client.ping()).toBeNull()
expect(await client.errorShapedOutput()).toEqual({
type: "ordinary",
message: "Success",
data: { value: 1 },
})
await expect(client.returned()).rejects.toEqual({
type: "rejected",
message: "returned failure",
data: { attempts: 1 },
})
await expect(client.thrown()).rejects.toEqual({
type: "rejected",
message: "thrown failure",
data: { attempts: 2 },
})
await expect(client.defect()).rejects.toThrow("handler defect")
await registration.dispose()
await registration.dispose()
await expect(client.ping()).rejects.toBeDefined()
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-cancel",
methods: { wait: { input: z.string(), output: z.string() } },
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-cancel-plugin",
setup: async (ctx) => {
const started = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const signals = new Map<string, AbortSignal>()
await ctx.rpc.register(service, {
wait: async (input, call) => {
signals.set(input, call.signal)
if (input === "complete") return input
started.resolve()
await new Promise<void>((resolve) => {
call.signal.addEventListener(
"abort",
() => {
cancelled.resolve()
resolve()
},
{ once: true },
)
})
return input
},
})
const client = ctx.rpc(service)
const controller = new AbortController()
const pending = client.wait("cancel", { signal: controller.signal })
const rejected = pending.then(
() => false,
() => true,
)
await started.promise
expect(await client.wait("complete")).toBe("complete")
controller.abort()
expect(await rejected).toBe(true)
await cancelled.promise
expect(signals.get("cancel")?.aborted).toBe(true)
expect(signals.get("complete")?.aborted).toBe(false)
expect(await client.wait("complete")).toBe("complete")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-async-listeners",
methods: {},
events: { updated: { schema: z.object({ value: z.number() }) } },
})
const error = new Error("Expected async plugin callback failure")
const reported = Promise.withResolvers<void>()
const logger = Logger.make((entry) => {
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-async-listeners-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const third = Promise.withResolvers<void>()
const failed: number[] = []
const healthy: number[] = []
client.events.on("updated", async (event) => {
failed.push(event.data.value)
started.resolve()
await release.promise
throw error
})
client.events.on("updated", (event) => {
healthy.push(event.data.value)
if (event.data.value === 2) second.resolve()
if (event.data.value === 3) third.resolve()
})
await registration.events.emit("updated", { value: 1 })
await started.promise
await registration.events.emit("updated", { value: 2 })
await second.promise
expect(failed).toEqual([1])
release.resolve()
await reported.promise
await registration.events.emit("updated", { value: 3 })
await third.promise
expect(failed).toEqual([1])
expect(healthy).toEqual([1, 2, 3])
},
}),
)
yield* plugins
.activate([{ ...adapted, version: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
}),
)
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-events",
methods: {},
events: {
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
const subscriptions = Promise.withResolvers<{
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
nativeIdle: AsyncIterator<unknown>
}>()
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-events-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const first: string[] = []
const second: string[] = []
const firstSeen = Promise.withResolvers<void>()
const secondSeen = Promise.withResolvers<void>()
const nextSeen = Promise.withResolvers<void>()
const unsubscribe = client.events.on("counted", (event) => {
first.push(event.data.text)
firstSeen.resolve()
})
client.events.on("counted", (event) => {
second.push(event.data.text)
if (event.data.text === "1") secondSeen.resolve()
if (event.data.text === "2") nextSeen.resolve()
})
const controller = new AbortController()
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
const next = iterator.next()
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
const idleNext = idle.next()
const nativeController = new AbortController()
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
const nativeNext = native.next()
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
const nativeIdleNext = nativeIdle.next()
await registration.events.emit("counted", { count: 1 })
await Promise.all([firstSeen.promise, secondSeen.promise])
const event = (await next).value
expect(event.type).toBe("rpc.promise-rpc-events.counted")
expect(event.data).toEqual({ text: "1" })
expect(typeof event.location.directory).toBe("string")
expect((await idleNext).value.data).toEqual({ text: "1" })
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
nativeController.abort()
expect((await native.next()).done).toBe(true)
unsubscribe()
unsubscribe()
controller.abort()
expect((await iterator.next()).done).toBe(true)
await registration.events.emit("counted", { count: 2 })
await nextSeen.promise
expect(first).toEqual(["1"])
expect(second).toEqual(["1", "2"])
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
expect((await aborted.next()).done).toBe(true)
subscriptions.resolve({
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
idle,
nativeIdle,
})
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
}),
)
})
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import type { Event } from "@opencode-ai/schema/event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { z } from "zod"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
[Location.node, Layer.succeed(Location.Service, location(ref))],
]),
)
const Echo = Rpc.define({
id: "test.rpc",
methods: { echo: { input: z.string(), output: z.string() } },
events: { updated: { schema: z.object({ text: z.string() }) } },
})
describe("Rpc", () => {
it.effect("creates handles before registration and resolves on every execution", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const request = client.echo("hello")
expect(yield* request.pipe(Effect.flip)).toEqual({
type: "rpc.unavailable",
message: "RPC is unavailable: test.rpc",
})
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
expect(yield* request).toBe("hello")
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
expect(yield* request).toBe("hello!")
expect(yield* rpc.call(Echo.id, "missing", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.missing",
})
expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.toString",
})
}),
)
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
expect(yield* client.echo("hello")).toBe("third")
yield* second.dispose
expect(yield* client.echo("hello")).toBe("third")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* first.dispose
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("removes registrations when their owning scope closes", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
const scope = yield* Scope.make()
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
yield* Scope.close(scope, Exit.void)
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
it.effect("validates inputs before running handlers and validates returned results", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const received: string[] = []
yield* rpc.register(Echo, {
echo: (value) =>
Effect.sync(() => {
received.push(value)
return value
}),
})
expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true)
expect(received).toEqual([])
const Checked = Rpc.define({
id: "checked",
methods: { echo: { input: z.string(), output: z.string().min(3) } },
events: {},
})
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("leaves local transport values to the declared schema", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Identity = Rpc.define({
id: "identity",
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
events: {},
})
yield* rpc.register(Identity, { echo: Effect.succeed })
const value = new Date(0)
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
}),
)
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const counts = { input: 0, output: 0, event: 0 }
const Transformed = Rpc.define({
id: "transformed",
methods: {
count: {
input: z.string().transform((value) => {
counts.input++
return Number(value)
}),
output: z.number().transform((value) => {
counts.output++
return String(value)
}),
},
},
events: {
counted: {
schema: z.object({ count: z.number() }).transform(({ count }) => {
counts.event++
return { text: String(count) }
}),
},
},
})
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
const client = rpc.client(Transformed)
expect(yield* client.count("41")).toBe("42")
const events = yield* client.events
.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
}),
)
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Codec = Rpc.define({
id: "codec",
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
})
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
expect(yield* rpc.call(Codec.id, "count", "41")).toBe("42")
expect(yield* rpc.client(Codec).count("41")).toBe(42)
const events = yield* rpc
.client(Codec)
.events.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
}),
)
it.effect("validates declared error data and decodes it for local clients", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Failing = Rpc.define({
id: "failing",
methods: {
standard: {
input: z.undefined(),
output: z.string(),
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
},
effect: {
input: Schema.Undefined,
output: Schema.String,
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: {},
})
yield* rpc.register(Failing, {
standard: (_input, context) =>
Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
})
expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: "3" },
})
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: 3 },
})
}),
)
it.effect("keeps other event consumers running after one subscription ends", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
const client = rpc.client(Echo)
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const second = yield* client.events
.subscribe("updated")
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("updated", { text: "first" })
const received = yield* Fiber.join(first)
expect(received.map((event) => event.data.text)).toEqual(["first"])
Reflect.set(received[0].location, "directory", "/consumer-mutated")
yield* registration.events.emit("updated", { text: "second" })
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
}),
)
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Raw = Rpc.define({
id: "raw",
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
events: {
counted: {
schema: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
},
})
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
expect(yield* rpc.call(Raw.id, "count", 42)).toBe(42)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
}),
)
it.effect("supports methods with no input and no returned value", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Empty = Rpc.define({
id: "empty",
methods: { ping: { input: z.undefined(), output: z.undefined() } },
events: {},
})
yield* rpc.register(Empty, { ping: () => Effect.undefined })
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
}),
)
it.effect("keeps in-flight calls on their original implementation after removal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registration = yield* rpc.register(Echo, {
echo: (value) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
})
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* registration.dispose
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(call)).toBe("original")
}),
)
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
yield* rpc.register(Echo, {
echo: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
})
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(call)
yield* Deferred.await(stopped)
}),
)
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
const otherContext = yield* Layer.build(
LayerNode.compile(Rpc.node, [
[Bus.node, Layer.succeed(Bus.Service, bus)],
[Location.node, Layer.succeed(Location.Service, location(otherRef))],
]).pipe(Layer.fresh),
)
const other = Context.get(otherContext, Rpc.Service)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
expect(yield* other.client(Echo).echo("hello")).toBe("second")
const all: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
all.push(event)
}),
)
const localEvents = yield* rpc
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const otherEvents = yield* other
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* second.events.emit("updated", { text: "second" })
yield* first.events
.emit("updated", { text: "first" })
.pipe(Effect.provideService(Location.Service, location(otherRef)))
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "first" },
location: ref,
})
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "second" },
location: otherRef,
})
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
yield* unsubscribe
}),
)
})
+1 -1
View File
@@ -6,7 +6,7 @@
"license": "MIT",
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo --noEmit -p tsconfig.tests.json",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
+1
View File
@@ -12,6 +12,7 @@ export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2 -1
View File
@@ -13,6 +13,7 @@ import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { RpcDomain } from "./rpc.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
@@ -39,6 +40,7 @@ export interface Context {
readonly permission: PermissionDomain
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
@@ -50,7 +52,6 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+29
View File
@@ -0,0 +1,29 @@
import type { RpcApi } from "@opencode-ai/client/effect/api"
export type { RpcClient } from "@opencode-ai/client/effect/api"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { Effect, Scope } from "effect"
import type { Registration } from "./registration.js"
export interface RpcCallContext<M extends Rpc.Method> {
readonly error: Rpc.ErrorFactory<M>
}
export type RpcHandlers<D extends Rpc.Definition> = {
readonly [Name in keyof D["methods"]]: (
input: Rpc.Output<D["methods"][Name]["input"]>,
context: RpcCallContext<D["methods"][Name]>,
) => Effect.Effect<Rpc.HandlerOutput<D["methods"][Name]["output"]>, Rpc.HandlerError<D["methods"][Name]>>
}
export interface RpcRegistration<D extends Rpc.Definition> extends Registration {
readonly events: {
readonly emit: (...args: Rpc.EventInput<D>) => Effect.Effect<void, unknown>
}
}
export interface RpcDomain extends RpcApi<Rpc.SystemError, never, unknown> {
readonly register: <const D extends Rpc.Definition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) => Effect.Effect<RpcRegistration<D>, unknown, Scope.Scope>
}
+154 -6
View File
@@ -1,14 +1,23 @@
import { Tool } from "@opencode-ai/schema/tool"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/client/promise/api"
import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { Context, Plugin } from "./plugin.js"
import type { Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
import type { RpcDomain, RpcHandlers } from "./rpc.js"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type PromiseContext = Parameters<Plugin["setup"]>[0]
type PromiseEvent = ReturnType<PromiseContext["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type HostRpc = Parameters<Parameters<typeof define>[0]["effect"]>[0]["rpc"]
type StreamAdapter = <A, E>(
stream: Stream.Stream<A, E>,
options?: { readonly signal?: AbortSignal },
) => AsyncIterable<A>
interface CompiledEndpoint {
readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
@@ -18,6 +27,143 @@ interface CompiledEndpoint {
const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
interface HostRpcCallContext {
readonly error: (type: string, message: string, data?: unknown) => unknown
}
class ReturnedRpcError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const makeStreams = Effect.fn("Plugin.Event.makeStreams")(function* () {
const context = yield* Effect.context<Scope.Scope>()
const subscriptions = new Set<() => Promise<IteratorResult<unknown>>>()
// Async iterators own separate scopes, so close them when the plugin unloads.
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.all(Array.from(subscriptions, (close) => close()))))
return (<A, E>(stream: Stream.Stream<A, E>, options?: { readonly signal?: AbortSignal }): AsyncIterable<A> => ({
[Symbol.asyncIterator]() {
const iterator = Stream.toAsyncIterableWith(stream, context)[Symbol.asyncIterator]()
const close = () => {
subscriptions.delete(close)
options?.signal?.removeEventListener("abort", abort)
return iterator.return?.() ?? Promise.resolve({ done: true as const, value: undefined })
}
const abort = () => {
void close()
}
subscriptions.add(close)
options?.signal?.addEventListener("abort", abort, { once: true })
if (options?.signal?.aborted) abort()
return {
next: () =>
iterator.next().then(
(result) => (result.done ? close().then(() => result) : result),
(error: unknown) => close().then(() => Promise.reject(error)),
),
return: close,
}
},
})) satisfies StreamAdapter
})
const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRpc, streams: StreamAdapter) {
const context = yield* Effect.context<Scope.Scope>()
const run = Effect.runPromiseWith(context)
const client = (definition: Rpc.PortableDefinition) => {
const local = host(definition)
const subscribe = (
name: string,
options?: Pick<RpcCallOptions, "signal">,
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => streams(local.events.subscribe(name), options)
return Object.assign(
Object.fromEntries(
Object.keys(definition.methods).map((name) => [
name,
(input: unknown, options?: Pick<RpcCallOptions, "signal">) => {
// SAFETY: The local client was built from this definition, so every declared key is an Effect method.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
const method = local[name] as (input: unknown) => Effect.Effect<unknown, unknown>
return run(method(input), { signal: options?.signal })
},
]),
),
{
events: {
subscribe,
on: (
name: string,
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
options?: Pick<RpcCallOptions, "signal">,
) => {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
void (async () => {
for await (const event of subscribe(name, { signal })) await handler(event)
})().catch((error: unknown) => run(Effect.logError(error)))
return () => controller.abort()
},
},
},
)
}
const register = (definition: Rpc.PortableDefinition, handlers: RpcHandlers<Rpc.PortableDefinition>) =>
run(
host.register(
definition,
// SAFETY: Each entry preserves its definition key; Core restores that method's erased schema and error types.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Object.fromEntries(
Object.entries(handlers).map(([name, handler]) => [
name,
(input: unknown, context: HostRpcCallContext) =>
Effect.tryPromise({
try: (signal) => {
// SAFETY: Promise RPC handlers return Promise values before this adapter erases their concrete types.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Reflect.apply(handler, undefined, [
input,
{
signal,
error: (type: string, message: string, data?: unknown) =>
new ReturnedRpcError(type, message, data),
},
]) as Promise<unknown>
},
catch: (error) => hostRpcError(context, error),
}).pipe(
Effect.flatMap((result) =>
result instanceof ReturnedRpcError
? Effect.fail(hostRpcError(context, result))
: Effect.succeed(result),
),
),
]),
) as never,
),
).then((registration) => ({
dispose: () => run(registration.dispose),
events: { emit: (...args: Rpc.EventInput<Rpc.PortableDefinition>) => run(registration.events.emit(...args)) },
}))
// SAFETY: Client and register implement RpcDomain from the same portable definitions and schema adapters.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(client, { register }) as RpcDomain
})
function hostRpcError(context: HostRpcCallContext, error: unknown) {
if (!(error instanceof ReturnedRpcError)) return error
return context.error(error.type, error.message, error.data)
}
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
const cached = compiledEndpoints.get(endpoint)
if (cached) return cached
@@ -68,7 +214,6 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
effect: (host) =>
Effect.gen(function* () {
@@ -91,6 +236,7 @@ export function fromPromise(plugin: Plugin) {
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const context = yield* Effect.context<Scope.Scope>()
const streams = yield* makeStreams()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
@@ -135,7 +281,7 @@ export function fromPromise(plugin: Plugin) {
}),
)
const context2: Context = {
const context2: PromiseContext = {
app: host.app,
location: host.location,
options: host.options,
@@ -181,12 +327,13 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.command.reload()),
},
event: {
subscribe: () =>
Stream.toAsyncIterable(
subscribe: (options) =>
streams(
host.event.subscribe().pipe(
Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
Stream.map((event) => event as unknown as PromiseEvent),
),
options,
),
},
experimental: {
@@ -295,6 +442,7 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
rpc: yield* rpcFromEffect(host.rpc, streams),
skill: {
list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
transform: transform(host.skill),
+1
View File
@@ -13,6 +13,7 @@ export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2 -1
View File
@@ -13,6 +13,7 @@ import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { RpcDomain } from "./rpc.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
@@ -39,6 +40,7 @@ export interface Context {
readonly permission: PermissionDomain
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
@@ -52,7 +54,6 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
+31
View File
@@ -0,0 +1,31 @@
import type { RpcApi, RpcCallOptions } from "@opencode-ai/client/promise/api"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { Registration } from "./registration.js"
export type { RpcEventPayload } from "@opencode-ai/client/promise/api"
export interface RpcCallContext<M extends Rpc.Method> {
readonly signal: AbortSignal
readonly error: Rpc.ErrorFactory<M>
}
export type RpcHandlers<D extends Rpc.PortableDefinition> = {
readonly [Name in keyof D["methods"]]: (
input: Rpc.Output<D["methods"][Name]["input"]>,
context: RpcCallContext<D["methods"][Name]>,
) => Promise<Rpc.HandlerOutput<D["methods"][Name]["output"]> | Rpc.HandlerError<D["methods"][Name]>>
}
export interface RpcRegistration<D extends Rpc.PortableDefinition> extends Registration {
readonly events: {
readonly emit: (...args: Rpc.EventInput<D>) => Promise<void>
}
}
export interface RpcDomain
extends RpcApi<Pick<RpcCallOptions, "signal"> & { readonly location?: never; readonly headers?: never }> {
readonly register: <const D extends Rpc.PortableDefinition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) => Promise<RpcRegistration<D>>
}
+1
View File
@@ -0,0 +1 @@
export { Rpc } from "@opencode-ai/schema/rpc"
+3 -1
View File
@@ -58,10 +58,12 @@ interface LocationCollection<Value> {
invalidate(location?: LocationRef): void
}
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export interface Data {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
handler: (event: OpenCodeEventMap[Type]) => void,
) => () => void
readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void
readonly session: {
@@ -11,6 +11,7 @@ import { Model } from "@opencode-ai/schema/model"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
@@ -18,6 +19,8 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
const PromisePlugin = await import("../src/promise/index")
const TuiPlugin = await import("../src/tui/index")
const PromiseEvent = await import("../src/promise/event")
const PromiseRpc = await import("../src/promise/rpc")
test.each([
["effect", Plugin],
@@ -34,6 +37,7 @@ test.each([
expect(entrypoint.PersistentPty).toBe(PersistentPty)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Rpc).toBe(Rpc)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
@@ -50,6 +54,7 @@ test.each([
"Plugin",
"Provider",
"Reference",
"Rpc",
"Skill",
"Vcs",
"WebSearch",
@@ -67,3 +72,8 @@ test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
})
test("Promise domain modules do not expose Effect adapter internals", () => {
expect(Object.keys(PromiseEvent)).toEqual([])
expect(Object.keys(PromiseRpc)).toEqual([])
})
+171
View File
@@ -0,0 +1,171 @@
import type { OpenCodeClient, RpcApi } from "@opencode-ai/client/effect"
import type { RpcHandlers, RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
declare const client: { readonly rpc: RpcApi<"transport-failure"> }
declare const ctx: Plugin.Context
declare const actualClient: OpenCodeClient
declare const name: "updated" | "progress"
declare const emission: Rpc.EventInput<typeof Acme>
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
const updates = acme.events.subscribe("updated")
const actualCall = actualClient.rpc(Acme).codec({ count: "42" })
const effectCall = actualClient.rpc(EffectAcme).codec({ count: "42" })
const effectUpdates = actualClient.rpc(EffectAcme).events.subscribe("progress")
const localCall = ctx.rpc(Acme).search({ query: "hello" })
export type Checks = [
Assert<Equal<Effect.Success<typeof search>, { text: string }>>,
Assert<
Equal<
Effect.Error<typeof search>,
| "transport-failure"
| { readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
| { readonly type: "unavailable"; readonly message: string; readonly data?: undefined }
>
>,
Assert<Equal<Effect.Services<typeof search>, never>>,
Assert<Equal<Effect.Success<typeof count>, string>>,
Assert<Equal<Effect.Success<typeof codec>, number>>,
Assert<Equal<Effect.Success<typeof raw>, unknown>>,
Assert<Equal<Effect.Success<typeof ping>, null>>,
Assert<Equal<Stream.Success<typeof updates>, Rpc.EventPayload<typeof Acme, "updated">>>,
Assert<Equal<Stream.Error<typeof updates>, "transport-failure">>,
Assert<Equal<Stream.Services<typeof updates>, never>>,
Assert<Equal<Effect.Success<typeof actualCall>, number>>,
Assert<Equal<Effect.Services<typeof actualCall>, never>>,
Assert<Equal<Effect.Success<typeof effectCall>, number>>,
Assert<Equal<Extract<Effect.Error<typeof effectCall>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<Equal<Extract<Stream.Error<typeof effectUpdates>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<
Equal<
Extract<Effect.Error<typeof effectCall>, { readonly type: "invalid_count" }>,
{ readonly type: "invalid_count"; readonly message: string; readonly data: { readonly count: number } }
>
>,
Assert<
Equal<
Extract<Effect.Error<typeof localCall>, { readonly type: "not_found" }>,
{ readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
>
>,
]
acme.search({ query: "hello" }, { location: { directory: "/project" } })
ctx.rpc(Acme).search({ query: "hello" })
// @ts-expect-error Effect callers supply the schema's accepted input representation too.
acme.count({ count: 42 })
// @ts-expect-error Unknown method names are rejected.
acme.missing()
// @ts-expect-error Plugin handles cannot override their location.
ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Effect event clients expose Streams, not callback convenience wrappers.
acme.events.on("updated", () => {})
// @ts-expect-error Only declared local event names can be subscribed to.
acme.events.subscribe("missing")
const handlers: RpcHandlers<typeof Acme> = {
search: (input, context) => {
context.error("not_found", "Missing", { query: input.query, attempts: "1" })
context.error("unavailable", "Unavailable")
return Effect.succeed({ text: input.query })
},
count: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
codec: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
raw: () => Effect.succeed(1),
ping: () => Effect.succeed(null),
}
const registration = ctx.rpc.register(Acme, handlers)
export type RegistrationChecks = [
Assert<Equal<Effect.Success<typeof registration>, RpcRegistration<typeof Acme>>>,
Assert<Equal<Effect.Error<typeof registration>, unknown>>,
Assert<Equal<Effect.Services<typeof registration>, Scope.Scope>>,
]
ctx.rpc.register(Acme, {
...handlers,
search: (input) => {
input.query satisfies string
return Effect.succeed({ text: input.query })
},
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(context.error("not_found", "Missing", { query: input.query, attempts: "1" })),
})
ctx.rpc.register(Acme, {
...handlers,
// @ts-expect-error Error names must be declared by the method.
search: (_input, context) => Effect.fail(context.error("missing", "Missing", {})),
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(
context.error("not_found", "Missing", {
query: input.query,
// @ts-expect-error Error data uses the schema's handler-side representation.
attempts: 1,
}),
),
})
// @ts-expect-error Wrong result types cannot widen the shared definition.
ctx.rpc.register(Acme, { ...handlers, search: () => Effect.succeed({ text: 42 }) })
// @ts-expect-error Effect handlers cannot return Promises.
ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: "hello" }) })
// @ts-expect-error All declared handlers are required.
ctx.rpc.register(Acme, { search: handlers.search })
Effect.gen(function* () {
const active = yield* registration
yield* active.events.emit("updated", { itemID: "123", text: "hello" })
yield* active.events.emit("counted", { count: 42 })
yield* active.events.emit(...emission)
yield* active.dispose
// @ts-expect-error Published payloads are inferred from the selected event schema.
yield* active.events.emit("progress", { percent: "50" })
// @ts-expect-error Only local event names are accepted for publishing.
yield* active.events.emit("rpc.acme.updated", { itemID: "123", text: "hello" })
// @ts-expect-error A union name must stay correlated with its publishing payload.
yield* active.events.emit(name, { percent: 50 })
})
Stream.map(updates, (event) => {
event.type satisfies "rpc.acme.updated"
event.location.directory satisfies string
return event.data.text satisfies string
})
// @ts-expect-error Effect custom event data must also be an object.
Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } })
Rpc.define({
id: "invalid-array-event",
methods: {},
// @ts-expect-error Effect custom event data cannot be an array.
events: { updated: { schema: Schema.Array(Schema.String) } },
})
+210
View File
@@ -0,0 +1,210 @@
import { OpenCode } from "@opencode-ai/client"
import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/client"
import { Rpc } from "@opencode-ai/plugin/rpc"
import type { RpcHandlers } from "@opencode-ai/plugin/promise/rpc"
import type { Plugin } from "@opencode-ai/plugin"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { z } from "zod"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
const client = OpenCode.make({ baseUrl: "http://localhost" })
declare const ctx: Plugin.Context
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
export type Checks = [
Assert<Equal<typeof Acme.id, "acme">>,
Assert<Equal<keyof typeof Acme.methods, "search" | "count" | "codec" | "raw" | "ping">>,
Assert<Equal<typeof search, Promise<{ text: string }>>>,
Assert<Equal<typeof count, Promise<string>>>,
Assert<Equal<typeof codec, Promise<number>>>,
Assert<Equal<typeof raw, Promise<unknown>>>,
Assert<Equal<typeof ping, Promise<null>>>,
Assert<Equal<Rpc.Input<typeof Acme.methods.count.input>, { count: string }>>,
Assert<Equal<Rpc.Output<typeof Acme.methods.count.input>, { count: number }>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.count.output>, number>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.codec.output>, number>>,
Assert<Equal<Rpc.EventPayload<typeof Acme, "updated">["type"], "rpc.acme.updated">>,
Assert<Equal<RpcEventPayload<typeof Acme, "updated">["location"], { directory: string; workspaceID?: string }>>,
Assert<Equal<Rpc.Input<StandardSchemaV1<string, number>>, string>>,
Assert<Equal<Rpc.Output<StandardSchemaV1<string, number>>, number>>,
Assert<Equal<Rpc.HandlerOutput<StandardSchemaV1<string, number>>, string>>,
]
await acme.search({ query: "hello" }, { location: { directory: "/project", workspace: "workspace" } })
await acme.search({ query: "hello" }, { signal: new AbortController().signal, headers: { "x-test": "yes" } })
await acme.ping(undefined, { location: { directory: "/project" } })
await ctx.rpc(Acme).search({ query: "hello" }, { signal: new AbortController().signal })
// @ts-expect-error Native event subscriptions share base headers, not subscriber overrides.
client.event.subscribe({ headers: { authorization: "override" } })
// @ts-expect-error Query must be a string.
await acme.search({ query: 1 })
// @ts-expect-error Required method inputs cannot be omitted.
await acme.search()
// @ts-expect-error Callers supply the input representation, not the parsed value.
await acme.count({ count: 42 })
// @ts-expect-error Standard Schema callers supply the accepted input representation.
await acme.codec({ count: 42 })
// @ts-expect-error Only declared methods are callable.
await acme.missing({})
// @ts-expect-error Location is call metadata, not injected into the declared input.
await acme.search({ query: "hello", location: { directory: "/project" } })
// @ts-expect-error Plugin handles cannot select another location.
await ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Plugin handles cannot use headers to override their location either.
await ctx.rpc(Acme).search({ query: "hello" }, { headers: { "x-opencode-directory": "/other" } })
declare const remoteOptions: RpcCallOptions
// @ts-expect-error Passing options through a variable must not enable local routing overrides.
await ctx.rpc(Acme).search({ query: "hello" }, remoteOptions)
const handlers: RpcHandlers<typeof Acme> = {
search: async (input, call) => {
input.query satisfies string
call.signal satisfies AbortSignal
if (input.query === "missing")
return call.error("not_found", "Missing", { query: input.query, attempts: "1" })
if (input.query === "unavailable") throw call.error("unavailable", "Unavailable")
return { text: input.query }
},
count: async (input) => {
input.count satisfies number
// @ts-expect-error Handlers receive the parsed representation.
input.count satisfies string
return input.count
},
codec: async (input) => {
input.count satisfies number
return input.count
},
raw: async (input) => {
// @ts-expect-error Plain JSON Schema does not infer an input shape.
input.value
return 1
},
ping: async () => null,
}
// @ts-expect-error Error names must be declared by the method.
handlers.search({ query: "missing" }, { signal: AbortSignal.abort(), error: () => ({ type: "missing" }) })
// @ts-expect-error Promise clients accept portable Standard or JSON schemas, not Effect Schema.
client.rpc(EffectAcme)
// @ts-expect-error Promise plugins cannot register Effect Schema contracts.
await ctx.rpc.register(EffectAcme, { codec: async ({ count }) => count })
const registration = await ctx.rpc.register(Acme, handlers)
await registration.events.emit("updated", { itemID: "123", text: "hello" })
await registration.events.emit("progress", { percent: 50 })
await registration.events.emit("counted", { count: 42 })
await registration.dispose()
await ctx.rpc.register(Acme, {
...handlers,
search: async ({ query }) => {
query satisfies string
return { text: query }
},
})
// @ts-expect-error The definition cannot widen to accommodate an incorrect handler result.
await ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: 42 }) })
// @ts-expect-error Every declared method must have a handler.
await ctx.rpc.register(Acme, { search: handlers.search })
// @ts-expect-error Additional handlers are not declared by the RPC.
await ctx.rpc.register(Acme, { ...handlers, missing: async () => null })
// @ts-expect-error Promise handlers must not return synchronous values.
await ctx.rpc.register(Acme, { ...handlers, ping: () => null })
// @ts-expect-error Standard Schema output transforms consume their input type.
await ctx.rpc.register(Acme, { ...handlers, count: async () => "42" })
// @ts-expect-error Effect output codecs encode the decoded result type.
await ctx.rpc.register(Acme, { ...handlers, codec: async () => "42" })
// @ts-expect-error Event payloads must match their schema.
await registration.events.emit("updated", { itemID: 123, text: "hello" })
// @ts-expect-error Publishing accepts only declared local event names.
await registration.events.emit("missing", {})
// @ts-expect-error Publishing applies the output schema, rather than accepting its transformed result.
await registration.events.emit("counted", { count: "42" })
const unsubscribe = acme.events.on("updated", (event) => {
event.type satisfies "rpc.acme.updated"
event.data.text satisfies string
event.location.directory satisfies string
// @ts-expect-error Payloads are selected by the event name.
event.data.percent
})
unsubscribe satisfies () => void
declare const withoutLocation: Omit<RpcEventPayload<typeof Acme, "updated">, "location">
// @ts-expect-error Custom events always carry their emitting location.
withoutLocation satisfies RpcEventPayload<typeof Acme, "updated">
for await (const event of acme.events.subscribe("counted")) {
event.data.text satisfies string
}
declare const name: "updated" | "progress"
// @ts-expect-error A union name cannot publish a payload matching only one possible event.
await registration.events.emit(name, { percent: 50 })
declare const emission: Rpc.EventInput<typeof Acme>
await registration.events.emit(...emission)
for await (const event of acme.events.subscribe(name)) {
if (event.type === "rpc.acme.updated") {
event.data.text satisfies string
continue
}
event.data.percent satisfies number
}
// @ts-expect-error Subscriptions use local names, not fully prefixed wire types.
acme.events.subscribe("rpc.acme.updated")
// @ts-expect-error Unknown event names are rejected by the convenience wrapper too.
acme.events.on("missing", () => {})
// @ts-expect-error Event subscriptions do not accept per-subscriber headers.
acme.events.subscribe("updated", { headers: { "x-test": "yes" } })
// @ts-expect-error Event subscriptions are not location-filtered externally.
acme.events.on("updated", () => {}, { location: { directory: "/project" } })
// @ts-expect-error Every method requires an output schema.
Rpc.define({ id: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} })
Rpc.define({
id: "invalid-error",
methods: {
search: {
input: z.string(),
output: z.string(),
// @ts-expect-error Error names beginning with rpc. are reserved for framework failures.
errors: { "rpc.internal": z.undefined() },
},
},
events: {},
})
// @ts-expect-error The subclient's events member is reserved, not an RPC method.
Rpc.define({ id: "invalid", methods: { events: Acme.methods.search }, events: {} })
// @ts-expect-error Custom event data must be an object.
Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } })
// @ts-expect-error Custom event data cannot be an array.
Rpc.define({ id: "invalid-array-event", methods: {}, events: { updated: { schema: z.array(z.string()) } } })
// @ts-expect-error Plain JSON Schema events must declare an object root.
Rpc.define({ id: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } })
const LocationInput = Rpc.define({
id: "location-input",
methods: {
echo: {
input: z.object({ location: z.string() }),
output: z.object({ location: z.string() }),
},
},
events: {},
})
await client.rpc(LocationInput).echo({ location: "a plugin-defined field" }, { location: { directory: "/project" } })
+54
View File
@@ -0,0 +1,54 @@
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Schema } from "effect"
import type { Types } from "effect"
import { z } from "zod"
export const Acme = Rpc.define({
id: "acme",
methods: {
search: {
input: z.object({ query: z.string() }),
output: z.object({ text: z.string() }),
errors: {
not_found: z.object({ query: z.string(), attempts: z.string().transform(Number) }),
unavailable: z.undefined(),
},
},
count: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number().transform(String),
},
codec: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number(),
},
raw: {
input: { type: "object", properties: { value: { type: "string" } }, required: ["value"] },
output: { type: "integer" },
},
ping: {
input: z.undefined(),
output: z.null(),
},
},
events: {
updated: { schema: z.object({ itemID: z.string(), text: z.string() }) },
progress: { schema: z.object({ percent: z.number() }) },
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
export const EffectAcme = Rpc.define({
id: "effect-acme",
methods: {
codec: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { invalid_count: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: { progress: { schema: Schema.Struct({ percent: Schema.Number }) } },
})
export type Equal<A, B> = Types.Equals<A, B>
export type Assert<T extends true> = T
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { fileURLToPath } from "node:url"
import { Acme } from "./rpc.fixture.js"
test("definitions preserve their schemas and ID without registering anything", () => {
expect(Rpc.define(Acme)).toBe(Acme)
expect(Acme.id).toBe("acme")
expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted"])
})
test("defining an RPC contract does not invoke its schema parser", () => {
const schema = {
"~standard": {
version: 1 as const,
vendor: "test",
validate: () => {
throw new Error("Definition must not parse values")
},
},
}
const definition = Rpc.define({
id: "portable",
methods: { echo: { input: schema, output: schema, errors: { rejected: schema } } },
events: { updated: { schema } },
})
expect(definition.methods.echo.input).toBe(schema)
expect(definition.methods.echo.output).toBe(schema)
expect(definition.methods.echo.errors.rejected).toBe(schema)
expect(definition.events.updated.schema).toBe(schema)
})
test("framework RPC error names are reserved", () => {
const schema = { type: "null" }
const errors = Object.fromEntries([["rpc.internal", schema]])
expect(() =>
Rpc.define({ id: "reserved", methods: { call: { input: schema, output: schema, errors } }, events: {} }),
).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal')
})
test("the shared definition entrypoint bundles without Effect or host runtime dependencies", async () => {
const inputs = new Set<string>()
const result = await Bun.build({
entrypoints: [fileURLToPath(import.meta.resolve("@opencode-ai/plugin/rpc"))],
target: "browser",
plugins: [
{
name: "rpc-import-boundary",
setup(build) {
build.onLoad({ filter: /.*/ }, (args) => {
inputs.add(args.path)
return undefined
})
},
},
],
})
expect(result.success).toBe(true)
expect([...inputs].sort((a, b) => a.localeCompare(b))).toEqual(
[import.meta.resolve("@opencode-ai/plugin/rpc"), import.meta.resolve("@opencode-ai/schema/rpc")]
.map((url) => fileURLToPath(url))
.sort((a, b) => a.localeCompare(b)),
)
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src", "test/**/*.types.ts"]
}
+259 -45
View File
@@ -8950,6 +8950,132 @@
"summary": "List skills"
}
},
"/api/rpc/{rpcID}/{method}": {
"post": {
"tags": ["rpc"],
"operationId": "v2.rpc.call",
"parameters": [
{
"name": "rpcID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "method",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Rpc.Output",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Output"
}
}
}
},
"400": {
"description": "RpcError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/RpcErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"500": {
"description": "RpcInternalError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RpcInternalErrorEncoded"
}
}
}
}
},
"description": "Dispatch a method to the currently registered RPC at the requested location.",
"summary": "Call a plugin RPC",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Input"
}
}
},
"required": true
}
}
},
"/api/event": {
"get": {
"tags": ["event"],
@@ -9069,7 +9195,7 @@
}
}
},
"description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"description": "Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"summary": "Subscribe to events"
}
},
@@ -16410,52 +16536,42 @@
"required": ["size"],
"additionalProperties": false
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
"Plugin.Features": {
"type": "object",
"properties": {
"server": {
"type": "boolean",
"enum": [true]
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
"tui": {
"type": "boolean",
"enum": [true]
},
"rpc": {
"type": "boolean",
"enum": [true]
}
]
},
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"features": {
"$ref": "#/components/schemas/Plugin.Features"
},
"state": {
"$ref": "#/components/schemas/Plugin.State"
}
},
"required": ["source", "features", "state"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
@@ -16511,6 +16627,35 @@
}
]
},
"Plugin.State": {
"anyOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active"]
}
},
"required": ["status"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
}
},
"required": ["status", "error"],
"additionalProperties": false
}
]
},
"Project": {
"type": "object",
"properties": {
@@ -16982,6 +17127,71 @@
}
]
},
"Rpc.Input": {
"type": "object",
"properties": {
"input": {}
},
"additionalProperties": false
},
"Rpc.Output": {
"type": "object",
"properties": {
"output": {}
},
"additionalProperties": false
},
"RpcErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcError"]
},
"type": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"RpcInternalErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcInternalError"]
},
"type": {
"type": "string",
"enum": ["rpc.internal", "rpc.invalid_output"]
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServiceHealth": {
"type": "object",
"properties": {
@@ -19157,6 +19367,10 @@
"name": "skill",
"description": "Experimental skill routes."
},
{
"name": "rpc",
"description": "Plugin RPC routes."
},
{
"name": "event",
"description": "Experimental event stream routes."
+3
View File
@@ -11,6 +11,7 @@ import { FileSystemGroup } from "./groups/fs.js"
import { makeFormGroup } from "./groups/form.js"
import { CommandGroup } from "./groups/command.js"
import { SkillGroup } from "./groups/skill.js"
import { RpcGroup } from "./groups/rpc.js"
import { EventGroup, makeEventGroup } from "./groups/event.js"
import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent.js"
@@ -49,6 +50,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof FileSystemGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CommandGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof SkillGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof RpcGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))
.add(SkillGroup.middleware(locationMiddleware))
.add(RpcGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
+1
View File
@@ -53,6 +53,7 @@ export const groupNames = {
"server.fs": "file",
"server.command": "command",
"server.skill": "skill",
"server.rpc": "rpc",
"server.event": "event",
"server.pty": "pty",
"server.experimental": "experimental",
+20
View File
@@ -11,6 +11,26 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
{ httpApiStatus: 400 },
) {}
export class RpcError extends Schema.TaggedError<RpcError>()(
"RpcError",
{
type: Schema.String,
message: Schema.String,
data: Schema.optional(Schema.Unknown),
},
{ httpApiStatus: 400 },
) {}
export class RpcInternalError extends Schema.TaggedError<RpcInternalError>()(
"RpcInternalError",
{
type: Schema.Literals(["rpc.internal", "rpc.invalid_output"]),
message: Schema.String,
data: Schema.optional(Schema.Unknown),
},
{ httpApiStatus: 500 },
) {}
export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
+12 -2
View File
@@ -11,9 +11,19 @@ const fields = {
location: Schema.optional(Location.Ref),
}
const rpcEvent = Schema.Struct({
id: Event.ID,
created: Schema.Finite,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.TemplateLiteral(["rpc.", Schema.String]),
location: Location.Ref,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "V2Event.rpc" })
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
Schema.Union([
...definitions,
rpcEvent,
...(definitions.some((definition) => definition.type === "server.connected")
? []
: [
@@ -38,7 +48,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description:
"Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
}),
),
)
@@ -55,4 +65,4 @@ export const OpenCodeEvent = event.schema
export type OpenCodeEvent = typeof OpenCodeEvent.Type
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
export const isOpenCodeEvent = (event: { readonly type: string }): event is OpenCodeEvent =>
event.type === "server.connected" || EventManifest.isServer(event)
event.type === "server.connected" || EventManifest.isServer(event) || event.type.startsWith("rpc.")
+30
View File
@@ -0,0 +1,30 @@
import { optional } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { RpcError, RpcInternalError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const RpcInput = Schema.Struct({ input: optional(Schema.Unknown) }).annotate({ identifier: "Rpc.Input" })
export const RpcOutput = Schema.Struct({ output: Schema.optionalKey(Schema.Unknown) }).annotate({
identifier: "Rpc.Output",
})
export const RpcGroup = HttpApiGroup.make("server.rpc")
.add(
HttpApiEndpoint.post("rpc.call", "/api/rpc/:rpcID/:method", {
params: { rpcID: Schema.String, method: Schema.String },
query: LocationQuery,
payload: RpcInput,
success: RpcOutput,
error: [RpcError, RpcInternalError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.rpc.call",
summary: "Call a plugin RPC",
description: "Dispatch a method to the currently registered RPC at the requested location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "rpc", description: "Plugin RPC routes." }))
+31 -1
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { isOpenCodeEvent, type OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
import { Schema } from "effect"
import { isOpenCodeEvent, OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
type JsonShape<Value> = Value extends string | number | boolean | null
? Value
@@ -21,11 +22,40 @@ type JsonShape<Value> = Value extends string | number | boolean | null
// requiring every runtime event shape to fit its encoded wire contract.
const wireReady: [JsonShape<OpenCodeEvent>] extends [JsonShape<OpenCodeEventEncoded>] ? true : false = true
// This fails to compile if the dynamic RPC branch absorbs native discriminants.
const nativeDataNarrows = (event: OpenCodeEvent) => {
if (event.type !== "session.created") return
const sessionID: string = event.data.sessionID
return sessionID
}
test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
expect(isOpenCodeEvent({ type: "rpc.acme.updated" })).toBe(true)
expect(isOpenCodeEvent({ type: "acme.updated" })).toBe(false)
})
test("decodes direct plugin RPC events", () => {
const event = {
id: "evt_rpc",
created: 1,
type: "rpc.acme.updated",
location: { directory: "/project" },
data: { itemID: "item-1", text: "hello" },
}
expect(Schema.decodeUnknownSync(OpenCodeEvent)(event)).toMatchObject(event)
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, location: undefined })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, type: "acme.updated" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: "value" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: [] })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: null })).toThrow()
})
test("keeps native event data discriminated by type", () => {
expect(nativeDataNarrows).toBeFunction()
})
test("keeps public event runtime values within the encoded contract", () => {
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { ClientApi, groupNames } from "../src/client.js"
import { RpcError, RpcInternalError } from "../src/errors.js"
import { RpcInput, RpcOutput } from "../src/groups/rpc.js"
test("RPC wrappers preserve JSON primitives and omit undefined fields", () => {
expect(Schema.encodeSync(RpcInput)({ input: undefined })).toEqual({})
expect(Schema.encodeSync(RpcOutput)({})).toEqual({})
expect(Schema.decodeUnknownSync(RpcInput)({})).toEqual({})
expect(Schema.decodeUnknownSync(RpcOutput)({})).toEqual({})
for (const value of [null, false, 123, "text", [1, 2], { location: "ordinary payload" }]) {
expect(Schema.decodeUnknownSync(RpcInput)({ input: value })).toEqual({ input: value })
expect(Schema.decodeUnknownSync(RpcOutput)({ output: value })).toEqual({ output: value })
}
})
test("RPC errors use the standard transport wrapper", () => {
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "not_found", message: "Missing", data: { id: "1" } }))).toEqual(
{
_tag: "RpcError",
type: "not_found",
message: "Missing",
data: { id: "1" },
},
)
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "internal", message: "Failed" }))).toEqual({
_tag: "RpcError",
type: "internal",
message: "Failed",
})
expect(
Schema.decodeUnknownSync(RpcError)({ _tag: "RpcError", type: "not_found", message: "Missing", data: {} }),
).toBeInstanceOf(RpcError)
expect(
Schema.encodeSync(RpcInternalError)(new RpcInternalError({ type: "rpc.internal", message: "Failed" })),
).toEqual({ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" })
expect(
Schema.encodeSync(RpcInternalError)(new RpcInternalError({ type: "rpc.invalid_output", message: "Invalid" })),
).toEqual({ _tag: "RpcInternalError", type: "rpc.invalid_output", message: "Invalid" })
})
test("exposes one generic RPC operation with location routing and ordinary transport errors", () => {
expect(groupNames["server.rpc"]).toBe("rpc")
expect(Object.keys(ClientApi.groups["server.rpc"].endpoints)).toEqual(["rpc.call"])
const document = OpenApi.fromApi(ClientApi)
expect(Object.keys(document.paths).filter((path) => path.startsWith("/api/rpc/"))).toEqual([
"/api/rpc/{rpcID}/{method}",
])
const operation = document.paths["/api/rpc/{rpcID}/{method}"]?.post
expect(operation?.operationId).toBe("v2.rpc.call")
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "rpcID", in: "path", required: true }),
)
expect(operation?.parameters).toContainEqual(expect.objectContaining({ name: "method", in: "path", required: true }))
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "location", in: "query", style: "deepObject", explode: true }),
)
expect(operation?.responses).toHaveProperty("200")
expect(operation?.responses).toHaveProperty("400")
expect(operation?.responses).toHaveProperty("401")
expect(operation?.responses).toHaveProperty("500")
})
+1
View File
@@ -18,6 +18,7 @@ export { Project } from "./project.js"
export { Worktree } from "./worktree.js"
export { Provider } from "./provider.js"
export { Reference } from "./reference.js"
export { Rpc } from "./rpc.js"
export { WebSearch } from "./websearch.js"
export { Session } from "./session.js"
export { Vcs } from "./vcs.js"
+20 -16
View File
@@ -15,22 +15,26 @@ export const Source = Schema.Union([
]).annotate({ identifier: "Plugin.Source" })
export type Source = typeof Source.Type
export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
}),
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export const Features = Schema.Struct({
server: Schema.Literal(true).pipe(optional),
tui: Schema.Literal(true).pipe(optional),
rpc: Schema.Literal(true).pipe(optional),
}).annotate({ identifier: "Plugin.Features" })
export type Features = typeof Features.Type
export const State = Schema.Union([
Schema.Struct({ status: Schema.Literal("active") }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
]).annotate({ identifier: "Plugin.State" })
export type State = typeof State.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID.pipe(optional),
source: Source,
features: Features,
state: State,
}).annotate({ identifier: "Plugin.Info" })
const Added = ephemeral({
type: "plugin.added",
+167
View File
@@ -0,0 +1,167 @@
export * as Rpc from "./rpc.js"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { JsonSchema, Schema } from "effect"
import type { Event } from "./event.js"
import type { Location } from "./location.js"
import type { Tool } from "./tool.js"
export type ErrorMap = Readonly<Record<string, Tool.ValueSchema>> & {
readonly [Name in `rpc.${string}`]?: never
}
export interface Method {
readonly input: Tool.ValueSchema
readonly output: Tool.ValueSchema
readonly errors?: ErrorMap
}
export type PortableValueSchema = StandardSchemaV1<unknown, unknown> | JsonSchema.JsonSchema
export interface PortableMethod extends Method {
readonly input: PortableValueSchema
readonly output: PortableValueSchema
readonly errors?: Readonly<Record<string, PortableValueSchema>> & {
readonly [Name in `rpc.${string}`]?: never
}
}
type EventDataObject = Readonly<Record<string, unknown>>
type EventValueSchema =
| Schema.Codec<EventDataObject, EventDataObject>
| StandardSchemaV1<unknown, EventDataObject>
| (JsonSchema.JsonSchema & { readonly type: "object" })
type PortableEventValueSchema =
| StandardSchemaV1<unknown, EventDataObject>
| (JsonSchema.JsonSchema & { readonly type: "object" })
export interface EventDefinition {
readonly schema: EventValueSchema
}
export type PortableEventDefinition = EventDefinition & { readonly schema: PortableEventValueSchema }
export interface Definition {
readonly id: string
readonly methods: Readonly<Record<string, Method>> & { readonly events?: never }
readonly events: Readonly<Record<string, EventDefinition>>
}
export interface PortableDefinition extends Definition {
readonly methods: Readonly<Record<string, PortableMethod>> & { readonly events?: never }
readonly events: Readonly<Record<string, PortableEventDefinition>>
}
export function define<const D extends Definition>(definition: D) {
const reserved = Object.values(definition.methods)
.flatMap((method) => Object.keys(method.errors ?? {}))
.find((name) => name.startsWith("rpc."))
if (reserved) throw new Error(`RPC error names starting with "rpc." are reserved: ${reserved}`)
return definition
}
export type Input<S extends Tool.ValueSchema> = S extends Schema.Top
? S["Encoded"]
: S extends StandardSchemaV1
? StandardSchemaV1.InferInput<S>
: unknown
export type Output<S extends Tool.ValueSchema> = S extends Schema.Top
? S["Type"]
: S extends StandardSchemaV1
? StandardSchemaV1.InferOutput<S>
: unknown
// Effect codecs encode handler results; Standard Schema parses them forward.
export type HandlerOutput<S extends Tool.ValueSchema> = S extends Schema.Top ? Output<S> : Input<S>
type MethodErrors<M extends Method> = M extends {
readonly errors: infer Errors extends ErrorMap
}
? Errors
: never
type ErrorSchema<M extends Method, Name extends ErrorName<M>> = MethodErrors<M>[Name]
type ErrorData<Data> = unknown extends Data
? { readonly data: Data }
: undefined extends Data
? { readonly data?: Data }
: { readonly data: Data }
type ErrorDataArguments<Data> = unknown extends Data
? [data: Data]
: undefined extends Data
? [data?: Data]
: [data: Data]
type Simplify<A> = { readonly [K in keyof A]: A[K] }
declare const HandlerErrorTypeId: unique symbol
export interface Failure<Type extends string = string, Data = unknown> {
readonly type: Type
readonly message: string
readonly data?: Data
}
export type SystemError = Failure<
| "rpc.unavailable"
| "rpc.method_not_found"
| "rpc.invalid_input"
| "rpc.invalid_output"
| "rpc.internal",
never
>
export type ErrorName<M extends Method> = M extends {
readonly errors: infer Errors extends ErrorMap
}
? Exclude<keyof Errors & string, `rpc.${string}`>
: never
export type HandlerErrorFor<M extends Method, Name extends ErrorName<M>> = Simplify<
{
readonly type: Name
readonly message: string
readonly [HandlerErrorTypeId]: true
} & ErrorData<HandlerOutput<ErrorSchema<M, Name>>>
>
export type HandlerError<M extends Method> = {
readonly [Name in ErrorName<M>]: HandlerErrorFor<M, Name>
}[ErrorName<M>]
export type MethodErrorFor<M extends Method, Name extends ErrorName<M>> = Simplify<
{
readonly type: Name
readonly message: string
} & ErrorData<Output<ErrorSchema<M, Name>>>
>
export type MethodError<M extends Method> = {
readonly [Name in ErrorName<M>]: MethodErrorFor<M, Name>
}[ErrorName<M>]
export type ErrorArguments<M extends Method, Name extends ErrorName<M>> = [
type: Name,
message: string,
...data: ErrorDataArguments<HandlerOutput<ErrorSchema<M, Name>>>,
]
export type ErrorFactory<M extends Method> = <Name extends ErrorName<M>>(
...args: ErrorArguments<M, Name>
) => HandlerErrorFor<M, Name>
export type EventInputData<S extends EventValueSchema> = S extends JsonSchema.JsonSchema
? EventDataObject
: HandlerOutput<S>
export type EventData<S extends EventValueSchema> = S extends JsonSchema.JsonSchema
? EventDataObject
: Output<S>
// Keep the event name correlated with its payload even when callers use unions.
export type EventInput<D extends Definition> = {
[Name in keyof D["events"] & string]: [name: Name, data: EventInputData<D["events"][Name]["schema"]>]
}[keyof D["events"] & string]
type EventPayloadFor<
D extends Definition,
Name extends keyof D["events"] & string,
> = Omit<Event.Payload<Event.EphemeralDefinition>, "type" | "data" | "durable" | "location"> & {
readonly type: `rpc.${D["id"]}.${Name}`
readonly data: EventData<D["events"][Name]["schema"]>
readonly location: Location.Ref
}
export type EventPayload<D extends Definition, Name extends keyof D["events"] & string = keyof D["events"] & string> = {
readonly [K in Name]: EventPayloadFor<D, K>
}[Name]
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import {
Agent,
Config,
@@ -47,6 +48,7 @@ describe("public event manifest", () => {
expect(EventManifest.Server.has("question.asked")).toBe(false)
expect(EventManifest.Server.has("question.replied")).toBe(false)
expect(EventManifest.Server.has("question.rejected")).toBe(false)
expect(EventManifest.Server.has("rpc.acme.updated")).toBe(false)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
})
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("embeds plugin state with a status discriminator", () => {
const decode = Schema.decodeUnknownSync(Plugin.Info)
const source = { type: "package" as const, package: "acme" }
const features = { server: true as const }
expect(decode({ id: "acme", source, features, state: { status: "active" } })).toEqual({
id: Plugin.ID.make("acme"),
source,
features,
state: { status: "active" },
})
expect(decode({ source, features, state: { status: "failed", error: "broken" } })).toEqual({
source,
features,
state: { status: "failed", error: "broken" },
})
})
+2
View File
@@ -9,6 +9,7 @@ import { FileSystemHandler } from "./handlers/fs"
import { FormHandler } from "./handlers/form"
import { CommandHandler } from "./handlers/command"
import { SkillHandler } from "./handlers/skill"
import { RpcHandler } from "./handlers/rpc"
import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { PluginHandler } from "./handlers/plugin"
@@ -55,6 +56,7 @@ export const handlers = Layer.mergeAll(
FileSystemHandler,
CommandHandler,
SkillHandler,
RpcHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
PersistentPtyHandler,
+37
View File
@@ -0,0 +1,37 @@
import { Rpc } from "@opencode-ai/core/rpc"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
handlers.handle("rpc.call", ({ params, payload }) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError(
(error) =>
error.type === "rpc.invalid_output"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
message: error.message,
...(error.data === undefined ? {} : { data: error.data }),
}),
),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
),
),
),
),
)
+364
View File
@@ -0,0 +1,364 @@
import { expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Plugin } from "@opencode-ai/plugin/effect"
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Rpc } from "@opencode-ai/schema/rpc"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
const authorization = `Basic ${btoa("opencode:secret")}`
const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-rpc-server-")),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const config = path.join(tmp.path, "config")
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
config: { directory: config, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* Effect.forEach(plugins, (plugin) => sdk.register(plugin))
const locations = Context.get(context, LocationServiceMap.Service)
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
return {
first,
second,
handler,
boot: (directory: string) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })))),
call: (
route: string,
body: unknown = {},
options: { directory?: string; headers?: Record<string, string>; signal?: AbortSignal } = {},
) =>
Effect.promise(() => {
const url = new URL(`/api/rpc/${route}`, "http://opencode.local")
if (options.directory) url.searchParams.set("location[directory]", options.directory)
return handler(
new Request(url, {
method: "POST",
headers: { authorization, "content-type": "application/json", ...options.headers },
body: JSON.stringify(body),
signal: options.signal,
}),
)
}),
}
})
it.live("dispatches RPC wrappers with query, header and default locations and generic failures", () =>
Effect.gen(function* () {
const Echo = Rpc.define({
id: "transport.echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
json: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
fail: {
input: Schema.Undefined,
output: Schema.String,
errors: { rejected: Schema.Struct({ reason: Schema.String }) },
},
defect: { input: Schema.Undefined, output: Schema.String },
invalid: { input: Schema.Undefined, output: { type: "string" } },
},
events: {},
})
const server = yield* fixture([
Plugin.define({
id: "transport-implementer",
effect: (ctx) =>
Effect.gen(function* () {
const location = (yield* ctx.agent.list()).location
yield* ctx.rpc.register(Echo, {
echo: (input) => Effect.succeed(`${location.directory}:${input}`),
json: (input) => Effect.succeed(input),
empty: () => Effect.succeed(undefined),
fail: (_input, context) =>
Effect.fail(context.error("rejected", "handler failed", { reason: "declared" })),
defect: () => Effect.die(new Error("handler defect")),
invalid: () => Effect.succeed(123),
})
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
yield* server.boot(process.cwd())
const selected = yield* server.call(
"transport.echo/echo",
{ input: "selected" },
{
directory: server.first,
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(selected.status).toBe(200)
expect(yield* Effect.promise(() => selected.json())).toEqual({ output: `${server.first}:selected` })
const header = yield* server.call(
"transport.echo/echo",
{ input: "header" },
{
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(yield* Effect.promise(() => header.json())).toEqual({ output: `${server.second}:header` })
const fallback = yield* server.call("transport.echo/echo", { input: "default" })
expect(yield* Effect.promise(() => fallback.json())).toEqual({ output: `${process.cwd()}:default` })
const empty = yield* server.call("transport.echo/empty")
expect(empty.status).toBe(200)
expect(yield* Effect.promise(() => empty.json())).toEqual({})
yield* Effect.forEach([null, false, 42, ["array"], { location: "ordinary input" }], (input) =>
Effect.gen(function* () {
const response = yield* server.call("transport.echo/json", { input })
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ output: input })
}),
)
const denied = yield* server.call("transport.echo/empty", {}, { headers: { authorization: "" } })
expect(denied.status).toBe(401)
yield* Effect.forEach(
[
{
route: "missing/echo",
body: {},
error: { type: "rpc.unavailable", message: "RPC is unavailable: missing" },
},
{
route: "transport.echo/missing",
body: {},
error: { type: "rpc.method_not_found", message: "Unknown RPC method: transport.echo.missing" },
},
{
route: "transport.echo/fail",
body: {},
error: { type: "rejected", message: "handler failed", data: { reason: "declared" } },
},
{ route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } },
],
(item) =>
Effect.gen(function* () {
const response = yield* server.call(item.route, item.body)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "RpcError",
message: expect.any(String),
...item.error,
})
}),
)
const defect = yield* server.call("transport.echo/defect")
expect(defect.status).toBe(500)
expect(yield* Effect.promise(() => defect.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "handler defect",
})
const invalid = yield* server.call("transport.echo/invalid")
expect(invalid.status).toBe(500)
expect(yield* Effect.promise(() => invalid.json())).toMatchObject({
_tag: "RpcInternalError",
type: "rpc.invalid_output",
message: expect.any(String),
})
const malformed = yield* server.call("transport.echo/echo", "not a wrapper")
expect(malformed.status).toBe(400)
expect(yield* Effect.promise(() => malformed.json())).toMatchObject({
_tag: "InvalidRequestError",
message: expect.any(String),
})
}),
)
it.live("request cancellation interrupts Effect RPC handlers and signals Promise RPC handlers", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const promiseStarted = Promise.withResolvers<void>()
const promiseStopped = Promise.withResolvers<void>()
const Blocking = Rpc.define({
id: "blocking",
methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } },
events: {},
})
const PromiseBlocking = Rpc.define({
id: "promise-blocking",
methods: { wait: { input: { type: "null" }, output: { type: "null" } } },
events: {},
})
const server = yield* fixture([
Plugin.define({
id: "effect-blocking",
effect: (ctx) =>
ctx.rpc
.register(Blocking, {
wait: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(stopped, undefined)),
),
})
.pipe(Effect.asVoid, Effect.orDie),
}),
fromPromise({
id: "promise-blocking",
async setup(ctx) {
await ctx.rpc.register(PromiseBlocking, {
wait: (_input, call) =>
new Promise<null>((resolve) => {
promiseStarted.resolve()
call.signal.addEventListener(
"abort",
() => {
promiseStopped.resolve()
resolve(null)
},
{ once: true },
)
}),
})
},
}),
])
yield* server.boot(server.first)
const controller = new AbortController()
const pending = yield* server
.call(
"blocking/wait",
{},
{
directory: server.first,
signal: controller.signal,
},
)
.pipe(Effect.forkScoped)
yield* Deferred.await(started)
controller.abort()
yield* Deferred.await(stopped)
expect((yield* Fiber.join(pending)).status).not.toBe(400)
const promiseController = new AbortController()
const promisePending = yield* server
.call(
"promise-blocking/wait",
{ input: null },
{
directory: server.first,
signal: promiseController.signal,
},
)
.pipe(Effect.forkScoped)
yield* Effect.promise(() => promiseStarted.promise)
promiseController.abort()
yield* Effect.promise(() => promiseStopped.promise)
expect((yield* Fiber.join(promisePending)).status).not.toBe(400)
}),
)
it.live("public SSE and generic native plugin subscriptions receive RPC events across locations", () =>
Effect.gen(function* () {
const Updates = Rpc.define({
id: "updates",
methods: { emit: { input: Schema.String, output: Schema.Undefined } },
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
const received: RpcEvent[] = []
const observed = yield* Deferred.make<void>()
const server = yield* fixture([
Plugin.define({
id: "updates-implementer",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Updates, {
emit: (input): Effect.Effect<undefined> =>
registration.events.emit("updated", { text: input }).pipe(Effect.as(undefined), Effect.orDie),
})
}).pipe(Effect.orDie),
}),
Plugin.define({
id: "native-observer",
effect: (ctx) =>
Effect.gen(function* () {
const directory = (yield* ctx.agent.list()).location.directory
// One observer instance should see both locations, just like the public native stream.
if (path.basename(directory) !== "first") return
yield* ctx.event.subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
Stream.take(2),
Stream.runForEach((event) => Effect.sync(() => received.push(event))),
Effect.andThen(Deferred.succeed(observed, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
const response = yield* Effect.promise(() =>
server.handler(
new Request("http://opencode.local/api/event", {
headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) },
}),
),
)
expect(response.status).toBe(200)
if (!response.body) throw new Error("Expected an SSE body")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel()))
expect((yield* Effect.promise(() => reader.read())).value).toContain('"type":"server.connected"')
const first = yield* server.call("updates/emit", { input: "first" }, { directory: server.first })
const second = yield* server.call("updates/emit", { input: "second" }, { directory: server.second })
expect(first.status).toBe(200)
expect(second.status).toBe(200)
const events: RpcEvent[] = []
while (events.length < 2) {
const chunk = yield* Effect.promise(() => reader.read())
if (chunk.done) throw new Error("Event stream closed before RPC events arrived")
events.push(
...chunk.value
.split("\n\n")
.filter((frame) => frame.startsWith("data: "))
.map((frame) => Schema.decodeUnknownSync(Schema.fromJsonString(OpenCodeEvent))(frame.slice(6)))
.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
)
}
yield* Deferred.await(observed)
expect(events).toMatchObject([
{
type: "rpc.updates.updated",
location: { directory: server.first },
data: { text: "first" },
},
{
type: "rpc.updates.updated",
location: { directory: server.second },
data: { text: "second" },
},
])
expect(received).toEqual(events)
}),
15_000,
)
+2 -2
View File
@@ -88,7 +88,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -210,7 +210,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
)
const directory = location.directory
const pluginDirectories = yield* Effect.promise(() => tuiPluginDirectories(process.cwd(), global.config))
const pluginDirectories = yield* Effect.promise(() => localPluginDirectories(process.cwd(), global.config))
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
const service = managed
+2 -1
View File
@@ -5,6 +5,7 @@ type EventMetadata = {
directory: string | undefined
workspace: string | undefined
}
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export function useEvent() {
const client = useClient()
@@ -18,7 +19,7 @@ export function useEvent() {
function on<T extends OpenCodeEvent["type"]>(
type: T,
handler: (event: Extract<OpenCodeEvent, { type: T }>, metadata: EventMetadata) => void,
handler: (event: OpenCodeEventMap[T], metadata: EventMetadata) => void,
) {
return client.event.on(type, (event) => {
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
@@ -198,12 +198,13 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
}
function status(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.status
if (entry.runtime === "server") return entry.plugin.state.status
return entry.status
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
if (entry?.runtime === "server")
return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
return entry?.error
}
+36 -18
View File
@@ -30,7 +30,8 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiEntrypoint } from "./discovery"
import { isMissingPath } from "../util/config-directories"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
@@ -100,7 +101,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
}
>
>([])
const directory = config.path ? path.dirname(config.path) : process.cwd()
@@ -262,9 +265,19 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
...(await discoverTuiPlugins(props.directories)).map((entry) => ({
entry,
install: true,
server: false,
discovered: true,
})),
...serverPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.package : path.dirname(plugin.source.path),
install: false,
server: true,
discovered: false,
})),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false, discovered: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
@@ -288,8 +301,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (
local &&
!source.discovered &&
(await stat(local).then(
(info) => info.isFile(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
))
)
continue
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
@@ -485,9 +507,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
response.data.filter(
(
plugin,
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
} =>
plugin.state.status === "active" &&
plugin.features.tui === true &&
(plugin.source.type === "package" || plugin.source.type === "local"),
),
),
)
@@ -650,15 +675,8 @@ async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
if (!info.isDirectory()) return
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
}
function resolve(specifier: string) {
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
const entrypoint = await tuiEntrypoint(fileURLToPath(url))
return entrypoint ? pathToFileURL(entrypoint).href : undefined
}
function isPlugin(value: unknown): value is Plugin.Definition {
+31 -9
View File
@@ -3,22 +3,22 @@ import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
export async function localPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
const exists = await Promise.all(
directories.map((directory) => {
directories.map(async (directory) => {
if (directory === configDirectory || directory === projectConfig) return true
return stat(directory).then(
return await stat(directory).then(
(info) => info.isDirectory(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
)
}),
)
return directories.filter((_, index) => exists[index]).map((directory) => path.join(directory, "plugins", "tui"))
return directories.filter((_, index) => exists[index]).map((directory) => path.join(directory, "plugins"))
}
export async function discoverTuiPlugins(directories: string[]) {
@@ -29,15 +29,37 @@ export async function discoverTuiPlugins(directories: string[]) {
if (isMissingPath(error)) return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
return (
await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (entry): Promise<string | undefined> => {
const plugin = path.join(directory, entry.name)
const isDirectory =
entry.isDirectory() ||
(await stat(plugin).then(
(info) => info.isDirectory(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
))
if (!isDirectory) return undefined
return tuiEntrypoint(plugin)
}),
)
).filter((entry): entry is string => entry !== undefined)
}),
)
).flat()
}
export async function tuiEntrypoint(directory: string) {
const files = await readdir(directory, { withFileTypes: true })
const names = new Set(files.filter((file) => file.isFile() || file.isSymbolicLink()).map((file) => file.name))
if (!extensions.some((extension) => names.has("index" + extension))) return undefined
const tui = extensions.find((extension) => names.has("tui" + extension))
return tui ? path.join(directory, "tui" + tui) : undefined
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
@@ -11,6 +11,14 @@ import type { LogLevel, LogSink } from "../../../src/context/log"
const projectID = "proj_test"
function acceptsRpcEvent(on: ReturnType<typeof useEvent>["on"]) {
on("rpc.acme.updated", (event) => {
event.type satisfies `rpc.${string}`
event.data satisfies unknown
})
}
void acceptsRpcEvent
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
+38 -24
View File
@@ -2,32 +2,35 @@ import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, test } from "bun:test"
import { discoverTuiPlugins, freshSpecifier, tuiPluginDirectories } from "../src/plugin/discovery"
import { discoverTuiPlugins, freshSpecifier, localPluginDirectories } from "../src/plugin/discovery"
import { localProjectDirectory } from "../src/util/config-directories"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
test("discovers sibling TUI entrypoints in stable order", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.join(directory, "nested"), { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
await Promise.all(["first", "second", "missing-server", "missing-tui"].map((name) => mkdir(path.join(directory, name), { recursive: true })))
await Promise.all([
writeFile(path.join(directory, "second.tsx"), "export default {}"),
writeFile(path.join(directory, "first.js"), "export default {}"),
writeFile(path.join(directory, "ignored.json"), "{}"),
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
writeFile(path.join(directory, "first", "index.ts"), "export default {}"),
writeFile(path.join(directory, "first", "tui.js"), "export default {}"),
writeFile(path.join(directory, "second", "index.js"), "export default {}"),
writeFile(path.join(directory, "second", "tui.tsx"), "export default {}"),
writeFile(path.join(directory, "missing-server", "tui.ts"), "export default {}"),
writeFile(path.join(directory, "missing-tui", "index.ts"), "export default {}"),
writeFile(path.join(directory, "legacy.ts"), "export default {}"),
])
expect(await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
expect(await discoverTuiPlugins(await localPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([
path.join(directory, "first", "tui.js"),
path.join(directory, "second", "tui.tsx"),
])
})
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
const roots = await localPluginDirectories(tmp.path, path.join(tmp.path, "config"))
expect(await discoverTuiPlugins(roots)).toEqual([])
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins"))
})
test("discovers global and ancestor plugin roots in precedence order", async () => {
@@ -36,23 +39,34 @@ test("discovers global and ancestor plugin roots in precedence order", async ()
const project = path.join(tmp.path, "repo")
const config = path.join(tmp.path, "config")
const directories = [
path.join(config, "plugins", "tui"),
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
path.join(config, "plugins"),
path.join(tmp.path, "repo", ".opencode", "plugins"),
path.join(tmp.path, "repo", "packages", ".opencode", "plugins"),
]
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
const outside = path.join(tmp.path, ".opencode", "plugins")
await mkdir(path.join(project, ".git"), { recursive: true })
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
await Promise.all(
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
directories.map(async (directory, index) => {
const plugin = path.join(directory, String(index))
await mkdir(plugin, { recursive: true })
await Promise.all([
writeFile(path.join(plugin, "index.ts"), "export default {}"),
writeFile(path.join(plugin, "tui.ts"), "export default {}"),
])
}),
)
await writeFile(path.join(outside, "outside.ts"), "export default {}")
await mkdir(path.join(outside, "outside"), { recursive: true })
await Promise.all([
writeFile(path.join(outside, "outside", "index.ts"), "export default {}"),
writeFile(path.join(outside, "outside", "tui.ts"), "export default {}"),
])
const roots = await tuiPluginDirectories(cwd, config)
const roots = await localPluginDirectories(cwd, config)
expect(await discoverTuiPlugins(roots)).toEqual(
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
directories.map((directory, index) => path.join(directory, String(index), "tui.ts")),
)
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins"))
expect(roots).not.toContain(outside)
})
@@ -63,8 +77,8 @@ test("uses an Hg root for a missing project plugin directory", async () => {
await mkdir(path.join(project, ".hg"), { recursive: true })
await mkdir(cwd, { recursive: true })
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
path.join(project, ".opencode", "plugins", "tui"),
expect(await localPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
path.join(project, ".opencode", "plugins"),
)
})
+61 -41
View File
@@ -122,8 +122,8 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
{
id: "test.server",
source: { type: "package", package: "test-plugin@1.0.0" },
status: "active",
tui: true,
state: { status: "active" },
features: { server: true, tui: true },
},
],
resolve: async (spec, install) => {
@@ -144,6 +144,31 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
await app.task
})
test("loads an advertised local TUI entrypoint beside its server entrypoint", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const plugin = path.join(tmp.path, "external", "plugin")
await mkdir(plugin, { recursive: true })
await writeFile(path.join(plugin, "index.ts"), "export default {}")
await writeFile(path.join(plugin, "tui.ts"), lifecycleSource(marker, "test.local", "local"))
await using app = await bootApp(tmp.path, {
plugins: [
{
id: "test.server",
source: { type: "local", path: path.join(plugin, "index.ts") },
state: { status: "active" },
features: { server: true, tui: true },
},
],
})
expect(await until(() => readFile(marker, "utf8"), (value) => value === "local:setup\n")).toBe("local:setup\n")
process.emit("SIGHUP")
await app.task
})
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
@@ -151,9 +176,8 @@ test("discovers an ancestor TUI plugin directory created after startup", async (
await mkdir(path.join(tmp.path, "repo", ".git"))
const ready = path.join(tmp.path, "ready.txt")
const marker = path.join(tmp.path, "marker.txt")
const initial = path.join(cwd, ".opencode", "plugins", "tui")
await mkdir(initial, { recursive: true })
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
const initial = path.join(cwd, ".opencode", "plugins")
await writeLocalPlugin(initial, "ready", lifecycleSource(ready, "test.ready", "ready"))
await using app = await bootApp(cwd)
expect(
@@ -162,9 +186,8 @@ test("discovers an ancestor TUI plugin directory created after startup", async (
(value) => value === "ready:setup\n",
),
).toBe("ready:setup\n")
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
const directory = path.join(tmp.path, "repo", ".opencode", "plugins")
await writeLocalPlugin(directory, "hot", lifecycleSource(marker, "test.hot", "v1"))
expect(
await until(
@@ -179,11 +202,9 @@ test("discovers an ancestor TUI plugin directory created after startup", async (
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const marker = path.join(tmp.path, "marker.txt")
const source = path.join(directory, "hot.ts")
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
const source = await writeLocalPlugin(directory, "hot", lifecycleSource(marker, "test.hot", "v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
@@ -198,13 +219,11 @@ test("editing a discovered TUI plugin hot-reloads its fresh module", async () =>
test("does not activate a local plugin whose source changes during import", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const marker = path.join(tmp.path, "marker.txt")
const ready = path.join(tmp.path, "ready.txt")
const gate = path.join(tmp.path, "gate.txt")
const source = path.join(directory, "hot.ts")
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
const source = await writeLocalPlugin(directory, "hot", lifecycleSource(marker, "test.hot", "v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
@@ -232,14 +251,13 @@ test("does not activate a local plugin whose source changes during import", asyn
test("a plugin whose slot render throws does not take down the TUI", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const markerA = path.join(tmp.path, "a.txt")
const markerCrash = path.join(tmp.path, "crash.txt")
const sourceA = path.join(directory, "a.ts")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
await writeFile(
path.join(directory, "crash.ts"),
const sourceA = await writeLocalPlugin(directory, "a", lifecycleSource(markerA, "test.a", "a1"))
await writeLocalPlugin(
directory,
"crash",
`
import { appendFile } from "node:fs/promises"
export default {
@@ -283,14 +301,11 @@ export default {
test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const markerA = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const sourceA = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
const sourceA = await writeLocalPlugin(directory, "a", lifecycleSource(markerA, "test.a", "a1"))
const sourceB = await writeLocalPlugin(directory, "b", lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const readA = () => readFile(markerA, "utf8")
@@ -324,14 +339,11 @@ test("editing one plugin leaves others untouched and a broken save keeps the las
test("a save whose setup throws restores the previous version", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const marker = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const source = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
const source = await writeLocalPlugin(directory, "a", lifecycleSource(marker, "test.a", "a1"))
const sourceB = await writeLocalPlugin(directory, "b", lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
@@ -373,16 +385,17 @@ export default {
test("editing a symlinked plugin's target hot-reloads it", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
const directory = path.join(tmp.path, ".opencode", "plugins")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
// The real source lives outside the discovery directory; only a symlink
// is discovered. Edits land at the target, which emits no event in the
// plugin directory itself.
const target = path.join(tmp.path, "elsewhere", "a.ts")
const target = path.join(tmp.path, "elsewhere", "a", "tui.ts")
await mkdir(path.dirname(target), { recursive: true })
await writeFile(path.join(path.dirname(target), "index.ts"), "export default {}")
await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
await symlink(target, path.join(directory, "a.ts"))
await symlink(path.dirname(target), path.join(directory, "a"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
@@ -397,10 +410,8 @@ test("editing a symlinked plugin's target hot-reloads it", async () => {
test("memory storage survives hot reload while disk storage persists", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const directory = path.join(tmp.path, ".opencode", "plugins")
const marker = path.join(tmp.path, "counter.txt")
const source = path.join(directory, "counter.ts")
const counterSource = (note: string) => `
import { appendFile } from "node:fs/promises"
// ${note}
@@ -415,7 +426,7 @@ export default {
},
}
`
await writeFile(source, counterSource("v1"))
const source = await writeLocalPlugin(directory, "counter", counterSource("v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
@@ -428,3 +439,12 @@ export default {
process.emit("SIGHUP")
await app.task
})
async function writeLocalPlugin(directory: string, name: string, source: string) {
const plugin = path.join(directory, name)
await mkdir(plugin, { recursive: true })
await writeFile(path.join(plugin, "index.ts"), "export default {}")
const entrypoint = path.join(plugin, "tui.ts")
await writeFile(entrypoint, source)
return entrypoint
}
+259 -45
View File
@@ -8950,6 +8950,132 @@
"summary": "List skills"
}
},
"/api/rpc/{rpcID}/{method}": {
"post": {
"tags": ["rpc"],
"operationId": "v2.rpc.call",
"parameters": [
{
"name": "rpcID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "method",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Rpc.Output",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Output"
}
}
}
},
"400": {
"description": "RpcError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/RpcErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"500": {
"description": "RpcInternalError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RpcInternalErrorEncoded"
}
}
}
}
},
"description": "Dispatch a method to the currently registered RPC at the requested location.",
"summary": "Call a plugin RPC",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Input"
}
}
},
"required": true
}
}
},
"/api/event": {
"get": {
"tags": ["event"],
@@ -9069,7 +9195,7 @@
}
}
},
"description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"description": "Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"summary": "Subscribe to events"
}
},
@@ -16410,52 +16536,42 @@
"required": ["size"],
"additionalProperties": false
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
"Plugin.Features": {
"type": "object",
"properties": {
"server": {
"type": "boolean",
"enum": [true]
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
"tui": {
"type": "boolean",
"enum": [true]
},
"rpc": {
"type": "boolean",
"enum": [true]
}
]
},
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"features": {
"$ref": "#/components/schemas/Plugin.Features"
},
"state": {
"$ref": "#/components/schemas/Plugin.State"
}
},
"required": ["source", "features", "state"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
@@ -16511,6 +16627,35 @@
}
]
},
"Plugin.State": {
"anyOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active"]
}
},
"required": ["status"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
}
},
"required": ["status", "error"],
"additionalProperties": false
}
]
},
"Project": {
"type": "object",
"properties": {
@@ -16982,6 +17127,71 @@
}
]
},
"Rpc.Input": {
"type": "object",
"properties": {
"input": {}
},
"additionalProperties": false
},
"Rpc.Output": {
"type": "object",
"properties": {
"output": {}
},
"additionalProperties": false
},
"RpcErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcError"]
},
"type": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"RpcInternalErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcInternalError"]
},
"type": {
"type": "string",
"enum": ["rpc.internal", "rpc.invalid_output"]
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServiceHealth": {
"type": "object",
"properties": {
@@ -19157,6 +19367,10 @@
"name": "skill",
"description": "Experimental skill routes."
},
{
"name": "rpc",
"description": "Plugin RPC routes."
},
{
"name": "event",
"description": "Experimental event stream routes."

Some files were not shown because too many files have changed in this diff Show More