diff --git a/AGENTS.md b/AGENTS.md index 9e7a45deb74b..9f8d8dc68fdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/bun.lock b/bun.lock index 39df20acab4a..6482b8d9e680 100644 --- a/bun.lock +++ b/bun.lock @@ -183,6 +183,7 @@ "@typescript/native-preview": "catalog:", "effect": "catalog:", "solid-js": "catalog:", + "zod": "catalog:", }, "peerDependencies": { "effect": "4.0.0-rc.112", diff --git a/packages/app/e2e/regression/project-extensions.spec.ts b/packages/app/e2e/regression/project-extensions.spec.ts index fdd0070eec8f..9825fa6402d9 100644 --- a/packages/app/e2e/regression/project-extensions.spec.ts +++ b/packages/app/e2e/regression/project-extensions.spec.ts @@ -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 }, })), }, }) diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index 9911c5449053..8e5c44a5aebb 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -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 }, + }, ], }, }) diff --git a/packages/app/src/providers/catalog/plugin.test.ts b/packages/app/src/providers/catalog/plugin.test.ts index 41a2f72a3751..06159ee2584d 100644 --- a/packages/app/src/providers/catalog/plugin.test.ts +++ b/packages/app/src/providers/catalog/plugin.test.ts @@ -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"]) diff --git a/packages/cli/src/commands/handlers/plugin/list.ts b/packages/cli/src/commands/handlers/plugin/list.ts index a8538cd9617a..0b052dfd3054 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -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) diff --git a/packages/cli/test/plugin-list.test.ts b/packages/cli/test/plugin-list.test.ts index 285a45209434..536a08fe8fe6 100644 --- a/packages/cli/test/plugin-list.test.ts +++ b/packages/cli/test/plugin-list.test.ts @@ -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)) }) diff --git a/packages/client/package.json b/packages/client/package.json index 81362f52229a..57b3eb1f27db 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -55,6 +55,7 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "effect": "catalog:", - "solid-js": "catalog:" + "solid-js": "catalog:", + "zod": "catalog:" } } diff --git a/packages/client/src/effect/api.ts b/packages/client/src/effect/api.ts index d0b217034545..1355428ab5d8 100644 --- a/packages/client/src/effect/api.ts +++ b/packages/client/src/effect/api.ts @@ -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 = WebsearchApi diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 7a028681ff9c..0aedf0b0d71a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1573,6 +1573,19 @@ export interface SkillApi { readonly list: SkillListOperation } +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 = (input: RpcCallInput) => Effect.Effect + +export interface RpcApi { + readonly call: RpcCallOperation +} + export type EventSubscribeOutput = OpenCodeEvent export type EventSubscribeOperation = () => Stream.Stream @@ -2073,6 +2086,7 @@ export interface AppApi { readonly file: FileApi readonly command: CommandApi readonly skill: SkillApi + readonly rpc: RpcApi readonly event: EventApi readonly pty: PtyApi readonly experimental: ExperimentalApi diff --git a/packages/client/src/effect/client.ts b/packages/client/src/effect/client.ts new file mode 100644 index 000000000000..2aba49b2137a --- /dev/null +++ b/packages/client/src/effect/client.ts @@ -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("@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>) {} + } + 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, + ), + } +}) diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 0cec39aa9bd7..3bda6a383e7b 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()( + 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()( 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"]), diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 787c2a987741..142f4e79b178 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -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> +export type OpenCodeClient = Effect.Success> diff --git a/packages/client/src/effect/rpc.ts b/packages/client/src/effect/rpc.ts new file mode 100644 index 000000000000..b1933ba11bae --- /dev/null +++ b/packages/client/src/effect/rpc.ts @@ -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 +type DecodeError = 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, Options> + ) => Effect.Effect< + Rpc.Output, + Rpc.MethodError | DecodeError | E + > +} & { + readonly events: { + readonly subscribe: ( + name: Name, + ) => Stream.Stream, DecodeError | EventError> + } +} + +export interface RpcApi { + (definition: D): RpcClient +} + +export function make( + call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect, + subscribe: () => Stream.Stream, +): RpcApi | Rpc.SystemError, RpcCallOptions, EventError> { + return (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 | Rpc.SystemError, RpcCallOptions, EventError> + } +} + +export function aborted(signal: AbortSignal) { + return Effect.callback((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)) + }) +} diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 3db663122c3a..69aaa1a869b5 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -1,4 +1,8 @@ -type Client = ReturnType +import type { OpenCode } from "./client.js" + +type Client = ReturnType + +export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js" export type AgentApi = Client["agent"] export type CommandApi = Client["command"] diff --git a/packages/client/src/promise/client.ts b/packages/client/src/promise/client.ts new file mode 100644 index 000000000000..ff07c1caa24e --- /dev/null +++ b/packages/client/src/promise/client.ts @@ -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, + } +} diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index c8a4130c62e6..8f05dded3638 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + 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 => sse( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9c05650f01c0..3c4bb6e9cf49 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 } +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 = { diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 9008f3be7bce..6821a93f81b3 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -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 +export type OpenCodeClient = ReturnType diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts new file mode 100644 index 000000000000..1d3c270bb5cf --- /dev/null +++ b/packages/client/src/promise/rpc.ts @@ -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 + +export interface RpcCallOptions extends RequestOptions { + readonly location?: LocationGetInput["location"] +} + +export type RpcArguments = unknown extends Input + ? [input: Input, options?: Options] + : undefined extends Input + ? [input?: Input, options?: Options] + : [input: Input, options?: Options] + +export type RpcClient = { + readonly [Name in keyof D["methods"]]: ( + ...args: RpcArguments, Options> + ) => Promise> +} & { + readonly events: { + readonly subscribe: ( + name: Name, + options?: Pick, + ) => AsyncIterable> + readonly on: ( + name: Name, + handler: (event: RpcEventPayload) => Promise | void, + options?: Pick, + ) => () => void + } +} + +type RpcEventPayloadFor< + D extends Rpc.PortableDefinition, + Name extends keyof D["events"] & string, +> = Omit & { + type: `rpc.${D["id"]}.${Name}` + data: Rpc.EventData +} + +export type RpcEventPayload< + D extends Rpc.PortableDefinition, + Name extends keyof D["events"] & string = keyof D["events"] & string, +> = { [K in Name]: RpcEventPayloadFor }[Name] + +export interface RpcApi { + (definition: D): RpcClient +} + +export function makeRpc( + raw: ReturnType, + events: { subscribe(options?: Pick): AsyncIterable }, +): RpcApi { + return (definition) => { + const subscribe = ( + name: string, + options?: Pick, + ): AsyncIterable> => { + 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 + } + } 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) => Promise | void, + options?: Pick, + ) => { + 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 + } +} + +function eventType(definition: Rpc.PortableDefinition, name: string) { + return `rpc.${definition.id}.${name}` as const +} diff --git a/packages/client/src/rpc-runtime.ts b/packages/client/src/rpc-runtime.ts new file mode 100644 index 000000000000..3b79ca933b42 --- /dev/null +++ b/packages/client/src/rpc-runtime.ts @@ -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 + +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 { + 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, 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 +}) + +export function eventType( + definition: D, + name: Name, +): `rpc.${D["id"]}.${Name}` { + return `rpc.${definition.id}.${name}` +} diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts new file mode 100644 index 000000000000..4dd07402e944 --- /dev/null +++ b/packages/client/src/shared-events.ts @@ -0,0 +1,137 @@ +export * as SharedEvents from "./shared-events.js" + +export function make(connect: (signal: AbortSignal) => AsyncIterable) { + type Completion = { readonly error: unknown } | Record + type Subscriber = { + push: (value: A) => Promise + finish: (completion: Completion) => void + } + type Connection = { + controller: AbortController + subscribers: Set + connected?: A + } + + let current: Connection | undefined + const delivered = Promise.resolve() + + function stop(connection: Connection) { + connection.connected = undefined + connection.controller.abort() + if (current === connection) current = undefined + } + + async function run(connection: Connection) { + let iterator: AsyncIterator | 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 + await Promise.all(Array.from(connection.subscribers, (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 { + return { + [Symbol.asyncIterator]() { + const pending: ReturnType>>[] = [] + let started = false + let completion: Completion | undefined + let connection: Connection | undefined + let offered: { readonly value: A; readonly accepted: ReturnType> } | undefined + + function finish(result: Completion) { + completion = result + offered?.accepted.resolve() + offered = undefined + 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() { + finish({}) + } + + const subscriber: Subscriber = { + finish, + push(value) { + if (completion) return delivered + const request = pending.shift() + if (request) { + request.resolve({ done: false, value }) + return delivered + } + const accepted = Promise.withResolvers() + offered = { value, accepted } + return accepted.promise + }, + } + + function start() { + if (completion) return + const fresh = !current + connection = current ?? { + controller: new AbortController(), + subscribers: new Set(), + } + current = connection + connection.subscribers.add(subscriber) + if (connection.connected) void subscriber.push(connection.connected) + if (fresh) void run(connection) + } + + return { + next(): Promise> { + if (offered) { + const current = offered + offered = undefined + current.accepted.resolve() + return Promise.resolve({ done: false, value: current.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>() + pending.push(request) + if (!started) { + started = true + options?.signal?.addEventListener("abort", abort, { once: true }) + start() + } + return request.promise + }, + return(): Promise> { + finish({}) + return Promise.resolve({ done: true, value: undefined }) + }, + } + }, + } + }, + } +} diff --git a/packages/client/src/solid/connection.ts b/packages/client/src/solid/connection.ts index 39b52922428e..32f5af5f43c6 100644 --- a/packages/client/src/solid/connection.ts +++ b/packages/client/src/solid/connection.ts @@ -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, diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 4d95036e8ef0..3e11a03de3cd 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -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 } export type CreateDataInput = { readonly api: () => OpenCodeClient @@ -58,7 +59,7 @@ export type CreateDataInput = { readonly event: { readonly on: ( type: Type, - handler: (event: Extract) => void, + handler: (event: OpenCodeEventMap[Type]) => void, ) => () => void readonly listen: (handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) => () => void } diff --git a/packages/client/test/api.types.ts b/packages/client/test/api.types.ts index 1cb35e5dcaea..5666b8e3ceb1 100644 --- a/packages/client/test/api.types.ts +++ b/packages/client/test/api.types.ts @@ -45,6 +45,7 @@ const promiseRemove: Promise = promiseClient.session.instructions.entry.re sessionID: "ses_test", key: "review-notes", }) +const emptyRpcOutput: Awaited> = {} void [ effectSession, @@ -54,6 +55,7 @@ void [ promiseList, promisePut, promiseRemove, + emptyRpcOutput, exactVersion, compatibleVersion, ] diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts index 6a979b00a71f..b323c6215e65 100644 --- a/packages/client/test/import-boundaries.test.ts +++ b/packages/client/test/import-boundaries.test.ts @@ -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 }> + } = await Bun.file(metafile).json() + const inputs = new Map(Object.entries(metadata.inputs).map(([file, input]) => [resolve(directory, file), input])) + const eager = new Set() + 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 }) } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 8c2e1e4c7872..989900e83f5b 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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() + 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`) diff --git a/packages/client/test/rpc-effect.test.ts b/packages/client/test/rpc-effect.test.ts new file mode 100644 index 000000000000..f19ad0a2fa79 --- /dev/null +++ b/packages/client/test/rpc-effect.test.ts @@ -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 + signal: AbortSignal + }>() + const cancelled = Promise.withResolvers() + 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({ + 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() + const started = Promise.withResolvers() + 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>> = [ + Promise.withResolvers(), + Promise.withResolvers(), + ] + 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([]) +}) diff --git a/packages/client/test/rpc-promise.test.ts b/packages/client/test/rpc-promise.test.ts new file mode 100644 index 000000000000..08ea70242699 --- /dev/null +++ b/packages/client/test/rpc-promise.test.ts @@ -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) { + 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>() + const cancelled = Promise.withResolvers() + 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({ + 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 = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => { + calls.input++ + return { value: Number(value) } + }, + }, + } + const output: StandardSchemaV1 = { + "~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() + const response = Promise.withResolvers() + 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() + 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() + const release = Promise.withResolvers() + 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) +}) diff --git a/packages/client/test/shared-events.test.ts b/packages/client/test/shared-events.test.ts new file mode 100644 index 000000000000..bd2820b4846b --- /dev/null +++ b/packages/client/test/shared-events.test.ts @@ -0,0 +1,281 @@ +import { expect, test } from "bun:test" +import { SharedEvents } from "../src/shared-events" + +type Event = { readonly type: string; readonly value?: number } + +function source(cleanup?: Promise) { + const connections: { + signal: AbortSignal + push: (event: Event) => void + close: () => void + fail: (error: unknown) => void + closing: Promise + closed: Promise + }[] = [] + const opened: ReturnType>[] = [] + + return { + connections, + async at(index: number) { + if (!connections[index]) await (opened[index] ??= Promise.withResolvers()).promise + return connections[index] + }, + connect(signal: AbortSignal): AsyncIterable { + let controller!: ReadableStreamDefaultController + let ended = false + const closing = Promise.withResolvers() + const closed = Promise.withResolvers() + const stream = new ReadableStream({ + 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("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() + 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((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) +}) diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts index 9f3e84080178..8e47d25fa2da 100644 --- a/packages/core/src/config/plugin/source.ts +++ b/packages/core/src/config/plugin/source.ts @@ -41,7 +41,7 @@ export const layer = Layer.effect( const configuredChanges = yield* PubSub.unbounded() const watched = new Set() - // 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() + } + if (!(yield* fs.isDir(operation.target))) return Option.some(operation) + const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target) + if (Option.isSome(entrypoint)) return Option.some({ ...operation, target: entrypoint.value }) + yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target }) + return Option.none() + }), + ).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) => ({ diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 5d0aedb4456d..dd553884c3b4 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -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, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 12183ab53532..6f7044eb0eed 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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[], + failures?: readonly Failure[], ) => Effect.Effect readonly list: () => Effect.Effect } +type Failure = Plugin.Info & { readonly state: Extract } + export type Versioned = PluginDefinition & { readonly version: string readonly source?: Plugin.Source + readonly features?: Plugin.Features } export class Service extends Context.Service()("@opencode/Plugin") {} @@ -80,7 +84,7 @@ const layer = Layer.effect( const activate = Effect.fn("Plugin.activate")(function* ( plugins: readonly Versioned[], - failures: readonly Extract[] = [], + failures: readonly Failure[] = [], ) { const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) })) const ids = new Set() @@ -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, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index c6aa51a45a24..3e1d5598d479 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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 = (value: T) => value as DeepMutable +type RpcEvent = Event.Payload & { + readonly type: `rpc.${string}` + readonly location: Location.Ref + readonly data: Readonly> +} +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: { diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 56cb837aaadc..aede3b5b791e 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -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((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[0]["setup"]>( (input): input is Parameters[0]["setup"] => typeof input === "function", @@ -38,9 +36,11 @@ export const load = Effect.fn("PluginModule.load")(function* ( operation: Extract, ) { 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 } : {}), + } + }), + ) +} diff --git a/packages/core/src/plugin/source-directory.ts b/packages/core/src/plugin/source-directory.ts index e4a2d1777a43..edbe212d814a 100644 --- a/packages/core/src/plugin/source-directory.ts +++ b/packages/core/src/plugin/source-directory.ts @@ -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() 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() }), ) 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) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index a656b4d42316..e33383136eff 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -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() - const failures = new Map>() + const failures = new Map< + string, + Plugin.Info & { readonly state: Extract } + >() 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 } diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts new file mode 100644 index 000000000000..1f306f61066b --- /dev/null +++ b/packages/core/src/rpc.ts @@ -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: (definition: D) => RpcClient + readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect +} + +export class Service extends Context.Service()("@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> + }> + >() + const definitions = new WeakMap< + Rpc.Definition, + ReadonlyMap + >() + 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* ( + definition: D, + handlers: RpcHandlers>, + ) { + 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) { + 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> + 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 = 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 = (definition: D): RpcClient => { + 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: 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 + } + + 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>() + +function eventType( + 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 +} + +function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect { + 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>( + 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 { + return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value) +} + +function encodeError(method: Rpc.Method, error: unknown): Effect.Effect { + 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 { + 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 { + return "~standard" in schema +} + +function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect { + // 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, 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 +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b42e1c008a67..2cf7a9847998 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -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 | undefined, files: Record, ) { 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)))) } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index b38ab70b697d..0a8f4b1e9c2d 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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 }, }, ]) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index b73f8e311726..d99a98bec3a3 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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() diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 9bb2804912ed..5ac8dfdfcc0e 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -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, diff --git a/packages/core/test/plugin/fixtures/config-effect/index.ts b/packages/core/test/plugin/fixtures/config-effect/index.ts new file mode 100644 index 000000000000..1ad95a35884d --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-effect/index.ts @@ -0,0 +1 @@ +export { default } from "../config-effect-plugin" diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index 1e8a0a9fdd64..91f4a1b176e1 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -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) => { diff --git a/packages/core/test/plugin/fixtures/config-promise/index.ts b/packages/core/test/plugin/fixtures/config-promise/index.ts new file mode 100644 index 000000000000..c2fff9abc217 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise/index.ts @@ -0,0 +1 @@ +export { default } from "../config-promise-plugin" diff --git a/packages/core/test/plugin/fixtures/config-promise/tui.ts b/packages/core/test/plugin/fixtures/config-promise/tui.ts new file mode 100644 index 000000000000..215ebafadff8 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise/tui.ts @@ -0,0 +1 @@ +export default { id: "config-promise-plugin.tui", setup() {} } diff --git a/packages/core/test/plugin/fixtures/failing/index.ts b/packages/core/test/plugin/fixtures/failing/index.ts new file mode 100644 index 000000000000..b5dfdae5c051 --- /dev/null +++ b/packages/core/test/plugin/fixtures/failing/index.ts @@ -0,0 +1 @@ +export { default } from "../failing-plugin" diff --git a/packages/core/test/plugin/fixtures/invalid/index.ts b/packages/core/test/plugin/fixtures/invalid/index.ts new file mode 100644 index 000000000000..00ad6473beb9 --- /dev/null +++ b/packages/core/test/plugin/fixtures/invalid/index.ts @@ -0,0 +1 @@ +export { default } from "../invalid-plugin" diff --git a/packages/core/test/plugin/fixtures/variant-source/index.ts b/packages/core/test/plugin/fixtures/variant-source/index.ts new file mode 100644 index 000000000000..07a8325f7b6c --- /dev/null +++ b/packages/core/test/plugin/fixtures/variant-source/index.ts @@ -0,0 +1 @@ +export { default } from "../variant-source-plugin" diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c94807e5b665..b544e0fac1d5 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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"), diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index 5b3fc2f20f2e..77f80ae74e7c 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -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"] }]) }) diff --git a/packages/core/test/plugin/rpc-effect.test.ts b/packages/core/test/plugin/rpc-effect.test.ts new file mode 100644 index 000000000000..f158bd78fd68 --- /dev/null +++ b/packages/core/test/plugin/rpc-effect.test.ts @@ -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") + }), +) diff --git a/packages/core/test/plugin/rpc-promise.test.ts b/packages/core/test/plugin/rpc-promise.test.ts new file mode 100644 index 000000000000..7bf64a7746f8 --- /dev/null +++ b/packages/core/test/plugin/rpc-promise.test.ts @@ -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() + const cancelled = Promise.withResolvers() + const signals = new Map() + await ctx.rpc.register(service, { + wait: async (input, call) => { + signals.set(input, call.signal) + if (input === "complete") return input + started.resolve() + await new Promise((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() + 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() + const release = Promise.withResolvers() + const second = Promise.withResolvers() + const third = Promise.withResolvers() + 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>> + idle: AsyncIterator> + nativeIdle: AsyncIterator + }>() + 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() + const secondSeen = Promise.withResolvers() + const nextSeen = Promise.withResolvers() + 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) + }), + ) +}) diff --git a/packages/core/test/rpc.test.ts b/packages/core/test/rpc.test.ts new file mode 100644 index 000000000000..59b23a4e5667 --- /dev/null +++ b/packages/core/test/rpc.test.ts @@ -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() + const release = yield* Deferred.make() + 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() + const stopped = yield* Deferred.make() + 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 + }), + ) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d90b028fc29a..7a345ea385c1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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": { diff --git a/packages/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index 5d16ebbef935..2ac5a13e1e8b 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -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" diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 7a504b086c03..5b0cea849a71 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -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 @@ -50,7 +52,6 @@ export interface Context { export interface Plugin { readonly id: string - readonly tui?: boolean readonly vcs?: VcsDiscovery readonly effect: (context: Context) => Effect.Effect } diff --git a/packages/plugin/src/effect/rpc.ts b/packages/plugin/src/effect/rpc.ts new file mode 100644 index 000000000000..5378d1474cc9 --- /dev/null +++ b/packages/plugin/src/effect/rpc.ts @@ -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 { + readonly error: Rpc.ErrorFactory +} + +export type RpcHandlers = { + readonly [Name in keyof D["methods"]]: ( + input: Rpc.Output, + context: RpcCallContext, + ) => Effect.Effect, Rpc.HandlerError> +} + +export interface RpcRegistration extends Registration { + readonly events: { + readonly emit: (...args: Rpc.EventInput) => Effect.Effect + } +} + +export interface RpcDomain extends RpcApi { + readonly register: ( + definition: D, + handlers: RpcHandlers>, + ) => Effect.Effect, unknown, Scope.Scope> +} diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 4b5fdd332fa8..0eaa50270d90 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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 } type Registration = { readonly dispose: () => Promise } -type PromiseEvent = ReturnType extends AsyncIterable ? Event : never +type PromiseContext = Parameters[0] +type PromiseEvent = ReturnType extends AsyncIterable ? Event : never +type HostRpc = Parameters[0]["effect"]>[0]["rpc"] +type StreamAdapter = ( + stream: Stream.Stream, + options?: { readonly signal?: AbortSignal }, +) => AsyncIterable interface CompiledEndpoint { readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect> @@ -18,6 +27,143 @@ interface CompiledEndpoint { const compiledEndpoints = new WeakMap() +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() + const subscriptions = new Set<() => Promise>>() + // 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 ((stream: Stream.Stream, options?: { readonly signal?: AbortSignal }): AsyncIterable => ({ + [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() + const run = Effect.runPromiseWith(context) + + const client = (definition: Rpc.PortableDefinition) => { + const local = host(definition) + const subscribe = ( + name: string, + options?: Pick, + ): AsyncIterable> => streams(local.events.subscribe(name), options) + return Object.assign( + Object.fromEntries( + Object.keys(definition.methods).map((name) => [ + name, + (input: unknown, options?: Pick) => { + // 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 + return run(method(input), { signal: options?.signal }) + }, + ]), + ), + { + events: { + subscribe, + on: ( + name: string, + handler: (event: RpcEventPayload) => Promise | void, + options?: Pick, + ) => { + 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) => + 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 + }, + 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) => 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() + const streams = yield* makeStreams() // Run a hook registration on the plugin scope and resolve once it is registered. const register = (effect: Effect.Effect): Promise => @@ -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), diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index a7d3e3f674f2..37dbf1e13be6 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -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" diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 68c13eb179a4..362685299add 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -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 export interface Plugin { readonly id: string - readonly tui?: boolean readonly vcs?: VcsDiscovery readonly setup: (context: Context) => Promise | Cleanup | void } diff --git a/packages/plugin/src/promise/rpc.ts b/packages/plugin/src/promise/rpc.ts new file mode 100644 index 000000000000..12075742953e --- /dev/null +++ b/packages/plugin/src/promise/rpc.ts @@ -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 { + readonly signal: AbortSignal + readonly error: Rpc.ErrorFactory +} + +export type RpcHandlers = { + readonly [Name in keyof D["methods"]]: ( + input: Rpc.Output, + context: RpcCallContext, + ) => Promise | Rpc.HandlerError> +} + +export interface RpcRegistration extends Registration { + readonly events: { + readonly emit: (...args: Rpc.EventInput) => Promise + } +} + +export interface RpcDomain + extends RpcApi & { readonly location?: never; readonly headers?: never }> { + readonly register: ( + definition: D, + handlers: RpcHandlers>, + ) => Promise> +} diff --git a/packages/plugin/src/rpc.ts b/packages/plugin/src/rpc.ts new file mode 100644 index 000000000000..5b5665e4243a --- /dev/null +++ b/packages/plugin/src/rpc.ts @@ -0,0 +1 @@ +export { Rpc } from "@opencode-ai/schema/rpc" diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 4753d2a09a3a..e80921b5a11e 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -58,10 +58,12 @@ interface LocationCollection { invalidate(location?: LocationRef): void } +type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract } + export interface Data { readonly on: ( type: Type, - handler: (event: Extract) => void, + handler: (event: OpenCodeEventMap[Type]) => void, ) => () => void readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void readonly session: { diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 1d56bffa4459..1b232ab68a8f 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -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([]) +}) diff --git a/packages/plugin/test/rpc-effect.types.ts b/packages/plugin/test/rpc-effect.types.ts new file mode 100644 index 000000000000..4c57e64d9d73 --- /dev/null +++ b/packages/plugin/test/rpc-effect.types.ts @@ -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 + +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, { text: string }>>, + Assert< + Equal< + Effect.Error, + | "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, never>>, + Assert, string>>, + Assert, number>>, + Assert, unknown>>, + Assert, null>>, + Assert, Rpc.EventPayload>>, + Assert, "transport-failure">>, + Assert, never>>, + Assert, number>>, + Assert, never>>, + Assert, number>>, + Assert, Schema.SchemaError>, Schema.SchemaError>>, + Assert, Schema.SchemaError>, Schema.SchemaError>>, + Assert< + Equal< + Extract, { readonly type: "invalid_count" }>, + { readonly type: "invalid_count"; readonly message: string; readonly data: { readonly count: number } } + > + >, + Assert< + Equal< + Extract, { 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 = { + 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, RpcRegistration>>, + Assert, unknown>>, + Assert, 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) } }, +}) diff --git a/packages/plugin/test/rpc-promise.types.ts b/packages/plugin/test/rpc-promise.types.ts new file mode 100644 index 000000000000..caac2771612a --- /dev/null +++ b/packages/plugin/test/rpc-promise.types.ts @@ -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>, + Assert>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert, { count: string }>>, + Assert, { count: number }>>, + Assert, number>>, + Assert, number>>, + Assert["type"], "rpc.acme.updated">>, + Assert["location"], { directory: string; workspaceID?: string }>>, + Assert>, string>>, + Assert>, number>>, + Assert>, 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 = { + 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, "location"> +// @ts-expect-error Custom events always carry their emitting location. +withoutLocation satisfies RpcEventPayload + +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 +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" } }) diff --git a/packages/plugin/test/rpc.fixture.ts b/packages/plugin/test/rpc.fixture.ts new file mode 100644 index 000000000000..d32debc2d9b7 --- /dev/null +++ b/packages/plugin/test/rpc.fixture.ts @@ -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 = Types.Equals +export type Assert = T diff --git a/packages/plugin/test/rpc.test.ts b/packages/plugin/test/rpc.test.ts new file mode 100644 index 000000000000..ed37b4af8d2d --- /dev/null +++ b/packages/plugin/test/rpc.test.ts @@ -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() + 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)), + ) +}) diff --git a/packages/plugin/tsconfig.tests.json b/packages/plugin/tsconfig.tests.json new file mode 100644 index 000000000000..7c4aaff097d3 --- /dev/null +++ b/packages/plugin/tsconfig.tests.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test/**/*.types.ts"] +} diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 576a4b72595a..b771af062774 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -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.Features": { + "type": "object", + "properties": { + "server": { + "type": "boolean", + "enum": [true] + }, + "tui": { + "type": "boolean", + "enum": [true] + }, + "rpc": { + "type": "boolean", + "enum": [true] + } + }, + "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 + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "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 + "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." diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index dbbf552e0e63..358918030f6d 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -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 = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -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) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 8b023c6453eb..0417f872a539 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index e9ca84d38788..54f8b396638f 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -11,6 +11,26 @@ export class InvalidRequestError extends Schema.TaggedError { httpApiStatus: 400 }, ) {} +export class RpcError extends Schema.TaggedError()( + "RpcError", + { + type: Schema.String, + message: Schema.String, + data: Schema.optional(Schema.Unknown), + }, + { httpApiStatus: 400 }, +) {} + +export class RpcInternalError extends Schema.TaggedError()( + "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", { message: Schema.String }, diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts index 242aa745ef53..6fbc12ba0505 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -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 = >(definitions: Definitions) => Schema.Union([ ...definitions, + rpcEvent, ...(definitions.some((definition) => definition.type === "server.connected") ? [] : [ @@ -38,7 +48,7 @@ const make = >(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.") diff --git a/packages/protocol/src/groups/rpc.ts b/packages/protocol/src/groups/rpc.ts new file mode 100644 index 000000000000..e291dce94a55 --- /dev/null +++ b/packages/protocol/src/groups/rpc.ts @@ -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." })) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 4de4240e0faa..12d162ce96f9 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -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 extends string | number | boolean | null ? Value @@ -21,11 +22,40 @@ type JsonShape = Value extends string | number | boolean | null // requiring every runtime event shape to fit its encoded wire contract. const wireReady: [JsonShape] extends [JsonShape] ? 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", () => { diff --git a/packages/protocol/test/rpc.test.ts b/packages/protocol/test/rpc.test.ts new file mode 100644 index 000000000000..f3f9d25ad1b6 --- /dev/null +++ b/packages/protocol/test/rpc.test.ts @@ -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") +}) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b8807b52c34c..8025a185f03a 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -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" diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 9d3c9e80320a..f7c24c59ef9b 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -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 {} +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", diff --git a/packages/schema/src/rpc.ts b/packages/schema/src/rpc.ts new file mode 100644 index 000000000000..2fed79a2cc5c --- /dev/null +++ b/packages/schema/src/rpc.ts @@ -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> & { + readonly [Name in `rpc.${string}`]?: never +} + +export interface Method { + readonly input: Tool.ValueSchema + readonly output: Tool.ValueSchema + readonly errors?: ErrorMap +} + +export type PortableValueSchema = StandardSchemaV1 | JsonSchema.JsonSchema + +export interface PortableMethod extends Method { + readonly input: PortableValueSchema + readonly output: PortableValueSchema + readonly errors?: Readonly> & { + readonly [Name in `rpc.${string}`]?: never + } +} + +type EventDataObject = Readonly> +type EventValueSchema = + | Schema.Codec + | StandardSchemaV1 + | (JsonSchema.JsonSchema & { readonly type: "object" }) +type PortableEventValueSchema = + | StandardSchemaV1 + | (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> & { readonly events?: never } + readonly events: Readonly> +} + +export interface PortableDefinition extends Definition { + readonly methods: Readonly> & { readonly events?: never } + readonly events: Readonly> +} + +export function define(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 Schema.Top + ? S["Encoded"] + : S extends StandardSchemaV1 + ? StandardSchemaV1.InferInput + : unknown + +export type Output = S extends Schema.Top + ? S["Type"] + : S extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : unknown + +// Effect codecs encode handler results; Standard Schema parses them forward. +export type HandlerOutput = S extends Schema.Top ? Output : Input + +type MethodErrors = M extends { + readonly errors: infer Errors extends ErrorMap +} + ? Errors + : never +type ErrorSchema> = MethodErrors[Name] +type ErrorData = unknown extends Data + ? { readonly data: Data } + : undefined extends Data + ? { readonly data?: Data } + : { readonly data: Data } +type ErrorDataArguments = unknown extends Data + ? [data: Data] + : undefined extends Data + ? [data?: Data] + : [data: Data] +type Simplify = { readonly [K in keyof A]: A[K] } +declare const HandlerErrorTypeId: unique symbol + +export interface Failure { + 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 { + readonly errors: infer Errors extends ErrorMap +} + ? Exclude + : never +export type HandlerErrorFor> = Simplify< + { + readonly type: Name + readonly message: string + readonly [HandlerErrorTypeId]: true + } & ErrorData>> +> +export type HandlerError = { + readonly [Name in ErrorName]: HandlerErrorFor +}[ErrorName] +export type MethodErrorFor> = Simplify< + { + readonly type: Name + readonly message: string + } & ErrorData>> +> +export type MethodError = { + readonly [Name in ErrorName]: MethodErrorFor +}[ErrorName] +export type ErrorArguments> = [ + type: Name, + message: string, + ...data: ErrorDataArguments>>, +] +export type ErrorFactory = >( + ...args: ErrorArguments +) => HandlerErrorFor + +export type EventInputData = S extends JsonSchema.JsonSchema + ? EventDataObject + : HandlerOutput +export type EventData = S extends JsonSchema.JsonSchema + ? EventDataObject + : Output + +// Keep the event name correlated with its payload even when callers use unions. +export type EventInput = { + [Name in keyof D["events"] & string]: [name: Name, data: EventInputData] +}[keyof D["events"] & string] + +type EventPayloadFor< + D extends Definition, + Name extends keyof D["events"] & string, +> = Omit, "type" | "data" | "durable" | "location"> & { + readonly type: `rpc.${D["id"]}.${Name}` + readonly data: EventData + readonly location: Location.Ref +} + +export type EventPayload = { + readonly [K in Name]: EventPayloadFor +}[Name] diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index eff51028bc91..9e139b94050d 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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) }) diff --git a/packages/schema/test/plugin.test.ts b/packages/schema/test/plugin.test.ts new file mode 100644 index 000000000000..94e082ba0901 --- /dev/null +++ b/packages/schema/test/plugin.test.ts @@ -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" }, + }) +}) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 8020b4625e5d..e55bb2839450 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -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, diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts new file mode 100644 index 000000000000..f6a80088171a --- /dev/null +++ b/packages/server/src/handlers/rpc.ts @@ -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", + }), + ), + ), + ), + ), +) diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts new file mode 100644 index 000000000000..33b2b0b736c2 --- /dev/null +++ b/packages/server/test/rpc.test.ts @@ -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 + +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; 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() + const stopped = yield* Deferred.make() + const promiseStarted = Promise.withResolvers() + const promiseStopped = Promise.withResolvers() + 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((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() + 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 => + 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, +) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c1bdf5cfd74b..b81c29b9b663 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 diff --git a/packages/tui/src/context/event.ts b/packages/tui/src/context/event.ts index eb8c5b031503..67d28dfd78bb 100644 --- a/packages/tui/src/context/event.ts +++ b/packages/tui/src/context/event.ts @@ -5,6 +5,7 @@ type EventMetadata = { directory: string | undefined workspace: string | undefined } +type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract } export function useEvent() { const client = useClient() @@ -18,7 +19,7 @@ export function useEvent() { function on( type: T, - handler: (event: Extract, 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 }) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index f32d9f1c0602..a0170d0c1f2e 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -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 } diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 7f4767db2f4f..408e3bf8bc35 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -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 @@ -100,7 +101,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d const data = useData() const [serverPlugins, setServerPlugins] = createSignal< ReadonlyArray< - Extract & { 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 & { - 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 { diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index d9d57fd6d173..17c535e6bea7 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -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 => { + 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)) diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 3b239b50e966..870d65266986 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -11,6 +11,14 @@ import type { LogLevel, LogSink } from "../../../src/context/log" const projectID = "proj_test" +function acceptsRpcEvent(on: ReturnType["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()) { diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts index 4a8b60c74bb9..502a8ede525b 100644 --- a/packages/tui/test/plugin-discovery.test.ts +++ b/packages/tui/test/plugin-discovery.test.ts @@ -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"), ) }) diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 6c40ad50e4d9..b78e703dce59 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -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 +} diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 576a4b72595a..b771af062774 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -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.Features": { + "type": "object", + "properties": { + "server": { + "type": "boolean", + "enum": [true] + }, + "tui": { + "type": "boolean", + "enum": [true] + }, + "rpc": { + "type": "boolean", + "enum": [true] + } + }, + "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 + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "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 + "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." diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 576a4b72595a..b771af062774 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -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.Features": { + "type": "object", + "properties": { + "server": { + "type": "boolean", + "enum": [true] + }, + "tui": { + "type": "boolean", + "enum": [true] + }, + "rpc": { + "type": "boolean", + "enum": [true] + } + }, + "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 + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "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 + "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." diff --git a/packages/www/src/docs/AGENTS.md b/packages/www/src/docs/AGENTS.md index 2e0ae767db29..93798d2b88e5 100644 --- a/packages/www/src/docs/AGENTS.md +++ b/packages/www/src/docs/AGENTS.md @@ -9,6 +9,14 @@ - Do not add a documentation frontend framework; this folder owns the UI directly. - Keep internal Markdown links docs-root-relative, for example `/config`; `remark-links.ts` applies the site and docs base paths. +## Writing Style + +- Keep prose sections brief and focused on one idea. Prefer one to three sentences over large paragraphs. +- Interleave explanations with concrete code, configuration, command, or output examples so pages do not become walls of text. +- Put the relevant example immediately after the text that introduces it, following `content/build/plugins/cli.mdx` as the reference pattern. +- Split long explanations with meaningful headings and examples rather than accumulating caveats in one paragraph. +- Lead with the common task and working example; place edge cases and supporting details afterward. + ## Validation - Run `bun typecheck` and `bun run build` from `packages/www` after changes. diff --git a/packages/www/src/docs/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index 1d6d96b785ec..93cac5580d93 100644 --- a/packages/www/src/docs/content/build/client/effect.mdx +++ b/packages/www/src/docs/content/build/client/effect.mdx @@ -35,18 +35,26 @@ const session = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpCli ## Headers and requests -Pass default headers to `OpenCode.make`. Each operation also accepts request options for cancellation or per-request -headers. +Configure default headers on the supplied `HttpClient`. Native operations use +normal Effect interruption for cancellation. RPC methods additionally accept +per-call location, header, and signal options. ```ts -const client = yield* OpenCode.make({ - baseUrl: "https://opencode.example.com", - headers: { authorization: `Bearer ${process.env.OPENCODE_TOKEN}` }, -}) - -const sessions = yield* client.session.list(undefined, { - signal: AbortSignal.timeout(10_000), -}) +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +const httpClient = yield * HttpClient.HttpClient +const client = + yield * + OpenCode.make({ baseUrl: "https://opencode.example.com" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.mapRequest(httpClient, (request) => + HttpClientRequest.setHeaders(request, { authorization: `Bearer ${process.env.OPENCODE_TOKEN}` }), + ), + ), + ) + +const sessions = yield * client.session.list() ``` ## Stream events @@ -56,11 +64,49 @@ Streaming operations such as `event.subscribe()` and `session.log()` return Effe ```ts import { Effect, Stream } from "effect" -yield* client.event.subscribe().pipe( - Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type })), -) +yield * + client.event.subscribe().pipe(Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type }))) +``` + +Native and RPC event Streams share one lazy connection per client. Constructing +a Stream does not connect; consuming it does. Stopping one consumer leaves others +running, and the last consumer leaving closes the source. Source EOF or failure +ends current subscriptions without automatic retry or replay. Late native consumers +receive the current connection marker before live events. + +## Plugin RPC + +Use the same shared contract as Promise clients and server plugins: + +```ts +import { Acme } from "opencode-acme-plugin/rpc" + +const acme = client.rpc(Acme) +const result = yield * acme.search({ query: "hello" }, { location: { directory: "/workspace" } }) + +yield * + acme.events + .subscribe("updated") + .pipe( + Stream.runForEach((event) => + Effect.logInfo("Plugin event", { type: event.type, location: event.location, text: event.data.text }), + ), + ) ``` +Method arguments and results are inferred from the contract. The second optional +argument holds `location`, `signal`, and `headers`; omitted location uses the +normal request defaults. Calls are interrupted with their consuming Effect. +Method error maps are inferred in the Effect error channel. Declared errors are +decoded through their data schemas. The typed subclient removes the generic HTTP +RPC error wrapper; reserved `rpc.*` types identify framework failures. + +RPC events are typed Streams, not callback-style `on` listeners. They receive the +RPC's events from all locations, each with required `location` and a normal +prefixed type such as `rpc.acme.updated`. This differs from server-plugin handles, +which are fixed to their own location. See [plugin RPC](/build/plugins#rpc) for +definitions, schemas, registration, and live subscription semantics. + ## Local background service The Node-only `@opencode-ai/client/effect/service` entrypoint discovers, starts, authenticates, and stops the local @@ -77,14 +123,17 @@ import { NodeFileSystem } from "@effect/platform-node" import { OpenCode } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service" import { Effect } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" const program = Effect.gen(function* () { const endpoint = yield* Service.ensure() - const client = yield* OpenCode.make({ - baseUrl: endpoint.url, - headers: Service.headers(endpoint), - }) + const httpClient = yield* HttpClient.HttpClient + const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.mapRequest(httpClient, (request) => HttpClientRequest.setHeaders(request, Service.headers(endpoint))), + ), + ) return yield* client.health.get() }) @@ -96,6 +145,6 @@ const health = await Effect.runPromise( Discover without starting, or stop the exact registered service. ```ts -const endpoint = yield* Service.discover() -yield* Service.stop() +const endpoint = yield * Service.discover() +yield * Service.stop() ``` diff --git a/packages/www/src/docs/content/build/client/index.mdx b/packages/www/src/docs/content/build/client/index.mdx index 233c9ff07e40..49aad6b3c89d 100644 --- a/packages/www/src/docs/content/build/client/index.mdx +++ b/packages/www/src/docs/content/build/client/index.mdx @@ -2,10 +2,10 @@ title: "JavaScript" --- -`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP +`@opencode-ai/client` is the TypeScript client for the OpenCode HTTP API. Use it when your application connects to an OpenCode server over the -network. Its types and methods are generated from the same contract as the -[API reference](/api). +network. Its native types and methods are generated from the same contract as the +[API reference](/api). Plugin RPC types come from imported RPC definitions. The V2 API and client are beta. Method names, inputs, and outputs may change before the stable release. @@ -43,7 +43,8 @@ await client.session.prompt({ Pass default authentication or application headers to `OpenCode.make` with `headers`. You can also supply a custom `fetch` implementation. Each operation accepts request options as its final argument for an `AbortSignal` or -per-request headers. +per-request headers. Event subscriptions are the exception: they accept only +subscriber-local cancellation and use the base client's headers. ```ts const client = OpenCode.make({ @@ -68,6 +69,75 @@ for await (const event of client.event.subscribe()) { } ``` +Native and RPC event subscribers share one lazy connection per client. Client, +handle, and iterable creation open no event connection; consumption starts it. +Breaking iteration or aborting a subscriber ends only that iterator. The last +subscriber leaving closes the connection. The shared source waits for active +subscribers to accept each event; consumers should buffer before performing slow work. + +Subscriptions are live-only, with no replay or automatic reconnection. A source +failure ends current subscriptions; subscribe again after recovery. A late native +subscriber receives the current `server.connected` marker, not past business events. + +## Plugin RPC + +Import a plugin's shared contract and pass it to `client.rpc`: + +```ts +import { OpenCode } from "@opencode-ai/client" +import { Acme } from "opencode-acme-plugin/rpc" + +const acme = client.rpc(Acme) +const result = await acme.search( + { query: "hello" }, + { location: { directory: "/workspace" }, signal: AbortSignal.timeout(10_000) }, +) + +const unsubscribe = acme.events.on("updated", (event) => { + console.log(event.type, event.location.directory, event.data.text) +}) +``` + +The second optional method argument holds `location`, `signal`, and `headers`, +separate from the plugin-defined input. Omitted location follows native request +defaults: base location headers, then the server's working directory. No location +is selected when constructing the subclient. + +Methods infer arguments and results from the definition. Schema parsing belongs +to the contract boundary; callers send the accepted input representation, while +handlers receive parsed values. The Promise client accepts Standard Schema or +plain JSON Schema definitions and does +not run schema parsers locally: the server returns already parsed and transformed +output. Effect Schema definitions require the Effect client. + +Declared method errors reject like errors from other Promise client endpoints, +and caught errors remain untyped. Generic HTTP RPC error wrappers are removed by +the typed subclient. Reserved `rpc.*` framework failures remain plain RPC failures, +while unrelated authentication, transport, and protocol errors keep their normal +client representations. + +RPC subscriptions use local names and receive that RPC's events across all +locations. Inspect the required `event.location` to filter them. `events.subscribe` +matches the native async iterable API: + +```ts +for await (const event of acme.events.subscribe("updated")) { + console.log(event.data.text) +} +``` + +`events.on` is a convenience wrapper over the same source. It returns unsubscribe; +async callbacks are awaited sequentially. Callback or source failures are logged +and end that listener. Native and typed subscriptions receive the same normal +`rpc..` envelope with direct object event data. Live subscriptions +do not replay missed events. + +The server plugin must be configured and implement the RPC; importing a +definition does not register it. See [plugin RPC](/build/plugins#rpc) for the +definition and registration API. Any HTTP client can also invoke the generic +`POST /api/rpc/{rpcID}/{method}` route with `{ "input": ... }` and receive +`{ "output": ... }`. Omitted input/output fields represent no value. + ## Local background service The main client entrypoints are browser-compatible and do not include local diff --git a/packages/www/src/docs/content/build/plugins/cli.mdx b/packages/www/src/docs/content/build/plugins/cli.mdx index c2500a6aad24..4cbb177c28e3 100644 --- a/packages/www/src/docs/content/build/plugins/cli.mdx +++ b/packages/www/src/docs/content/build/plugins/cli.mdx @@ -436,14 +436,13 @@ Expose the CLI plugin through `./tui`; add OpenTUI peers when the plugin renders } ``` -Set `tui: true` on the [main plugin](/build/plugins) for automatic loading. +Export `./tui` beside the [main plugin](/build/plugins) for automatic loading. ```ts title="src/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ id: "acme.server", - tui: true, setup() {}, }) ``` diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 17201b444bbf..398f0eda1af8 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -12,7 +12,7 @@ bun add @opencode-ai/plugin@beta effect Export an Effect plugin from `.opencode/plugins/` to load it automatically. -```ts title=".opencode/plugins/concise.ts" +```ts title=".opencode/plugins/concise/index.ts" import { Plugin } from "@opencode-ai/plugin/effect" import { Effect } from "effect" @@ -26,7 +26,7 @@ export default Plugin.define({ }) ``` -Published packages and files outside `.opencode/plugins/` use the same `plugins` configuration as other server +Published packages and plugin directories outside `.opencode/plugins/` use the same `plugins` configuration as other server plugins. ```jsonc title="opencode.jsonc" @@ -36,18 +36,18 @@ plugins. "opencode-acme-effect-plugin", "opencode-acme-effect-plugin@1.2.0", "@acme/opencode-effect-plugin", - "./plugins/local-effect.ts", + "./plugins/local-effect", { "package": "@acme/opencode-effect-plugin", - "options": { "agent": "reviewer", "strict": true } - } - ] + "options": { "agent": "reviewer", "strict": true }, + }, + ], } ``` See [Configure plugins](/plugins) for enablement, package resolution, and configuration precedence. -```ts title="plugins/local-effect.ts" +```ts title="plugins/local-effect/index.ts" import { Plugin } from "@opencode-ai/plugin/effect" import { Effect } from "effect" @@ -120,6 +120,7 @@ interface Context { readonly mcp: MCPDomain readonly plugin: PluginApi readonly reference: ReferenceDomain + readonly rpc: RpcDomain readonly session: SessionDomain readonly shell: ShellDomain readonly skill: SkillDomain @@ -131,7 +132,6 @@ interface Context { interface Plugin { readonly id: string - readonly tui?: boolean readonly effect: (context: Context) => Effect.Effect } ``` @@ -144,16 +144,16 @@ Pass options with the object form in `opencode.json(c)`. { "plugins": [ { - "package": "./plugins/company-effect.ts", - "options": { "strict": true } - } - ] + "package": "./plugins/company-effect", + "options": { "strict": true }, + }, + ], } ``` Read options from `ctx.options`. Narrow unknown values before use. -```ts title="plugins/company-effect.ts" +```ts title="plugins/company-effect/index.ts" import { Plugin } from "@opencode-ai/plugin/effect" import { Effect } from "effect" @@ -172,7 +172,7 @@ export default Plugin.define({ Transforms synchronously edit a mutable draft. OpenCode applies transforms in plugin order, so later transforms see earlier changes. Yielding the registration keeps it in the plugin scope. -```ts title="plugins/models-effect.ts" +```ts title="plugins/models-effect/index.ts" import { Plugin } from "@opencode-ai/plugin/effect" import { Effect } from "effect" @@ -193,7 +193,7 @@ export default Plugin.define({ A later transform can enforce policy across the composed catalog. -```ts title="plugins/model-budget-effect.ts" +```ts title="plugins/model-budget-effect/index.ts" effect: (ctx) => Effect.gen(function* () { const catalog = ctx.catalog @@ -211,7 +211,7 @@ effect: (ctx) => Call `reload` when external state used by a transform changes. Reload replays every transform in order. -```ts title="plugins/models-effect.ts" +```ts title="plugins/models-effect/index.ts" effect: (ctx) => Effect.gen(function* () { const catalog = ctx.catalog @@ -631,6 +631,59 @@ interface Context { } ``` +### RPC + +Use the same execution-neutral [`Rpc.define` builder](/build/plugins#rpc). +Effect clients and plugins accept Effect Schema, Standard Schema, or plain JSON +Schema. Promise consumers accept only the portable Standard and JSON formats. + +```ts +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" +import { Acme } from "./rpc.js" + +export default Plugin.define({ + id: "acme-effect-plugin", + effect: (ctx) => + Effect.gen(function* () { + const registration = yield* ctx.rpc.register(Acme, { + search: ({ query }, context) => + findText(query).pipe( + Effect.flatMap((text) => + text + ? Effect.succeed({ text }) + : Effect.fail(context.error("not_found", "Result not found", { query })), + ), + ), + }) + yield* registration.events.emit("updated", { itemID: "item-1", text: "ready" }) + }).pipe(Effect.orDie), +}) +``` + +Effect handlers use normal interruption. Registrations belong to the plugin +scope; `yield* registration.dispose` removes one explicitly. Later registrations +override earlier ones at the same location, without changing in-flight handlers. + +`ctx.rpc(Acme)` returns a local typed subclient. Its methods return Effects and +`events.subscribe(name)` returns a Stream. Use scoped fibers when listening +during plugin lifetime: + +```ts +const acme = ctx.rpc(Acme) +yield * + acme.events.subscribe("updated").pipe( + Stream.runForEach((event) => Effect.logInfo(event.data.text)), + Effect.forkScoped, + ) +``` + +There is no Effect callback-style `on` API. Subscriptions are location-bound, +live-only, and close when Stream consumption stops. Events use the normal +ephemeral Bus path. Method `errors` maps become typed Effect error channels. Construct one +with `context.error(...)` and fail it with `Effect.fail`; unexpected failures and +transport errors remain separate from the declared method errors. + ### References Read references available at the current location. diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index a56394c2f55a..11e000cace77 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -5,7 +5,7 @@ title: "Overview" Plugins can modify OpenCode's behavior and add new features. To change the terminal UI, build a [CLI plugin](/build/plugins/cli). -```ts title=".opencode/plugins/example.ts" +```ts title=".opencode/plugins/example/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ @@ -17,7 +17,7 @@ export default Plugin.define({ ``` Plugins under `.opencode/plugins/` are loaded automatically, like the local example above. To load published packages -or files from other locations, add them to `plugins` in `opencode.json(c)`: +or plugin directories from other locations, add them to `plugins` in `opencode.json(c)`: ```jsonc title="opencode.jsonc" { @@ -26,10 +26,10 @@ or files from other locations, add them to `plugins` in `opencode.json(c)`: "opencode-acme-plugin", "opencode-acme-plugin@1.2.0", "@acme/opencode-plugin", - "./plugins/local.ts", - "../shared/plugin.ts", - "/absolute/path/plugin.ts", - "file:///home/me/plugins/local.ts", + "./plugins/local", + "../shared/plugin", + "/absolute/path/plugin", + "file:///home/me/plugins/local", { "package": "@acme/opencode-plugin", "options": { @@ -93,18 +93,18 @@ Pass plugin options with the object form in `opencode.json(c)`. { "plugins": [ { - "package": "./plugins/company.ts", + "package": "./plugins/company", "options": { - "strict": true - } - } - ] + "strict": true, + }, + }, + ], } ``` Read those values from `ctx.options` during `setup`. -```ts title="plugins/company.ts" +```ts title="plugins/company/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ @@ -122,7 +122,7 @@ transforms and each builds on the changes made before it. Say we have a plugin that adds one model to the catalog. -```ts title="plugins/models.ts" +```ts title="plugins/models/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ @@ -140,7 +140,7 @@ export default Plugin.define({ A later plugin can enforce a maximum output price across every model, including models added by earlier plugins. -```ts title="plugins/model-budget.ts" +```ts title="plugins/model-budget/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ @@ -162,7 +162,7 @@ export default Plugin.define({ Now say the first plugin dynamically fetches can fetch its model list from a dynamic source. It can call `reload` when that list changes. -```ts title="plugins/models.ts" +```ts title="plugins/models/index.ts" import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ @@ -468,14 +468,23 @@ interface IntegrationContext { key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise } oauth: { - connect(input: IntegrationOauthConnectInput, requestOptions?: RequestOptions): Promise + connect( + input: IntegrationOauthConnectInput, + requestOptions?: RequestOptions, + ): Promise status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise } command: { - connect(input: IntegrationCommandConnectInput, requestOptions?: RequestOptions): Promise - status(input: IntegrationCommandStatusInput, requestOptions?: RequestOptions): Promise + connect( + input: IntegrationCommandConnectInput, + requestOptions?: RequestOptions, + ): Promise + status( + input: IntegrationCommandStatusInput, + requestOptions?: RequestOptions, + ): Promise cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise } transform(callback: (draft: IntegrationDraft) => void): Promise @@ -995,7 +1004,7 @@ Schema: [`V2EventEncoded`](/api#schema-V2EventEncoded) ```ts interface EventContext { - subscribe(requestOptions?: RequestOptions): AsyncIterable + subscribe(options?: { signal?: AbortSignal }): AsyncIterable } ``` @@ -1237,10 +1246,7 @@ await ctx.shell.hook("create.before", (event) => { ```ts interface ShellHookContext { - hook( - name: "create.before", - callback: (event: ShellCreateBefore) => Promise | void, - ): Promise + hook(name: "create.before", callback: (event: ShellCreateBefore) => Promise | void): Promise } interface ShellCreateBefore { @@ -1287,6 +1293,115 @@ interface ToolHookContext { } ``` +## RPC + +Expose typed methods and custom events through a shared RPC definition. Keep +the contract in a browser-safe module, separate from plugin setup and server code. +`Rpc.define` is synchronous and independent of Promise or Effect execution. + +```ts title="src/rpc.ts" +import { Rpc } from "@opencode-ai/plugin/rpc" +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() }), + }, + }, + }, + events: { + updated: { + schema: z.object({ itemID: z.string(), text: z.string() }), + }, + }, +}) +``` + +Promise plugin contracts accept Standard Schema such as Zod or plain JSON Schema. +Standard Schema infers types; plain JSON Schema uses `unknown` while still +validating at runtime. Effect Schema is supported only by the Effect plugin and +client APIs. Use Standard or JSON Schema when both API styles consume a contract. +Every method declares `input` and `output` and may declare an `errors` map. +Error keys become literal error `type` values, while each schema validates and +transforms that error's `data`. Names starting with `rpc.` are reserved for +framework failures. Schemas own parsing, transformations, and Effect encoding. +RPC does not add a second generic JSON validation pass. + +Custom event schemas must produce JSON objects. Scalars, arrays, `null`, and +`undefined` are not valid event data. Plain JSON Schema event definitions are +checked at emission even though they do not infer a TypeScript payload type. + +Plain JSON Schema is interpreted as Draft 2020-12 and delegated directly to +Effect's JSON Schema importer and decoder. Use Standard Schema when another +dialect or parser is required. + +Use `{}` for an empty event payload; only omitted method input/output represents +no value. + +Register the implementation inside `setup`: + +```ts title="src/index.ts" +import { Plugin } from "@opencode-ai/plugin" +import { Acme } from "./rpc.js" + +export default Plugin.define({ + id: "acme-plugin", + async setup(ctx) { + const registration = await ctx.rpc.register(Acme, { + search: async ({ query }, context) => { + const text = await findText(query, { signal: context.signal }) + if (!text) return context.error("not_found", "Result not found", { query }) + return { text } + }, + }) + + await registration.events.emit("updated", { itemID: "item-1", text: "ready" }) + }, +}) +``` + +Promise handlers receive a general second context argument with `signal` and a +typed `error(type, message, data)` constructor. They may return or throw the +constructed error; both reject callers with `{ type, message, data? }`. RPC IDs +are independent of plugin IDs. One plugin can implement several RPCs, and later registrations override earlier ones +at the same location. Disposal or unload removes only that registration and +reveals the previous implementation. In-flight calls retain their original handler. + +Other server plugins can obtain a handle without implementing the RPC: + +```ts +const acme = ctx.rpc(Acme) +const result = await acme.search({ query: "hello" }) + +const unsubscribe = acme.events.on("updated", (event) => { + console.log(event.type, event.location.directory, event.data.text) +}) +``` + +Handles are immediate; each call finds the current registration. Server-plugin +handles call and subscribe within their own location and cannot override it. +`events.subscribe("updated")` returns an async iterable; `events.on` is a +callback convenience returning unsubscribe. Plugin unload closes its subscriptions. + +Event keys are local names. Subscribers see normal prefixed types such as +`rpc.acme.updated`, with `id`, `created`, direct `data`, required `location`, and optional +`metadata`. Events publish through the normal ephemeral Bus path. + +Subscriptions remain live-only: there is no plugin log/replay API yet, and events +while disconnected are missed. The method name `events` is reserved for the +subclient's event API. + +External [clients](/build/client#plugin-rpc) use `client.rpc(Acme)` and receive +that RPC's events across all locations. The native `/api/event` stream and +typed subclients observe the same direct `rpc..` envelope. +Neither importing the contract nor constructing a handle loads the server implementation. +Configure the plugin on the server separately. + ## Publish A package plugin uses the same default export as a local plugin. A minimal @@ -1298,7 +1413,8 @@ manifest is: "version": "1.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rpc": "./src/rpc.ts" }, "dependencies": { "@opencode-ai/plugin": "beta" @@ -1306,6 +1422,9 @@ manifest is: } ``` +The `./rpc` export is optional; include it when publishing a shared RPC contract +for other plugins and clients to import without loading your implementation. + Use versions compatible with the OpenCode release you target and test the installed package, not only a workspace-linked copy. Because the plugin API is beta, publish compatible plugin updates when V2 entrypoints or contracts diff --git a/packages/www/src/docs/content/cli/config.mdx b/packages/www/src/docs/content/cli/config.mdx index 1a8535f9169e..110aaacd6c10 100644 --- a/packages/www/src/docs/content/cli/config.mdx +++ b/packages/www/src/docs/content/cli/config.mdx @@ -77,7 +77,7 @@ Load terminal plugins in order: "plugins": [ "-opencode.notifications", { - "package": "./plugins/status.ts", + "package": "./plugins/status", "options": { "compact": true } @@ -86,7 +86,7 @@ Load terminal plugins in order: } ``` -See [Plugins](/cli/plugins) for packages, local files, options, and enablement directives. +See [Plugins](/cli/plugins) for packages, local plugins, options, and enablement directives. ## Scroll diff --git a/packages/www/src/docs/content/cli/plugins.mdx b/packages/www/src/docs/content/cli/plugins.mdx index c6ad7527e61b..84d27b5e0f35 100644 --- a/packages/www/src/docs/content/cli/plugins.mdx +++ b/packages/www/src/docs/content/cli/plugins.mdx @@ -16,10 +16,10 @@ to a remote server: "opencode.example@1.0.0", "@example/opencode-tui", "@example/opencode-tui@1.0.0", - "./plugins/status.ts", - "../plugins/status.ts", - "/home/user/plugins/status.ts", - "file:///home/user/plugins/status.ts" + "./plugins/status", + "../plugins/status", + "/home/user/plugins/status", + "file:///home/user/plugins/status" ] } ``` @@ -47,12 +47,14 @@ Pass plugin options with the object form: } ``` -OpenCode also discovers JavaScript and TypeScript plugins from `plugins/tui` under the global config directory and project -`.opencode` directories. +OpenCode also discovers plugins under the global config directory and project `.opencode` directories. Each plugin uses +the same layout as a published package, with server and TUI entrypoints kept together. ```text title="Plugin discovery paths" -/plugins/tui/status.ts -/.opencode/plugins/tui/status.ts +/plugins/status/index.ts +/plugins/status/tui.ts +/.opencode/plugins/status/index.ts +/.opencode/plugins/status/tui.ts ``` Discovered plugins can import `@opencode-ai/plugin/tui` directly; OpenCode resolves the package at runtime. See diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index f2d10ca40891..6ac20222a14a 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -419,7 +419,7 @@ See the [references guide](/references) for shorthand, visibility, and path reso ### Plugins -Load plugins from packages or local files. Use the object form when a plugin +Load plugins from packages or local plugin directories. Use the object form when a plugin accepts options. ```jsonc @@ -427,7 +427,7 @@ accepts options. "plugins": [ "opencode-example-plugin", { - "package": "./plugins/local.ts", + "package": "./plugins/local", "options": { "enabled": true, }, diff --git a/packages/www/src/docs/content/plugins.mdx b/packages/www/src/docs/content/plugins.mdx index 7119963b4df4..9e36b6f53604 100644 --- a/packages/www/src/docs/content/plugins.mdx +++ b/packages/www/src/docs/content/plugins.mdx @@ -2,7 +2,8 @@ title: "Plugins" --- -Load published packages, versioned packages, scoped packages, local files, or configured plugins from `opencode.json(c)`. +Load published packages, versioned packages, scoped packages, local plugin directories, or configured plugins from +`opencode.json(c)`. ```jsonc title="opencode.jsonc" { @@ -11,10 +12,10 @@ Load published packages, versioned packages, scoped packages, local files, or co "opencode-acme-plugin", "opencode-acme-plugin@1.2.0", "@acme/opencode-plugin", - "./plugins/local.ts", + "./plugins/local", "../shared/plugin.ts", "/absolute/path/plugin.ts", - "file:///home/me/plugins/local.ts", + "file:///home/me/plugins/local", { "package": "@acme/opencode-plugin", "options": { @@ -57,7 +58,7 @@ explicitly or move it under `.opencode/`. ```jsonc title="opencode.jsonc" { - "plugins": ["./plugins/local.ts"] + "plugins": ["./plugins/local"] } ``` @@ -99,7 +100,7 @@ starts. Exact npm versions and full Git commit hashes stay pinned. Changes to un restarting OpenCode. ```sh -touch .opencode/plugins/concise.ts +touch .opencode/plugins/concise/index.ts opencode2 service restart ``` diff --git a/packages/www/src/docs/styles/global.css b/packages/www/src/docs/styles/global.css index 30cdb0125f13..da723fc6a55e 100644 --- a/packages/www/src/docs/styles/global.css +++ b/packages/www/src/docs/styles/global.css @@ -758,6 +758,8 @@ main { .docs-toc { position: sticky; top: calc(var(--header-height) + 2rem); + max-height: calc(100vh - var(--header-height) - 4rem); + overflow-y: auto; } .docs-toc .nested {