From 9455a761c5726765e8fa0c3e02efaf79cd15061e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 28 Aug 2026 23:03:05 -0400 Subject: [PATCH 01/20] feat(plugin): add typed rpc and custom events --- .changeset/plugin-rpc-events.md | 11 + AGENTS.md | 1 + PLUGIN_RPC_DESIGN.md | 505 +++++++++++++++ bun.lock | 2 + packages/client/package.json | 3 +- packages/client/src/effect/api.ts | 2 + packages/client/src/effect/api/api.ts | 14 + packages/client/src/effect/client.ts | 58 ++ .../client/src/effect/generated/client.ts | 14 + packages/client/src/effect/index.ts | 6 +- packages/client/src/effect/rpc.ts | 88 +++ packages/client/src/promise/api.ts | 6 +- packages/client/src/promise/client.ts | 18 + .../client/src/promise/generated/client.ts | 17 + .../client/src/promise/generated/types.ts | 33 + packages/client/src/promise/index.ts | 9 +- packages/client/src/promise/rpc.ts | 184 ++++++ packages/client/src/rpc-runtime.ts | 76 +++ packages/client/src/shared-events.ts | 155 +++++ packages/client/src/solid/connection.ts | 2 +- packages/client/src/solid/data.ts | 3 +- .../client/test/import-boundaries.test.ts | 55 +- packages/client/test/promise.test.ts | 46 ++ packages/client/test/rpc-effect.test.ts | 493 +++++++++++++++ packages/client/test/rpc-promise.test.ts | 431 +++++++++++++ packages/client/test/shared-events.test.ts | 397 ++++++++++++ packages/core/src/instance.ts | 2 + packages/core/src/plugin.ts | 2 + packages/core/src/plugin/host.ts | 19 +- packages/core/src/rpc.ts | 292 +++++++++ packages/core/test/plugin/fixture.ts | 2 + packages/core/test/plugin/host.ts | 8 + packages/core/test/plugin/rpc-effect.test.ts | 106 ++++ packages/core/test/plugin/rpc-promise.test.ts | 291 +++++++++ packages/core/test/rpc.test.ts | 490 ++++++++++++++ packages/plugin/package.json | 2 +- packages/plugin/src/effect/index.ts | 1 + packages/plugin/src/effect/plugin.ts | 2 + packages/plugin/src/effect/rpc.ts | 29 + packages/plugin/src/promise/adapter.ts | 160 ++++- packages/plugin/src/promise/index.ts | 1 + packages/plugin/src/promise/plugin.ts | 2 + packages/plugin/src/promise/rpc.ts | 40 ++ packages/plugin/src/rpc.ts | 1 + packages/plugin/src/tui/context.ts | 4 +- .../plugin/test/contract-identity.test.ts | 10 + packages/plugin/test/rpc-effect.types.ts | 169 +++++ packages/plugin/test/rpc-promise.types.ts | 237 +++++++ packages/plugin/test/rpc.fixture.ts | 58 ++ packages/plugin/test/rpc.test.ts | 66 ++ packages/plugin/tsconfig.tests.json | 7 + packages/protocol/openapi.json | 136 +++- packages/protocol/src/api.ts | 3 + packages/protocol/src/client.ts | 1 + packages/protocol/src/errors.ts | 10 + packages/protocol/src/groups/event.ts | 21 +- packages/protocol/src/groups/rpc.ts | 28 + packages/protocol/test/event.test.ts | 42 +- packages/protocol/test/rpc.test.ts | 57 ++ packages/schema/src/index.ts | 1 + packages/schema/src/rpc.ts | 205 ++++++ packages/schema/test/event-manifest.test.ts | 3 + packages/sdk/package.json | 3 +- packages/sdk/test/promise.test.ts | 3 +- packages/sdk/test/rpc.test.ts | 596 ++++++++++++++++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/rpc.ts | 36 ++ packages/server/test/rpc.test.ts | 449 +++++++++++++ packages/tui/src/context/event.ts | 3 +- packages/tui/test/cli/tui/use-event.test.tsx | 8 + packages/www/openapi.json | 136 +++- packages/www/public/openapi.json | 136 +++- .../src/docs/content/build/client/effect.mdx | 90 ++- .../src/docs/content/build/client/index.mdx | 89 ++- .../src/docs/content/build/plugins/effect.mdx | 67 +- .../src/docs/content/build/plugins/index.mdx | 150 ++++- 76 files changed, 6817 insertions(+), 88 deletions(-) create mode 100644 .changeset/plugin-rpc-events.md create mode 100644 PLUGIN_RPC_DESIGN.md create mode 100644 packages/client/src/effect/client.ts create mode 100644 packages/client/src/effect/rpc.ts create mode 100644 packages/client/src/promise/client.ts create mode 100644 packages/client/src/promise/rpc.ts create mode 100644 packages/client/src/rpc-runtime.ts create mode 100644 packages/client/src/shared-events.ts create mode 100644 packages/client/test/rpc-effect.test.ts create mode 100644 packages/client/test/rpc-promise.test.ts create mode 100644 packages/client/test/shared-events.test.ts create mode 100644 packages/core/src/rpc.ts create mode 100644 packages/core/test/plugin/rpc-effect.test.ts create mode 100644 packages/core/test/plugin/rpc-promise.test.ts create mode 100644 packages/core/test/rpc.test.ts create mode 100644 packages/plugin/src/effect/rpc.ts create mode 100644 packages/plugin/src/promise/rpc.ts create mode 100644 packages/plugin/src/rpc.ts create mode 100644 packages/plugin/test/rpc-effect.types.ts create mode 100644 packages/plugin/test/rpc-promise.types.ts create mode 100644 packages/plugin/test/rpc.fixture.ts create mode 100644 packages/plugin/test/rpc.test.ts create mode 100644 packages/plugin/tsconfig.tests.json create mode 100644 packages/protocol/src/groups/rpc.ts create mode 100644 packages/protocol/test/rpc.test.ts create mode 100644 packages/schema/src/rpc.ts create mode 100644 packages/sdk/test/rpc.test.ts create mode 100644 packages/server/src/handlers/rpc.ts create mode 100644 packages/server/test/rpc.test.ts diff --git a/.changeset/plugin-rpc-events.md b/.changeset/plugin-rpc-events.md new file mode 100644 index 000000000000..549d07d913a8 --- /dev/null +++ b/.changeset/plugin-rpc-events.md @@ -0,0 +1,11 @@ +--- +"@opencode-ai/schema": minor +"@opencode-ai/protocol": minor +"@opencode-ai/client": minor +"@opencode-ai/plugin": minor +"@opencode-ai/core": minor +"@opencode-ai/server": minor +"@opencode-ai/sdk": minor +--- + +Add typed plugin RPC methods, declared errors, and custom event publishing for Promise and Effect APIs. 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/PLUGIN_RPC_DESIGN.md b/PLUGIN_RPC_DESIGN.md new file mode 100644 index 000000000000..091e6abdedc2 --- /dev/null +++ b/PLUGIN_RPC_DESIGN.md @@ -0,0 +1,505 @@ +# Plugin RPC and Custom Events + +Design notes and implementation record. Shared definitions, the location-scoped +Core registry, local Promise/Effect plugin APIs, HTTP dispatch, external typed +clients, shared event connections, and durable Bus publication are implemented. +Plugin log/replay APIs and per-namespace OpenAPI discovery are intentionally deferred. + +## First Slice + +- `@opencode-ai/schema/rpc` owns the execution-neutral `Rpc.define` contract; `@opencode-ai/plugin/rpc` re-exports the canonical namespace. +- Promise and Effect client API types describe typed subclients, while plugin domain types add registration and event publishing. +- Portable Standard Schema and JSON Schema definitions work with both client and plugin styles. Effect Schema definitions are accepted only by Effect consumers. +- Compile-time fixtures check inference and rejected inputs, outputs, names, payloads, and location overrides through public exports. +- `bun typecheck` in `packages/plugin` includes the inference fixtures; runtime tests verify contract identity and a browser-safe definition entrypoint with no Effect runtime dependency. + +The method name `events` is reserved for the subclient's event API. It cannot +also be declared as an RPC method. + +## Second Slice + +- `packages/core/src/rpc.ts` owns the location-scoped `Rpc.Service`, with scoped registration stacks, per-call active lookup, and direct dispatch. +- `Rpc.call(namespace, method, input)` validates wire input and returns wire output; typed local subclients apply the corresponding result schema decoding without an HTTP request. +- Both plugin contexts expose `ctx.rpc(definition)` and `ctx.rpc.register(definition, handlers)`. Promise adaptation forwards cancellation and supplies `context.signal` to handlers. +- Local custom events publish through the existing bus with captured location and typed subscriptions. Location objects at public event boundaries do not alias private routing state. +- Promise subscriptions close independently on unsubscribe, abort, or plugin unload. Effect subscriptions use normal Stream scope cleanup. +- Core tests cover actual registrations, overrides, scope disposal, schemas/transforms, JSON boundaries, cancellation, location isolation, and plugin activation in both API styles. + +## Transport and Client Slice + +- Public Promise and Effect `OpenCode.make` factories expose callable `client.rpc(definition)`, retaining `client.rpc.call` for generic wire calls. +- One `POST /api/rpc/:namespace/:method` handler routes through existing location and authentication middleware. Input/output wrappers support primitives and omitted values. +- The HTTP boundary awaits the existing plugin activation barrier so cold locations are ready. Core and `ctx.rpc` lookup do not wait for registrations or reload implementations. +- Custom events use direct `rpc..` envelopes with required location. Native and typed RPC subscriptions observe the same event; typed subclients apply the declared payload schema. +- One lazy shared source per base client fans out native and RPC events, caches connection metadata only, bounds each subscriber queue, and closes on the last subscriber leaving. +- Promise RPC stays runtime-independent from Effect and accepts only portable definitions. Effect clients decode Effect codecs normally. +- Native and RPC Promise plugin subscriptions share scoped iterator cleanup and respect subscriber-local signals. +- Public protocol/client/OpenAPI artifacts are regenerated; plugin/client guides document the feature. +- Real SDK integration tests cover cold-start calls, cross-style plugins, locations, events, overrides, cancellation, and shutdown. + +Intended usage passes one concrete RPC definition. Conditional definition +unions and numeric event names are not part of the supported usage being designed. + +## Goals + +- Let server plugins expose RPC methods callable by any OpenCode client or other server plugins. +- Let plugins define and publish custom events that consumers can subscribe to. +- Infer types for method arguments, results, handlers, and event payloads from a shared contract. +- Support both Promise and Effect execution without forcing plugin authors to use Effect. + +Custom events may be ephemeral or durable. Both use normal Bus publication. +Durable events use existing Bus sequencing and persistence; no plugin-facing +log replay/follow API is exposed yet. + +## Shared Definition + +`Rpc.define(...)` is a plain, synchronous, execution-neutral contract builder. +It defines an RPC namespace, method input/output schemas, and event payload +schemas. It contains no handlers and does not register or execute the plugin. + +RPC namespaces are independent of plugin IDs. One plugin can register multiple +namespaces, and another plugin can override one using the same definition. The +namespace determines RPC routing and event prefixes; the plugin ID determines +registration ownership and cleanup. Do not add an automatic plugin-ID prefix. + +Each method declares `input` and `output`, plus an optional `errors` map. The +output schema checks handler return types, validates results at runtime, and +determines the caller's inferred result type. Error map keys become the error +`type`; each value validates and transforms that error's `data`. + +Publish the definition in a browser-safe module such as `acme-plugin/rpc`. +Clients and other plugins can import it without importing server implementation +code or running plugin setup. + +The complete definition format accepts the existing `Tool.ValueSchema` options: + +- Effect Schema, with TypeScript inference, for Effect clients and plugins only. +- Standard Schema, including Zod, with TypeScript inference. +- Plain JSON Schema, without automatic TypeScript inference. + +Promise clients and plugins accept Standard Schema or plain JSON Schema. Effect +clients and plugins accept all three. Use a portable Standard or JSON Schema +definition when the same contract must be consumed through both API styles. + +```ts +// acme-plugin/rpc +import { Rpc } from "@opencode-ai/plugin/rpc" +import { z } from "zod" + +export const Acme = Rpc.define({ + namespace: "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() }), + durable: { version: 1, aggregate: "itemID" }, + }, + progress: { + schema: z.object({ percent: z.number() }), + }, + }, +}) +``` + +## Event Definitions + +Define events inline as a map within `Rpc.define(...)`, not an array. No +separate event builder or explicit `type` field is required. Each map key is a +local event name; the public event type is automatically prefixed with the namespace: +`rpc.${namespace}.${eventName}`. The example defines `rpc.acme.updated` and `rpc.acme.progress`. + +Each event definition has a `schema` accepting `Tool.ValueSchema` and optional +`durable: { version, aggregate }`. Omit it for an ephemeral event. When present, +`aggregate` names a string field in the encoded/output payload passed to Bus. +Publishing uses the normal durable Bus path, including aggregate validation, +sequence allocation, and configured persistence. + +Custom event data must be an object. Effect and Standard Schema definitions +enforce that in their inferred types; plain JSON Schema is checked when emitting. +Scalars, arrays, `null`, and `undefined` are not valid event payloads. + +Publishing supplies only the payload. Subscribers receive the standard event +envelope with `id`, `created`, `type`, `data`, required `location`, optional +`metadata`, and, for durable events, `durable: { aggregateID, seq, version }`. +OpenCode supplies the emitting plugin instance's location; publishers do not +provide or override it. + +The subclient uses local event names for subscriptions and publishing, with +inferred payload and envelope types. Consumers import only the RPC +definition, not individual event definitions. + +External subclients receive the namespace's events across all server locations, +not just the default location or a location used by an RPC call. Consumers can +filter using the required `event.location` field. Server plugin subscriptions +are bound to the calling plugin instance's location. + +Live subscriptions do not replay missed events. Events emitted while a consumer +is disconnected are missed, including durable events. Persistence does not turn +the live subscription into replay; there is no plugin log API in this design yet. + +## Client API + +The factory belongs to the OpenCode client, not the RPC definition: + +```ts +import { Acme } from "acme-plugin/rpc" + +const acme = client.rpc(Acme) +const result = await acme.search({ query: "hello" }) + +const unsubscribe = acme.events.on("updated", (event) => { + console.log(event.type, event.data.text) // type: "rpc.acme.updated" + console.log(event.location.directory) // Emitting plugin instance's location +}) +``` + +The subclient exposes only the namespace's methods and events. It reuses the +supplied OpenCode client's connection, authentication, and transport. +Creating a subclient does not load the server plugin. + +Other server plugins use the same calling shape: `ctx.rpc(Acme)`. +Whether calls return Promises or Effects is determined by the supplied client +or context, not by how the RPC handlers are implemented. + +Calling and implementing are independent capabilities. A server plugin can +obtain a consumer handle without implementing the namespace, implement a namespace, +or do both. `ctx.rpc(Acme)` provides the consumer API; +`ctx.rpc.register(Acme, handlers)` registers the implementation. + +### Handle Lifecycle + +`client.rpc(Acme)` and `ctx.rpc(Acme)` return a handle immediately, even if no +implementation is registered yet. Creating a handle does not wait for namespace +availability. + +Each method call resolves the currently active registration at its target +location. Handles do not cache implementations, reload, or track registration +changes; each call simply looks up the active implementation. A call already +running finishes against the implementation it started with, rather than +switching handlers mid-call. +If no implementation is available when called, fail immediately instead of +waiting for a registration. The HTTP boundary waits for normal plugin activation +at the requested location before this lookup; it does not wait for a particular +namespace to appear. Direct plugin calls do not use that barrier, avoiding setup +recursion. + +## Location and Call Options + +RPC namespaces are implemented at the registering server plugin instance's location. +Select an external call's location through a second optional options argument, +not through subclient construction or the method's declared input: + +```ts +const acme = client.rpc(Acme) + +await acme.search({ query: "hello" }) +await acme.search({ query: "hello" }, { location: { directory: "/path/to/project" } }) +``` + +External call options can include `location`, `signal`, and `headers`. When +location is omitted, use the existing request defaults: explicit location +headers, if present, then the server's working directory. The base OpenCode +client has no dedicated configured location; it has connection and header options. + +Server plugin consumer handles are bound to the calling plugin instance's +location and do not expose a location override: + +```ts +const acme = ctx.rpc(Acme) +await acme.search({ query: "hello" }) +``` + +Both consumers use the same RPC definition and inferred method input/output +types. Routing metadata is separate from the payload and never injected into +handler arguments. Do not reserve a top-level `location` input field or require +object-shaped inputs just to support routing. This intentionally differs from +native endpoints such as `skill.list`, which put optional location inside the +first input argument. + +The same location rules apply to Promise and Effect RPC calls. Event subscriptions +are different: external clients receive namespace events from all locations, while +server plugin handles receive only events from their own location. Per-call RPC +location options do not change a subclient's subscriptions. + +## RPC Transport + +Use one generic HTTP handler with a distinct URL for each namespace method: + +```text +POST /api/rpc/acme/search +POST /api/rpc/acme/refresh +``` + +The handler dispatches by RPC namespace, method name, and resolved request location. +Plugins register dynamically; they do not need separate handler implementations +or generated OpenCode clients for each method. + +Registration supplies the RPC definition and handlers to the server at each +location. The dispatcher resolves them dynamically; it does not require the +server to separately import a well-known RPC export from each plugin package. + +The request body is `{ input?: unknown }` and the success body is `{ output?: unknown }`. +Omitted fields represent no value. Location uses the existing native deep-object +query/header resolution; call metadata is not part of the method input. +The endpoint uses the standard `RpcError` HTTP wrapper around a generic +`{ type, message, data? }` RPC failure. Typed clients remove that transport +wrapper and decode declared error data through the selected method's error map. +Validation and lookup failures retain reserved `rpc.*` types. Interruption is +not converted to a method failure. + +## Deferred OpenAPI Integration + +Do not add individual plugin RPC methods to the server's OpenAPI document yet. +The initial implementation uses the imported shared definition for typed clients +and the runtime registration for dispatch and validation. + +The generic dispatch operation and dynamic `rpc.${string}` event envelope are +part of the native API contract and generated OpenAPI document, not a dynamic +per-namespace inventory. + +Registration is per location, so discovering contracts for a server-wide spec +or a location-specific spec requires further design. Revisit that separately, +including whether packages need a well-known declaration export. Do not add +declaration discovery or dynamic per-namespace OpenAPI generation now. + +Existing tool JSON Schema conversion may help with future OpenAPI integration, +but Standard Schema validation alone does not guarantee JSON Schema conversion. +OpenAPI representability is not an initial RPC requirement. + +## Event Subscription APIs + +Both subclient versions expose `events.subscribe(name)` as the primitive, +matching the native clients' event subscription representations: + +- Promise: a typed `AsyncIterable`. +- Effect: a typed `Stream`. + +```ts +// Promise client +for await (const event of acme.events.subscribe("updated")) { + console.log(event.data.text) +} +``` + +Promise subclients also expose `events.on(name, handler)` as a convenience +wrapper over the same subscription primitive, returning an unsubscribe function: + +```ts +const unsubscribe = acme.events.on("updated", (event) => { + console.log(event.data.text) +}) +``` + +Effect subclients keep the Stream API without a callback convenience wrapper: + +```ts +const updates = acme.events.subscribe("updated") +``` + +The local event name selects its exact envelope and payload type. Effect +subscriptions compose with normal Stream operators. Ending async iteration, +stopping Stream consumption, or calling the Promise convenience unsubscribe +function removes only that subscriber. Constructing an iterable or Stream alone +does not open a connection; `on` starts consuming for the listener. + +`on` and `subscribe` share the same event source. The convenience wrapper does +not create a separate HTTP connection. + +These APIs apply to both external and server plugin subclients, preserving their +different location rules. External subscriptions share the base client's event +connection; plugin subscriptions use the internal bus. Unsubscribing or stopping +one consumer does not stop other consumers. + +## Custom Event Transport + +Reuse the existing `/api/event` stream for custom RPC events alongside native +events. Do not add a separate event endpoint per namespace. + +The native stream carries the actual `rpc..` type and direct +JSON object payload. Ephemeral and durable events share that type pattern; durable events +also carry the normal Bus envelope. Reserving the `rpc.` prefix keeps dynamic +events disjoint from native event literals, preserving native union narrowing. + +The subclient's `subscribe` API and Promise `on` wrapper match namespace and local +name, then apply the declared payload schema. External +clients receive matching namespace events across all locations. Server plugin RPC +subscriptions stay bound to their own location. The shared definition supplies +the payload schema and inferred types. Durable publication may persist, but live +delivery still has no implicit replay. + +### Shared Connection Lifecycle + +The base OpenCode client owns one lazy, shared event connection. Creating a +client or RPC handle opens no event connection. The first active event subscriber +opens it; native and RPC subscribers share it through local fan-out. When the +last subscriber leaves, close the connection. Sharing is per base client instance, +not process-global or per RPC namespace. + +Handwritten public client facades wrap the generated raw event transport with +this shared source. Server plugin subscriptions use the internal bus directly +and do not open HTTP event connections. + +Cache and copy only the latest `server.connected` marker for late subscribers, +so native connection consumers still receive their initial handshake. Do not +replay business events. A replacement connection waits for the previous source's +cleanup rather than overlapping it. + +Each subscriber has a 4096-event queue limit, matching the existing native +overflow contract. A slow subscriber fails independently; it does not block +other consumers or create an unbounded queue. Source EOF/failure ends current +subscriptions, without automatic retry. Consumers resubscribe after recovery. +Promise `on` logs callback/source failures and ends its listener. +Callbacks may be async: each listener awaits its callback before processing the +next event, so rejected callbacks are caught and only that listener ends. + +Native event subscriptions have no payload, location, or filter arguments. The +Effect client exposes `subscribe()`; the Promise client may accept an optional `signal` +for subscriber-local cancellation. Cancelling one subscriber removes only that +subscriber and does not disconnect others; close the shared connection only if +no subscribers remain. + +Use the base client's headers for the shared event connection. Remove existing +Promise subscription-level header overrides rather than opening separate +connections for listeners with different headers. + +## Server Registration + +Register handlers during plugin initialization, with access to the plugin context: + +```ts +import { Plugin } from "@opencode-ai/plugin" +import { Acme } from "acme-plugin/rpc" + +export default Plugin.define({ + id: "acme", + async setup(ctx) { + const registration = await ctx.rpc.register(Acme, { + search: async ({ query }) => ({ text: query }), + }) + + await registration.events.emit("updated", { + itemID: "123", + text: "hello", + }) + }, +}) +``` + +The handler map implements every declared method. The returned registration +handle provides typed event publishing. Registration belongs to the plugin +instance and is automatically removed when that instance unloads. + +For the same namespace at the same location, the latest active registration +wins, matching custom tool registration behavior. It replaces the effective +namespace implementation as a whole, rather than merging individual handlers. +Removing or unloading a registration removes only that registration and reveals +the previous active implementation, if any. Registrations at different locations +do not override one another. + +Provide both execution APIs, matching existing plugins: + +- Promise plugins register inside `setup`, use `await`, and supply Promise handlers. +- Effect plugins register inside the existing `effect` initializer, use `yield*`, and supply Effect handlers. +- Event publishing likewise returns a Promise or Effect according to the registration API. + +## Call Cancellation + +Handlers receive a general second call-context argument. Its typed `error` +constructor builds declared failures. Promise contexts also contain `signal`: + +```ts +search: async ({ query }, context) => { + const result = await fetchResults(query, { signal: context.signal }) + if (!result) return context.error("not_found", "Result not found", { query }) + return result +} +``` + +Promise handlers may either return or throw a value made by `context.error`. +Effect handlers use the native error channel: + +```ts +search: ({ query }, context) => + findResult(query).pipe( + Effect.flatMap((result) => + result + ? Effect.succeed(result) + : Effect.fail(context.error("not_found", "Result not found", { query })), + ), + ) +``` + +Cancelling an external request signals the Promise handler to stop. Cancellation +is cooperative: the handler must observe the signal or pass it to cancellable +operations. Effect handlers use normal Effect interruption instead. + +Changing the active registration does not cancel already-running calls. They +continue against their original implementation unless the call itself is cancelled. + +## Type Safety + +### Local and Remote Contract Boundaries + +Server-plugin calls dispatch directly to the active implementation without an +HTTP request. Both local and external dispatch apply the declared input and output +schemas. Local dispatch does not simulate JSON serialization; the actual HTTP +client owns transport serialization for external calls. + +Plain JSON Schema is interpreted as Draft 2020-12 and delegated directly to +Effect's JSON Schema importer and decoder. RPC adds no dialect compatibility or +keyword policy. + +Schema parsers own validation and transformation. RPC does not invent conversion +rules; it applies the schema at the contract boundary and derives the corresponding +caller and handler types. For input schemas, the caller supplies the accepted +input representation and the handler receives the parsed value. Parse input at +dispatch rather than transforming it on the client and parsing it again on the +server. Local calls follow the same rule. + +Effect codecs own their encoded representation. Standard and plain JSON schemas +are responsible for returning values appropriate for their eventual transport. +Event schemas require an object encoded/output type. RPC applies the schema and +passes that object directly to Bus publication. + +### Inference and Validation + +- Preserve literal namespace, method, and event names in `Rpc.define(...)`. +- Infer handler argument types and check handler return values against their schemas. +- Check caller arguments and infer RPC results and declared errors. +- Check published event payloads and infer subscriber payload types. +- Infer fully prefixed event types from the namespace and local map keys, while exposing local names to callers. +- Reject unknown method and event names at compile time. +- Validate data crossing the network at runtime, rather than relying only on TypeScript. + +Plain JSON Schema remains supported but does not provide the same automatic +TypeScript inference. Schema transforms need explicit treatment of wire input +and decoded output types; execution neutrality must not erase those distinctions. + +## Existing References + +- `packages/schema/src/tool.ts`: `Tool.ValueSchema` and schema-based inference. +- `packages/schema/src/event.ts`: internal event definitions and ephemeral envelopes. +- `packages/core/src/bus.ts`: publication and live subscriptions. +- `packages/core/src/tool/runtime.ts`: schema validation and input/output JSON Schema conversion. +- `packages/plugin/src/promise/tool.ts`: Promise tool handlers. +- `packages/plugin/src/effect/tool.ts`: Effect tool registrations. +- `packages/plugin/src/promise/plugin.ts`: Promise plugin `setup` and context. +- `packages/plugin/src/effect/plugin.ts`: Effect plugin initializer and context. +- `packages/client/src/promise/generated/client.ts`: base client options, request options, and native `skill.list` calling convention. +- `packages/server/src/location.ts`: per-request location resolution from query, headers, and server working directory. +- `packages/server/src/routes.ts`: current static OpenAPI generation through `HttpApiBuilder.layer`. +- `packages/protocol/src/groups/event.ts`: current public SSE event contract, which is volatile and uses a static event union. +- `packages/server/src/event-feed.ts`: current public event filtering and subscriber lifecycle. + +Implementation should preserve package dependency boundaries. Effect plugin +domains extend the corresponding Effect client API and add only plugin-specific +capabilities, such as registration. Public protocol changes require client +generation rather than manual edits to generated clients. diff --git a/bun.lock b/bun.lock index 39df20acab4a..8e506481675a 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", @@ -673,6 +674,7 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "zod": "catalog:", }, }, "packages/server": { 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..bb3b326bb9f5 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 namespace: 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 | undefined } +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..f6ecc3dbe352 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: { namespace: input["namespace"], 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..d4aa3311d8ee --- /dev/null +++ b/packages/client/src/effect/rpc.ts @@ -0,0 +1,88 @@ +export * as RpcClientRuntime from "./rpc.js" + +import type { Rpc } from "@opencode-ai/schema/rpc" +import type { RpcError } from "@opencode-ai/protocol/errors" +import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Effect, 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 + +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.MethodError | E> +} & { + readonly events: { + readonly subscribe: ( + name: Name, + ) => Stream.Stream, 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( + { + namespace: definition.namespace, + 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)), + ) + }, + ]), + ) + // Runtime keys and decoded values follow the definition's mapped public type. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + return Object.assign(methods, { + events: { + subscribe: (name: keyof D["events"] & string) => { + const type = RpcRuntime.eventType(definition, name) + return subscribe().pipe( + Stream.filter((event): event is RpcEvent => event.type.startsWith("rpc.") && event.type === type), + Stream.mapEffect((event) => RpcRuntime.event(definition, name, 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..d39a178704f4 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.namespace)}/${encodeURIComponent(input.method)}`, + query: { location: input["location"] }, + body: { input: input["input"] }, + successStatus: 200, + declaredStatuses: [400, 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..82b4cf8d1755 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -333,6 +333,8 @@ export type SkillInfo = { content: string } +export type RpcOutput = { output: JsonValue } + export type PermissionReply = "once" | "always" | "reject" export type Pty = { @@ -459,6 +461,16 @@ 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 } + durable?: { aggregateID: string; seq: number; version: number } | undefined +} + export type V2EventServerConnected = { id: string metadata?: { [x: string]: any } | undefined @@ -2315,6 +2327,7 @@ export type V2Event = | VcsBranchUpdated | McpStatusChanged | McpResourcesChanged + | V2EventRpc | V2EventServerConnected export type SessionLogItem = SessionEventDurable | EventLogSynced @@ -2481,6 +2494,15 @@ 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 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 +5691,17 @@ export type SkillListOutput = { data: Array } +export type RpcCallInput = { + readonly namespace: { readonly namespace: string; readonly method: string }["namespace"] + readonly method: { readonly namespace: 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..8f912ddf88de --- /dev/null +++ b/packages/client/src/promise/rpc.ts @@ -0,0 +1,184 @@ +import type { Rpc } from "@opencode-ai/schema/rpc" +import type { make, RequestOptions } from "./generated/client.js" +import { isRpcError } 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, + E extends Rpc.PortableEventDefinition = D["events"][Name], +> = E extends Rpc.DurableEventDefinition + ? Omit & { + type: `rpc.${D["namespace"]}.${Name}` + data: Rpc.EventData + durable: { aggregateID: string; seq: number; version: number } + } + : Omit & { + durable?: never + type: `rpc.${D["namespace"]}.${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.namespace}.${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 (!isRpcEvent(published) || published.type !== type) continue + if (!signal.aborted) yield event(definition, name, published) + } + } 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() + }, + } + }, + } + } + // Runtime keys are built directly from the checked definition's mapped public type. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + return Object.assign( + Object.fromEntries( + Object.keys(definition.methods).map((name) => [ + name, + async (input: unknown, options?: RpcCallOptions) => { + try { + const result = await raw.rpc.call( + { + namespace: definition.namespace, + method: name, + // The generated transport owns JSON serialization; RPC adds no preflight parser. + // 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)) 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 event( + definition: Rpc.PortableDefinition, + name: string, + event: RpcEvent, +): RpcEventPayload { + const schema = definition.events[name] + if (!schema) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) + if (!schema.durable) { + if (event.durable) throw new Error(`Expected ephemeral RPC event: ${eventType(definition, name)}`) + return { + ...event, + type: eventType(definition, name), + location: { ...event.location }, + } + } + if (!event.durable) throw new Error(`Expected durable RPC event: ${eventType(definition, name)}`) + if (event.durable.version !== schema.durable.version) + throw new Error( + `RPC event version mismatch for ${definition.namespace}.${name}: expected ${schema.durable.version}, got ${event.durable.version}`, + ) + return { + ...event, + type: eventType(definition, name), + location: { ...event.location }, + } +} + +function isRpcEvent(event: EventSubscribeOutput): event is RpcEvent { + return event.type.startsWith("rpc.") +} + +function eventType(definition: Rpc.PortableDefinition, name: string) { + return `rpc.${definition.namespace}.${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..8aae4cf10859 --- /dev/null +++ b/packages/client/src/rpc-runtime.ts @@ -0,0 +1,76 @@ +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 } 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)) 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, event: RpcEvent): Effect.fn.Return, unknown> { + const schema = definition.events[name] + if (!schema) return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.namespace}.${name}`)) + if (event.type !== eventType(definition, name)) + return yield* Effect.fail(new Error(`Unexpected RPC event type: ${event.type}`)) + const data = yield* read(schema.schema, event.data) + if (!schema.durable) { + if (event.durable) return yield* Effect.fail(new Error(`Expected ephemeral RPC event: ${event.type}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope and definition durability are checked above. + return { + ...event, + type: eventType(definition, name), + data, + location: { ...event.location }, + } as Rpc.EventPayload + } + if (!event.durable) return yield* Effect.fail(new Error(`Expected durable RPC event: ${event.type}`)) + if (event.durable.version !== schema.durable.version) + return yield* Effect.fail( + new Error( + `RPC event version mismatch for ${definition.namespace}.${name}: expected ${schema.durable.version}, got ${event.durable.version}`, + ), + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope, version, and definition are checked above. + return { + ...event, + type: eventType(definition, name), + data, + location: { ...event.location }, + } as Rpc.EventPayload +}) + +export function eventType( + definition: D, + name: Name, +): `rpc.${D["namespace"]}.${Name}` { + return `rpc.${definition.namespace}.${name}` +} diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts new file mode 100644 index 000000000000..e97ad8729e31 --- /dev/null +++ b/packages/client/src/shared-events.ts @@ -0,0 +1,155 @@ +export * as SharedEvents from "./shared-events.js" + +export class SubscriberOverflowError extends Error { + constructor() { + super("Event subscriber queue overflow") + this.name = "SubscriberOverflowError" + } +} + +export function make( + connect: (signal: AbortSignal) => AsyncIterable, + options?: { readonly capacity?: number }, +) { + type Completion = { readonly error: unknown } | Record + type Subscriber = { + push: (value: A) => void + finish: (completion: Completion) => void + } + type Connection = { + controller: AbortController + subscribers: Set + connected?: A + read?: ReturnType>> + done: ReturnType> + } + + const capacity = options?.capacity ?? 4096 + let current: Connection | undefined + + function stop(connection: Connection) { + connection.connected = undefined + connection.read?.resolve({ done: true, value: undefined }) + connection.controller.abort() + } + + 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) { + // Cancellation must reach return() even when the source has a pending next(). + connection.read = Promise.withResolvers>() + Promise.resolve(iterator.next()).then(connection.read.resolve, connection.read.reject) + const item = await connection.read.promise + connection.read = undefined + if (item.done || connection.controller.signal.aborted) break + if (item.value.type === "server.connected") connection.connected = { ...item.value } + connection.subscribers.forEach((subscriber) => subscriber.push(item.value)) + } + } catch (error) { + completion = { error } + } finally { + stop(connection) + try { + await iterator?.return?.() + } catch (error) { + if (!("error" in completion)) completion = { error } + } + connection.subscribers.forEach((subscriber) => subscriber.finish(completion)) + current = undefined + connection.done.resolve() + } + } + + return { + subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable { + return { + [Symbol.asyncIterator]() { + const queue: A[] = [] + const pending: ReturnType>>[] = [] + let started = false + let completion: Completion | undefined + let connection: Connection | undefined + + function finish(result: Completion, discard = false) { + completion = result + if (discard || "error" in result) queue.length = 0 + 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({}, true) + } + + const subscriber: Subscriber = { + finish, + push(value) { + const event = value.type === "server.connected" ? { ...value } : value + const request = pending.shift() + if (request) { + request.resolve({ done: false, value: event }) + return + } + if (queue.length >= capacity) { + finish({ error: new SubscriberOverflowError() }) + return + } + queue.push(event) + }, + } + + async function start() { + // A replacement connection cannot overlap the previous iterator's cleanup. + while (current?.controller.signal.aborted) await current.done.promise + if (completion) return + const fresh = !current + connection = current ?? { + controller: new AbortController(), + subscribers: new Set(), + done: Promise.withResolvers(), + } + current = connection + connection.subscribers.add(subscriber) + if (connection.connected) subscriber.push(connection.connected) + if (fresh) void run(connection) + } + + return { + next(): Promise> { + const value = queue.shift() + if (value !== undefined) return Promise.resolve({ done: false, value }) + if (completion) { + if ("error" in completion) return Promise.reject(completion.error) + return Promise.resolve({ done: true, value: undefined }) + } + if (options?.signal?.aborted) { + abort() + return Promise.resolve({ done: true, value: undefined }) + } + const request = Promise.withResolvers>() + pending.push(request) + if (!started) { + started = true + options?.signal?.addEventListener("abort", abort, { once: true }) + void start() + } + return request.promise + }, + return(): Promise> { + finish({}, true) + 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/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..ef33268778d6 --- /dev/null +++ b/packages/client/test/rpc-effect.test.ts @@ -0,0 +1,493 @@ +import { expect, test } from "bun:test" +import { Rpc } from "@opencode-ai/schema/rpc" +import { Cause, Context, Effect, Exit, Fiber, Option, Schema, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { OpenCode } from "../src/effect/index" + +const definition = Rpc.define({ + namespace: "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 }) }, + recorded: { + schema: Schema.Struct({ itemID: Schema.String, count: Schema.FiniteFromString }), + durable: { version: 2, aggregate: "itemID" }, + }, + }, +}) + +const connected = { id: "evt_connected", type: "server.connected", data: {} } + +function rpcEvent(count: unknown, directory = "/project/one", namespace = "example", name = "progress") { + return { + id: "evt_progress", + created: 123, + type: `rpc.${namespace}.${name}`, + location: { directory }, + metadata: { origin: "test" }, + data: { count }, + } +} + +function durableEvent(data: unknown, directory = "/project/one") { + return { + id: "evt_recorded", + created: 124, + type: "rpc.example.recorded", + durable: { aggregateID: "item-1", seq: 3, version: 2 }, + location: { directory }, + data, + } +} + +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({ namespace: "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({ + namespace: "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 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 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) + + await native.return?.() + await late.return?.() + 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("Effect RPC streams receive direct durable Bus events", async () => { + const source = eventSource() + const result = Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client + .rpc(definition) + .events.subscribe("recorded") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)) + }).pipe(Effect.provideService(HttpClient.HttpClient, source.httpClient), Effect.runPromise) + await source.push(durableEvent({ itemID: "item-1", count: "42" })) + expect(await result).toMatchObject({ + type: "rpc.example.recorded", + data: { itemID: "item-1", count: 42 }, + durable: { aggregateID: "item-1", seq: 3, version: 2 }, + location: { directory: "/project/one" }, + }) + 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) +}) diff --git a/packages/client/test/rpc-promise.test.ts b/packages/client/test/rpc-promise.test.ts new file mode 100644 index 000000000000..97ed10c1d938 --- /dev/null +++ b/packages/client/test/rpc-promise.test.ts @@ -0,0 +1,431 @@ +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({ + namespace: "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() }) }, + recorded: { + schema: z.object({ itemID: z.string(), count: z.number() }), + durable: { version: 2, aggregate: "itemID" }, + }, + }, +}) +const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} } +const rpcEvent = (data: unknown, directory = "/first", namespace = Echo.namespace, name = "updated") => ({ + id: "evt_rpc", + created: 10, + type: `rpc.${namespace}.${name}`, + location: { directory }, + metadata: { source: "test" }, + data, +}) +const durableEvent = (data: unknown, directory = "/first", namespace = Echo.namespace, name = "recorded") => ({ + id: "evt_rpc_durable", + created: 11, + type: `rpc.${namespace}.${name}`, + durable: { aggregateID: "item-1", seq: 3, version: 2 }, + 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({ namespace: Echo.namespace, 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({ + namespace: "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.namespace, "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" } }) + expect(Rpc.isError(Echo, "echo", error)).toBe(true) + await expect(client.rpc.call({ namespace: Echo.namespace, method: "echo", input: "hello" })).rejects.toEqual(response) +}) + +test("RPC transport failures remove the generic transport wrapper", async () => { + const response = { _tag: "RpcError", type: "rpc.internal", message: "Failed" } + await expect(http(() => Response.json(response, { status: 400 })).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, namespace: "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 source.send(rpcEvent({ ignored: true }, "/first", Echo.namespace, "unknown")) + await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.namespace)) + await source.send(rpcEvent({ count: 42 })) + const expected = { + id: "evt_rpc", + created: 10, + type: `rpc.${Echo.namespace}.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 }) + expect((await otherNext).value).toMatchObject({ + type: "rpc.other.updated", + location: { directory: "/other" }, + data: { count: 9 }, + }) + 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([native.return?.(), late.return?.(), first.return?.(), second.return?.(), other.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 subscriptions receive direct durable Bus events", async () => { + const source = events() + const native = source.client.event.subscribe()[Symbol.asyncIterator]() + const recorded = source.client.rpc(Echo).events.subscribe("recorded")[Symbol.asyncIterator]() + await native.next() + const raw = native.next() + const typed = recorded.next() + await source.send(durableEvent({ itemID: "item-1", count: 42 })) + expect((await raw).value).toEqual(durableEvent({ itemID: "item-1", count: 42 })) + expect((await typed).value).toMatchObject({ + type: "rpc.acme/jobs.recorded", + data: { itemID: "item-1", count: 42 }, + durable: { aggregateID: "item-1", seq: 3, version: 2 }, + location: { directory: "/first" }, + }) + await native.return?.() + await recorded.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.namespace}.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 EOF ends subscribers without reconnecting", async () => { + const source = events() + const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() + const next = iterator.next() + await source.end() + expect((await next).done).toBe(true) + expect((await iterator.next()).done).toBe(true) + expect(source.requests).toHaveLength(1) +}) + +test("RPC source transport errors propagate to native and RPC subscribers", async () => { + const source = events() + const native = source.client.event.subscribe()[Symbol.asyncIterator]() + const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() + await native.next() + const rpcError = iterator.next().catch((error: unknown) => error) + const nativeError = native.next().catch((error: unknown) => error) + await source.fail(new Error("Connection failed")) + expect(await rpcError).toMatchObject({ name: "ClientError", reason: "Transport" }) + expect(await nativeError).toMatchObject({ name: "ClientError", reason: "Transport" }) + expect(source.requests).toHaveLength(1) +}) + +test("RPC event envelope mismatches close only the matching subscriber", async () => { + const source = events() + const native = source.client.event.subscribe()[Symbol.asyncIterator]() + await native.next() + const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() + const failed = iterator.next().catch((error: unknown) => error) + const mismatched = durableEvent({ count: 42 }, "/first", Echo.namespace, "updated") + await source.send(mismatched) + expect(await failed).toBeInstanceOf(Error) + expect(source.requests[0].signal.aborted).toBe(false) + expect((await native.next()).value).toEqual(mismatched) + await native.return?.() + await source.cancelled +}) + +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.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..2d59cf281fc6 --- /dev/null +++ b/packages/client/test/shared-events.test.ts @@ -0,0 +1,397 @@ +import { expect, test } from "bun:test" +import { SharedEvents, SubscriberOverflowError } 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("connection metadata is isolated from source and subscriber root-field mutations", async () => { + const events = source() + const shared = SharedEvents.make(events.connect) + const first = shared.subscribe()[Symbol.asyncIterator]() + const second = shared.subscribe()[Symbol.asyncIterator]() + const firstRead = first.next() + const secondRead = second.next() + const marker = { type: "server.connected", value: 1 } + events.connections[0].push(marker) + Object.assign((await firstRead).value, { type: "subscriber.mutated", value: 2 }) + Object.assign(marker, { type: "source.mutated", value: 3 }) + expect(await secondRead).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) + + const late = shared.subscribe()[Symbol.asyncIterator]() + const cached = await late.next() + expect(cached).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) + Object.assign(cached.value, { type: "late.mutated", value: 4 }) + const latest = shared.subscribe()[Symbol.asyncIterator]() + expect(await latest.next()).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) + + await Promise.all([first.return!(), second.return!(), late.return!(), latest.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("source return runs with next pending, and its completion gates a replacement connection", async () => { + const read = Promise.withResolvers>() + const closing = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const connections: AbortSignal[] = [] + const shared = SharedEvents.make((signal) => { + connections.push(signal) + return { + [Symbol.asyncIterator]() { + return { + next: () => read.promise, + async return() { + closing.resolve() + await cleanup.promise + read.resolve({ done: true, value: undefined }) + return { done: true as const, value: undefined } + }, + } + }, + } + }) + const first = shared.subscribe()[Symbol.asyncIterator]() + const pending = first.next() + await first.return!() + expect(await pending).toEqual({ done: true, value: undefined }) + await closing.promise + expect(connections[0].aborted).toBe(true) + + const replacement = shared.subscribe()[Symbol.asyncIterator]().next() + expect(connections).toHaveLength(1) + cleanup.resolve() + expect(await replacement).toEqual({ done: true, value: undefined }) + expect(connections).toHaveLength(2) +}) + +test("rapid resubscription waits for delayed shutdown and resets connection metadata", 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(1) + + cleanup.resolve() + 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 } }, + ]) + expect(events.connections).toHaveLength(2) + await second.return!() + await third.return!() + await replacement.closed +}) + +test("source EOF drains queued events, 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) + events.connections[0].push({ type: "rpc.example.updated", value: 2 }) + events.connections[0].close() + await events.connections[0].closed + expect(await first.next()).toEqual({ done: false, value: { type: "rpc.example.updated", value: 2 } }) + expect(await second.next()).toEqual({ done: false, value: { type: "rpc.example.updated", value: 2 } }) + 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) +}) + +test("slow subscriber overflow is isolated and does not block a fast consumer", async () => { + const events = source() + const shared = SharedEvents.make(events.connect, { capacity: 2 }) + const slow = shared.subscribe()[Symbol.asyncIterator]() + const fast = shared.subscribe()[Symbol.asyncIterator]() + const reads = [slow.next(), fast.next()] + events.connections[0].push({ type: "server.connected" }) + await Promise.all(reads) + + for (const value of [1, 2, 3, 4]) { + const next = fast.next() + events.connections[0].push({ type: "rpc.example.updated", value }) + expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value } }) + } + await expect(slow.next()).rejects.toBeInstanceOf(SubscriberOverflowError) + expect(events.connections[0].signal.aborted).toBe(false) + expect(events.connections).toHaveLength(1) + await slow.return!() + await fast.return!() + await events.connections[0].closed +}) + +test("the default subscriber capacity is 4096 events", async () => { + const events = source() + const shared = SharedEvents.make(events.connect) + const slow = shared.subscribe()[Symbol.asyncIterator]() + const fast = shared.subscribe()[Symbol.asyncIterator]() + const reads = [slow.next(), fast.next()] + events.connections[0].push({ type: "server.connected" }) + await Promise.all(reads) + + for (let value = 1; value <= 4096; value++) { + const next = fast.next() + events.connections[0].push({ type: "rpc.example.updated", value }) + await next + } + expect(await slow.next()).toEqual({ done: false, value: { type: "rpc.example.updated", value: 1 } }) + for (const value of [4097, 4098]) { + const next = fast.next() + events.connections[0].push({ type: "rpc.example.updated", value }) + await next + } + await expect(slow.next()).rejects.toBeInstanceOf(SubscriberOverflowError) + await fast.return!() + await events.connections[0].closed +}) + +test("last subscriber overflow closes its source", async () => { + const events = source() + const shared = SharedEvents.make(events.connect, { capacity: 0 }) + const iterator = shared.subscribe()[Symbol.asyncIterator]() + const next = iterator.next() + events.connections[0].push({ type: "server.connected" }) + await next + events.connections[0].push({ type: "rpc.example.updated" }) + await events.connections[0].closed + await expect(iterator.next()).rejects.toBeInstanceOf(SubscriberOverflowError) + expect(events.connections[0].signal.aborted).toBe(true) +}) 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..bcd0e9a31b4c 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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" @@ -195,6 +196,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/rpc.ts b/packages/core/src/rpc.ts new file mode 100644 index 000000000000..03f8ed2838d4 --- /dev/null +++ b/packages/core/src/rpc.ts @@ -0,0 +1,292 @@ +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: (namespace: 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, event) }, + ]), + ) + 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.namespace) ?? []).filter((candidate) => candidate !== entry) + if (remaining.length === 0) { + registrations.delete(definition.namespace) + return + } + registrations.set(definition.namespace, remaining) + }) + yield* Effect.acquireRelease( + Effect.sync(() => + registrations.set(definition.namespace, [...(registrations.get(definition.namespace) ?? []), 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.namespace}.${args[0]}`)) + const event = registered.event + const data = yield* applyEventSchema(event.schema, args[1]) + return yield* bus.publish(registered.definition, data, { location: { ...ref } }).pipe(Effect.asVoid) + }), + }, + } + }) + + const call = Effect.fn("Rpc.call")(function* (namespace: string, name: string, input: unknown) { + const entry = registrations.get(namespace)?.at(-1) + if (!entry) + return yield* Effect.fail(failure("rpc.namespace_unavailable", `RPC namespace is unavailable: ${namespace}`)) + const method = entry.definition.methods[name] + const handler = entry.handlers[name] + if (!method || !handler) + return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${namespace}.${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.namespace, name, input).pipe( + Effect.catch((error) => decodeError(method, error)), + Effect.flatMap((value) => read(method.output, value).pipe(Effect.catch((cause) => Effect.die(cause)))), + ), + ]), + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + return { + ...methods, + events: { + subscribe: (name: Name) => { + const registered = events.get(name) + if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.namespace}.${name}`)) + return bus.subscribe(registered.definition).pipe( + Stream.provideService(Location.Service, location), + Stream.mapEffect((payload) => logicalEvent(definition, name, payload)), + ) + }, + }, + } 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) + +function eventType( + definition: D, + name: Name, +): `rpc.${D["namespace"]}.${Name}` { + return `rpc.${definition.namespace}.${name}` +} + +function eventDefinition(definition: Rpc.Definition, name: string, event: Rpc.EventDefinition): Event.Definition { + const type = eventType(definition, name) + if (event.durable) { + const data = EventData + return Schema.Struct({ + ...fields, + type: Schema.Literal(type), + durable: Schema.Struct({ aggregateID: Schema.String, seq: Event.Seq, version: Event.Version }), + data, + }).pipe( + statics(() => ({ type, durability: "durable" as const, durable: event.durable, data })), + ) satisfies Event.DurableDefinition + } + 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: () => + Schema.make>( + SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast, + ), + 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 applyEventSchema(schema: Rpc.EventDefinition["schema"], value: unknown) { + // The public event-schema contract guarantees an object encoded/output type. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + return encode(schema, value) as Effect.Effect>, unknown> +} + +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): Effect.fn.Return, unknown> { + const event = definition.events[name] + const data = yield* read(event.schema, payload.data) + if (!payload.location) return yield* Effect.fail(new Error(`RPC event is missing location: ${payload.type}`)) + if (!event.durable) { + if (payload.durable) return yield* Effect.fail(new Error(`Expected ephemeral RPC event: ${payload.type}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope and definition durability are checked above. + return { + ...payload, + type: eventType(definition, name), + data, + location: Location.Ref.make({ directory: payload.location.directory, workspaceID: payload.location.workspaceID }), + } as Rpc.EventPayload + } + if (!payload.durable) return yield* Effect.fail(new Error(`Expected durable RPC event: ${payload.type}`)) + if (payload.durable.version !== event.durable.version) + return yield* Effect.fail( + new Error( + `RPC event version mismatch for ${definition.namespace}.${name}: expected ${event.durable.version}, got ${payload.durable.version}`, + ), + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope, version, and definition are checked above. + return { + ...payload, + type: eventType(definition, name), + data, + durable: payload.durable, + location: Location.Ref.make({ directory: payload.location.directory, workspaceID: payload.location.workspaceID }), + } as Rpc.EventPayload +}) 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/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/rpc-effect.test.ts b/packages/core/test/plugin/rpc-effect.test.ts new file mode 100644 index 000000000000..d0ff0f826f25 --- /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({ + namespace: "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 namespaces 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..f969d8f1179a --- /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({ + namespace: "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, 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({ + namespace: "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, 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({ + namespace: "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, 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({ + namespace: "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, 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..48c1578dfeb8 --- /dev/null +++ b/packages/core/test/rpc.test.ts @@ -0,0 +1,490 @@ +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({ + namespace: "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.namespace_unavailable", + message: "RPC namespace 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.namespace, "missing", "hello").pipe(Effect.flip)).toEqual({ + type: "rpc.method_not_found", + message: "Unknown RPC method: test.rpc.missing", + }) + }), + ) + + 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.namespace, "echo", 42).pipe(Effect.exit))).toBe(true) + expect(received).toEqual([]) + + const Checked = Rpc.define({ + namespace: "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({ + namespace: "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({ + namespace: "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({ + namespace: "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.namespace, "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({ + namespace: "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.namespace, "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.namespace, "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("publishes durable custom events through normal Bus sequencing", () => + Effect.gen(function* () { + const rpc = yield* Rpc.Service + const bus = yield* Bus.Service + const Updates = Rpc.define({ + namespace: "durable-updates", + methods: {}, + events: { + recorded: { + schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), + durable: { version: 2, aggregate: "itemID" }, + }, + }, + }) + const registration = yield* rpc.register(Updates, {}) + const logical = yield* rpc + .client(Updates) + .events.subscribe("recorded") + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const published = yield* bus.subscribe().pipe( + Stream.filter((event) => event.type === "rpc.durable-updates.recorded"), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ) + yield* Effect.yieldNow + yield* registration.events.emit("recorded", { itemID: "item-1", text: "first" }) + yield* registration.events.emit("recorded", { itemID: "item-1", text: "second" }) + + const events = Array.from(yield* Fiber.join(logical)) + expect(events.map((event) => event.type)).toEqual([ + "rpc.durable-updates.recorded", + "rpc.durable-updates.recorded", + ]) + expect(events.map((event) => event.data.text)).toEqual(["first", "second"]) + expect( + events.map((event) => ({ + aggregateID: event.durable.aggregateID, + seq: Number(event.durable.seq), + version: Number(event.durable.version), + })), + ).toEqual([ + { aggregateID: "item-1", seq: 0, version: 2 }, + { aggregateID: "item-1", seq: 1, version: 2 }, + ]) + + const busEvents = Array.from(yield* Fiber.join(published)) + expect(busEvents.map((event) => event.type)).toEqual([ + "rpc.durable-updates.recorded", + "rpc.durable-updates.recorded", + ]) + expect( + busEvents.map((event) => ({ + aggregateID: event.durable?.aggregateID, + seq: Number(event.durable?.seq), + version: Number(event.durable?.version), + })), + ).toEqual([ + { aggregateID: "item-1", seq: 0, version: 2 }, + { aggregateID: "item-1", seq: 1, version: 2 }, + ]) + expect(busEvents.map((event) => event.data)).toEqual([ + { itemID: "item-1", text: "first" }, + { itemID: "item-1", text: "second" }, + ]) + }), + ) + + it.effect("requires durable aggregate fields to parse as strings", () => + Effect.gen(function* () { + const rpc = yield* Rpc.Service + const Invalid = Rpc.define({ + namespace: "invalid-durable", + methods: {}, + events: { + recorded: { + schema: Schema.Struct({ itemID: Schema.Number }), + durable: { version: 1, aggregate: "itemID" }, + }, + }, + }) + const registration = yield* rpc.register(Invalid, {}) + const exit = yield* registration.events.emit("recorded", { itemID: 1 }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.pretty(exit.cause)).toContain("Expected string aggregate field itemID") + }), + ) + + it.effect("selects durable aggregates from the schema-parsed payload", () => + Effect.gen(function* () { + const rpc = yield* Rpc.Service + const Parsed = Rpc.define({ + namespace: "parsed-durable", + methods: {}, + events: { + recorded: { + schema: z.object({ source: z.string() }).transform(({ source }) => ({ itemID: source })), + durable: { version: 1, aggregate: "itemID" }, + }, + }, + }) + const registration = yield* rpc.register(Parsed, {}) + const received = yield* rpc + .client(Parsed) + .events.subscribe("recorded") + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + yield* registration.events.emit("recorded", { source: "item-1" }) + const event = Array.from(yield* Fiber.join(received))[0] + expect(event.data).toEqual({ itemID: "item-1" }) + expect(event.durable.aggregateID).toBe("item-1") + }), + ) + + 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({ + namespace: "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.namespace, "count", 42)).toBe(42) + expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "count", "42").pipe(Effect.exit))).toBe(true) + expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "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({ + namespace: "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]) + expect(all.every((event) => !("durable" in event))).toBe(true) + 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..78bcb722f5e7 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 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..8545aecc0389 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,144 @@ 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.entries(local).flatMap(([name, method]) => + typeof method !== "function" + ? [] + : [ + [ + name, + (input: unknown, options?: Pick) => + 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, + // The runtime adapter restores each concrete method's erased error map below. + // 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) => + Promise.resolve( + Reflect.apply(handler, undefined, [ + input, + { + signal, + error: (type: string, message: string, data?: unknown) => + new ReturnedRpcError(type, message, data), + }, + ]), + ), + 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)) }, + })) + + // The adapter implements the portable callable domain dynamically from each checked definition. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime methods adapt the portable typed domain. + 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 @@ -91,6 +238,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 +283,7 @@ export function fromPromise(plugin: Plugin) { }), ) - const context2: Context = { + const context2: PromiseContext = { app: host.app, location: host.location, options: host.options, @@ -181,12 +329,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 +444,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..5c20208ed135 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 diff --git a/packages/plugin/src/promise/rpc.ts b/packages/plugin/src/promise/rpc.ts new file mode 100644 index 000000000000..2d3dd677ffe7 --- /dev/null +++ b/packages/plugin/src/promise/rpc.ts @@ -0,0 +1,40 @@ +import type { RpcApi, RpcCallOptions, RpcEventPayload } 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" + +declare const ReturnedErrorTypeId: unique symbol +interface ReturnedError { + readonly [ReturnedErrorTypeId]: true +} + +export interface RpcCallContext { + readonly signal: AbortSignal + readonly error: >( + ...args: Rpc.ErrorArguments + ) => Rpc.HandlerErrorFor & ReturnedError +} + +export type RpcHandlers = { + readonly [Name in keyof D["methods"]]: ( + input: Rpc.Output, + context: RpcCallContext, + ) => Promise< + Rpc.HandlerOutput | (Rpc.HandlerError & ReturnedError) + > +} + +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..562bafd94584 --- /dev/null +++ b/packages/plugin/test/rpc-effect.types.ts @@ -0,0 +1,169 @@ +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 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< + 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("recorded", { itemID: "item-1", text: "saved" }) + 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({ namespace: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } }) +Rpc.define({ + namespace: "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..79a1052ee12d --- /dev/null +++ b/packages/plugin/test/rpc-promise.types.ts @@ -0,0 +1,237 @@ +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["durable"]["aggregateID"], string>>, + Assert>, string>>, + Assert>, number>>, + Assert>, string>>, + Assert< + Equal< + Rpc.Error, + | { readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } } + | { readonly type: "unavailable"; readonly message: string; readonly data?: undefined } + > + >, +] + +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, +} + +declare const caught: unknown +if (Rpc.isError(Acme, "search", caught)) { + caught.type satisfies "not_found" | "unavailable" +} + +// @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.events.emit("recorded", { itemID: "item-1", text: "saved" }) +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 namespace. +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 +} + +for await (const event of acme.events.subscribe("recorded")) { + event.durable.aggregateID satisfies string + event.durable.seq satisfies number + event.durable.version satisfies number + event.data.itemID 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({ namespace: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} }) +Rpc.define({ + namespace: "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({ namespace: "invalid", methods: { events: Acme.methods.search }, events: {} }) +Rpc.define({ + namespace: "invalid", + methods: {}, + // @ts-expect-error Durable event metadata requires both version and aggregate. + events: { updated: { schema: Acme.events.updated.schema, durable: { version: 1 } } }, +}) +// @ts-expect-error Custom event data must be an object. +Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } }) +// @ts-expect-error Custom event data cannot be an array. +Rpc.define({ namespace: "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({ namespace: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } }) + +const LocationInput = Rpc.define({ + namespace: "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..88034bf05769 --- /dev/null +++ b/packages/plugin/test/rpc.fixture.ts @@ -0,0 +1,58 @@ +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({ + namespace: "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) })) }, + recorded: { + schema: z.object({ itemID: z.string(), text: z.string() }), + durable: { version: 2, aggregate: "itemID" }, + }, + }, +}) + +export const EffectAcme = Rpc.define({ + namespace: "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..09aa3d648e2c --- /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 namespace without registering anything", () => { + expect(Rpc.define(Acme)).toBe(Acme) + expect(Acme.namespace).toBe("acme") + expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted", "recorded"]) +}) + +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({ + namespace: "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({ namespace: "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..4eadef962fd4 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -8950,6 +8950,122 @@ "summary": "List skills" } }, + "/api/rpc/{namespace}/{method}": { + "post": { + "tags": ["rpc"], + "operationId": "v2.rpc.call", + "parameters": [ + { + "name": "namespace", + "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": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Dispatch a method to the currently registered RPC namespace 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 +9185,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" } }, @@ -16982,6 +17098,20 @@ } ] }, + "Rpc.Input": { + "type": "object", + "properties": { + "input": {} + }, + "additionalProperties": false + }, + "Rpc.Output": { + "type": "object", + "properties": { + "output": {} + }, + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { @@ -19157,6 +19287,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..3a35bbc7fc35 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -11,6 +11,16 @@ 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 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..a877c0820737 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -11,9 +11,26 @@ 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), + durable: Schema.optional( + Schema.Struct({ + aggregateID: Schema.String, + seq: Event.Seq, + version: Event.Version, + }), + ), +}).annotate({ identifier: "V2Event.rpc" }) + const schema = >(definitions: Definitions) => Schema.Union([ ...definitions, + rpcEvent, ...(definitions.some((definition) => definition.type === "server.connected") ? [] : [ @@ -38,7 +55,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 +72,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..a0c40cda4fbf --- /dev/null +++ b/packages/protocol/src/groups/rpc.ts @@ -0,0 +1,28 @@ +import { optional } from "@opencode-ai/schema/schema" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { RpcError } 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: optional(Schema.Unknown) }).annotate({ identifier: "Rpc.Output" }) + +export const RpcGroup = HttpApiGroup.make("server.rpc") + .add( + HttpApiEndpoint.post("rpc.call", "/api/rpc/:namespace/:method", { + params: { namespace: Schema.String, method: Schema.String }, + query: LocationQuery, + payload: RpcInput, + success: RpcOutput, + error: RpcError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.rpc.call", + summary: "Call a plugin RPC", + description: "Dispatch a method to the currently registered RPC namespace 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..0be432a6f69f 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,50 @@ 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: "rpc.acme.recorded" })).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() + const durable = { + id: "evt_rpc_durable", + created: 2, + type: "rpc.acme.recorded", + durable: { aggregateID: "item-1", seq: 3, version: 2 }, + location: { directory: "/project" }, + data: { itemID: "item-1" }, + } + expect(Schema.decodeUnknownSync(OpenCodeEvent)(durable)).toMatchObject(durable) +}) + +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..f2881430b2c5 --- /dev/null +++ b/packages/protocol/test/rpc.test.ts @@ -0,0 +1,57 @@ +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 } 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)({ output: undefined })).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) +}) + +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/{namespace}/{method}", + ]) + const operation = document.paths["/api/rpc/{namespace}/{method}"]?.post + expect(operation?.operationId).toBe("v2.rpc.call") + expect(operation?.parameters).toContainEqual( + expect.objectContaining({ name: "namespace", 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") +}) 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/rpc.ts b/packages/schema/src/rpc.ts new file mode 100644 index 000000000000..b96b3bdf0a0f --- /dev/null +++ b/packages/schema/src/rpc.ts @@ -0,0 +1,205 @@ +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 EphemeralEventDefinition { + readonly schema: EventValueSchema + readonly durable?: never +} + +export interface DurableEventDefinition { + readonly schema: EventValueSchema + readonly durable: { + readonly version: number + readonly aggregate: string + } +} + +export type EventDefinition = EphemeralEventDefinition | DurableEventDefinition +export type PortableEventDefinition = EventDefinition & { readonly schema: PortableEventValueSchema } + +export interface Definition { + readonly namespace: 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.namespace_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 Error = MethodError +export type ErrorArguments> = [ + type: Name, + message: string, + ...data: ErrorDataArguments>>, +] +export type ErrorFactory = >( + ...args: ErrorArguments +) => HandlerErrorFor + +export function isError< + D extends Definition, + Name extends keyof D["methods"] & string, +>(definition: D, method: Name, error: unknown): error is Error { + if ( + typeof error !== "object" || + error === null || + !("type" in error) || + typeof error.type !== "string" || + !("message" in error) || + typeof error.message !== "string" + ) + return false + const errors = definition.methods[method].errors + return errors !== undefined && Object.hasOwn(errors, error.type) +} + +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, + E extends EventDefinition = D["events"][Name], +> = E extends DurableEventDefinition + ? Omit, "type" | "data" | "durable" | "location"> & { + readonly durable: Event.DurableEnvelope + readonly type: `rpc.${D["namespace"]}.${Name}` + readonly data: EventData + readonly location: Location.Ref + } + : Omit, "type" | "data" | "durable" | "location"> & { + readonly durable?: never + readonly type: `rpc.${D["namespace"]}.${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..3dbfc03652ff 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,8 @@ 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(Array.from(EventManifest.Durable.keys()).some((type) => type.startsWith("rpc."))).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a595fef58dda..28fe6d3b620b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -42,6 +42,7 @@ "@opencode-ai/protocol": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "zod": "catalog:" } } diff --git a/packages/sdk/test/promise.test.ts b/packages/sdk/test/promise.test.ts index 4aeb86f29362..292ef489f779 100644 --- a/packages/sdk/test/promise.test.ts +++ b/packages/sdk/test/promise.test.ts @@ -69,8 +69,7 @@ test("Promise event streams support cancellation", async () => { expect(await events.next()).toMatchObject({ value: { type: "server.connected" }, done: false }) const pending = events.next() controller.abort() - const error = await pending.catch((error: unknown) => error) - expect(error).toMatchObject({ name: "ClientError", reason: "Transport" }) + expect(await pending).toEqual({ done: true, value: undefined }) await events.return?.() } }) diff --git a/packages/sdk/test/rpc.test.ts b/packages/sdk/test/rpc.test.ts new file mode 100644 index 000000000000..d5d9320a2778 --- /dev/null +++ b/packages/sdk/test/rpc.test.ts @@ -0,0 +1,596 @@ +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import { join } from "node:path" +import { fromPromise } from "@opencode-ai/plugin/promise/adapter" +import { Rpc } from "@opencode-ai/schema/rpc" +import { Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" +import { z } from "zod" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { testEffect } from "../../core/test/lib/effect" +import { AbsolutePath, OpenCode, type OpenCodeEvent } from "../src" + +const it = testEffect(Layer.empty) +export const Echo = Rpc.define({ + namespace: "sdk/echo", + methods: { + echo: { input: z.string(), output: z.object({ message: z.string(), directory: z.string() }) }, + empty: { input: z.undefined(), output: z.undefined() }, + fail: { + input: z.undefined(), + output: z.string(), + errors: { rejected: z.object({ source: z.string() }) }, + }, + }, + events: {}, +}) +const Update = z.object({ message: z.string(), at: z.string() }) +const Updates = Rpc.define({ + namespace: "sdk/updates", + methods: { emit: { input: Update, output: z.undefined() } }, + events: { updated: { schema: Update } }, +}) +const Blocking = Rpc.define({ + namespace: "sdk/blocking", + methods: { wait: { input: z.string(), output: z.string() } }, + events: {}, +}) + +async function fixture() { + const directory = await tmpdir("opencode-sdk-rpc-") + const first = AbsolutePath.make(join(directory.path, "first project")) + const second = AbsolutePath.make(join(directory.path, "second project")) + const config = join(directory.path, "config") + await Promise.all([first, second, config].map((path) => mkdir(path))) + return { + first, + second, + options: { config: { directory: config, project: false, content: "{}" }, fs: { filewatcher: false } }, + [Symbol.asyncDispose]: () => directory[Symbol.asyncDispose](), + } +} + +test("Promise SDK calls configured Promise RPC plugins on cold locations", async () => { + await using dirs = await fixture() + await using opencode = await OpenCode.create({ + ...dirs.options, + plugins: [ + { + id: "sdk-promise-echo", + async setup(ctx) { + const location = (await ctx.agent.list()).location + await ctx.rpc.register(Echo, { + echo: async (message) => ({ message, directory: location.directory }), + empty: async () => undefined, + fail: async (_input, ctx) => ctx.error("rejected", "plugin handler failed", { source: "return" }), + }) + }, + }, + ], + }) + const rpc = opencode.rpc(Echo) + expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ + message: "first", + directory: dirs.first, + }) + expect(await rpc.echo("header", { headers: { "x-opencode-directory": encodeURIComponent(dirs.second) } })).toEqual({ + message: "header", + directory: dirs.second, + }) + expect( + await rpc.echo("explicit", { + location: { directory: dirs.first }, + headers: { "x-opencode-directory": encodeURIComponent(dirs.second) }, + }), + ).toEqual({ message: "explicit", directory: dirs.first }) + expect(await rpc.echo("default")).toEqual({ message: "default", directory: process.cwd() }) + expect(await rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() + expect( + await opencode.rpc.call({ + namespace: Echo.namespace, + method: "echo", + input: "raw", + location: { directory: dirs.first }, + }), + ).toEqual({ + output: { message: "raw", directory: dirs.first }, + }) + expect( + await rpc.fail(undefined, { location: { directory: dirs.first } }).catch((error: unknown) => error), + ).toMatchObject({ + type: "rejected", + message: "plugin handler failed", + data: { source: "return" }, + }) +}, 30_000) + +it.live( + "Effect SDK calls Promise plugin RPC handlers without prebooting locations", + () => + Effect.gen(function* () { + const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => + Effect.promise(() => dirs[Symbol.asyncDispose]()), + ) + const sdk = yield* Effect.promise(() => import("../src/effect")) + const opencode = yield* sdk.OpenCode.create(dirs.options) + yield* opencode.plugin( + fromPromise({ + id: "sdk-cross-style-echo", + async setup(ctx) { + const location = (await ctx.agent.list()).location + await ctx.rpc.register(Echo, { + echo: async (message) => ({ message, directory: location.directory }), + empty: async () => undefined, + fail: async (_input, ctx) => { + throw ctx.error("rejected", "cross-style handler failed", { source: "throw" }) + }, + }) + }, + }), + ) + const rpc = opencode.rpc(Echo) + expect(yield* rpc.echo("cross-style", { location: { directory: dirs.first } })).toEqual({ + message: "cross-style", + directory: dirs.first, + }) + expect( + yield* rpc.echo("header", { headers: { "x-opencode-directory": encodeURIComponent(dirs.second) } }), + ).toEqual({ message: "header", directory: dirs.second }) + expect( + yield* rpc.echo("explicit", { + location: { directory: dirs.first }, + headers: { "x-opencode-directory": encodeURIComponent(dirs.second) }, + }), + ).toEqual({ message: "explicit", directory: dirs.first }) + expect(yield* rpc.echo("default")).toEqual({ message: "default", directory: process.cwd() }) + expect(yield* rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() + expect(yield* rpc.fail(undefined, { location: { directory: dirs.first } }).pipe(Effect.flip)).toMatchObject({ + type: "rejected", + message: "cross-style handler failed", + data: { source: "throw" }, + }) + }), + 30_000, +) + +test("Promise SDK calls a config-loaded Effect plugin through the shared RPC definition", async () => { + await using dirs = await fixture() + const plugin = join(dirs.options.config.directory, "effect-plugin.ts") + // The config-loaded plugin and the external caller use the exact same contract. + await Bun.write( + plugin, + ` + import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))} + import { Plugin } from ${JSON.stringify(import.meta.resolve("@opencode-ai/plugin/effect"))} + import { Echo } from ${JSON.stringify(import.meta.url)} + export default Plugin.define({ + id: "sdk-config-effect-echo", + effect: (ctx) => Effect.gen(function* () { + const location = (yield* ctx.agent.list()).location + yield* ctx.rpc.register(Echo, { + echo: (message) => Effect.succeed({ message, directory: location.directory }), + empty: () => Effect.succeed(undefined), + fail: (_input, ctx) => + Effect.fail(ctx.error("rejected", "Effect plugin handler failed", { source: "effect" })), + }) + }).pipe(Effect.orDie), + }) + `, + ) + await using opencode = await OpenCode.create({ + ...dirs.options, + config: { ...dirs.options.config, content: JSON.stringify({ plugins: [plugin] }) }, + }) + const rpc = opencode.rpc(Echo) + expect(await rpc.echo("Effect", { location: { directory: dirs.first } })).toEqual({ + message: "Effect", + directory: dirs.first, + }) + expect(await rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() + expect( + await rpc.fail(undefined, { location: { directory: dirs.first } }).catch((error: unknown) => error), + ).toMatchObject({ + type: "rejected", + message: "Effect plugin handler failed", + data: { source: "effect" }, + }) +}, 30_000) + +test("Promise SDK native and typed RPC subscriptions carry real plugin events across locations", async () => { + await using dirs = await fixture() + await using opencode = await OpenCode.create({ + ...dirs.options, + plugins: [ + { + id: "sdk-promise-updates", + async setup(ctx) { + const registration = await ctx.rpc.register(Updates, { + emit: async (input): Promise => { + await registration.events.emit("updated", input) + return undefined + }, + }) + }, + }, + ], + }) + expect(opencode.events).toBe(opencode.event) + const native = opencode.events.subscribe()[Symbol.asyncIterator]() + const rpc = opencode.rpc(Updates) + const first = rpc.events.subscribe("updated")[Symbol.asyncIterator]() + const second = opencode.rpc(Updates).events.subscribe("updated")[Symbol.asyncIterator]() + const controller = new AbortController() + const cancelled = rpc.events.subscribe("updated", { signal: controller.signal })[Symbol.asyncIterator]() + try { + const firstNext = first.next() + const secondNext = second.next() + const cancelledNext = cancelled.next() + expect(await native.next()).toMatchObject({ done: false, value: { type: "server.connected" } }) + controller.abort() + expect(await cancelledNext).toMatchObject({ done: true }) + const at = "2026-08-27T12:00:00.000Z" + expect(await rpc.emit({ message: "first", at }, { location: { directory: dirs.first } })).toBeUndefined() + const published = await nextRpcEvent(native, "rpc.sdk/updates.updated") + const event = (await firstNext).value + expect(published).toMatchObject({ + type: "rpc.sdk/updates.updated", + location: { directory: dirs.first }, + data: { message: "first", at }, + }) + expect(event).toMatchObject({ + id: published.id, + created: published.created, + type: "rpc.sdk/updates.updated", + location: { directory: dirs.first }, + data: { message: "first", at }, + }) + expect((await secondNext).value).toEqual(event) + const returning = first.next() + await first.return?.() + expect(await returning).toMatchObject({ done: true }) + const next = second.next() + await rpc.emit({ message: "second", at }, { location: { directory: dirs.second } }) + const other = await nextRpcEvent(native, "rpc.sdk/updates.updated") + expect((await next).value).toMatchObject({ + id: other.id, + type: "rpc.sdk/updates.updated", + location: { directory: dirs.second }, + data: { message: "second", at }, + }) + expect(other).toMatchObject({ type: "rpc.sdk/updates.updated", location: { directory: dirs.second } }) + } finally { + controller.abort() + await Promise.all([native.return?.(), first.return?.(), second.return?.(), cancelled.return?.()]) + } + // Reopening after the last subscriber leaves must not wait on a leaked source. + const reopened = opencode.events.subscribe()[Symbol.asyncIterator]() + try { + expect(await reopened.next()).toMatchObject({ done: false, value: { type: "server.connected" } }) + } finally { + await reopened.return?.() + } +}, 30_000) + +async function nextRpcEvent(events: AsyncIterator, type: `rpc.${string}`) { + while (true) { + const event = await events.next() + if (event.done) throw new Error("Event stream ended before the RPC event") + if (event.value.type === type) return event.value + } +} + +it.live( + "Effect SDK native and typed streams receive Effect plugin RPC events across locations", + () => + Effect.gen(function* () { + const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => + Effect.promise(() => dirs[Symbol.asyncDispose]()), + ) + const sdk = yield* Effect.promise(() => import("../src/effect")) + const opencode = yield* sdk.OpenCode.create(dirs.options) + yield* opencode.plugin({ + id: "sdk-effect-updates", + effect: (ctx) => + Effect.gen(function* () { + const registration = yield* ctx.rpc.register(Updates, { + emit: (input): Effect.Effect => + registration.events.emit("updated", input).pipe(Effect.as(undefined), Effect.orDie), + }) + }).pipe(Effect.orDie), + }) + expect(opencode.events).toBe(opencode.event) + const connected = yield* Deferred.make() + const rpc = opencode.rpc(Updates) + const typed = yield* rpc.events + .subscribe("updated") + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) + const cancelled = yield* rpc.events + .subscribe("updated") + .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + const native = yield* opencode.events.subscribe().pipe( + Stream.tap((event) => + event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void, + ), + Stream.filter((event) => event.type === "rpc.sdk/updates.updated"), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) + yield* Deferred.await(connected).pipe(Effect.timeout("5 seconds")) + yield* Fiber.interrupt(cancelled) + const at = "2026-08-27T12:00:00.000Z" + yield* rpc.emit({ message: "first", at }, { location: { directory: dirs.first } }) + yield* rpc.emit({ message: "second", at }, { location: { directory: dirs.second } }) + const events = yield* Fiber.join(typed).pipe(Effect.timeout("5 seconds")) + const nativeEvents = yield* Fiber.join(native).pipe(Effect.timeout("5 seconds")) + expect(events).toHaveLength(2) + expect(nativeEvents).toHaveLength(2) + expect( + events.map((event) => ({ + id: event.id, + type: event.type, + directory: event.location.directory, + data: event.data, + })), + ).toEqual([ + { + id: nativeEvents[0].id, + type: "rpc.sdk/updates.updated", + directory: dirs.first, + data: { message: "first", at }, + }, + { + id: nativeEvents[1].id, + type: "rpc.sdk/updates.updated", + directory: dirs.second, + data: { message: "second", at }, + }, + ]) + expect(nativeEvents.map((event) => event.data)).toEqual([ + { message: "first", at }, + { message: "second", at }, + ]) + const reconnected = yield* opencode.events + .subscribe() + .pipe(Stream.take(1), Stream.runCollect, Effect.timeout("5 seconds")) + expect(reconnected).toMatchObject([{ type: "server.connected" }]) + }), + 30_000, +) + +test("Promise SDK stable RPC handles use the latest plugin override in every booted location", async () => { + await using dirs = await fixture() + await using opencode = await OpenCode.create({ + ...dirs.options, + plugins: [ + { + id: "sdk-original", + async setup(ctx) { + const location = (await ctx.agent.list()).location + await ctx.rpc.register(Echo, { + echo: async (input) => ({ message: `original:${input}`, directory: location.directory }), + empty: async () => undefined, + fail: async () => "unused", + }) + }, + }, + ], + }) + const rpc = opencode.rpc(Echo) + expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ + message: "original:first", + directory: dirs.first, + }) + expect(await rpc.echo("second", { location: { directory: dirs.second } })).toEqual({ + message: "original:second", + directory: dirs.second, + }) + const events = opencode.events.subscribe()[Symbol.asyncIterator]() + try { + expect(await events.next()).toMatchObject({ value: { type: "server.connected" } }) + for (const version of ["override", "replacement"]) { + await opencode.plugin({ + id: "sdk-override", + async setup(ctx) { + const location = (await ctx.agent.list()).location + await ctx.rpc.register(Echo, { + echo: async (input) => ({ message: `${version}:${input}`, directory: location.directory }), + empty: async () => undefined, + fail: async () => "unused", + }) + }, + }) + const pending = new Set([dirs.first, dirs.second]) + while (pending.size) { + const event = await events.next() + if (event.done) throw new Error("Event stream ended before plugin reload completed") + if (event.value.type === "plugin.updated" && event.value.location) + pending.delete(event.value.location.directory) + } + expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ + message: `${version}:first`, + directory: dirs.first, + }) + expect(await rpc.echo("second", { location: { directory: dirs.second } })).toEqual({ + message: `${version}:second`, + directory: dirs.second, + }) + } + } finally { + await events.return?.() + } +}, 30_000) + +test("Promise SDK cancellation reaches the actual RPC handler without cancelling other calls", async () => { + await using dirs = await fixture() + const started = Promise.withResolvers() + const stopped = Promise.withResolvers() + await using opencode = await OpenCode.create({ + ...dirs.options, + plugins: [ + { + id: "sdk-promise-blocking", + async setup(ctx) { + await ctx.rpc.register(Blocking, { + wait: async (input, call) => { + if (input === "complete") return input + started.resolve() + await new Promise((resolve) => + call.signal.addEventListener( + "abort", + () => { + stopped.resolve(call.signal) + resolve() + }, + { once: true }, + ), + ) + return input + }, + }) + }, + }, + ], + }) + const rpc = opencode.rpc(Blocking) + const controller = new AbortController() + const pending = rpc + .wait("cancel", { location: { directory: dirs.first }, signal: controller.signal }) + .catch((error: unknown) => error) + try { + await Effect.promise(() => started.promise).pipe(Effect.timeout("5 seconds"), Effect.runPromise) + expect(await rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") + controller.abort() + expect(await pending).toMatchObject({ name: "ClientError", reason: "Transport" }) + expect( + (await Effect.promise(() => stopped.promise).pipe(Effect.timeout("5 seconds"), Effect.runPromise)).aborted, + ).toBe(true) + expect(await rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") + } finally { + controller.abort() + await pending + } +}, 30_000) + +it.live( + "Effect SDK interruption finalizes Effect RPC handlers and keeps independent calls usable", + () => + Effect.gen(function* () { + const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => + Effect.promise(() => dirs[Symbol.asyncDispose]()), + ) + const sdk = yield* Effect.promise(() => import("../src/effect")) + const opencode = yield* sdk.OpenCode.create(dirs.options) + const started = yield* Deferred.make() + const stopped = yield* Deferred.make() + yield* opencode.plugin({ + id: "sdk-effect-blocking", + effect: (ctx) => + ctx.rpc + .register(Blocking, { + wait: (input) => + input === "complete" + ? Effect.succeed(input) + : Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(stopped, undefined)), + ), + }) + .pipe(Effect.asVoid, Effect.orDie), + }) + const rpc = opencode.rpc(Blocking) + const pending = yield* rpc.wait("cancel", { location: { directory: dirs.first } }).pipe(Effect.forkScoped) + yield* Deferred.await(started).pipe(Effect.timeout("5 seconds")) + expect(yield* rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") + yield* Fiber.interrupt(pending) + yield* Deferred.await(stopped).pipe(Effect.timeout("5 seconds")) + expect(yield* rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") + }), + 30_000, +) + +test("Promise SDK close cancels active native and typed RPC subscriptions and releases the plugin", async () => { + await using dirs = await fixture() + const released = Promise.withResolvers() + await using opencode = await OpenCode.create({ + ...dirs.options, + plugins: [ + { + id: "sdk-close-updates", + async setup(ctx) { + const registration = await ctx.rpc.register(Updates, { + emit: async (input): Promise => { + await registration.events.emit("updated", input) + return undefined + }, + }) + return () => released.resolve() + }, + }, + ], + }) + const rpc = opencode.rpc(Updates) + const typed = rpc.events.subscribe("updated")[Symbol.asyncIterator]() + const native = opencode.events.subscribe()[Symbol.asyncIterator]() + try { + const first = typed.next() + expect(await native.next()).toMatchObject({ value: { type: "server.connected" } }) + await rpc.emit({ message: "ready", at: "2026-08-27T12:00:00.000Z" }, { location: { directory: dirs.first } }) + expect((await first).value).toMatchObject({ type: "rpc.sdk/updates.updated" }) + await nextRpcEvent(native, "rpc.sdk/updates.updated") + const typedPending = typed.next().catch((error: unknown) => error) + const nativePending = native.next().catch((error: unknown) => error) + await opencode.close() + expect(await typedPending).toMatchObject({ name: "ClientError", reason: "Transport" }) + expect(await nativePending).toMatchObject({ name: "ClientError", reason: "Transport" }) + await released.promise + await opencode.close() + } finally { + await Promise.all([native.return?.(), typed.return?.()]) + } +}, 30_000) + +it.live( + "closing the Effect SDK scope stops active native and typed RPC subscriptions", + () => + Effect.gen(function* () { + const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => + Effect.promise(() => dirs[Symbol.asyncDispose]()), + ) + const sdk = yield* Effect.promise(() => import("../src/effect")) + const hostScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)) + const opencode = yield* sdk.OpenCode.create(dirs.options).pipe(Effect.provideService(Scope.Scope, hostScope)) + const connected = yield* Deferred.make() + const received = yield* Deferred.make() + const released = yield* Deferred.make() + yield* opencode.plugin({ + id: "sdk-effect-close-updates", + effect: (ctx) => + Effect.gen(function* () { + const registration = yield* ctx.rpc.register(Updates, { + emit: (input): Effect.Effect => + registration.events.emit("updated", input).pipe(Effect.as(undefined), Effect.orDie), + }) + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined).pipe(Effect.asVoid)) + }).pipe(Effect.orDie), + }) + const rpc = opencode.rpc(Updates) + const typed = yield* rpc.events.subscribe("updated").pipe( + Stream.runForEach(() => Deferred.succeed(received, undefined)), + Effect.forkScoped({ startImmediately: true }), + ) + const native = yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void, + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Deferred.await(connected).pipe(Effect.timeout("5 seconds")) + yield* rpc.emit({ message: "ready", at: "2026-08-27T12:00:00.000Z" }, { location: { directory: dirs.first } }) + yield* Deferred.await(received).pipe(Effect.timeout("5 seconds")) + yield* Scope.close(hostScope, Exit.void).pipe(Effect.timeout("5 seconds")) + expect(Exit.isFailure(yield* Fiber.await(typed).pipe(Effect.timeout("5 seconds")))).toBe(true) + expect(Exit.isFailure(yield* Fiber.await(native).pipe(Effect.timeout("5 seconds")))).toBe(true) + yield* Deferred.await(released).pipe(Effect.timeout("5 seconds")) + }), + 30_000, +) 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..7973a86ab1d6 --- /dev/null +++ b/packages/server/src/handlers/rpc.ts @@ -0,0 +1,36 @@ +import { Rpc } from "@opencode-ai/core/rpc" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { RpcError } 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.namespace, params.method, payload.input) + return output === undefined ? {} : { output } + }).pipe( + Effect.mapError(toRpcError), + Effect.catchDefect((error) => + Effect.fail( + new RpcError({ + type: "rpc.internal", + message: error instanceof Error ? error.message : "RPC call failed", + }), + ), + ), + ), + ), +) + +function toRpcError(error: Rpc.Failure): RpcError { + return new RpcError({ + type: error.type, + message: error.message, + ...(error.data === undefined ? {} : { data: error.data }), + }) +} diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts new file mode 100644 index 000000000000..83f4f9398599 --- /dev/null +++ b/packages/server/test/rpc.test.ts @@ -0,0 +1,449 @@ +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 +type DurableRpcEvent = RpcEvent & { durable: NonNullable } + +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({ + namespace: "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.namespace_unavailable", message: "RPC namespace 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/defect", + body: {}, + error: { type: "rpc.internal", message: "handler defect" }, + }, + { route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } }, + { route: "transport.echo/invalid", body: {}, error: { type: "rpc.invalid_output" } }, + ], + (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 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({ + namespace: "blocking", + methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } }, + events: {}, + }) + const PromiseBlocking = Rpc.define({ + namespace: "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({ + namespace: "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 (!directory.endsWith("/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" && event.durable === undefined, + ), + ) + } + 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) + expect(events.every((event) => event.durable === undefined)).toBe(true) + }), +) + +it.live("durable RPC events use the Bus sequence on the public stream", () => + Effect.gen(function* () { + const Updates = Rpc.define({ + namespace: "durable-updates", + methods: { + emit: { + input: Schema.Struct({ itemID: Schema.String, text: Schema.String }), + output: Schema.Undefined, + }, + }, + events: { + recorded: { + schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), + durable: { version: 2, aggregate: "itemID" }, + }, + }, + }) + const server = yield* fixture([ + Plugin.define({ + id: "durable-updates-implementer", + effect: (ctx) => + Effect.gen(function* () { + const registration = yield* ctx.rpc.register(Updates, { + emit: (input): Effect.Effect => + registration.events.emit("recorded", input).pipe(Effect.as(undefined), Effect.orDie), + }) + }).pipe(Effect.orDie), + }), + ]) + yield* server.boot(server.first) + const response = yield* Effect.promise(() => + server.handler( + new Request("http://opencode.local/api/event", { + headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) }, + }), + ), + ) + 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"') + yield* server.call( + "durable-updates/emit", + { input: { itemID: "item-1", text: "first" } }, + { directory: server.first }, + ) + yield* server.call( + "durable-updates/emit", + { input: { itemID: "item-1", text: "second" } }, + { directory: server.first }, + ) + const events: DurableRpcEvent[] = [] + while (events.length < 2) { + const chunk = yield* Effect.promise(() => reader.read()) + if (chunk.done) throw new Error("Event stream closed before durable 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 DurableRpcEvent => + event.type === "rpc.durable-updates.recorded" && event.durable !== undefined, + ), + ) + } + expect( + events.map((event) => ({ + aggregateID: event.durable.aggregateID, + seq: Number(event.durable.seq), + version: Number(event.durable.version), + data: event.data, + })), + ).toEqual([ + { + aggregateID: "item-1", + seq: 0, + version: 2, + data: { itemID: "item-1", text: "first" }, + }, + { + aggregateID: "item-1", + seq: 1, + version: 2, + data: { itemID: "item-1", text: "second" }, + }, + ]) + }), +) 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/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/www/openapi.json b/packages/www/openapi.json index 576a4b72595a..4eadef962fd4 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -8950,6 +8950,122 @@ "summary": "List skills" } }, + "/api/rpc/{namespace}/{method}": { + "post": { + "tags": ["rpc"], + "operationId": "v2.rpc.call", + "parameters": [ + { + "name": "namespace", + "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": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Dispatch a method to the currently registered RPC namespace 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 +9185,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" } }, @@ -16982,6 +17098,20 @@ } ] }, + "Rpc.Input": { + "type": "object", + "properties": { + "input": {} + }, + "additionalProperties": false + }, + "Rpc.Output": { + "type": "object", + "properties": { + "output": {} + }, + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { @@ -19157,6 +19287,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..4eadef962fd4 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -8950,6 +8950,122 @@ "summary": "List skills" } }, + "/api/rpc/{namespace}/{method}": { + "post": { + "tags": ["rpc"], + "operationId": "v2.rpc.call", + "parameters": [ + { + "name": "namespace", + "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": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Dispatch a method to the currently registered RPC namespace 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 +9185,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" } }, @@ -16982,6 +17098,20 @@ } ] }, + "Rpc.Input": { + "type": "object", + "properties": { + "input": {} + }, + "additionalProperties": false + }, + "Rpc.Output": { + "type": "object", + "properties": { + "output": {} + }, + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { @@ -19157,6 +19287,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/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index 1d6d96b785ec..01609a7bd2c7 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,50 @@ 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 +`RpcError` wrapper; reserved `rpc.*` types identify framework failures. + +RPC events are typed Streams, not callback-style `on` listeners. They receive the +namespace's events from all locations, each with required `location` and a normal +prefixed type such as `rpc.acme.updated`. Durable definitions additionally carry the +declared aggregate, sequence, and version. This differs from server-plugin handles, +which are fixed to their own location. See [plugin RPC](/build/plugins#rpc) for +definitions, schemas, registration, durability, 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 +124,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 +146,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..d7d985795d21 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,86 @@ 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. A slow subscriber that exceeds the +4096-event queue fails without stopping other consumers. + +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 { Rpc } from "@opencode-ai/plugin/rpc" +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) +}) + +try { + await acme.search({ query: "missing" }) +} catch (error) { + if (Rpc.isError(Acme, "search", error) && error.type === "not_found") { + console.log(error.message, error.data.query) + } +} +``` + +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. +`Rpc.isError(definition, method, error)` narrows a caught value to the union +inferred from that method's error map. The generic HTTP `RpcError` wrapper is +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 namespace'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. Durable definitions +also expose the declared `aggregateID`, `seq`, and `version`, but live subscriptions +do not replay missed events. + +The server plugin must be configured and implement the namespace; 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/{namespace}/{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/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 17201b444bbf..f43e6478ce51 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -39,9 +39,9 @@ plugins. "./plugins/local-effect.ts", { "package": "@acme/opencode-effect-plugin", - "options": { "agent": "reviewer", "strict": true } - } - ] + "options": { "agent": "reviewer", "strict": true }, + }, + ], } ``` @@ -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 @@ -145,9 +146,9 @@ Pass options with the object form in `opencode.json(c)`. "plugins": [ { "package": "./plugins/company-effect.ts", - "options": { "strict": true } - } - ] + "options": { "strict": true }, + }, + ], } ``` @@ -631,6 +632,60 @@ 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. Durable definitions still use +the normal Bus sequence and persistence path; this API does not currently expose +replay. 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..087f68cee1d6 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -95,10 +95,10 @@ Pass plugin options with the object form in `opencode.json(c)`. { "package": "./plugins/company.ts", "options": { - "strict": true - } - } - ] + "strict": true, + }, + }, + ], } ``` @@ -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 @@ -570,6 +579,120 @@ interface PluginContext { } ``` +### 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({ + namespace: "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() }), + durable: { version: 1, aggregate: "itemID" }, + }, + }, +}) +``` + +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 +namespaces are independent of plugin IDs. One plugin +can implement several namespaces, 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 namespace: + +```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`. Event definitions may include `durable: { version, aggregate }`, where +`aggregate` names a string field in the schema output passed to Bus. Durable emits +use the normal Bus sequence and configured persistence flow, and subscribers also +receive `durable: { aggregateID, seq, version }`. + +Subscriptions remain live-only: there is no plugin log/replay API yet, and events +while disconnected are missed even when they were published durably. 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 namespace'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. + ### References Read the references available at a location. @@ -995,7 +1118,7 @@ Schema: [`V2EventEncoded`](/api#schema-V2EventEncoded) ```ts interface EventContext { - subscribe(requestOptions?: RequestOptions): AsyncIterable + subscribe(options?: { signal?: AbortSignal }): AsyncIterable } ``` @@ -1237,10 +1360,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 { @@ -1298,7 +1418,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 +1427,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 From 3e36c51f6907b2ee6579d4aaf5907c63d0d6e33e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 28 Aug 2026 23:38:06 -0400 Subject: [PATCH 02/20] refactor(rpc): remove redundant validation --- packages/client/src/effect/rpc.ts | 22 ++++++------ packages/client/src/promise/rpc.ts | 36 +++++++++---------- packages/client/src/rpc-runtime.ts | 17 +++++---- packages/core/src/rpc.ts | 50 ++++++++++++-------------- packages/plugin/src/promise/adapter.ts | 26 +++++++------- packages/plugin/test/rpc.test.ts | 5 +++ packages/schema/src/rpc.ts | 2 +- 7 files changed, 79 insertions(+), 79 deletions(-) diff --git a/packages/client/src/effect/rpc.ts b/packages/client/src/effect/rpc.ts index d4aa3311d8ee..63b4da64bf6d 100644 --- a/packages/client/src/effect/rpc.ts +++ b/packages/client/src/effect/rpc.ts @@ -62,17 +62,19 @@ export function make( }, ]), ) - // Runtime keys and decoded values follow the definition's mapped public type. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + // 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) - return subscribe().pipe( - Stream.filter((event): event is RpcEvent => event.type.startsWith("rpc.") && event.type === type), - Stream.mapEffect((event) => RpcRuntime.event(definition, name, event)), - ) - }, + events: { + subscribe: (name: keyof D["events"] & string) => { + const type = RpcRuntime.eventType(definition, name) + const schema = definition.events[name] + if (!schema) return Stream.fail(new Error(`Unknown RPC event: ${type}`)) + 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> } diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts index 8f912ddf88de..51053ac030d6 100644 --- a/packages/client/src/promise/rpc.ts +++ b/packages/client/src/promise/rpc.ts @@ -4,6 +4,7 @@ import { isRpcError } from "./generated/types.js" import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js" type RpcEvent = Extract +type RpcEventType = RpcEventPayload["type"] export interface RpcCallOptions extends RequestOptions { readonly location?: LocationGetInput["location"] @@ -67,7 +68,8 @@ export function makeRpc( name: string, options?: Pick, ): AsyncIterable> => { - if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) + const schema = definition.events[name] + if (!schema) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) const type = eventType(definition, name) return { [Symbol.asyncIterator]() { @@ -77,8 +79,8 @@ export function makeRpc( try { for await (const published of events.subscribe({ signal })) { if (signal.aborted) return - if (!isRpcEvent(published) || published.type !== type) continue - if (!signal.aborted) yield event(definition, name, published) + if (!isRpcEvent(published, type)) continue + yield event(type, schema, published) } } catch (error) { if (!signal.aborted) throw error @@ -97,8 +99,8 @@ export function makeRpc( }, } } - // Runtime keys are built directly from the checked definition's mapped public type. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + // 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) => [ @@ -109,7 +111,7 @@ export function makeRpc( { namespace: definition.namespace, method: name, - // The generated transport owns JSON serialization; RPC adds no preflight parser. + // 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, @@ -149,34 +151,30 @@ export function makeRpc( } function event( - definition: Rpc.PortableDefinition, - name: string, + type: RpcEventType, + schema: Rpc.PortableEventDefinition, event: RpcEvent, ): RpcEventPayload { - const schema = definition.events[name] - if (!schema) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) if (!schema.durable) { - if (event.durable) throw new Error(`Expected ephemeral RPC event: ${eventType(definition, name)}`) + if (event.durable) throw new Error(`Expected ephemeral RPC event: ${type}`) return { ...event, - type: eventType(definition, name), + type, location: { ...event.location }, } } - if (!event.durable) throw new Error(`Expected durable RPC event: ${eventType(definition, name)}`) + if (!event.durable) throw new Error(`Expected durable RPC event: ${type}`) if (event.durable.version !== schema.durable.version) - throw new Error( - `RPC event version mismatch for ${definition.namespace}.${name}: expected ${schema.durable.version}, got ${event.durable.version}`, - ) + throw new Error(`RPC event version mismatch for ${type}: expected ${schema.durable.version}, got ${event.durable.version}`) return { ...event, - type: eventType(definition, name), + type, location: { ...event.location }, } } -function isRpcEvent(event: EventSubscribeOutput): event is RpcEvent { - return event.type.startsWith("rpc.") +function isRpcEvent(event: EventSubscribeOutput, type: RpcEventType): event is RpcEvent { + return event.type === type } function eventType(definition: Rpc.PortableDefinition, name: string) { diff --git a/packages/client/src/rpc-runtime.ts b/packages/client/src/rpc-runtime.ts index 8aae4cf10859..4e6b7dcf47f3 100644 --- a/packages/client/src/rpc-runtime.ts +++ b/packages/client/src/rpc-runtime.ts @@ -36,15 +36,17 @@ export function readError(method: Rpc.Method, error: unknown): Effect.Effect(definition: D, name: Name, event: RpcEvent): Effect.fn.Return, unknown> { - const schema = definition.events[name] - if (!schema) return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.namespace}.${name}`)) - if (event.type !== eventType(definition, name)) - return yield* Effect.fail(new Error(`Unexpected RPC event type: ${event.type}`)) +>( + definition: D, + name: Name, + schema: Rpc.EventDefinition, + event: RpcEvent, +): Effect.fn.Return, unknown> { const data = yield* read(schema.schema, event.data) if (!schema.durable) { if (event.durable) return yield* Effect.fail(new Error(`Expected ephemeral RPC event: ${event.type}`)) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope and definition durability are checked above. + // SAFETY: The event type and ephemeral envelope were checked above, 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), @@ -59,7 +61,8 @@ export const event = Effect.fn("Client.Rpc.event")(function* < `RPC event version mismatch for ${definition.namespace}.${name}: expected ${schema.durable.version}, got ${event.durable.version}`, ), ) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope, version, and definition are checked above. + // SAFETY: The event type, durable envelope/version, and decoded data all match this definition. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion return { ...event, type: eventType(definition, name), diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index 03f8ed2838d4..b934d3b6e661 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -133,7 +133,8 @@ const layer = Layer.effect( ), ]), ) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime keys come from the checked definition. + // 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: { @@ -142,7 +143,7 @@ const layer = Layer.effect( if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.namespace}.${name}`)) return bus.subscribe(registered.definition).pipe( Stream.provideService(Location.Service, location), - Stream.mapEffect((payload) => logicalEvent(definition, name, payload)), + Stream.mapEffect((payload) => logicalEvent(definition, name, payload, ref)), ) }, }, @@ -162,6 +163,7 @@ const fields = { location: optional(Location.Ref), } const EventData = Schema.Record(Schema.String, Schema.Unknown) +const jsonSchemas = new WeakMap>() function eventType( definition: D, @@ -201,10 +203,15 @@ function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect - Schema.make>( + 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))) } @@ -243,7 +250,7 @@ function errorMessage(error: unknown, fallback: string) { } function applyEventSchema(schema: Rpc.EventDefinition["schema"], value: unknown) { - // The public event-schema contract guarantees an object encoded/output type. + // SAFETY: The public event-schema contract guarantees an object encoded/output type. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion return encode(schema, value) as Effect.Effect>, unknown> } @@ -260,33 +267,20 @@ function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect(definition: D, name: Name, payload: Event.Payload): Effect.fn.Return, unknown> { +>( + 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) - if (!payload.location) return yield* Effect.fail(new Error(`RPC event is missing location: ${payload.type}`)) - if (!event.durable) { - if (payload.durable) return yield* Effect.fail(new Error(`Expected ephemeral RPC event: ${payload.type}`)) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope and definition durability are checked above. - return { - ...payload, - type: eventType(definition, name), - data, - location: Location.Ref.make({ directory: payload.location.directory, workspaceID: payload.location.workspaceID }), - } as Rpc.EventPayload - } - if (!payload.durable) return yield* Effect.fail(new Error(`Expected durable RPC event: ${payload.type}`)) - if (payload.durable.version !== event.durable.version) - return yield* Effect.fail( - new Error( - `RPC event version mismatch for ${definition.namespace}.${name}: expected ${event.durable.version}, got ${payload.durable.version}`, - ), - ) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- event envelope, version, and definition are checked above. + // SAFETY: The private Bus definition owns the envelope, durability, version, and location. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion return { ...payload, type: eventType(definition, name), data, - durable: payload.durable, - location: Location.Ref.make({ directory: payload.location.directory, workspaceID: payload.location.workspaceID }), + location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }), } as Rpc.EventPayload }) diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 8545aecc0389..81c6a7dc0c0e 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -85,17 +85,15 @@ const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRp ): AsyncIterable> => streams(local.events.subscribe(name), options) return Object.assign( Object.fromEntries( - Object.entries(local).flatMap(([name, method]) => - typeof method !== "function" - ? [] - : [ - [ - name, - (input: unknown, options?: Pick) => - run(method(input), { signal: options?.signal }), - ], - ], - ), + 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: { @@ -121,7 +119,7 @@ const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRp run( host.register( definition, - // The runtime adapter restores each concrete method's erased error map below. + // 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]) => [ @@ -155,8 +153,8 @@ const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRp events: { emit: (...args: Rpc.EventInput) => run(registration.events.emit(...args)) }, })) - // The adapter implements the portable callable domain dynamically from each checked definition. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runtime methods adapt the portable typed domain. + // 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 }) diff --git a/packages/plugin/test/rpc.test.ts b/packages/plugin/test/rpc.test.ts index 09aa3d648e2c..2fe750d397ff 100644 --- a/packages/plugin/test/rpc.test.ts +++ b/packages/plugin/test/rpc.test.ts @@ -39,6 +39,11 @@ test("framework RPC error names are reserved", () => { ).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal') }) +test("error narrowing returns false for unknown methods", () => { + const definition: Rpc.Definition = Acme + expect(Rpc.isError(definition, "missing", { type: "not_found", message: "Missing" })).toBe(false) +}) + test("the shared definition entrypoint bundles without Effect or host runtime dependencies", async () => { const inputs = new Set() const result = await Bun.build({ diff --git a/packages/schema/src/rpc.ts b/packages/schema/src/rpc.ts index b96b3bdf0a0f..b0508074c071 100644 --- a/packages/schema/src/rpc.ts +++ b/packages/schema/src/rpc.ts @@ -166,7 +166,7 @@ export function isError< typeof error.message !== "string" ) return false - const errors = definition.methods[method].errors + const errors = definition.methods[method]?.errors return errors !== undefined && Object.hasOwn(errors, error.type) } From 23fc225eee89736209a887196b53a370eaa9a2b2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 29 Aug 2026 03:17:23 -0400 Subject: [PATCH 03/20] refactor(rpc): simplify event delivery --- .changeset/plugin-rpc-events.md | 11 - PLUGIN_RPC_DESIGN.md | 53 +- bun.lock | 1 - packages/client/src/effect/rpc.ts | 6 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 10 +- packages/client/src/promise/rpc.ts | 34 +- packages/client/src/rpc-runtime.ts | 24 +- packages/client/src/shared-events.ts | 55 +- packages/client/test/rpc-effect.test.ts | 60 +- packages/client/test/rpc-promise.test.ts | 68 +- packages/client/test/shared-events.test.ts | 138 +--- packages/core/src/rpc.ts | 15 +- packages/core/test/rpc.test.ts | 116 ---- packages/plugin/test/rpc-effect.types.ts | 1 - packages/plugin/test/rpc-promise.types.ts | 27 - packages/plugin/test/rpc.fixture.ts | 4 - packages/plugin/test/rpc.test.ts | 7 +- packages/protocol/src/errors.ts | 10 + packages/protocol/src/groups/event.ts | 7 - packages/protocol/src/groups/rpc.ts | 4 +- packages/protocol/test/event.test.ts | 9 - packages/protocol/test/rpc.test.ts | 6 +- packages/schema/src/rpc.ts | 50 +- packages/sdk/package.json | 3 +- packages/sdk/test/promise.test.ts | 3 +- packages/sdk/test/rpc.test.ts | 596 ------------------ packages/server/src/handlers/rpc.ts | 4 +- packages/server/test/rpc.test.ts | 108 +--- .../src/docs/content/build/client/effect.mdx | 5 +- .../src/docs/content/build/client/index.mdx | 27 +- .../src/docs/content/build/plugins/effect.mdx | 5 +- .../src/docs/content/build/plugins/index.mdx | 10 +- 33 files changed, 165 insertions(+), 1314 deletions(-) delete mode 100644 .changeset/plugin-rpc-events.md delete mode 100644 packages/sdk/test/rpc.test.ts diff --git a/.changeset/plugin-rpc-events.md b/.changeset/plugin-rpc-events.md deleted file mode 100644 index 549d07d913a8..000000000000 --- a/.changeset/plugin-rpc-events.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@opencode-ai/schema": minor -"@opencode-ai/protocol": minor -"@opencode-ai/client": minor -"@opencode-ai/plugin": minor -"@opencode-ai/core": minor -"@opencode-ai/server": minor -"@opencode-ai/sdk": minor ---- - -Add typed plugin RPC methods, declared errors, and custom event publishing for Promise and Effect APIs. diff --git a/PLUGIN_RPC_DESIGN.md b/PLUGIN_RPC_DESIGN.md index 091e6abdedc2..0142f7286e8d 100644 --- a/PLUGIN_RPC_DESIGN.md +++ b/PLUGIN_RPC_DESIGN.md @@ -2,7 +2,7 @@ Design notes and implementation record. Shared definitions, the location-scoped Core registry, local Promise/Effect plugin APIs, HTTP dispatch, external typed -clients, shared event connections, and durable Bus publication are implemented. +clients, shared event connections, and Bus publication are implemented. Plugin log/replay APIs and per-namespace OpenAPI discovery are intentionally deferred. ## First Slice @@ -35,7 +35,6 @@ also be declared as an RPC method. - Promise RPC stays runtime-independent from Effect and accepts only portable definitions. Effect clients decode Effect codecs normally. - Native and RPC Promise plugin subscriptions share scoped iterator cleanup and respect subscriber-local signals. - Public protocol/client/OpenAPI artifacts are regenerated; plugin/client guides document the feature. -- Real SDK integration tests cover cold-start calls, cross-style plugins, locations, events, overrides, cancellation, and shutdown. Intended usage passes one concrete RPC definition. Conditional definition unions and numeric event names are not part of the supported usage being designed. @@ -47,9 +46,7 @@ unions and numeric event names are not part of the supported usage being designe - Infer types for method arguments, results, handlers, and event payloads from a shared contract. - Support both Promise and Effect execution without forcing plugin authors to use Effect. -Custom events may be ephemeral or durable. Both use normal Bus publication. -Durable events use existing Bus sequencing and persistence; no plugin-facing -log replay/follow API is exposed yet. +Custom events are ephemeral and use normal Bus publication. ## Shared Definition @@ -100,7 +97,6 @@ export const Acme = Rpc.define({ events: { updated: { schema: z.object({ itemID: z.string(), text: z.string() }), - durable: { version: 1, aggregate: "itemID" }, }, progress: { schema: z.object({ percent: z.number() }), @@ -116,11 +112,8 @@ separate event builder or explicit `type` field is required. Each map key is a local event name; the public event type is automatically prefixed with the namespace: `rpc.${namespace}.${eventName}`. The example defines `rpc.acme.updated` and `rpc.acme.progress`. -Each event definition has a `schema` accepting `Tool.ValueSchema` and optional -`durable: { version, aggregate }`. Omit it for an ephemeral event. When present, -`aggregate` names a string field in the encoded/output payload passed to Bus. -Publishing uses the normal durable Bus path, including aggregate validation, -sequence allocation, and configured persistence. +Each event definition has a `schema` accepting `Tool.ValueSchema`. Publishing +uses the normal ephemeral Bus path. Custom event data must be an object. Effect and Standard Schema definitions enforce that in their inferred types; plain JSON Schema is checked when emitting. @@ -128,7 +121,7 @@ Scalars, arrays, `null`, and `undefined` are not valid event payloads. Publishing supplies only the payload. Subscribers receive the standard event envelope with `id`, `created`, `type`, `data`, required `location`, optional -`metadata`, and, for durable events, `durable: { aggregateID, seq, version }`. +`metadata`. OpenCode supplies the emitting plugin instance's location; publishers do not provide or override it. @@ -142,8 +135,7 @@ filter using the required `event.location` field. Server plugin subscriptions are bound to the calling plugin instance's location. Live subscriptions do not replay missed events. Events emitted while a consumer -is disconnected are missed, including durable events. Persistence does not turn -the live subscription into replay; there is no plugin log API in this design yet. +is disconnected are missed; there is no plugin log API in this design yet. ## Client API @@ -249,11 +241,12 @@ server to separately import a well-known RPC export from each plugin package. The request body is `{ input?: unknown }` and the success body is `{ output?: unknown }`. Omitted fields represent no value. Location uses the existing native deep-object query/header resolution; call metadata is not part of the method input. -The endpoint uses the standard `RpcError` HTTP wrapper around a generic -`{ type, message, data? }` RPC failure. Typed clients remove that transport -wrapper and decode declared error data through the selected method's error map. -Validation and lookup failures retain reserved `rpc.*` types. Interruption is -not converted to a method failure. +The endpoint uses standard HTTP error wrappers around generic +`{ type, message, data? }` RPC failures. Declared and request failures use +`RpcError` at 400; unexpected defects use `RpcInternalError` at 500. Typed +clients remove those transport wrappers and decode declared error data through +the selected method's error map. Validation and lookup failures retain reserved +`rpc.*` types. Interruption is not converted to a method failure. ## Deferred OpenAPI Integration @@ -324,16 +317,14 @@ Reuse the existing `/api/event` stream for custom RPC events alongside native events. Do not add a separate event endpoint per namespace. The native stream carries the actual `rpc..` type and direct -JSON object payload. Ephemeral and durable events share that type pattern; durable events -also carry the normal Bus envelope. Reserving the `rpc.` prefix keeps dynamic -events disjoint from native event literals, preserving native union narrowing. +JSON object payload. Reserving the `rpc.` prefix keeps dynamic events disjoint +from native event literals, preserving native union narrowing. The subclient's `subscribe` API and Promise `on` wrapper match namespace and local name, then apply the declared payload schema. External clients receive matching namespace events across all locations. Server plugin RPC subscriptions stay bound to their own location. The shared definition supplies -the payload schema and inferred types. Durable publication may persist, but live -delivery still has no implicit replay. +the payload schema and inferred types. Live delivery has no implicit replay. ### Shared Connection Lifecycle @@ -347,15 +338,15 @@ Handwritten public client facades wrap the generated raw event transport with this shared source. Server plugin subscriptions use the internal bus directly and do not open HTTP event connections. -Cache and copy only the latest `server.connected` marker for late subscribers, +Cache only the latest `server.connected` marker for late subscribers, so native connection consumers still receive their initial handshake. Do not -replay business events. A replacement connection waits for the previous source's -cleanup rather than overlapping it. +replay business events. A replacement connection may open while the previous +source finishes cleanup. -Each subscriber has a 4096-event queue limit, matching the existing native -overflow contract. A slow subscriber fails independently; it does not block -other consumers or create an unbounded queue. Source EOF/failure ends current -subscriptions, without automatic retry. Consumers resubscribe after recovery. +The shared source advances after every active subscriber accepts the current +event. Consumers that perform slow work should drain and buffer events themselves. +Source EOF/failure ends current subscriptions, without automatic retry. Consumers +resubscribe after recovery. Promise `on` logs callback/source failures and ends its listener. Callbacks may be async: each listener awaits its callback before processing the next event, so rejected callbacks are caught and only that listener ends. diff --git a/bun.lock b/bun.lock index 8e506481675a..6482b8d9e680 100644 --- a/bun.lock +++ b/bun.lock @@ -674,7 +674,6 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", - "zod": "catalog:", }, }, "packages/server": { diff --git a/packages/client/src/effect/rpc.ts b/packages/client/src/effect/rpc.ts index 63b4da64bf6d..12f0412a7f71 100644 --- a/packages/client/src/effect/rpc.ts +++ b/packages/client/src/effect/rpc.ts @@ -1,7 +1,7 @@ export * as RpcClientRuntime from "./rpc.js" import type { Rpc } from "@opencode-ai/schema/rpc" -import type { RpcError } from "@opencode-ai/protocol/errors" +import type { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors" import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import { Effect, Stream } from "effect" import type { RpcArguments, RpcCallOptions } from "../promise/rpc.js" @@ -34,7 +34,7 @@ export interface RpcApi { export function make( call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect, subscribe: () => Stream.Stream, -): RpcApi | Rpc.SystemError, RpcCallOptions, EventError> { +): RpcApi | Rpc.SystemError, RpcCallOptions, EventError> { return (definition: D) => { const methods = Object.fromEntries( Object.entries(definition.methods).map(([name, method]) => [ @@ -76,7 +76,7 @@ export function make( ) }, }, - }) as RpcClient | Rpc.SystemError, RpcCallOptions, EventError> + }) as RpcClient | Rpc.SystemError, RpcCallOptions, EventError> } } diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index d39a178704f4..2fef3277f48f 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1605,7 +1605,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { input: input["input"] }, successStatus: 200, - declaredStatuses: [400, 401], + declaredStatuses: [400, 500, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 82b4cf8d1755..38d6b90c7d96 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -468,7 +468,6 @@ export type V2EventRpc = { type: `${"rpc."}${string}` location: LocationRef data: { [x: string]: any } - durable?: { aggregateID: string; seq: number; version: number } | undefined } export type V2EventServerConnected = { @@ -2503,6 +2502,15 @@ export type RpcError = { 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" + 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" diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts index 51053ac030d6..6ac2adba2e10 100644 --- a/packages/client/src/promise/rpc.ts +++ b/packages/client/src/promise/rpc.ts @@ -1,6 +1,6 @@ import type { Rpc } from "@opencode-ai/schema/rpc" import type { make, RequestOptions } from "./generated/client.js" -import { isRpcError } from "./generated/types.js" +import { isRpcError, isRpcInternalError } from "./generated/types.js" import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js" type RpcEvent = Extract @@ -37,18 +37,10 @@ export type RpcClient = E extends Rpc.DurableEventDefinition - ? Omit & { - type: `rpc.${D["namespace"]}.${Name}` - data: Rpc.EventData - durable: { aggregateID: string; seq: number; version: number } - } - : Omit & { - durable?: never - type: `rpc.${D["namespace"]}.${Name}` - data: Rpc.EventData - } +> = Omit & { + type: `rpc.${D["namespace"]}.${Name}` + data: Rpc.EventData +} export type RpcEventPayload< D extends Rpc.PortableDefinition, @@ -80,7 +72,7 @@ export function makeRpc( for await (const published of events.subscribe({ signal })) { if (signal.aborted) return if (!isRpcEvent(published, type)) continue - yield event(type, schema, published) + yield event(type, published) } } catch (error) { if (!signal.aborted) throw error @@ -120,7 +112,7 @@ export function makeRpc( ) return result.output } catch (error) { - if (!isRpcError(error)) throw 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 } @@ -152,20 +144,8 @@ export function makeRpc( function event( type: RpcEventType, - schema: Rpc.PortableEventDefinition, event: RpcEvent, ): RpcEventPayload { - if (!schema.durable) { - if (event.durable) throw new Error(`Expected ephemeral RPC event: ${type}`) - return { - ...event, - type, - location: { ...event.location }, - } - } - if (!event.durable) throw new Error(`Expected durable RPC event: ${type}`) - if (event.durable.version !== schema.durable.version) - throw new Error(`RPC event version mismatch for ${type}: expected ${schema.durable.version}, got ${event.durable.version}`) return { ...event, type, diff --git a/packages/client/src/rpc-runtime.ts b/packages/client/src/rpc-runtime.ts index 4e6b7dcf47f3..2badde8bbf4f 100644 --- a/packages/client/src/rpc-runtime.ts +++ b/packages/client/src/rpc-runtime.ts @@ -2,7 +2,7 @@ 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 } from "@opencode-ai/protocol/errors" +import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors" import { Effect, Schema } from "effect" type RpcEvent = Extract @@ -13,7 +13,7 @@ export function read(schema: Rpc.Method["output"], value: unknown) { } export function readError(method: Rpc.Method, error: unknown): Effect.Effect { - if (!(error instanceof RpcError)) return Effect.fail(error) + 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 @@ -43,25 +43,7 @@ export const event = Effect.fn("Client.Rpc.event")(function* < event: RpcEvent, ): Effect.fn.Return, unknown> { const data = yield* read(schema.schema, event.data) - if (!schema.durable) { - if (event.durable) return yield* Effect.fail(new Error(`Expected ephemeral RPC event: ${event.type}`)) - // SAFETY: The event type and ephemeral envelope were checked above, 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, - location: { ...event.location }, - } as Rpc.EventPayload - } - if (!event.durable) return yield* Effect.fail(new Error(`Expected durable RPC event: ${event.type}`)) - if (event.durable.version !== schema.durable.version) - return yield* Effect.fail( - new Error( - `RPC event version mismatch for ${definition.namespace}.${name}: expected ${schema.durable.version}, got ${event.durable.version}`, - ), - ) - // SAFETY: The event type, durable envelope/version, and decoded data all match this definition. + // 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, diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts index e97ad8729e31..77e563e096ea 100644 --- a/packages/client/src/shared-events.ts +++ b/packages/client/src/shared-events.ts @@ -1,19 +1,9 @@ export * as SharedEvents from "./shared-events.js" -export class SubscriberOverflowError extends Error { - constructor() { - super("Event subscriber queue overflow") - this.name = "SubscriberOverflowError" - } -} - -export function make( - connect: (signal: AbortSignal) => AsyncIterable, - options?: { readonly capacity?: number }, -) { +export function make(connect: (signal: AbortSignal) => AsyncIterable) { type Completion = { readonly error: unknown } | Record type Subscriber = { - push: (value: A) => void + push: (value: A) => Promise finish: (completion: Completion) => void } type Connection = { @@ -21,16 +11,15 @@ export function make( subscribers: Set connected?: A read?: ReturnType>> - done: ReturnType> } - const capacity = options?.capacity ?? 4096 let current: Connection | undefined function stop(connection: Connection) { connection.connected = undefined connection.read?.resolve({ done: true, value: undefined }) connection.controller.abort() + if (current === connection) current = undefined } async function run(connection: Connection) { @@ -46,8 +35,8 @@ export function make( const item = await connection.read.promise connection.read = undefined if (item.done || connection.controller.signal.aborted) break - if (item.value.type === "server.connected") connection.connected = { ...item.value } - connection.subscribers.forEach((subscriber) => subscriber.push(item.value)) + 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 } @@ -59,8 +48,6 @@ export function make( if (!("error" in completion)) completion = { error } } connection.subscribers.forEach((subscriber) => subscriber.finish(completion)) - current = undefined - connection.done.resolve() } } @@ -68,15 +55,18 @@ export function make( subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable { return { [Symbol.asyncIterator]() { - const queue: A[] = [] 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, discard = false) { completion = result - if (discard || "error" in result) queue.length = 0 + if (discard || "error" in 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) => { @@ -92,29 +82,24 @@ export function make( const subscriber: Subscriber = { finish, push(value) { - const event = value.type === "server.connected" ? { ...value } : value + if (completion) return Promise.resolve() const request = pending.shift() if (request) { - request.resolve({ done: false, value: event }) - return - } - if (queue.length >= capacity) { - finish({ error: new SubscriberOverflowError() }) - return + request.resolve({ done: false, value }) + return Promise.resolve() } - queue.push(event) + const accepted = Promise.withResolvers() + offered = { value, accepted } + return accepted.promise }, } async function start() { - // A replacement connection cannot overlap the previous iterator's cleanup. - while (current?.controller.signal.aborted) await current.done.promise if (completion) return const fresh = !current connection = current ?? { controller: new AbortController(), subscribers: new Set(), - done: Promise.withResolvers(), } current = connection connection.subscribers.add(subscriber) @@ -124,8 +109,12 @@ export function make( return { next(): Promise> { - const value = queue.shift() - if (value !== undefined) return Promise.resolve({ done: false, value }) + 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 }) diff --git a/packages/client/test/rpc-effect.test.ts b/packages/client/test/rpc-effect.test.ts index ef33268778d6..e125acefadc4 100644 --- a/packages/client/test/rpc-effect.test.ts +++ b/packages/client/test/rpc-effect.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { Rpc } from "@opencode-ai/schema/rpc" -import { Cause, Context, Effect, Exit, Fiber, Option, Schema, Stream } from "effect" +import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { OpenCode } from "../src/effect/index" @@ -19,10 +19,6 @@ const definition = Rpc.define({ events: { progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) }, message: { schema: Schema.Struct({ text: Schema.String }) }, - recorded: { - schema: Schema.Struct({ itemID: Schema.String, count: Schema.FiniteFromString }), - durable: { version: 2, aggregate: "itemID" }, - }, }, }) @@ -39,17 +35,6 @@ function rpcEvent(count: unknown, directory = "/project/one", namespace = "examp } } -function durableEvent(data: unknown, directory = "/project/one") { - return { - id: "evt_recorded", - created: 124, - type: "rpc.example.recorded", - durable: { aggregateID: "item-1", seq: 3, version: 2 }, - location: { directory }, - data, - } -} - function eventSource() { const requests: HttpClientRequest.HttpClientRequest[] = [] const opened = Promise.withResolvers<{ @@ -216,6 +201,26 @@ test("Effect RPC decodes declared errors and removes the generic transport wrapp 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() @@ -323,6 +328,8 @@ test("native and RPC Effect streams share one lazy source, cache connected, and 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")) @@ -341,8 +348,6 @@ test("native and RPC Effect streams share one lazy source, cache connected, and ) expect(source.requests).toHaveLength(1) - await native.return?.() - await late.return?.() expect((await source.opened).signal.aborted).toBe(false) const third = progress.next() await source.push(rpcEvent("3")) @@ -375,25 +380,6 @@ test("interrupting a native Effect stream leaves an active RPC consumer running" await source.cancelled }) -test("Effect RPC streams receive direct durable Bus events", async () => { - const source = eventSource() - const result = Effect.gen(function* () { - const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - return yield* client - .rpc(definition) - .events.subscribe("recorded") - .pipe(Stream.runHead, Effect.map(Option.getOrThrow)) - }).pipe(Effect.provideService(HttpClient.HttpClient, source.httpClient), Effect.runPromise) - await source.push(durableEvent({ itemID: "item-1", count: "42" })) - expect(await result).toMatchObject({ - type: "rpc.example.recorded", - data: { itemID: "item-1", count: 42 }, - durable: { aggregateID: "item-1", seq: 3, version: 2 }, - location: { directory: "/project/one" }, - }) - await source.cancelled -}) - test("shared Effect streams preserve EOF without reconnecting", async () => { const source = eventSource() const client = await Effect.runPromise( diff --git a/packages/client/test/rpc-promise.test.ts b/packages/client/test/rpc-promise.test.ts index 97ed10c1d938..aad9e59e7770 100644 --- a/packages/client/test/rpc-promise.test.ts +++ b/packages/client/test/rpc-promise.test.ts @@ -23,10 +23,6 @@ const Echo = Rpc.define({ }, events: { updated: { schema: z.object({ count: z.number() }) }, - recorded: { - schema: z.object({ itemID: z.string(), count: z.number() }), - durable: { version: 2, aggregate: "itemID" }, - }, }, }) const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} } @@ -38,16 +34,6 @@ const rpcEvent = (data: unknown, directory = "/first", namespace = Echo.namespac metadata: { source: "test" }, data, }) -const durableEvent = (data: unknown, directory = "/first", namespace = Echo.namespace, name = "recorded") => ({ - id: "evt_rpc_durable", - created: 11, - type: `rpc.${namespace}.${name}`, - durable: { aggregateID: "item-1", seq: 3, version: 2 }, - 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)) @@ -237,13 +223,12 @@ test("RPC method failures remove the generic transport wrapper", async () => { const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error) expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } }) - expect(Rpc.isError(Echo, "echo", error)).toBe(true) await expect(client.rpc.call({ namespace: Echo.namespace, method: "echo", input: "hello" })).rejects.toEqual(response) }) test("RPC transport failures remove the generic transport wrapper", async () => { - const response = { _tag: "RpcError", type: "rpc.internal", message: "Failed" } - await expect(http(() => Response.json(response, { status: 400 })).rpc(Echo).echo("hello")).rejects.toEqual({ + 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", }) @@ -265,8 +250,15 @@ test("native events and multiple RPC clients share one lazy source across locati 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.namespace, "unknown")) await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.namespace)) + 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", @@ -278,15 +270,10 @@ test("native events and multiple RPC clients share one lazy source across locati } expect(await firstNext).toEqual({ done: false, value: expected }) expect(await secondNext).toEqual({ done: false, value: expected }) - expect((await otherNext).value).toMatchObject({ - type: "rpc.other.updated", - location: { directory: "/other" }, - data: { count: 9 }, - }) 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([native.return?.(), late.return?.(), first.return?.(), second.return?.(), other.return?.()]) + await Promise.all([first.return?.(), second.return?.()]) await source.cancelled expect(source.requests[0].signal.aborted).toBe(true) expect(source.requests).toHaveLength(1) @@ -316,26 +303,6 @@ test("RPC iterator return and abort cancel only their pending subscribers", asyn await source.cancelled }) -test("RPC subscriptions receive direct durable Bus events", async () => { - const source = events() - const native = source.client.event.subscribe()[Symbol.asyncIterator]() - const recorded = source.client.rpc(Echo).events.subscribe("recorded")[Symbol.asyncIterator]() - await native.next() - const raw = native.next() - const typed = recorded.next() - await source.send(durableEvent({ itemID: "item-1", count: 42 })) - expect((await raw).value).toEqual(durableEvent({ itemID: "item-1", count: 42 })) - expect((await typed).value).toMatchObject({ - type: "rpc.acme/jobs.recorded", - data: { itemID: "item-1", count: 42 }, - durable: { aggregateID: "item-1", seq: 3, version: 2 }, - location: { directory: "/first" }, - }) - await native.return?.() - await recorded.return?.() - await source.cancelled -}) - test("RPC callback subscriptions unsubscribe independently", async () => { const source = events() const received = Promise.withResolvers() @@ -404,21 +371,6 @@ test("RPC source transport errors propagate to native and RPC subscribers", asyn expect(source.requests).toHaveLength(1) }) -test("RPC event envelope mismatches close only the matching subscriber", async () => { - const source = events() - const native = source.client.event.subscribe()[Symbol.asyncIterator]() - await native.next() - const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() - const failed = iterator.next().catch((error: unknown) => error) - const mismatched = durableEvent({ count: 42 }, "/first", Echo.namespace, "updated") - await source.send(mismatched) - expect(await failed).toBeInstanceOf(Error) - expect(source.requests[0].signal.aborted).toBe(false) - expect((await native.next()).value).toEqual(mismatched) - await native.return?.() - await source.cancelled -}) - test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => { const source = events() const broad: Rpc.PortableDefinition = Echo diff --git a/packages/client/test/shared-events.test.ts b/packages/client/test/shared-events.test.ts index 2d59cf281fc6..bd2820b4846b 100644 --- a/packages/client/test/shared-events.test.ts +++ b/packages/client/test/shared-events.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { SharedEvents, SubscriberOverflowError } from "../src/shared-events" +import { SharedEvents } from "../src/shared-events" type Event = { readonly type: string; readonly value?: number } @@ -140,30 +140,6 @@ test("late consumers receive the latest connection marker but no business event await events.connections[0].closed }) -test("connection metadata is isolated from source and subscriber root-field mutations", async () => { - const events = source() - const shared = SharedEvents.make(events.connect) - const first = shared.subscribe()[Symbol.asyncIterator]() - const second = shared.subscribe()[Symbol.asyncIterator]() - const firstRead = first.next() - const secondRead = second.next() - const marker = { type: "server.connected", value: 1 } - events.connections[0].push(marker) - Object.assign((await firstRead).value, { type: "subscriber.mutated", value: 2 }) - Object.assign(marker, { type: "source.mutated", value: 3 }) - expect(await secondRead).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) - - const late = shared.subscribe()[Symbol.asyncIterator]() - const cached = await late.next() - expect(cached).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) - Object.assign(cached.value, { type: "late.mutated", value: 4 }) - const latest = shared.subscribe()[Symbol.asyncIterator]() - expect(await latest.next()).toEqual({ done: false, value: { type: "server.connected", value: 1 } }) - - await Promise.all([first.return!(), second.return!(), late.return!(), latest.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) @@ -202,42 +178,7 @@ test("breaking a native for-await loop closes the last source", async () => { await events.connections[0].closed }) -test("source return runs with next pending, and its completion gates a replacement connection", async () => { - const read = Promise.withResolvers>() - const closing = Promise.withResolvers() - const cleanup = Promise.withResolvers() - const connections: AbortSignal[] = [] - const shared = SharedEvents.make((signal) => { - connections.push(signal) - return { - [Symbol.asyncIterator]() { - return { - next: () => read.promise, - async return() { - closing.resolve() - await cleanup.promise - read.resolve({ done: true, value: undefined }) - return { done: true as const, value: undefined } - }, - } - }, - } - }) - const first = shared.subscribe()[Symbol.asyncIterator]() - const pending = first.next() - await first.return!() - expect(await pending).toEqual({ done: true, value: undefined }) - await closing.promise - expect(connections[0].aborted).toBe(true) - - const replacement = shared.subscribe()[Symbol.asyncIterator]().next() - expect(connections).toHaveLength(1) - cleanup.resolve() - expect(await replacement).toEqual({ done: true, value: undefined }) - expect(connections).toHaveLength(2) -}) - -test("rapid resubscription waits for delayed shutdown and resets connection metadata", async () => { +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) @@ -257,22 +198,22 @@ test("rapid resubscription waits for delayed shutdown and resets connection meta const cancelledRead = cancelled.next() controller.abort() expect(await cancelledRead).toEqual({ done: true, value: undefined }) - expect(events.connections).toHaveLength(1) + expect(events.connections).toHaveLength(2) - cleanup.resolve() 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 } }, ]) - expect(events.connections).toHaveLength(2) + cleanup.resolve() + await events.connections[0].closed await second.return!() await third.return!() await replacement.closed }) -test("source EOF drains queued events, finishes all consumers, and permits a fresh subscription without retry", async () => { +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]() @@ -280,11 +221,14 @@ test("source EOF drains queued events, finishes all consumers, and permits a fre 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: false, value: { type: "rpc.example.updated", value: 2 } }) - expect(await second.next()).toEqual({ done: false, value: { type: "rpc.example.updated", value: 2 } }) expect(await first.next()).toEqual({ done: true, value: undefined }) expect(await second.next()).toEqual({ done: true, value: undefined }) expect(events.connections).toHaveLength(1) @@ -335,63 +279,3 @@ test("synchronous source creation failures reject subscribers without automatic await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure) expect(attempts).toHaveLength(2) }) - -test("slow subscriber overflow is isolated and does not block a fast consumer", async () => { - const events = source() - const shared = SharedEvents.make(events.connect, { capacity: 2 }) - const slow = shared.subscribe()[Symbol.asyncIterator]() - const fast = shared.subscribe()[Symbol.asyncIterator]() - const reads = [slow.next(), fast.next()] - events.connections[0].push({ type: "server.connected" }) - await Promise.all(reads) - - for (const value of [1, 2, 3, 4]) { - const next = fast.next() - events.connections[0].push({ type: "rpc.example.updated", value }) - expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value } }) - } - await expect(slow.next()).rejects.toBeInstanceOf(SubscriberOverflowError) - expect(events.connections[0].signal.aborted).toBe(false) - expect(events.connections).toHaveLength(1) - await slow.return!() - await fast.return!() - await events.connections[0].closed -}) - -test("the default subscriber capacity is 4096 events", async () => { - const events = source() - const shared = SharedEvents.make(events.connect) - const slow = shared.subscribe()[Symbol.asyncIterator]() - const fast = shared.subscribe()[Symbol.asyncIterator]() - const reads = [slow.next(), fast.next()] - events.connections[0].push({ type: "server.connected" }) - await Promise.all(reads) - - for (let value = 1; value <= 4096; value++) { - const next = fast.next() - events.connections[0].push({ type: "rpc.example.updated", value }) - await next - } - expect(await slow.next()).toEqual({ done: false, value: { type: "rpc.example.updated", value: 1 } }) - for (const value of [4097, 4098]) { - const next = fast.next() - events.connections[0].push({ type: "rpc.example.updated", value }) - await next - } - await expect(slow.next()).rejects.toBeInstanceOf(SubscriberOverflowError) - await fast.return!() - await events.connections[0].closed -}) - -test("last subscriber overflow closes its source", async () => { - const events = source() - const shared = SharedEvents.make(events.connect, { capacity: 0 }) - const iterator = shared.subscribe()[Symbol.asyncIterator]() - const next = iterator.next() - events.connections[0].push({ type: "server.connected" }) - await next - events.connections[0].push({ type: "rpc.example.updated" }) - await events.connections[0].closed - await expect(iterator.next()).rejects.toBeInstanceOf(SubscriberOverflowError) - expect(events.connections[0].signal.aborted).toBe(true) -}) diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index b934d3b6e661..f1b8406cea27 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -57,7 +57,7 @@ const layer = Layer.effect( const events = new Map( Object.entries(definition.events).map(([name, event]) => [ name, - { event, definition: eventDefinition(definition, name, event) }, + { event, definition: eventDefinition(definition, name) }, ]), ) definitions.set(definition, events) @@ -172,19 +172,8 @@ function eventType ({ type, durability: "durable" as const, durable: event.durable, data })), - ) satisfies Event.DurableDefinition - } const data = EventData return Schema.Struct({ ...fields, type: Schema.Literal(type), data }).pipe( statics(() => ({ type, durability: "ephemeral" as const, durable: undefined, data })), diff --git a/packages/core/test/rpc.test.ts b/packages/core/test/rpc.test.ts index 48c1578dfeb8..b05e89146d8b 100644 --- a/packages/core/test/rpc.test.ts +++ b/packages/core/test/rpc.test.ts @@ -224,121 +224,6 @@ describe("Rpc", () => { }), ) - it.effect("publishes durable custom events through normal Bus sequencing", () => - Effect.gen(function* () { - const rpc = yield* Rpc.Service - const bus = yield* Bus.Service - const Updates = Rpc.define({ - namespace: "durable-updates", - methods: {}, - events: { - recorded: { - schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), - durable: { version: 2, aggregate: "itemID" }, - }, - }, - }) - const registration = yield* rpc.register(Updates, {}) - const logical = yield* rpc - .client(Updates) - .events.subscribe("recorded") - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - const published = yield* bus.subscribe().pipe( - Stream.filter((event) => event.type === "rpc.durable-updates.recorded"), - Stream.take(2), - Stream.runCollect, - Effect.forkScoped, - ) - yield* Effect.yieldNow - yield* registration.events.emit("recorded", { itemID: "item-1", text: "first" }) - yield* registration.events.emit("recorded", { itemID: "item-1", text: "second" }) - - const events = Array.from(yield* Fiber.join(logical)) - expect(events.map((event) => event.type)).toEqual([ - "rpc.durable-updates.recorded", - "rpc.durable-updates.recorded", - ]) - expect(events.map((event) => event.data.text)).toEqual(["first", "second"]) - expect( - events.map((event) => ({ - aggregateID: event.durable.aggregateID, - seq: Number(event.durable.seq), - version: Number(event.durable.version), - })), - ).toEqual([ - { aggregateID: "item-1", seq: 0, version: 2 }, - { aggregateID: "item-1", seq: 1, version: 2 }, - ]) - - const busEvents = Array.from(yield* Fiber.join(published)) - expect(busEvents.map((event) => event.type)).toEqual([ - "rpc.durable-updates.recorded", - "rpc.durable-updates.recorded", - ]) - expect( - busEvents.map((event) => ({ - aggregateID: event.durable?.aggregateID, - seq: Number(event.durable?.seq), - version: Number(event.durable?.version), - })), - ).toEqual([ - { aggregateID: "item-1", seq: 0, version: 2 }, - { aggregateID: "item-1", seq: 1, version: 2 }, - ]) - expect(busEvents.map((event) => event.data)).toEqual([ - { itemID: "item-1", text: "first" }, - { itemID: "item-1", text: "second" }, - ]) - }), - ) - - it.effect("requires durable aggregate fields to parse as strings", () => - Effect.gen(function* () { - const rpc = yield* Rpc.Service - const Invalid = Rpc.define({ - namespace: "invalid-durable", - methods: {}, - events: { - recorded: { - schema: Schema.Struct({ itemID: Schema.Number }), - durable: { version: 1, aggregate: "itemID" }, - }, - }, - }) - const registration = yield* rpc.register(Invalid, {}) - const exit = yield* registration.events.emit("recorded", { itemID: 1 }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isSuccess(exit)) return - expect(Cause.pretty(exit.cause)).toContain("Expected string aggregate field itemID") - }), - ) - - it.effect("selects durable aggregates from the schema-parsed payload", () => - Effect.gen(function* () { - const rpc = yield* Rpc.Service - const Parsed = Rpc.define({ - namespace: "parsed-durable", - methods: {}, - events: { - recorded: { - schema: z.object({ source: z.string() }).transform(({ source }) => ({ itemID: source })), - durable: { version: 1, aggregate: "itemID" }, - }, - }, - }) - const registration = yield* rpc.register(Parsed, {}) - const received = yield* rpc - .client(Parsed) - .events.subscribe("recorded") - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) - yield* Effect.yieldNow - yield* registration.events.emit("recorded", { source: "item-1" }) - const event = Array.from(yield* Fiber.join(received))[0] - expect(event.data).toEqual({ itemID: "item-1" }) - expect(event.durable.aggregateID).toBe("item-1") - }), - ) - it.effect("keeps other event consumers running after one subscription ends", () => Effect.gen(function* () { const rpc = yield* Rpc.Service @@ -483,7 +368,6 @@ describe("Rpc", () => { location: otherRef, }) expect(all.map((event) => event.location)).toEqual([otherRef, ref]) - expect(all.every((event) => !("durable" in event))).toBe(true) yield* unsubscribe }), ) diff --git a/packages/plugin/test/rpc-effect.types.ts b/packages/plugin/test/rpc-effect.types.ts index 562bafd94584..33ce7857db7e 100644 --- a/packages/plugin/test/rpc-effect.types.ts +++ b/packages/plugin/test/rpc-effect.types.ts @@ -142,7 +142,6 @@ 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("recorded", { itemID: "item-1", text: "saved" }) yield* active.events.emit(...emission) yield* active.dispose // @ts-expect-error Published payloads are inferred from the selected event schema. diff --git a/packages/plugin/test/rpc-promise.types.ts b/packages/plugin/test/rpc-promise.types.ts index 79a1052ee12d..14c6589c27d1 100644 --- a/packages/plugin/test/rpc-promise.types.ts +++ b/packages/plugin/test/rpc-promise.types.ts @@ -32,17 +32,9 @@ export type Checks = [ Assert, number>>, Assert["type"], "rpc.acme.updated">>, Assert["location"], { directory: string; workspaceID?: string }>>, - Assert["durable"]["aggregateID"], string>>, Assert>, string>>, Assert>, number>>, Assert>, string>>, - Assert< - Equal< - Rpc.Error, - | { readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } } - | { readonly type: "unavailable"; readonly message: string; readonly data?: undefined } - > - >, ] await acme.search({ query: "hello" }, { location: { directory: "/project", workspace: "workspace" } }) @@ -101,11 +93,6 @@ const handlers: RpcHandlers = { ping: async () => null, } -declare const caught: unknown -if (Rpc.isError(Acme, "search", caught)) { - caught.type satisfies "not_found" | "unavailable" -} - // @ts-expect-error Error names must be declared by the method. handlers.search({ query: "missing" }, { signal: AbortSignal.abort(), error: () => ({ type: "missing" }) }) @@ -118,7 +105,6 @@ 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.events.emit("recorded", { itemID: "item-1", text: "saved" }) await registration.dispose() await ctx.rpc.register(Acme, { @@ -165,13 +151,6 @@ for await (const event of acme.events.subscribe("counted")) { event.data.text satisfies string } -for await (const event of acme.events.subscribe("recorded")) { - event.durable.aggregateID satisfies string - event.durable.seq satisfies number - event.durable.version satisfies number - event.data.itemID 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 }) @@ -211,12 +190,6 @@ Rpc.define({ }) // @ts-expect-error The subclient's events member is reserved, not an RPC method. Rpc.define({ namespace: "invalid", methods: { events: Acme.methods.search }, events: {} }) -Rpc.define({ - namespace: "invalid", - methods: {}, - // @ts-expect-error Durable event metadata requires both version and aggregate. - events: { updated: { schema: Acme.events.updated.schema, durable: { version: 1 } } }, -}) // @ts-expect-error Custom event data must be an object. Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } }) // @ts-expect-error Custom event data cannot be an array. diff --git a/packages/plugin/test/rpc.fixture.ts b/packages/plugin/test/rpc.fixture.ts index 88034bf05769..d955684a16ee 100644 --- a/packages/plugin/test/rpc.fixture.ts +++ b/packages/plugin/test/rpc.fixture.ts @@ -35,10 +35,6 @@ export const Acme = Rpc.define({ 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) })) }, - recorded: { - schema: z.object({ itemID: z.string(), text: z.string() }), - durable: { version: 2, aggregate: "itemID" }, - }, }, }) diff --git a/packages/plugin/test/rpc.test.ts b/packages/plugin/test/rpc.test.ts index 2fe750d397ff..b74f9fc54d05 100644 --- a/packages/plugin/test/rpc.test.ts +++ b/packages/plugin/test/rpc.test.ts @@ -6,7 +6,7 @@ import { Acme } from "./rpc.fixture.js" test("definitions preserve their schemas and namespace without registering anything", () => { expect(Rpc.define(Acme)).toBe(Acme) expect(Acme.namespace).toBe("acme") - expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted", "recorded"]) + expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted"]) }) test("defining an RPC contract does not invoke its schema parser", () => { @@ -39,11 +39,6 @@ test("framework RPC error names are reserved", () => { ).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal') }) -test("error narrowing returns false for unknown methods", () => { - const definition: Rpc.Definition = Acme - expect(Rpc.isError(definition, "missing", { type: "not_found", message: "Missing" })).toBe(false) -}) - test("the shared definition entrypoint bundles without Effect or host runtime dependencies", async () => { const inputs = new Set() const result = await Bun.build({ diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 3a35bbc7fc35..2e19fba1e40c 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -21,6 +21,16 @@ export class RpcError extends Schema.TaggedError()( { httpApiStatus: 400 }, ) {} +export class RpcInternalError extends Schema.TaggedError()( + "RpcInternalError", + { + type: Schema.Literal("rpc.internal"), + 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 a877c0820737..6fbc12ba0505 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -18,13 +18,6 @@ const rpcEvent = Schema.Struct({ type: Schema.TemplateLiteral(["rpc.", Schema.String]), location: Location.Ref, data: Schema.Record(Schema.String, Schema.Unknown), - durable: Schema.optional( - Schema.Struct({ - aggregateID: Schema.String, - seq: Event.Seq, - version: Event.Version, - }), - ), }).annotate({ identifier: "V2Event.rpc" }) const schema = >(definitions: Definitions) => diff --git a/packages/protocol/src/groups/rpc.ts b/packages/protocol/src/groups/rpc.ts index a0c40cda4fbf..0772640ea228 100644 --- a/packages/protocol/src/groups/rpc.ts +++ b/packages/protocol/src/groups/rpc.ts @@ -1,7 +1,7 @@ import { optional } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { RpcError } from "../errors.js" +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" }) @@ -14,7 +14,7 @@ export const RpcGroup = HttpApiGroup.make("server.rpc") query: LocationQuery, payload: RpcInput, success: RpcOutput, - error: RpcError, + error: [RpcError, RpcInternalError], }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 0be432a6f69f..985aa24d2ae7 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -53,15 +53,6 @@ test("decodes direct plugin RPC events", () => { expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: "value" })).toThrow() expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: [] })).toThrow() expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: null })).toThrow() - const durable = { - id: "evt_rpc_durable", - created: 2, - type: "rpc.acme.recorded", - durable: { aggregateID: "item-1", seq: 3, version: 2 }, - location: { directory: "/project" }, - data: { itemID: "item-1" }, - } - expect(Schema.decodeUnknownSync(OpenCodeEvent)(durable)).toMatchObject(durable) }) test("keeps native event data discriminated by type", () => { diff --git a/packages/protocol/test/rpc.test.ts b/packages/protocol/test/rpc.test.ts index f2881430b2c5..f7a246d5e20b 100644 --- a/packages/protocol/test/rpc.test.ts +++ b/packages/protocol/test/rpc.test.ts @@ -2,7 +2,7 @@ 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 } from "../src/errors.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", () => { @@ -33,6 +33,9 @@ test("RPC errors use the standard transport wrapper", () => { 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" }) }) test("exposes one generic RPC operation with location routing and ordinary transport errors", () => { @@ -54,4 +57,5 @@ test("exposes one generic RPC operation with location routing and ordinary trans 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/rpc.ts b/packages/schema/src/rpc.ts index b0508074c071..5dc1afd39569 100644 --- a/packages/schema/src/rpc.ts +++ b/packages/schema/src/rpc.ts @@ -35,20 +35,9 @@ type PortableEventValueSchema = | StandardSchemaV1 | (JsonSchema.JsonSchema & { readonly type: "object" }) -export interface EphemeralEventDefinition { +export interface EventDefinition { readonly schema: EventValueSchema - readonly durable?: never } - -export interface DurableEventDefinition { - readonly schema: EventValueSchema - readonly durable: { - readonly version: number - readonly aggregate: string - } -} - -export type EventDefinition = EphemeralEventDefinition | DurableEventDefinition export type PortableEventDefinition = EventDefinition & { readonly schema: PortableEventValueSchema } export interface Definition { @@ -143,7 +132,6 @@ export type MethodErrorFor> = Simpli export type MethodError = { readonly [Name in ErrorName]: MethodErrorFor }[ErrorName] -export type Error = MethodError export type ErrorArguments> = [ type: Name, message: string, @@ -153,23 +141,6 @@ export type ErrorFactory = >( ...args: ErrorArguments ) => HandlerErrorFor -export function isError< - D extends Definition, - Name extends keyof D["methods"] & string, ->(definition: D, method: Name, error: unknown): error is Error { - if ( - typeof error !== "object" || - error === null || - !("type" in error) || - typeof error.type !== "string" || - !("message" in error) || - typeof error.message !== "string" - ) - return false - const errors = definition.methods[method]?.errors - return errors !== undefined && Object.hasOwn(errors, error.type) -} - export type EventInputData = S extends JsonSchema.JsonSchema ? EventDataObject : HandlerOutput @@ -185,20 +156,11 @@ export type EventInput = { type EventPayloadFor< D extends Definition, Name extends keyof D["events"] & string, - E extends EventDefinition = D["events"][Name], -> = E extends DurableEventDefinition - ? Omit, "type" | "data" | "durable" | "location"> & { - readonly durable: Event.DurableEnvelope - readonly type: `rpc.${D["namespace"]}.${Name}` - readonly data: EventData - readonly location: Location.Ref - } - : Omit, "type" | "data" | "durable" | "location"> & { - readonly durable?: never - readonly type: `rpc.${D["namespace"]}.${Name}` - readonly data: EventData - readonly location: Location.Ref - } +> = Omit, "type" | "data" | "durable" | "location"> & { + readonly type: `rpc.${D["namespace"]}.${Name}` + readonly data: EventData + readonly location: Location.Ref +} export type EventPayload = { readonly [K in Name]: EventPayloadFor diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 28fe6d3b620b..a595fef58dda 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -42,7 +42,6 @@ "@opencode-ai/protocol": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "zod": "catalog:" + "@typescript/native-preview": "catalog:" } } diff --git a/packages/sdk/test/promise.test.ts b/packages/sdk/test/promise.test.ts index 292ef489f779..4aeb86f29362 100644 --- a/packages/sdk/test/promise.test.ts +++ b/packages/sdk/test/promise.test.ts @@ -69,7 +69,8 @@ test("Promise event streams support cancellation", async () => { expect(await events.next()).toMatchObject({ value: { type: "server.connected" }, done: false }) const pending = events.next() controller.abort() - expect(await pending).toEqual({ done: true, value: undefined }) + const error = await pending.catch((error: unknown) => error) + expect(error).toMatchObject({ name: "ClientError", reason: "Transport" }) await events.return?.() } }) diff --git a/packages/sdk/test/rpc.test.ts b/packages/sdk/test/rpc.test.ts deleted file mode 100644 index d5d9320a2778..000000000000 --- a/packages/sdk/test/rpc.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { expect, test } from "bun:test" -import { mkdir } from "node:fs/promises" -import { join } from "node:path" -import { fromPromise } from "@opencode-ai/plugin/promise/adapter" -import { Rpc } from "@opencode-ai/schema/rpc" -import { Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" -import { z } from "zod" -import { tmpdir } from "../../core/test/fixture/tmpdir" -import { testEffect } from "../../core/test/lib/effect" -import { AbsolutePath, OpenCode, type OpenCodeEvent } from "../src" - -const it = testEffect(Layer.empty) -export const Echo = Rpc.define({ - namespace: "sdk/echo", - methods: { - echo: { input: z.string(), output: z.object({ message: z.string(), directory: z.string() }) }, - empty: { input: z.undefined(), output: z.undefined() }, - fail: { - input: z.undefined(), - output: z.string(), - errors: { rejected: z.object({ source: z.string() }) }, - }, - }, - events: {}, -}) -const Update = z.object({ message: z.string(), at: z.string() }) -const Updates = Rpc.define({ - namespace: "sdk/updates", - methods: { emit: { input: Update, output: z.undefined() } }, - events: { updated: { schema: Update } }, -}) -const Blocking = Rpc.define({ - namespace: "sdk/blocking", - methods: { wait: { input: z.string(), output: z.string() } }, - events: {}, -}) - -async function fixture() { - const directory = await tmpdir("opencode-sdk-rpc-") - const first = AbsolutePath.make(join(directory.path, "first project")) - const second = AbsolutePath.make(join(directory.path, "second project")) - const config = join(directory.path, "config") - await Promise.all([first, second, config].map((path) => mkdir(path))) - return { - first, - second, - options: { config: { directory: config, project: false, content: "{}" }, fs: { filewatcher: false } }, - [Symbol.asyncDispose]: () => directory[Symbol.asyncDispose](), - } -} - -test("Promise SDK calls configured Promise RPC plugins on cold locations", async () => { - await using dirs = await fixture() - await using opencode = await OpenCode.create({ - ...dirs.options, - plugins: [ - { - id: "sdk-promise-echo", - async setup(ctx) { - const location = (await ctx.agent.list()).location - await ctx.rpc.register(Echo, { - echo: async (message) => ({ message, directory: location.directory }), - empty: async () => undefined, - fail: async (_input, ctx) => ctx.error("rejected", "plugin handler failed", { source: "return" }), - }) - }, - }, - ], - }) - const rpc = opencode.rpc(Echo) - expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ - message: "first", - directory: dirs.first, - }) - expect(await rpc.echo("header", { headers: { "x-opencode-directory": encodeURIComponent(dirs.second) } })).toEqual({ - message: "header", - directory: dirs.second, - }) - expect( - await rpc.echo("explicit", { - location: { directory: dirs.first }, - headers: { "x-opencode-directory": encodeURIComponent(dirs.second) }, - }), - ).toEqual({ message: "explicit", directory: dirs.first }) - expect(await rpc.echo("default")).toEqual({ message: "default", directory: process.cwd() }) - expect(await rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() - expect( - await opencode.rpc.call({ - namespace: Echo.namespace, - method: "echo", - input: "raw", - location: { directory: dirs.first }, - }), - ).toEqual({ - output: { message: "raw", directory: dirs.first }, - }) - expect( - await rpc.fail(undefined, { location: { directory: dirs.first } }).catch((error: unknown) => error), - ).toMatchObject({ - type: "rejected", - message: "plugin handler failed", - data: { source: "return" }, - }) -}, 30_000) - -it.live( - "Effect SDK calls Promise plugin RPC handlers without prebooting locations", - () => - Effect.gen(function* () { - const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => - Effect.promise(() => dirs[Symbol.asyncDispose]()), - ) - const sdk = yield* Effect.promise(() => import("../src/effect")) - const opencode = yield* sdk.OpenCode.create(dirs.options) - yield* opencode.plugin( - fromPromise({ - id: "sdk-cross-style-echo", - async setup(ctx) { - const location = (await ctx.agent.list()).location - await ctx.rpc.register(Echo, { - echo: async (message) => ({ message, directory: location.directory }), - empty: async () => undefined, - fail: async (_input, ctx) => { - throw ctx.error("rejected", "cross-style handler failed", { source: "throw" }) - }, - }) - }, - }), - ) - const rpc = opencode.rpc(Echo) - expect(yield* rpc.echo("cross-style", { location: { directory: dirs.first } })).toEqual({ - message: "cross-style", - directory: dirs.first, - }) - expect( - yield* rpc.echo("header", { headers: { "x-opencode-directory": encodeURIComponent(dirs.second) } }), - ).toEqual({ message: "header", directory: dirs.second }) - expect( - yield* rpc.echo("explicit", { - location: { directory: dirs.first }, - headers: { "x-opencode-directory": encodeURIComponent(dirs.second) }, - }), - ).toEqual({ message: "explicit", directory: dirs.first }) - expect(yield* rpc.echo("default")).toEqual({ message: "default", directory: process.cwd() }) - expect(yield* rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() - expect(yield* rpc.fail(undefined, { location: { directory: dirs.first } }).pipe(Effect.flip)).toMatchObject({ - type: "rejected", - message: "cross-style handler failed", - data: { source: "throw" }, - }) - }), - 30_000, -) - -test("Promise SDK calls a config-loaded Effect plugin through the shared RPC definition", async () => { - await using dirs = await fixture() - const plugin = join(dirs.options.config.directory, "effect-plugin.ts") - // The config-loaded plugin and the external caller use the exact same contract. - await Bun.write( - plugin, - ` - import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))} - import { Plugin } from ${JSON.stringify(import.meta.resolve("@opencode-ai/plugin/effect"))} - import { Echo } from ${JSON.stringify(import.meta.url)} - export default Plugin.define({ - id: "sdk-config-effect-echo", - effect: (ctx) => Effect.gen(function* () { - const location = (yield* ctx.agent.list()).location - yield* ctx.rpc.register(Echo, { - echo: (message) => Effect.succeed({ message, directory: location.directory }), - empty: () => Effect.succeed(undefined), - fail: (_input, ctx) => - Effect.fail(ctx.error("rejected", "Effect plugin handler failed", { source: "effect" })), - }) - }).pipe(Effect.orDie), - }) - `, - ) - await using opencode = await OpenCode.create({ - ...dirs.options, - config: { ...dirs.options.config, content: JSON.stringify({ plugins: [plugin] }) }, - }) - const rpc = opencode.rpc(Echo) - expect(await rpc.echo("Effect", { location: { directory: dirs.first } })).toEqual({ - message: "Effect", - directory: dirs.first, - }) - expect(await rpc.empty(undefined, { location: { directory: dirs.first } })).toBeUndefined() - expect( - await rpc.fail(undefined, { location: { directory: dirs.first } }).catch((error: unknown) => error), - ).toMatchObject({ - type: "rejected", - message: "Effect plugin handler failed", - data: { source: "effect" }, - }) -}, 30_000) - -test("Promise SDK native and typed RPC subscriptions carry real plugin events across locations", async () => { - await using dirs = await fixture() - await using opencode = await OpenCode.create({ - ...dirs.options, - plugins: [ - { - id: "sdk-promise-updates", - async setup(ctx) { - const registration = await ctx.rpc.register(Updates, { - emit: async (input): Promise => { - await registration.events.emit("updated", input) - return undefined - }, - }) - }, - }, - ], - }) - expect(opencode.events).toBe(opencode.event) - const native = opencode.events.subscribe()[Symbol.asyncIterator]() - const rpc = opencode.rpc(Updates) - const first = rpc.events.subscribe("updated")[Symbol.asyncIterator]() - const second = opencode.rpc(Updates).events.subscribe("updated")[Symbol.asyncIterator]() - const controller = new AbortController() - const cancelled = rpc.events.subscribe("updated", { signal: controller.signal })[Symbol.asyncIterator]() - try { - const firstNext = first.next() - const secondNext = second.next() - const cancelledNext = cancelled.next() - expect(await native.next()).toMatchObject({ done: false, value: { type: "server.connected" } }) - controller.abort() - expect(await cancelledNext).toMatchObject({ done: true }) - const at = "2026-08-27T12:00:00.000Z" - expect(await rpc.emit({ message: "first", at }, { location: { directory: dirs.first } })).toBeUndefined() - const published = await nextRpcEvent(native, "rpc.sdk/updates.updated") - const event = (await firstNext).value - expect(published).toMatchObject({ - type: "rpc.sdk/updates.updated", - location: { directory: dirs.first }, - data: { message: "first", at }, - }) - expect(event).toMatchObject({ - id: published.id, - created: published.created, - type: "rpc.sdk/updates.updated", - location: { directory: dirs.first }, - data: { message: "first", at }, - }) - expect((await secondNext).value).toEqual(event) - const returning = first.next() - await first.return?.() - expect(await returning).toMatchObject({ done: true }) - const next = second.next() - await rpc.emit({ message: "second", at }, { location: { directory: dirs.second } }) - const other = await nextRpcEvent(native, "rpc.sdk/updates.updated") - expect((await next).value).toMatchObject({ - id: other.id, - type: "rpc.sdk/updates.updated", - location: { directory: dirs.second }, - data: { message: "second", at }, - }) - expect(other).toMatchObject({ type: "rpc.sdk/updates.updated", location: { directory: dirs.second } }) - } finally { - controller.abort() - await Promise.all([native.return?.(), first.return?.(), second.return?.(), cancelled.return?.()]) - } - // Reopening after the last subscriber leaves must not wait on a leaked source. - const reopened = opencode.events.subscribe()[Symbol.asyncIterator]() - try { - expect(await reopened.next()).toMatchObject({ done: false, value: { type: "server.connected" } }) - } finally { - await reopened.return?.() - } -}, 30_000) - -async function nextRpcEvent(events: AsyncIterator, type: `rpc.${string}`) { - while (true) { - const event = await events.next() - if (event.done) throw new Error("Event stream ended before the RPC event") - if (event.value.type === type) return event.value - } -} - -it.live( - "Effect SDK native and typed streams receive Effect plugin RPC events across locations", - () => - Effect.gen(function* () { - const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => - Effect.promise(() => dirs[Symbol.asyncDispose]()), - ) - const sdk = yield* Effect.promise(() => import("../src/effect")) - const opencode = yield* sdk.OpenCode.create(dirs.options) - yield* opencode.plugin({ - id: "sdk-effect-updates", - effect: (ctx) => - Effect.gen(function* () { - const registration = yield* ctx.rpc.register(Updates, { - emit: (input): Effect.Effect => - registration.events.emit("updated", input).pipe(Effect.as(undefined), Effect.orDie), - }) - }).pipe(Effect.orDie), - }) - expect(opencode.events).toBe(opencode.event) - const connected = yield* Deferred.make() - const rpc = opencode.rpc(Updates) - const typed = yield* rpc.events - .subscribe("updated") - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) - const cancelled = yield* rpc.events - .subscribe("updated") - .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) - const native = yield* opencode.events.subscribe().pipe( - Stream.tap((event) => - event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void, - ), - Stream.filter((event) => event.type === "rpc.sdk/updates.updated"), - Stream.take(2), - Stream.runCollect, - Effect.forkScoped({ startImmediately: true }), - ) - yield* Deferred.await(connected).pipe(Effect.timeout("5 seconds")) - yield* Fiber.interrupt(cancelled) - const at = "2026-08-27T12:00:00.000Z" - yield* rpc.emit({ message: "first", at }, { location: { directory: dirs.first } }) - yield* rpc.emit({ message: "second", at }, { location: { directory: dirs.second } }) - const events = yield* Fiber.join(typed).pipe(Effect.timeout("5 seconds")) - const nativeEvents = yield* Fiber.join(native).pipe(Effect.timeout("5 seconds")) - expect(events).toHaveLength(2) - expect(nativeEvents).toHaveLength(2) - expect( - events.map((event) => ({ - id: event.id, - type: event.type, - directory: event.location.directory, - data: event.data, - })), - ).toEqual([ - { - id: nativeEvents[0].id, - type: "rpc.sdk/updates.updated", - directory: dirs.first, - data: { message: "first", at }, - }, - { - id: nativeEvents[1].id, - type: "rpc.sdk/updates.updated", - directory: dirs.second, - data: { message: "second", at }, - }, - ]) - expect(nativeEvents.map((event) => event.data)).toEqual([ - { message: "first", at }, - { message: "second", at }, - ]) - const reconnected = yield* opencode.events - .subscribe() - .pipe(Stream.take(1), Stream.runCollect, Effect.timeout("5 seconds")) - expect(reconnected).toMatchObject([{ type: "server.connected" }]) - }), - 30_000, -) - -test("Promise SDK stable RPC handles use the latest plugin override in every booted location", async () => { - await using dirs = await fixture() - await using opencode = await OpenCode.create({ - ...dirs.options, - plugins: [ - { - id: "sdk-original", - async setup(ctx) { - const location = (await ctx.agent.list()).location - await ctx.rpc.register(Echo, { - echo: async (input) => ({ message: `original:${input}`, directory: location.directory }), - empty: async () => undefined, - fail: async () => "unused", - }) - }, - }, - ], - }) - const rpc = opencode.rpc(Echo) - expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ - message: "original:first", - directory: dirs.first, - }) - expect(await rpc.echo("second", { location: { directory: dirs.second } })).toEqual({ - message: "original:second", - directory: dirs.second, - }) - const events = opencode.events.subscribe()[Symbol.asyncIterator]() - try { - expect(await events.next()).toMatchObject({ value: { type: "server.connected" } }) - for (const version of ["override", "replacement"]) { - await opencode.plugin({ - id: "sdk-override", - async setup(ctx) { - const location = (await ctx.agent.list()).location - await ctx.rpc.register(Echo, { - echo: async (input) => ({ message: `${version}:${input}`, directory: location.directory }), - empty: async () => undefined, - fail: async () => "unused", - }) - }, - }) - const pending = new Set([dirs.first, dirs.second]) - while (pending.size) { - const event = await events.next() - if (event.done) throw new Error("Event stream ended before plugin reload completed") - if (event.value.type === "plugin.updated" && event.value.location) - pending.delete(event.value.location.directory) - } - expect(await rpc.echo("first", { location: { directory: dirs.first } })).toEqual({ - message: `${version}:first`, - directory: dirs.first, - }) - expect(await rpc.echo("second", { location: { directory: dirs.second } })).toEqual({ - message: `${version}:second`, - directory: dirs.second, - }) - } - } finally { - await events.return?.() - } -}, 30_000) - -test("Promise SDK cancellation reaches the actual RPC handler without cancelling other calls", async () => { - await using dirs = await fixture() - const started = Promise.withResolvers() - const stopped = Promise.withResolvers() - await using opencode = await OpenCode.create({ - ...dirs.options, - plugins: [ - { - id: "sdk-promise-blocking", - async setup(ctx) { - await ctx.rpc.register(Blocking, { - wait: async (input, call) => { - if (input === "complete") return input - started.resolve() - await new Promise((resolve) => - call.signal.addEventListener( - "abort", - () => { - stopped.resolve(call.signal) - resolve() - }, - { once: true }, - ), - ) - return input - }, - }) - }, - }, - ], - }) - const rpc = opencode.rpc(Blocking) - const controller = new AbortController() - const pending = rpc - .wait("cancel", { location: { directory: dirs.first }, signal: controller.signal }) - .catch((error: unknown) => error) - try { - await Effect.promise(() => started.promise).pipe(Effect.timeout("5 seconds"), Effect.runPromise) - expect(await rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") - controller.abort() - expect(await pending).toMatchObject({ name: "ClientError", reason: "Transport" }) - expect( - (await Effect.promise(() => stopped.promise).pipe(Effect.timeout("5 seconds"), Effect.runPromise)).aborted, - ).toBe(true) - expect(await rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") - } finally { - controller.abort() - await pending - } -}, 30_000) - -it.live( - "Effect SDK interruption finalizes Effect RPC handlers and keeps independent calls usable", - () => - Effect.gen(function* () { - const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => - Effect.promise(() => dirs[Symbol.asyncDispose]()), - ) - const sdk = yield* Effect.promise(() => import("../src/effect")) - const opencode = yield* sdk.OpenCode.create(dirs.options) - const started = yield* Deferred.make() - const stopped = yield* Deferred.make() - yield* opencode.plugin({ - id: "sdk-effect-blocking", - effect: (ctx) => - ctx.rpc - .register(Blocking, { - wait: (input) => - input === "complete" - ? Effect.succeed(input) - : Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(stopped, undefined)), - ), - }) - .pipe(Effect.asVoid, Effect.orDie), - }) - const rpc = opencode.rpc(Blocking) - const pending = yield* rpc.wait("cancel", { location: { directory: dirs.first } }).pipe(Effect.forkScoped) - yield* Deferred.await(started).pipe(Effect.timeout("5 seconds")) - expect(yield* rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") - yield* Fiber.interrupt(pending) - yield* Deferred.await(stopped).pipe(Effect.timeout("5 seconds")) - expect(yield* rpc.wait("complete", { location: { directory: dirs.first } })).toBe("complete") - }), - 30_000, -) - -test("Promise SDK close cancels active native and typed RPC subscriptions and releases the plugin", async () => { - await using dirs = await fixture() - const released = Promise.withResolvers() - await using opencode = await OpenCode.create({ - ...dirs.options, - plugins: [ - { - id: "sdk-close-updates", - async setup(ctx) { - const registration = await ctx.rpc.register(Updates, { - emit: async (input): Promise => { - await registration.events.emit("updated", input) - return undefined - }, - }) - return () => released.resolve() - }, - }, - ], - }) - const rpc = opencode.rpc(Updates) - const typed = rpc.events.subscribe("updated")[Symbol.asyncIterator]() - const native = opencode.events.subscribe()[Symbol.asyncIterator]() - try { - const first = typed.next() - expect(await native.next()).toMatchObject({ value: { type: "server.connected" } }) - await rpc.emit({ message: "ready", at: "2026-08-27T12:00:00.000Z" }, { location: { directory: dirs.first } }) - expect((await first).value).toMatchObject({ type: "rpc.sdk/updates.updated" }) - await nextRpcEvent(native, "rpc.sdk/updates.updated") - const typedPending = typed.next().catch((error: unknown) => error) - const nativePending = native.next().catch((error: unknown) => error) - await opencode.close() - expect(await typedPending).toMatchObject({ name: "ClientError", reason: "Transport" }) - expect(await nativePending).toMatchObject({ name: "ClientError", reason: "Transport" }) - await released.promise - await opencode.close() - } finally { - await Promise.all([native.return?.(), typed.return?.()]) - } -}, 30_000) - -it.live( - "closing the Effect SDK scope stops active native and typed RPC subscriptions", - () => - Effect.gen(function* () { - const dirs = yield* Effect.acquireRelease(Effect.promise(fixture), (dirs) => - Effect.promise(() => dirs[Symbol.asyncDispose]()), - ) - const sdk = yield* Effect.promise(() => import("../src/effect")) - const hostScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)) - const opencode = yield* sdk.OpenCode.create(dirs.options).pipe(Effect.provideService(Scope.Scope, hostScope)) - const connected = yield* Deferred.make() - const received = yield* Deferred.make() - const released = yield* Deferred.make() - yield* opencode.plugin({ - id: "sdk-effect-close-updates", - effect: (ctx) => - Effect.gen(function* () { - const registration = yield* ctx.rpc.register(Updates, { - emit: (input): Effect.Effect => - registration.events.emit("updated", input).pipe(Effect.as(undefined), Effect.orDie), - }) - yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined).pipe(Effect.asVoid)) - }).pipe(Effect.orDie), - }) - const rpc = opencode.rpc(Updates) - const typed = yield* rpc.events.subscribe("updated").pipe( - Stream.runForEach(() => Deferred.succeed(received, undefined)), - Effect.forkScoped({ startImmediately: true }), - ) - const native = yield* opencode.events.subscribe().pipe( - Stream.runForEach((event) => - event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void, - ), - Effect.forkScoped({ startImmediately: true }), - ) - yield* Deferred.await(connected).pipe(Effect.timeout("5 seconds")) - yield* rpc.emit({ message: "ready", at: "2026-08-27T12:00:00.000Z" }, { location: { directory: dirs.first } }) - yield* Deferred.await(received).pipe(Effect.timeout("5 seconds")) - yield* Scope.close(hostScope, Exit.void).pipe(Effect.timeout("5 seconds")) - expect(Exit.isFailure(yield* Fiber.await(typed).pipe(Effect.timeout("5 seconds")))).toBe(true) - expect(Exit.isFailure(yield* Fiber.await(native).pipe(Effect.timeout("5 seconds")))).toBe(true) - yield* Deferred.await(released).pipe(Effect.timeout("5 seconds")) - }), - 30_000, -) diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts index 7973a86ab1d6..0beacce84f01 100644 --- a/packages/server/src/handlers/rpc.ts +++ b/packages/server/src/handlers/rpc.ts @@ -1,6 +1,6 @@ import { Rpc } from "@opencode-ai/core/rpc" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" -import { RpcError } from "@opencode-ai/protocol/errors" +import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -17,7 +17,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) => Effect.mapError(toRpcError), Effect.catchDefect((error) => Effect.fail( - new RpcError({ + 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 index 83f4f9398599..ccd16e892312 100644 --- a/packages/server/test/rpc.test.ts +++ b/packages/server/test/rpc.test.ts @@ -17,7 +17,6 @@ import { it } from "../../core/test/lib/effect" import { createRoutes } from "../src/routes" type RpcEvent = Extract -type DurableRpcEvent = RpcEvent & { durable: NonNullable } const authorization = `Basic ${btoa("opencode:secret")}` @@ -159,11 +158,6 @@ it.live("dispatches RPC wrappers with query, header and default locations and ge body: {}, error: { type: "rejected", message: "handler failed", data: { reason: "declared" } }, }, - { - route: "transport.echo/defect", - body: {}, - error: { type: "rpc.internal", message: "handler defect" }, - }, { route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } }, { route: "transport.echo/invalid", body: {}, error: { type: "rpc.invalid_output" } }, ], @@ -178,6 +172,13 @@ it.live("dispatches RPC wrappers with query, header and default locations and ge }) }), ) + 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 malformed = yield* server.call("transport.echo/echo", "not a wrapper") expect(malformed.status).toBe(400) expect(yield* Effect.promise(() => malformed.json())).toMatchObject({ @@ -335,9 +336,7 @@ it.live("public SSE and generic native plugin subscriptions receive RPC events a .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" && event.durable === undefined, - ), + .filter((event): event is RpcEvent => event.type === "rpc.updates.updated"), ) } yield* Deferred.await(observed) @@ -354,96 +353,5 @@ it.live("public SSE and generic native plugin subscriptions receive RPC events a }, ]) expect(received).toEqual(events) - expect(events.every((event) => event.durable === undefined)).toBe(true) - }), -) - -it.live("durable RPC events use the Bus sequence on the public stream", () => - Effect.gen(function* () { - const Updates = Rpc.define({ - namespace: "durable-updates", - methods: { - emit: { - input: Schema.Struct({ itemID: Schema.String, text: Schema.String }), - output: Schema.Undefined, - }, - }, - events: { - recorded: { - schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), - durable: { version: 2, aggregate: "itemID" }, - }, - }, - }) - const server = yield* fixture([ - Plugin.define({ - id: "durable-updates-implementer", - effect: (ctx) => - Effect.gen(function* () { - const registration = yield* ctx.rpc.register(Updates, { - emit: (input): Effect.Effect => - registration.events.emit("recorded", input).pipe(Effect.as(undefined), Effect.orDie), - }) - }).pipe(Effect.orDie), - }), - ]) - yield* server.boot(server.first) - const response = yield* Effect.promise(() => - server.handler( - new Request("http://opencode.local/api/event", { - headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) }, - }), - ), - ) - 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"') - yield* server.call( - "durable-updates/emit", - { input: { itemID: "item-1", text: "first" } }, - { directory: server.first }, - ) - yield* server.call( - "durable-updates/emit", - { input: { itemID: "item-1", text: "second" } }, - { directory: server.first }, - ) - const events: DurableRpcEvent[] = [] - while (events.length < 2) { - const chunk = yield* Effect.promise(() => reader.read()) - if (chunk.done) throw new Error("Event stream closed before durable 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 DurableRpcEvent => - event.type === "rpc.durable-updates.recorded" && event.durable !== undefined, - ), - ) - } - expect( - events.map((event) => ({ - aggregateID: event.durable.aggregateID, - seq: Number(event.durable.seq), - version: Number(event.durable.version), - data: event.data, - })), - ).toEqual([ - { - aggregateID: "item-1", - seq: 0, - version: 2, - data: { itemID: "item-1", text: "first" }, - }, - { - aggregateID: "item-1", - seq: 1, - version: 2, - data: { itemID: "item-1", text: "second" }, - }, - ]) }), ) diff --git a/packages/www/src/docs/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index 01609a7bd2c7..23d6fe4a9470 100644 --- a/packages/www/src/docs/content/build/client/effect.mdx +++ b/packages/www/src/docs/content/build/client/effect.mdx @@ -99,12 +99,11 @@ 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 -`RpcError` wrapper; reserved `rpc.*` types identify framework failures. +RPC error wrapper; reserved `rpc.*` types identify framework failures. RPC events are typed Streams, not callback-style `on` listeners. They receive the namespace's events from all locations, each with required `location` and a normal -prefixed type such as `rpc.acme.updated`. Durable definitions additionally carry the -declared aggregate, sequence, and version. This differs from server-plugin handles, +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, durability, and live subscription semantics. diff --git a/packages/www/src/docs/content/build/client/index.mdx b/packages/www/src/docs/content/build/client/index.mdx index d7d985795d21..20bfda99201d 100644 --- a/packages/www/src/docs/content/build/client/index.mdx +++ b/packages/www/src/docs/content/build/client/index.mdx @@ -72,8 +72,8 @@ 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. A slow subscriber that exceeds the -4096-event queue fails without stopping other consumers. +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 @@ -85,7 +85,6 @@ Import a plugin's shared contract and pass it to `client.rpc`: ```ts import { OpenCode } from "@opencode-ai/client" -import { Rpc } from "@opencode-ai/plugin/rpc" import { Acme } from "opencode-acme-plugin/rpc" const acme = client.rpc(Acme) @@ -97,14 +96,6 @@ const result = await acme.search( const unsubscribe = acme.events.on("updated", (event) => { console.log(event.type, event.location.directory, event.data.text) }) - -try { - await acme.search({ query: "missing" }) -} catch (error) { - if (Rpc.isError(Acme, "search", error) && error.type === "not_found") { - console.log(error.message, error.data.query) - } -} ``` The second optional method argument holds `location`, `signal`, and `headers`, @@ -119,12 +110,11 @@ 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. -`Rpc.isError(definition, method, error)` narrows a caught value to the union -inferred from that method's error map. The generic HTTP `RpcError` wrapper is -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. +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 namespace's events across all locations. Inspect the required `event.location` to filter them. `events.subscribe` @@ -139,8 +129,7 @@ for await (const event of acme.events.subscribe("updated")) { `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. Durable definitions -also expose the declared `aggregateID`, `seq`, and `version`, but live subscriptions +`rpc..` envelope with direct object event data. Live subscriptions do not replay missed events. The server plugin must be configured and implement the namespace; importing a diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index f43e6478ce51..74623b94e14c 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -680,9 +680,8 @@ yield * ``` There is no Effect callback-style `on` API. Subscriptions are location-bound, -live-only, and close when Stream consumption stops. Durable definitions still use -the normal Bus sequence and persistence path; this API does not currently expose -replay. Method `errors` maps become typed Effect error channels. Construct one +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. diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 087f68cee1d6..82c6e2b1ada8 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -603,7 +603,6 @@ export const Acme = Rpc.define({ events: { updated: { schema: z.object({ itemID: z.string(), text: z.string() }), - durable: { version: 1, aggregate: "itemID" }, }, }, }) @@ -678,14 +677,11 @@ callback convenience returning unsubscribe. Plugin unload closes its subscriptio 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`. Event definitions may include `durable: { version, aggregate }`, where -`aggregate` names a string field in the schema output passed to Bus. Durable emits -use the normal Bus sequence and configured persistence flow, and subscribers also -receive `durable: { aggregateID, seq, version }`. +`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 even when they were published durably. The method -name `events` is reserved for the subclient's event API. +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 namespace's events across all locations. The native `/api/event` stream and From 501d67ebf311b744576135aa4c7ef5675d475728 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 29 Aug 2026 03:20:11 -0400 Subject: [PATCH 04/20] docs(rpc): align event behavior --- PLUGIN_RPC_DESIGN.md | 2 +- packages/www/src/docs/content/build/client/effect.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PLUGIN_RPC_DESIGN.md b/PLUGIN_RPC_DESIGN.md index 0142f7286e8d..1dd5453d7833 100644 --- a/PLUGIN_RPC_DESIGN.md +++ b/PLUGIN_RPC_DESIGN.md @@ -31,7 +31,7 @@ also be declared as an RPC method. - One `POST /api/rpc/:namespace/:method` handler routes through existing location and authentication middleware. Input/output wrappers support primitives and omitted values. - The HTTP boundary awaits the existing plugin activation barrier so cold locations are ready. Core and `ctx.rpc` lookup do not wait for registrations or reload implementations. - Custom events use direct `rpc..` envelopes with required location. Native and typed RPC subscriptions observe the same event; typed subclients apply the declared payload schema. -- One lazy shared source per base client fans out native and RPC events, caches connection metadata only, bounds each subscriber queue, and closes on the last subscriber leaving. +- One lazy shared source per base client fans out native and RPC events, caches connection metadata only, and closes on the last subscriber leaving. - Promise RPC stays runtime-independent from Effect and accepts only portable definitions. Effect clients decode Effect codecs normally. - Native and RPC Promise plugin subscriptions share scoped iterator cleanup and respect subscriber-local signals. - Public protocol/client/OpenAPI artifacts are regenerated; plugin/client guides document the feature. diff --git a/packages/www/src/docs/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index 23d6fe4a9470..784e8d879101 100644 --- a/packages/www/src/docs/content/build/client/effect.mdx +++ b/packages/www/src/docs/content/build/client/effect.mdx @@ -105,7 +105,7 @@ RPC events are typed Streams, not callback-style `on` listeners. They receive th namespace'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, durability, and live subscription semantics. +definitions, schemas, registration, and live subscription semantics. ## Local background service From 089521816d5601a71ba16504af865b761597140d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 29 Aug 2026 03:45:05 -0400 Subject: [PATCH 05/20] fix(rpc): address final review findings --- packages/client/src/effect/client.ts | 5 ++-- packages/client/src/effect/rpc.ts | 12 ++++++---- .../client/src/promise/generated/types.ts | 4 ++-- packages/client/src/promise/rpc.ts | 18 ++++----------- packages/client/src/rpc-runtime.ts | 1 - packages/client/src/shared-events.ts | 10 ++++---- packages/client/test/api.types.ts | 2 ++ packages/client/test/rpc-effect.test.ts | 16 +++++++++++++ packages/client/test/rpc-promise.test.ts | 1 + packages/core/src/rpc.ts | 10 +++++--- packages/core/test/rpc.test.ts | 4 ++++ packages/httpapi-codegen/src/index.ts | 2 +- .../httpapi-codegen/test/generate.test.ts | 22 ++++++++++++++++++ packages/plugin/src/promise/adapter.ts | 23 ++++++++++--------- packages/plugin/test/rpc-effect.types.ts | 3 +++ 15 files changed, 90 insertions(+), 43 deletions(-) diff --git a/packages/client/src/effect/client.ts b/packages/client/src/effect/client.ts index 2aba49b2137a..11eabe6a0246 100644 --- a/packages/client/src/effect/client.ts +++ b/packages/client/src/effect/client.ts @@ -13,7 +13,8 @@ const CurrentHeaders = Context.Reference("@opencode-a 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( + const raw = yield* OpenCode.make(options) + const rpc = yield* OpenCode.make(options).pipe( Effect.provideService( HttpClient.HttpClient, HttpClient.mapRequestEffect(httpClient, (request) => @@ -49,7 +50,7 @@ export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly b event: { ...raw.event, subscribe }, rpc: Object.assign( RpcClientRuntime.make( - (input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)), + (input, options) => rpc.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)), subscribe, ), raw.rpc, diff --git a/packages/client/src/effect/rpc.ts b/packages/client/src/effect/rpc.ts index 12f0412a7f71..4603b008960f 100644 --- a/packages/client/src/effect/rpc.ts +++ b/packages/client/src/effect/rpc.ts @@ -3,12 +3,13 @@ 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, Stream } from "effect" +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, @@ -18,12 +19,15 @@ export type RpcClient< > = { readonly [Name in keyof D["methods"]]: ( ...args: RpcArguments, Options> - ) => Effect.Effect, Rpc.MethodError | E> + ) => Effect.Effect< + Rpc.Output, + Rpc.MethodError | DecodeError | E + > } & { readonly events: { readonly subscribe: ( name: Name, - ) => Stream.Stream, EventError> + ) => Stream.Stream, DecodeError | EventError> } } @@ -68,8 +72,8 @@ export function make( 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] - if (!schema) return Stream.fail(new Error(`Unknown RPC event: ${type}`)) return subscribe().pipe( Stream.filter((event): event is RpcEvent => event.type === type), Stream.mapEffect((event) => RpcRuntime.event(definition, name, schema, event)), diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 38d6b90c7d96..ad86c031147b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -333,7 +333,7 @@ export type SkillInfo = { content: string } -export type RpcOutput = { output: JsonValue } +export type RpcOutput = { output?: JsonValue } export type PermissionReply = "once" | "always" | "reject" @@ -5705,7 +5705,7 @@ export type RpcCallInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] - readonly input?: { readonly input: JsonValue }["input"] + readonly input?: { readonly input?: JsonValue }["input"] } export type RpcCallOutput = RpcOutput diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts index 6ac2adba2e10..cb28d8dd79a9 100644 --- a/packages/client/src/promise/rpc.ts +++ b/packages/client/src/promise/rpc.ts @@ -60,8 +60,7 @@ export function makeRpc( name: string, options?: Pick, ): AsyncIterable> => { - const schema = definition.events[name] - if (!schema) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) + if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) const type = eventType(definition, name) return { [Symbol.asyncIterator]() { @@ -72,7 +71,9 @@ export function makeRpc( for await (const published of events.subscribe({ signal })) { if (signal.aborted) return if (!isRpcEvent(published, type)) continue - yield event(type, published) + // 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 @@ -142,17 +143,6 @@ export function makeRpc( } } -function event( - type: RpcEventType, - event: RpcEvent, -): RpcEventPayload { - return { - ...event, - type, - location: { ...event.location }, - } -} - function isRpcEvent(event: EventSubscribeOutput, type: RpcEventType): event is RpcEvent { return event.type === type } diff --git a/packages/client/src/rpc-runtime.ts b/packages/client/src/rpc-runtime.ts index 2badde8bbf4f..71ff31abd1ba 100644 --- a/packages/client/src/rpc-runtime.ts +++ b/packages/client/src/rpc-runtime.ts @@ -49,7 +49,6 @@ export const event = Effect.fn("Client.Rpc.event")(function* < ...event, type: eventType(definition, name), data, - location: { ...event.location }, } as Rpc.EventPayload }) diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts index 77e563e096ea..2146ee635afb 100644 --- a/packages/client/src/shared-events.ts +++ b/packages/client/src/shared-events.ts @@ -3,7 +3,7 @@ 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 + push: (value: A) => void | Promise finish: (completion: Completion) => void } type Connection = { @@ -31,7 +31,7 @@ export function make(connect: (signal: Abor while (!connection.controller.signal.aborted) { // Cancellation must reach return() even when the source has a pending next(). connection.read = Promise.withResolvers>() - Promise.resolve(iterator.next()).then(connection.read.resolve, connection.read.reject) + iterator.next().then(connection.read.resolve, connection.read.reject) const item = await connection.read.promise connection.read = undefined if (item.done || connection.controller.signal.aborted) break @@ -82,11 +82,11 @@ export function make(connect: (signal: Abor const subscriber: Subscriber = { finish, push(value) { - if (completion) return Promise.resolve() + if (completion) return const request = pending.shift() if (request) { request.resolve({ done: false, value }) - return Promise.resolve() + return } const accepted = Promise.withResolvers() offered = { value, accepted } @@ -94,7 +94,7 @@ export function make(connect: (signal: Abor }, } - async function start() { + function start() { if (completion) return const fresh = !current connection = current ?? { 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/rpc-effect.test.ts b/packages/client/test/rpc-effect.test.ts index e125acefadc4..5c15c1fec94e 100644 --- a/packages/client/test/rpc-effect.test.ts +++ b/packages/client/test/rpc-effect.test.ts @@ -477,3 +477,19 @@ test("shared event source runs with the Effect context captured by make", async ) 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 index aad9e59e7770..1cf5b2855135 100644 --- a/packages/client/test/rpc-promise.test.ts +++ b/packages/client/test/rpc-promise.test.ts @@ -375,6 +375,7 @@ test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", 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]() diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index f1b8406cea27..b8332282e2d6 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -94,7 +94,11 @@ const layer = Layer.effect( return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.namespace}.${args[0]}`)) const event = registered.event const data = yield* applyEventSchema(event.schema, args[1]) - return yield* bus.publish(registered.definition, data, { location: { ...ref } }).pipe(Effect.asVoid) + return yield* bus + .publish(registered.definition, data, { + location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }), + }) + .pipe(Effect.asVoid) }), }, } @@ -104,10 +108,10 @@ const layer = Layer.effect( const entry = registrations.get(namespace)?.at(-1) if (!entry) return yield* Effect.fail(failure("rpc.namespace_unavailable", `RPC namespace is unavailable: ${namespace}`)) + if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name)) + return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${namespace}.${name}`)) const method = entry.definition.methods[name] const handler = entry.handlers[name] - if (!method || !handler) - return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${namespace}.${name}`)) const parsed = yield* parse(method.input, input).pipe( Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))), ) diff --git a/packages/core/test/rpc.test.ts b/packages/core/test/rpc.test.ts index b05e89146d8b..25d9939af4a2 100644 --- a/packages/core/test/rpc.test.ts +++ b/packages/core/test/rpc.test.ts @@ -43,6 +43,10 @@ describe("Rpc", () => { type: "rpc.method_not_found", message: "Unknown RPC method: test.rpc.missing", }) + expect(yield* rpc.call(Echo.namespace, "toString", "hello").pipe(Effect.flip)).toEqual({ + type: "rpc.method_not_found", + message: "Unknown RPC method: test.rpc.toString", + }) }), ) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 5c15bc36e9e5..8ba0a5b36436 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1200,7 +1200,7 @@ function codegenAsts(roots: ReadonlyArray) { "id" in representation && representation.id === "effect/schema/Json" ) { - return Schema.Json.ast + return ast.context?.isOptional ? Schema.optionalKey(Schema.Json).ast : Schema.Json.ast } if (ast.annotations?.["~constructor"] !== undefined && ast.typeParameters[0] !== undefined) { const identifier = SchemaAST.resolveIdentifier(ast) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 8f1675bd752c..ae8aa3746c2d 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -582,6 +582,28 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("preserves optional keys when HTTP normalization converts unknown to JSON", () => { + const OptionalUnknown = Schema.optionalKey(Schema.Unknown).pipe( + Schema.decodeTo(Schema.optional(Schema.Unknown), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.passthrough({ strict: false }), + }), + ) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/rpc", { + success: Schema.Struct({ output: OptionalUnknown }).annotate({ identifier: "RpcOutput" }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type RpcOutput = { readonly "output"?: JsonValue }', + ) + }) + test("supports name-discriminated Promise errors", () => { class NamedError extends Schema.Error("NamedError")( { name: Schema.Literal("NamedError"), message: Schema.String }, diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 81c6a7dc0c0e..b20d704b8309 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -126,17 +126,18 @@ const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRp name, (input: unknown, context: HostRpcCallContext) => Effect.tryPromise({ - try: (signal) => - Promise.resolve( - Reflect.apply(handler, undefined, [ - input, - { - signal, - error: (type: string, message: string, data?: unknown) => - new ReturnedRpcError(type, message, data), - }, - ]), - ), + 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) => diff --git a/packages/plugin/test/rpc-effect.types.ts b/packages/plugin/test/rpc-effect.types.ts index 33ce7857db7e..540795d60783 100644 --- a/packages/plugin/test/rpc-effect.types.ts +++ b/packages/plugin/test/rpc-effect.types.ts @@ -22,6 +22,7 @@ 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 = [ @@ -45,6 +46,8 @@ export type Checks = [ Assert, number>>, Assert, never>>, Assert, number>>, + Assert, Schema.SchemaError>, Schema.SchemaError>>, + Assert, Schema.SchemaError>, Schema.SchemaError>>, Assert< Equal< Extract, { readonly type: "invalid_count" }>, From 2c48f2c6338371fc3538a2b056649837d88a6626 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 29 Aug 2026 03:50:20 -0400 Subject: [PATCH 06/20] chore(rpc): regenerate openapi --- packages/core/src/rpc.ts | 2 +- packages/protocol/openapi.json | 65 +++++++++++++++++++++++++++++++- packages/www/openapi.json | 65 +++++++++++++++++++++++++++++++- packages/www/public/openapi.json | 65 +++++++++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 7 deletions(-) diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index b8332282e2d6..7880ce83880a 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -268,7 +268,7 @@ const logicalEvent = Effect.fn("Rpc.logicalEvent")(function* < ): 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, durability, version, and location. + // SAFETY: The private Bus definition owns the envelope and location. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion return { ...payload, diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 4eadef962fd4..23471fbc7582 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -9025,13 +9025,13 @@ } }, "400": { - "description": "InvalidRequestError", + "description": "RpcError | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + "$ref": "#/components/schemas/RpcErrorEncoded" }, { "$ref": "#/components/schemas/InvalidRequestErrorEncoded" @@ -9050,6 +9050,16 @@ } } } + }, + "500": { + "description": "RpcInternalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RpcInternalErrorEncoded" + } + } + } } }, "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", @@ -17112,6 +17122,57 @@ }, "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"] + }, + "message": { + "type": "string" + }, + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "type", "message"], + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 4eadef962fd4..23471fbc7582 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -9025,13 +9025,13 @@ } }, "400": { - "description": "InvalidRequestError", + "description": "RpcError | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + "$ref": "#/components/schemas/RpcErrorEncoded" }, { "$ref": "#/components/schemas/InvalidRequestErrorEncoded" @@ -9050,6 +9050,16 @@ } } } + }, + "500": { + "description": "RpcInternalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RpcInternalErrorEncoded" + } + } + } } }, "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", @@ -17112,6 +17122,57 @@ }, "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"] + }, + "message": { + "type": "string" + }, + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "type", "message"], + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 4eadef962fd4..23471fbc7582 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -9025,13 +9025,13 @@ } }, "400": { - "description": "InvalidRequestError", + "description": "RpcError | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + "$ref": "#/components/schemas/RpcErrorEncoded" }, { "$ref": "#/components/schemas/InvalidRequestErrorEncoded" @@ -9050,6 +9050,16 @@ } } } + }, + "500": { + "description": "RpcInternalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RpcInternalErrorEncoded" + } + } + } } }, "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", @@ -17112,6 +17122,57 @@ }, "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"] + }, + "message": { + "type": "string" + }, + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "type", "message"], + "additionalProperties": false + }, "ServiceHealth": { "type": "object", "properties": { From e73d68f811dd2ae7a34b28e145d4d22fb4b0acac Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 29 Aug 2026 04:00:17 -0400 Subject: [PATCH 07/20] refactor(rpc): remove redundant machinery --- PLUGIN_RPC_DESIGN.md | 496 -------------------- packages/client/src/effect/client.ts | 5 +- packages/client/src/promise/rpc.ts | 7 +- packages/client/src/shared-events.ts | 11 +- packages/client/test/rpc-promise.test.ts | 23 - packages/core/src/rpc.ts | 10 +- packages/plugin/src/promise/rpc.ts | 15 +- packages/protocol/test/event.test.ts | 1 - packages/schema/src/rpc.ts | 2 +- packages/schema/test/event-manifest.test.ts | 1 - packages/server/src/handlers/rpc.ts | 17 +- 11 files changed, 24 insertions(+), 564 deletions(-) delete mode 100644 PLUGIN_RPC_DESIGN.md diff --git a/PLUGIN_RPC_DESIGN.md b/PLUGIN_RPC_DESIGN.md deleted file mode 100644 index 1dd5453d7833..000000000000 --- a/PLUGIN_RPC_DESIGN.md +++ /dev/null @@ -1,496 +0,0 @@ -# Plugin RPC and Custom Events - -Design notes and implementation record. Shared definitions, the location-scoped -Core registry, local Promise/Effect plugin APIs, HTTP dispatch, external typed -clients, shared event connections, and Bus publication are implemented. -Plugin log/replay APIs and per-namespace OpenAPI discovery are intentionally deferred. - -## First Slice - -- `@opencode-ai/schema/rpc` owns the execution-neutral `Rpc.define` contract; `@opencode-ai/plugin/rpc` re-exports the canonical namespace. -- Promise and Effect client API types describe typed subclients, while plugin domain types add registration and event publishing. -- Portable Standard Schema and JSON Schema definitions work with both client and plugin styles. Effect Schema definitions are accepted only by Effect consumers. -- Compile-time fixtures check inference and rejected inputs, outputs, names, payloads, and location overrides through public exports. -- `bun typecheck` in `packages/plugin` includes the inference fixtures; runtime tests verify contract identity and a browser-safe definition entrypoint with no Effect runtime dependency. - -The method name `events` is reserved for the subclient's event API. It cannot -also be declared as an RPC method. - -## Second Slice - -- `packages/core/src/rpc.ts` owns the location-scoped `Rpc.Service`, with scoped registration stacks, per-call active lookup, and direct dispatch. -- `Rpc.call(namespace, method, input)` validates wire input and returns wire output; typed local subclients apply the corresponding result schema decoding without an HTTP request. -- Both plugin contexts expose `ctx.rpc(definition)` and `ctx.rpc.register(definition, handlers)`. Promise adaptation forwards cancellation and supplies `context.signal` to handlers. -- Local custom events publish through the existing bus with captured location and typed subscriptions. Location objects at public event boundaries do not alias private routing state. -- Promise subscriptions close independently on unsubscribe, abort, or plugin unload. Effect subscriptions use normal Stream scope cleanup. -- Core tests cover actual registrations, overrides, scope disposal, schemas/transforms, JSON boundaries, cancellation, location isolation, and plugin activation in both API styles. - -## Transport and Client Slice - -- Public Promise and Effect `OpenCode.make` factories expose callable `client.rpc(definition)`, retaining `client.rpc.call` for generic wire calls. -- One `POST /api/rpc/:namespace/:method` handler routes through existing location and authentication middleware. Input/output wrappers support primitives and omitted values. -- The HTTP boundary awaits the existing plugin activation barrier so cold locations are ready. Core and `ctx.rpc` lookup do not wait for registrations or reload implementations. -- Custom events use direct `rpc..` envelopes with required location. Native and typed RPC subscriptions observe the same event; typed subclients apply the declared payload schema. -- One lazy shared source per base client fans out native and RPC events, caches connection metadata only, and closes on the last subscriber leaving. -- Promise RPC stays runtime-independent from Effect and accepts only portable definitions. Effect clients decode Effect codecs normally. -- Native and RPC Promise plugin subscriptions share scoped iterator cleanup and respect subscriber-local signals. -- Public protocol/client/OpenAPI artifacts are regenerated; plugin/client guides document the feature. - -Intended usage passes one concrete RPC definition. Conditional definition -unions and numeric event names are not part of the supported usage being designed. - -## Goals - -- Let server plugins expose RPC methods callable by any OpenCode client or other server plugins. -- Let plugins define and publish custom events that consumers can subscribe to. -- Infer types for method arguments, results, handlers, and event payloads from a shared contract. -- Support both Promise and Effect execution without forcing plugin authors to use Effect. - -Custom events are ephemeral and use normal Bus publication. - -## Shared Definition - -`Rpc.define(...)` is a plain, synchronous, execution-neutral contract builder. -It defines an RPC namespace, method input/output schemas, and event payload -schemas. It contains no handlers and does not register or execute the plugin. - -RPC namespaces are independent of plugin IDs. One plugin can register multiple -namespaces, and another plugin can override one using the same definition. The -namespace determines RPC routing and event prefixes; the plugin ID determines -registration ownership and cleanup. Do not add an automatic plugin-ID prefix. - -Each method declares `input` and `output`, plus an optional `errors` map. The -output schema checks handler return types, validates results at runtime, and -determines the caller's inferred result type. Error map keys become the error -`type`; each value validates and transforms that error's `data`. - -Publish the definition in a browser-safe module such as `acme-plugin/rpc`. -Clients and other plugins can import it without importing server implementation -code or running plugin setup. - -The complete definition format accepts the existing `Tool.ValueSchema` options: - -- Effect Schema, with TypeScript inference, for Effect clients and plugins only. -- Standard Schema, including Zod, with TypeScript inference. -- Plain JSON Schema, without automatic TypeScript inference. - -Promise clients and plugins accept Standard Schema or plain JSON Schema. Effect -clients and plugins accept all three. Use a portable Standard or JSON Schema -definition when the same contract must be consumed through both API styles. - -```ts -// acme-plugin/rpc -import { Rpc } from "@opencode-ai/plugin/rpc" -import { z } from "zod" - -export const Acme = Rpc.define({ - namespace: "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() }), - }, - progress: { - schema: z.object({ percent: z.number() }), - }, - }, -}) -``` - -## Event Definitions - -Define events inline as a map within `Rpc.define(...)`, not an array. No -separate event builder or explicit `type` field is required. Each map key is a -local event name; the public event type is automatically prefixed with the namespace: -`rpc.${namespace}.${eventName}`. The example defines `rpc.acme.updated` and `rpc.acme.progress`. - -Each event definition has a `schema` accepting `Tool.ValueSchema`. Publishing -uses the normal ephemeral Bus path. - -Custom event data must be an object. Effect and Standard Schema definitions -enforce that in their inferred types; plain JSON Schema is checked when emitting. -Scalars, arrays, `null`, and `undefined` are not valid event payloads. - -Publishing supplies only the payload. Subscribers receive the standard event -envelope with `id`, `created`, `type`, `data`, required `location`, optional -`metadata`. -OpenCode supplies the emitting plugin instance's location; publishers do not -provide or override it. - -The subclient uses local event names for subscriptions and publishing, with -inferred payload and envelope types. Consumers import only the RPC -definition, not individual event definitions. - -External subclients receive the namespace's events across all server locations, -not just the default location or a location used by an RPC call. Consumers can -filter using the required `event.location` field. Server plugin subscriptions -are bound to the calling plugin instance's location. - -Live subscriptions do not replay missed events. Events emitted while a consumer -is disconnected are missed; there is no plugin log API in this design yet. - -## Client API - -The factory belongs to the OpenCode client, not the RPC definition: - -```ts -import { Acme } from "acme-plugin/rpc" - -const acme = client.rpc(Acme) -const result = await acme.search({ query: "hello" }) - -const unsubscribe = acme.events.on("updated", (event) => { - console.log(event.type, event.data.text) // type: "rpc.acme.updated" - console.log(event.location.directory) // Emitting plugin instance's location -}) -``` - -The subclient exposes only the namespace's methods and events. It reuses the -supplied OpenCode client's connection, authentication, and transport. -Creating a subclient does not load the server plugin. - -Other server plugins use the same calling shape: `ctx.rpc(Acme)`. -Whether calls return Promises or Effects is determined by the supplied client -or context, not by how the RPC handlers are implemented. - -Calling and implementing are independent capabilities. A server plugin can -obtain a consumer handle without implementing the namespace, implement a namespace, -or do both. `ctx.rpc(Acme)` provides the consumer API; -`ctx.rpc.register(Acme, handlers)` registers the implementation. - -### Handle Lifecycle - -`client.rpc(Acme)` and `ctx.rpc(Acme)` return a handle immediately, even if no -implementation is registered yet. Creating a handle does not wait for namespace -availability. - -Each method call resolves the currently active registration at its target -location. Handles do not cache implementations, reload, or track registration -changes; each call simply looks up the active implementation. A call already -running finishes against the implementation it started with, rather than -switching handlers mid-call. -If no implementation is available when called, fail immediately instead of -waiting for a registration. The HTTP boundary waits for normal plugin activation -at the requested location before this lookup; it does not wait for a particular -namespace to appear. Direct plugin calls do not use that barrier, avoiding setup -recursion. - -## Location and Call Options - -RPC namespaces are implemented at the registering server plugin instance's location. -Select an external call's location through a second optional options argument, -not through subclient construction or the method's declared input: - -```ts -const acme = client.rpc(Acme) - -await acme.search({ query: "hello" }) -await acme.search({ query: "hello" }, { location: { directory: "/path/to/project" } }) -``` - -External call options can include `location`, `signal`, and `headers`. When -location is omitted, use the existing request defaults: explicit location -headers, if present, then the server's working directory. The base OpenCode -client has no dedicated configured location; it has connection and header options. - -Server plugin consumer handles are bound to the calling plugin instance's -location and do not expose a location override: - -```ts -const acme = ctx.rpc(Acme) -await acme.search({ query: "hello" }) -``` - -Both consumers use the same RPC definition and inferred method input/output -types. Routing metadata is separate from the payload and never injected into -handler arguments. Do not reserve a top-level `location` input field or require -object-shaped inputs just to support routing. This intentionally differs from -native endpoints such as `skill.list`, which put optional location inside the -first input argument. - -The same location rules apply to Promise and Effect RPC calls. Event subscriptions -are different: external clients receive namespace events from all locations, while -server plugin handles receive only events from their own location. Per-call RPC -location options do not change a subclient's subscriptions. - -## RPC Transport - -Use one generic HTTP handler with a distinct URL for each namespace method: - -```text -POST /api/rpc/acme/search -POST /api/rpc/acme/refresh -``` - -The handler dispatches by RPC namespace, method name, and resolved request location. -Plugins register dynamically; they do not need separate handler implementations -or generated OpenCode clients for each method. - -Registration supplies the RPC definition and handlers to the server at each -location. The dispatcher resolves them dynamically; it does not require the -server to separately import a well-known RPC export from each plugin package. - -The request body is `{ input?: unknown }` and the success body is `{ output?: unknown }`. -Omitted fields represent no value. Location uses the existing native deep-object -query/header resolution; call metadata is not part of the method input. -The endpoint uses standard HTTP error wrappers around generic -`{ type, message, data? }` RPC failures. Declared and request failures use -`RpcError` at 400; unexpected defects use `RpcInternalError` at 500. Typed -clients remove those transport wrappers and decode declared error data through -the selected method's error map. Validation and lookup failures retain reserved -`rpc.*` types. Interruption is not converted to a method failure. - -## Deferred OpenAPI Integration - -Do not add individual plugin RPC methods to the server's OpenAPI document yet. -The initial implementation uses the imported shared definition for typed clients -and the runtime registration for dispatch and validation. - -The generic dispatch operation and dynamic `rpc.${string}` event envelope are -part of the native API contract and generated OpenAPI document, not a dynamic -per-namespace inventory. - -Registration is per location, so discovering contracts for a server-wide spec -or a location-specific spec requires further design. Revisit that separately, -including whether packages need a well-known declaration export. Do not add -declaration discovery or dynamic per-namespace OpenAPI generation now. - -Existing tool JSON Schema conversion may help with future OpenAPI integration, -but Standard Schema validation alone does not guarantee JSON Schema conversion. -OpenAPI representability is not an initial RPC requirement. - -## Event Subscription APIs - -Both subclient versions expose `events.subscribe(name)` as the primitive, -matching the native clients' event subscription representations: - -- Promise: a typed `AsyncIterable`. -- Effect: a typed `Stream`. - -```ts -// Promise client -for await (const event of acme.events.subscribe("updated")) { - console.log(event.data.text) -} -``` - -Promise subclients also expose `events.on(name, handler)` as a convenience -wrapper over the same subscription primitive, returning an unsubscribe function: - -```ts -const unsubscribe = acme.events.on("updated", (event) => { - console.log(event.data.text) -}) -``` - -Effect subclients keep the Stream API without a callback convenience wrapper: - -```ts -const updates = acme.events.subscribe("updated") -``` - -The local event name selects its exact envelope and payload type. Effect -subscriptions compose with normal Stream operators. Ending async iteration, -stopping Stream consumption, or calling the Promise convenience unsubscribe -function removes only that subscriber. Constructing an iterable or Stream alone -does not open a connection; `on` starts consuming for the listener. - -`on` and `subscribe` share the same event source. The convenience wrapper does -not create a separate HTTP connection. - -These APIs apply to both external and server plugin subclients, preserving their -different location rules. External subscriptions share the base client's event -connection; plugin subscriptions use the internal bus. Unsubscribing or stopping -one consumer does not stop other consumers. - -## Custom Event Transport - -Reuse the existing `/api/event` stream for custom RPC events alongside native -events. Do not add a separate event endpoint per namespace. - -The native stream carries the actual `rpc..` type and direct -JSON object payload. Reserving the `rpc.` prefix keeps dynamic events disjoint -from native event literals, preserving native union narrowing. - -The subclient's `subscribe` API and Promise `on` wrapper match namespace and local -name, then apply the declared payload schema. External -clients receive matching namespace events across all locations. Server plugin RPC -subscriptions stay bound to their own location. The shared definition supplies -the payload schema and inferred types. Live delivery has no implicit replay. - -### Shared Connection Lifecycle - -The base OpenCode client owns one lazy, shared event connection. Creating a -client or RPC handle opens no event connection. The first active event subscriber -opens it; native and RPC subscribers share it through local fan-out. When the -last subscriber leaves, close the connection. Sharing is per base client instance, -not process-global or per RPC namespace. - -Handwritten public client facades wrap the generated raw event transport with -this shared source. Server plugin subscriptions use the internal bus directly -and do not open HTTP event connections. - -Cache only the latest `server.connected` marker for late subscribers, -so native connection consumers still receive their initial handshake. Do not -replay business events. A replacement connection may open while the previous -source finishes cleanup. - -The shared source advances after every active subscriber accepts the current -event. Consumers that perform slow work should drain and buffer events themselves. -Source EOF/failure ends current subscriptions, without automatic retry. Consumers -resubscribe after recovery. -Promise `on` logs callback/source failures and ends its listener. -Callbacks may be async: each listener awaits its callback before processing the -next event, so rejected callbacks are caught and only that listener ends. - -Native event subscriptions have no payload, location, or filter arguments. The -Effect client exposes `subscribe()`; the Promise client may accept an optional `signal` -for subscriber-local cancellation. Cancelling one subscriber removes only that -subscriber and does not disconnect others; close the shared connection only if -no subscribers remain. - -Use the base client's headers for the shared event connection. Remove existing -Promise subscription-level header overrides rather than opening separate -connections for listeners with different headers. - -## Server Registration - -Register handlers during plugin initialization, with access to the plugin context: - -```ts -import { Plugin } from "@opencode-ai/plugin" -import { Acme } from "acme-plugin/rpc" - -export default Plugin.define({ - id: "acme", - async setup(ctx) { - const registration = await ctx.rpc.register(Acme, { - search: async ({ query }) => ({ text: query }), - }) - - await registration.events.emit("updated", { - itemID: "123", - text: "hello", - }) - }, -}) -``` - -The handler map implements every declared method. The returned registration -handle provides typed event publishing. Registration belongs to the plugin -instance and is automatically removed when that instance unloads. - -For the same namespace at the same location, the latest active registration -wins, matching custom tool registration behavior. It replaces the effective -namespace implementation as a whole, rather than merging individual handlers. -Removing or unloading a registration removes only that registration and reveals -the previous active implementation, if any. Registrations at different locations -do not override one another. - -Provide both execution APIs, matching existing plugins: - -- Promise plugins register inside `setup`, use `await`, and supply Promise handlers. -- Effect plugins register inside the existing `effect` initializer, use `yield*`, and supply Effect handlers. -- Event publishing likewise returns a Promise or Effect according to the registration API. - -## Call Cancellation - -Handlers receive a general second call-context argument. Its typed `error` -constructor builds declared failures. Promise contexts also contain `signal`: - -```ts -search: async ({ query }, context) => { - const result = await fetchResults(query, { signal: context.signal }) - if (!result) return context.error("not_found", "Result not found", { query }) - return result -} -``` - -Promise handlers may either return or throw a value made by `context.error`. -Effect handlers use the native error channel: - -```ts -search: ({ query }, context) => - findResult(query).pipe( - Effect.flatMap((result) => - result - ? Effect.succeed(result) - : Effect.fail(context.error("not_found", "Result not found", { query })), - ), - ) -``` - -Cancelling an external request signals the Promise handler to stop. Cancellation -is cooperative: the handler must observe the signal or pass it to cancellable -operations. Effect handlers use normal Effect interruption instead. - -Changing the active registration does not cancel already-running calls. They -continue against their original implementation unless the call itself is cancelled. - -## Type Safety - -### Local and Remote Contract Boundaries - -Server-plugin calls dispatch directly to the active implementation without an -HTTP request. Both local and external dispatch apply the declared input and output -schemas. Local dispatch does not simulate JSON serialization; the actual HTTP -client owns transport serialization for external calls. - -Plain JSON Schema is interpreted as Draft 2020-12 and delegated directly to -Effect's JSON Schema importer and decoder. RPC adds no dialect compatibility or -keyword policy. - -Schema parsers own validation and transformation. RPC does not invent conversion -rules; it applies the schema at the contract boundary and derives the corresponding -caller and handler types. For input schemas, the caller supplies the accepted -input representation and the handler receives the parsed value. Parse input at -dispatch rather than transforming it on the client and parsing it again on the -server. Local calls follow the same rule. - -Effect codecs own their encoded representation. Standard and plain JSON schemas -are responsible for returning values appropriate for their eventual transport. -Event schemas require an object encoded/output type. RPC applies the schema and -passes that object directly to Bus publication. - -### Inference and Validation - -- Preserve literal namespace, method, and event names in `Rpc.define(...)`. -- Infer handler argument types and check handler return values against their schemas. -- Check caller arguments and infer RPC results and declared errors. -- Check published event payloads and infer subscriber payload types. -- Infer fully prefixed event types from the namespace and local map keys, while exposing local names to callers. -- Reject unknown method and event names at compile time. -- Validate data crossing the network at runtime, rather than relying only on TypeScript. - -Plain JSON Schema remains supported but does not provide the same automatic -TypeScript inference. Schema transforms need explicit treatment of wire input -and decoded output types; execution neutrality must not erase those distinctions. - -## Existing References - -- `packages/schema/src/tool.ts`: `Tool.ValueSchema` and schema-based inference. -- `packages/schema/src/event.ts`: internal event definitions and ephemeral envelopes. -- `packages/core/src/bus.ts`: publication and live subscriptions. -- `packages/core/src/tool/runtime.ts`: schema validation and input/output JSON Schema conversion. -- `packages/plugin/src/promise/tool.ts`: Promise tool handlers. -- `packages/plugin/src/effect/tool.ts`: Effect tool registrations. -- `packages/plugin/src/promise/plugin.ts`: Promise plugin `setup` and context. -- `packages/plugin/src/effect/plugin.ts`: Effect plugin initializer and context. -- `packages/client/src/promise/generated/client.ts`: base client options, request options, and native `skill.list` calling convention. -- `packages/server/src/location.ts`: per-request location resolution from query, headers, and server working directory. -- `packages/server/src/routes.ts`: current static OpenAPI generation through `HttpApiBuilder.layer`. -- `packages/protocol/src/groups/event.ts`: current public SSE event contract, which is volatile and uses a static event union. -- `packages/server/src/event-feed.ts`: current public event filtering and subscriber lifecycle. - -Implementation should preserve package dependency boundaries. Effect plugin -domains extend the corresponding Effect client API and add only plugin-specific -capabilities, such as registration. Public protocol changes require client -generation rather than manual edits to generated clients. diff --git a/packages/client/src/effect/client.ts b/packages/client/src/effect/client.ts index 11eabe6a0246..2aba49b2137a 100644 --- a/packages/client/src/effect/client.ts +++ b/packages/client/src/effect/client.ts @@ -13,8 +13,7 @@ const CurrentHeaders = Context.Reference("@opencode-a export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) { const httpClient = yield* HttpClient.HttpClient - const raw = yield* OpenCode.make(options) - const rpc = yield* OpenCode.make(options).pipe( + const raw = yield* OpenCode.make(options).pipe( Effect.provideService( HttpClient.HttpClient, HttpClient.mapRequestEffect(httpClient, (request) => @@ -50,7 +49,7 @@ export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly b event: { ...raw.event, subscribe }, rpc: Object.assign( RpcClientRuntime.make( - (input, options) => rpc.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)), + (input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)), subscribe, ), raw.rpc, diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts index cb28d8dd79a9..ba89315e995a 100644 --- a/packages/client/src/promise/rpc.ts +++ b/packages/client/src/promise/rpc.ts @@ -4,7 +4,6 @@ import { isRpcError, isRpcInternalError } from "./generated/types.js" import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js" type RpcEvent = Extract -type RpcEventType = RpcEventPayload["type"] export interface RpcCallOptions extends RequestOptions { readonly location?: LocationGetInput["location"] @@ -70,7 +69,7 @@ export function makeRpc( try { for await (const published of events.subscribe({ signal })) { if (signal.aborted) return - if (!isRpcEvent(published, type)) continue + 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 @@ -143,10 +142,6 @@ export function makeRpc( } } -function isRpcEvent(event: EventSubscribeOutput, type: RpcEventType): event is RpcEvent { - return event.type === type -} - function eventType(definition: Rpc.PortableDefinition, name: string) { return `rpc.${definition.namespace}.${name}` as const } diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts index 2146ee635afb..474a36f9ec8e 100644 --- a/packages/client/src/shared-events.ts +++ b/packages/client/src/shared-events.ts @@ -3,7 +3,7 @@ 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) => void | Promise + push: (value: A) => Promise finish: (completion: Completion) => void } type Connection = { @@ -14,6 +14,7 @@ export function make(connect: (signal: Abor } let current: Connection | undefined + const delivered = Promise.resolve() function stop(connection: Connection) { connection.connected = undefined @@ -82,11 +83,11 @@ export function make(connect: (signal: Abor const subscriber: Subscriber = { finish, push(value) { - if (completion) return + if (completion) return delivered const request = pending.shift() if (request) { request.resolve({ done: false, value }) - return + return delivered } const accepted = Promise.withResolvers() offered = { value, accepted } @@ -103,7 +104,7 @@ export function make(connect: (signal: Abor } current = connection connection.subscribers.add(subscriber) - if (connection.connected) subscriber.push(connection.connected) + if (connection.connected) void subscriber.push(connection.connected) if (fresh) void run(connection) } @@ -128,7 +129,7 @@ export function make(connect: (signal: Abor if (!started) { started = true options?.signal?.addEventListener("abort", abort, { once: true }) - void start() + start() } return request.promise }, diff --git a/packages/client/test/rpc-promise.test.ts b/packages/client/test/rpc-promise.test.ts index 1cf5b2855135..4488ea3bc1d2 100644 --- a/packages/client/test/rpc-promise.test.ts +++ b/packages/client/test/rpc-promise.test.ts @@ -348,29 +348,6 @@ test("RPC async callback failures stop only that listener and are not unhandled" expect(failed).toEqual([1]) }) -test("RPC EOF ends subscribers without reconnecting", async () => { - const source = events() - const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() - const next = iterator.next() - await source.end() - expect((await next).done).toBe(true) - expect((await iterator.next()).done).toBe(true) - expect(source.requests).toHaveLength(1) -}) - -test("RPC source transport errors propagate to native and RPC subscribers", async () => { - const source = events() - const native = source.client.event.subscribe()[Symbol.asyncIterator]() - const iterator = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]() - await native.next() - const rpcError = iterator.next().catch((error: unknown) => error) - const nativeError = native.next().catch((error: unknown) => error) - await source.fail(new Error("Connection failed")) - expect(await rpcError).toMatchObject({ name: "ClientError", reason: "Transport" }) - expect(await nativeError).toMatchObject({ name: "ClientError", reason: "Transport" }) - expect(source.requests).toHaveLength(1) -}) - test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => { const source = events() const broad: Rpc.PortableDefinition = Echo diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index 7880ce83880a..3ce0f7002e37 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -93,7 +93,9 @@ const layer = Layer.effect( if (!registered) return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.namespace}.${args[0]}`)) const event = registered.event - const data = yield* applyEventSchema(event.schema, args[1]) + // 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 }), @@ -242,12 +244,6 @@ function errorMessage(error: unknown, fallback: string) { return fallback } -function applyEventSchema(schema: Rpc.EventDefinition["schema"], value: unknown) { - // SAFETY: The public event-schema contract guarantees an object encoded/output type. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - return encode(schema, value) as Effect.Effect>, unknown> -} - function isStandardSchema(schema: Tool.ValueSchema): schema is Extract { return "~standard" in schema } diff --git a/packages/plugin/src/promise/rpc.ts b/packages/plugin/src/promise/rpc.ts index 2d3dd677ffe7..12075742953e 100644 --- a/packages/plugin/src/promise/rpc.ts +++ b/packages/plugin/src/promise/rpc.ts @@ -1,28 +1,19 @@ -import type { RpcApi, RpcCallOptions, RpcEventPayload } from "@opencode-ai/client/promise/api" +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" -declare const ReturnedErrorTypeId: unique symbol -interface ReturnedError { - readonly [ReturnedErrorTypeId]: true -} - export interface RpcCallContext { readonly signal: AbortSignal - readonly error: >( - ...args: Rpc.ErrorArguments - ) => Rpc.HandlerErrorFor & ReturnedError + readonly error: Rpc.ErrorFactory } export type RpcHandlers = { readonly [Name in keyof D["methods"]]: ( input: Rpc.Output, context: RpcCallContext, - ) => Promise< - Rpc.HandlerOutput | (Rpc.HandlerError & ReturnedError) - > + ) => Promise | Rpc.HandlerError> } export interface RpcRegistration extends Registration { diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 985aa24d2ae7..12d162ce96f9 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -35,7 +35,6 @@ test("classifies public events by type", () => { 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: "rpc.acme.recorded" })).toBe(true) expect(isOpenCodeEvent({ type: "acme.updated" })).toBe(false) }) diff --git a/packages/schema/src/rpc.ts b/packages/schema/src/rpc.ts index 5dc1afd39569..4fac00abe9f0 100644 --- a/packages/schema/src/rpc.ts +++ b/packages/schema/src/rpc.ts @@ -16,7 +16,7 @@ export interface Method { readonly errors?: ErrorMap } -export type PortableValueSchema = StandardSchemaV1 | JsonSchema.JsonSchema +export type PortableValueSchema = StandardSchemaV1 | JsonSchema.JsonSchema export interface PortableMethod extends Method { readonly input: PortableValueSchema diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 3dbfc03652ff..9e139b94050d 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -49,7 +49,6 @@ describe("public event manifest", () => { 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(Array.from(EventManifest.Durable.keys()).some((type) => type.startsWith("rpc."))).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts index 0beacce84f01..568578b698ad 100644 --- a/packages/server/src/handlers/rpc.ts +++ b/packages/server/src/handlers/rpc.ts @@ -14,7 +14,14 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) => const output = yield* rpc.call(params.namespace, params.method, payload.input) return output === undefined ? {} : { output } }).pipe( - Effect.mapError(toRpcError), + Effect.mapError( + (error) => + new RpcError({ + type: error.type, + message: error.message, + ...(error.data === undefined ? {} : { data: error.data }), + }), + ), Effect.catchDefect((error) => Effect.fail( new RpcInternalError({ @@ -26,11 +33,3 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) => ), ), ) - -function toRpcError(error: Rpc.Failure): RpcError { - return new RpcError({ - type: error.type, - message: error.message, - ...(error.data === undefined ? {} : { data: error.data }), - }) -} From 22a2072b9596271bcdf08205fa3e198d242d9427 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:02:01 -0400 Subject: [PATCH 08/20] fix(plugin): align local plugin entrypoints --- .../cli/src/commands/handlers/plugin/list.ts | 15 ++- packages/cli/test/plugin-list.test.ts | 8 ++ packages/core/src/config/plugin/source.ts | 21 +++- packages/core/src/plugin/source-directory.ts | 26 +---- packages/core/test/config/plugin.test.ts | 93 ++++++++---------- packages/core/test/location-layer.test.ts | 6 +- .../plugin/fixtures/config-effect/index.ts | 1 + .../plugin/fixtures/config-promise/index.ts | 1 + .../test/plugin/fixtures/failing/index.ts | 1 + .../test/plugin/fixtures/invalid/index.ts | 1 + .../plugin/fixtures/variant-source/index.ts | 1 + packages/tui/src/app.tsx | 4 +- packages/tui/src/plugin/context.tsx | 52 ++++++---- packages/tui/src/plugin/discovery.ts | 40 ++++++-- packages/tui/test/plugin-discovery.test.ts | 62 +++++++----- packages/tui/test/plugin-hot-reload.test.tsx | 98 +++++++++++-------- .../src/docs/content/build/plugins/effect.mdx | 18 ++-- .../src/docs/content/build/plugins/index.mdx | 22 ++--- packages/www/src/docs/content/cli/config.mdx | 4 +- packages/www/src/docs/content/cli/plugins.mdx | 18 ++-- packages/www/src/docs/content/config.mdx | 4 +- packages/www/src/docs/content/plugins.mdx | 11 ++- 22 files changed, 291 insertions(+), 216 deletions(-) create mode 100644 packages/core/test/plugin/fixtures/config-effect/index.ts create mode 100644 packages/core/test/plugin/fixtures/config-promise/index.ts create mode 100644 packages/core/test/plugin/fixtures/failing/index.ts create mode 100644 packages/core/test/plugin/fixtures/invalid/index.ts create mode 100644 packages/core/test/plugin/fixtures/variant-source/index.ts diff --git a/packages/cli/src/commands/handlers/plugin/list.ts b/packages/cli/src/commands/handlers/plugin/list.ts index a8538cd9617a..6e12d3ddf02e 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, @@ -50,9 +51,13 @@ export function format( .toSorted((a, b) => name(a).localeCompare(name(b))) .map((plugin) => `${name(plugin)} (${plugin.status})`) const advertised = plugins.flatMap((plugin) => - plugin.status === "active" && plugin.tui && plugin.source.type === "package" - ? [{ target: plugin.source.package, source: "advertised" as const }] - : [], + plugin.status !== "active" || !plugin.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..1a14f24580ee 100644 --- a/packages/cli/test/plugin-list.test.ts +++ b/packages/cli/test/plugin-list.test.ts @@ -19,6 +19,12 @@ test("formats server and TUI plugins in sections without builtins", () => { error: "broken", tui: false, }, + { + id: "local.dual", + source: { type: "local", path: "/tmp/local/index.ts" }, + status: "active", + tui: true, + }, ], [ { target: "tui-only", source: "configured" }, @@ -28,6 +34,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,6 +42,7 @@ test("formats server and TUI plugins in sections without builtins", () => { "Server", "acme.dual (active)", "broken-plugin (failed)", + "local.dual (active)", ].join(EOL), ) }) 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/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/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b42e1c008a67..ddb2064061c2 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,7 +121,7 @@ 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, @@ -131,7 +131,7 @@ describe("PluginSupervisor config", () => { ) 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), ).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..c0936569ca3d 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,7 +561,7 @@ 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")], }), ), ) @@ -572,7 +572,7 @@ describe("LocationServiceMap", () => { 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") }, + source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") }, status: "failed", error: expect.stringContaining("plugin failed"), tui: false, 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/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/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/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/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 7f4767db2f4f..8a2b77a3c44f 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" } } + Extract & { + 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) @@ -486,8 +508,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d ( plugin, ): plugin is Extract & { - readonly source: { readonly type: "package" } - } => plugin.status === "active" && plugin.tui && plugin.source.type === "package", + readonly source: { readonly type: "package" } | { readonly type: "local" } + } => + plugin.status === "active" && + plugin.tui && + (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/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..ddcfa68bae58 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -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") }, + status: "active", + 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/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 74623b94e14c..111dba2f98d1 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,7 +36,7 @@ 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 }, @@ -47,7 +47,7 @@ plugins. 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" @@ -145,7 +145,7 @@ Pass options with the object form in `opencode.json(c)`. { "plugins": [ { - "package": "./plugins/company-effect.ts", + "package": "./plugins/company-effect", "options": { "strict": true }, }, ], @@ -154,7 +154,7 @@ Pass options with the object form in `opencode.json(c)`. 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" @@ -173,7 +173,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" @@ -194,7 +194,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 @@ -212,7 +212,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 diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 82c6e2b1ada8..bf1cf15c446e 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,7 +93,7 @@ Pass plugin options with the object form in `opencode.json(c)`. { "plugins": [ { - "package": "./plugins/company.ts", + "package": "./plugins/company", "options": { "strict": true, }, @@ -104,7 +104,7 @@ Pass plugin options with the object form in `opencode.json(c)`. 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({ 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 ``` From 6c3b8fffedda45923e4a2fb66fdf933a5776f59d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:03:05 -0400 Subject: [PATCH 09/20] fix(rpc): finalize transport semantics --- packages/client/src/effect/api/api.ts | 2 +- .../client/src/promise/generated/types.ts | 4 ++-- packages/client/src/shared-events.ts | 20 +++++------------ packages/httpapi-codegen/src/index.ts | 2 +- .../httpapi-codegen/test/generate.test.ts | 22 ------------------- packages/protocol/src/errors.ts | 2 +- packages/protocol/src/groups/rpc.ts | 4 +++- packages/protocol/test/rpc.test.ts | 5 ++++- packages/server/src/handlers/rpc.ts | 12 +++++----- packages/server/test/rpc.test.ts | 8 ++++++- 10 files changed, 32 insertions(+), 49 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index bb3b326bb9f5..6dd17e698bc0 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1579,7 +1579,7 @@ export type RpcCallInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly input?: unknown | undefined } -export type RpcCallOutput = { readonly output?: unknown | undefined } +export type RpcCallOutput = { readonly output?: unknown } export type RpcCallOperation = (input: RpcCallInput) => Effect.Effect export interface RpcApi { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ad86c031147b..d228103ad75c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -333,7 +333,7 @@ export type SkillInfo = { content: string } -export type RpcOutput = { output?: JsonValue } +export type RpcOutput = { output?: any } export type PermissionReply = "once" | "always" | "reject" @@ -5705,7 +5705,7 @@ export type RpcCallInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] - readonly input?: { readonly input?: JsonValue }["input"] + readonly input?: { readonly input: JsonValue }["input"] } export type RpcCallOutput = RpcOutput diff --git a/packages/client/src/shared-events.ts b/packages/client/src/shared-events.ts index 474a36f9ec8e..4dd07402e944 100644 --- a/packages/client/src/shared-events.ts +++ b/packages/client/src/shared-events.ts @@ -10,7 +10,6 @@ export function make(connect: (signal: Abor controller: AbortController subscribers: Set connected?: A - read?: ReturnType>> } let current: Connection | undefined @@ -18,7 +17,6 @@ export function make(connect: (signal: Abor function stop(connection: Connection) { connection.connected = undefined - connection.read?.resolve({ done: true, value: undefined }) connection.controller.abort() if (current === connection) current = undefined } @@ -30,11 +28,7 @@ export function make(connect: (signal: Abor if (connection.controller.signal.aborted) return iterator = connect(connection.controller.signal)[Symbol.asyncIterator]() while (!connection.controller.signal.aborted) { - // Cancellation must reach return() even when the source has a pending next(). - connection.read = Promise.withResolvers>() - iterator.next().then(connection.read.resolve, connection.read.reject) - const item = await connection.read.promise - connection.read = undefined + 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))) @@ -62,12 +56,10 @@ export function make(connect: (signal: Abor let connection: Connection | undefined let offered: { readonly value: A; readonly accepted: ReturnType> } | undefined - function finish(result: Completion, discard = false) { + function finish(result: Completion) { completion = result - if (discard || "error" in result) { - offered?.accepted.resolve() - offered = undefined - } + 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) => { @@ -77,7 +69,7 @@ export function make(connect: (signal: Abor } function abort() { - finish({}, true) + finish({}) } const subscriber: Subscriber = { @@ -134,7 +126,7 @@ export function make(connect: (signal: Abor return request.promise }, return(): Promise> { - finish({}, true) + finish({}) return Promise.resolve({ done: true, value: undefined }) }, } diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 8ba0a5b36436..5c15bc36e9e5 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1200,7 +1200,7 @@ function codegenAsts(roots: ReadonlyArray) { "id" in representation && representation.id === "effect/schema/Json" ) { - return ast.context?.isOptional ? Schema.optionalKey(Schema.Json).ast : Schema.Json.ast + return Schema.Json.ast } if (ast.annotations?.["~constructor"] !== undefined && ast.typeParameters[0] !== undefined) { const identifier = SchemaAST.resolveIdentifier(ast) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index ae8aa3746c2d..8f1675bd752c 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -582,28 +582,6 @@ describe("HttpApiCodegen.generate", () => { ) }) - test("preserves optional keys when HTTP normalization converts unknown to JSON", () => { - const OptionalUnknown = Schema.optionalKey(Schema.Unknown).pipe( - Schema.decodeTo(Schema.optional(Schema.Unknown), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.passthrough({ strict: false }), - }), - ) - const output = emitPromise( - compileContract( - api( - HttpApiEndpoint.get("get", "/rpc", { - success: Schema.Struct({ output: OptionalUnknown }).annotate({ identifier: "RpcOutput" }), - }), - ), - ), - ) - - expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( - 'export type RpcOutput = { readonly "output"?: JsonValue }', - ) - }) - test("supports name-discriminated Promise errors", () => { class NamedError extends Schema.Error("NamedError")( { name: Schema.Literal("NamedError"), message: Schema.String }, diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 2e19fba1e40c..54f8b396638f 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -24,7 +24,7 @@ export class RpcError extends Schema.TaggedError()( export class RpcInternalError extends Schema.TaggedError()( "RpcInternalError", { - type: Schema.Literal("rpc.internal"), + type: Schema.Literals(["rpc.internal", "rpc.invalid_output"]), message: Schema.String, data: Schema.optional(Schema.Unknown), }, diff --git a/packages/protocol/src/groups/rpc.ts b/packages/protocol/src/groups/rpc.ts index 0772640ea228..df9edc5c43ac 100644 --- a/packages/protocol/src/groups/rpc.ts +++ b/packages/protocol/src/groups/rpc.ts @@ -5,7 +5,9 @@ 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: optional(Schema.Unknown) }).annotate({ identifier: "Rpc.Output" }) +export const RpcOutput = Schema.Struct({ output: Schema.optionalKey(Schema.Unknown) }).annotate({ + identifier: "Rpc.Output", +}) export const RpcGroup = HttpApiGroup.make("server.rpc") .add( diff --git a/packages/protocol/test/rpc.test.ts b/packages/protocol/test/rpc.test.ts index f7a246d5e20b..220e8b55f929 100644 --- a/packages/protocol/test/rpc.test.ts +++ b/packages/protocol/test/rpc.test.ts @@ -7,7 +7,7 @@ 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)({ output: 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" }]) { @@ -36,6 +36,9 @@ test("RPC errors use the standard transport wrapper", () => { 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", () => { diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts index 568578b698ad..661e6b559e1c 100644 --- a/packages/server/src/handlers/rpc.ts +++ b/packages/server/src/handlers/rpc.ts @@ -16,11 +16,13 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) => }).pipe( Effect.mapError( (error) => - new RpcError({ - type: error.type, - message: error.message, - ...(error.data === undefined ? {} : { data: error.data }), - }), + 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( diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts index ccd16e892312..a37e25d1f61f 100644 --- a/packages/server/test/rpc.test.ts +++ b/packages/server/test/rpc.test.ts @@ -159,7 +159,6 @@ it.live("dispatches RPC wrappers with query, header and default locations and ge error: { type: "rejected", message: "handler failed", data: { reason: "declared" } }, }, { route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } }, - { route: "transport.echo/invalid", body: {}, error: { type: "rpc.invalid_output" } }, ], (item) => Effect.gen(function* () { @@ -179,6 +178,13 @@ it.live("dispatches RPC wrappers with query, header and default locations and ge 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({ From 835414bdaff0caebf2dceda78d535a1e6b040fd4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:15:45 -0400 Subject: [PATCH 10/20] feat(plugin): report plugin features --- .../e2e/regression/project-extensions.spec.ts | 2 +- .../e2e/regression/settings-loading.spec.ts | 7 ++- .../app/src/providers/catalog/plugin.test.ts | 18 ++++++-- .../cli/src/commands/handlers/plugin/list.ts | 2 +- packages/cli/test/plugin-list.test.ts | 14 +++--- .../client/src/promise/generated/types.ts | 8 ++-- packages/core/src/plugin.ts | 4 +- packages/core/src/plugin/module.ts | 8 ++-- packages/core/src/plugin/supervisor.ts | 2 +- packages/core/test/config/plugin.test.ts | 2 +- packages/core/test/location-layer.test.ts | 2 +- packages/core/test/plugin.test.ts | 43 +++++++++++++++---- .../plugin/fixtures/config-promise-plugin.ts | 2 +- packages/plugin/src/effect/plugin.ts | 3 +- packages/plugin/src/promise/adapter.ts | 2 +- packages/plugin/src/promise/plugin.ts | 3 +- packages/protocol/openapi.json | 32 +++++++++++--- packages/schema/src/plugin.ts | 11 ++++- packages/tui/src/plugin/context.tsx | 2 +- packages/tui/test/plugin-hot-reload.test.tsx | 4 +- packages/www/openapi.json | 32 +++++++++++--- packages/www/public/openapi.json | 32 +++++++++++--- .../src/docs/content/build/plugins/cli.mdx | 4 +- .../src/docs/content/build/plugins/effect.mdx | 6 ++- .../src/docs/content/build/plugins/index.mdx | 1 + 25 files changed, 180 insertions(+), 66 deletions(-) diff --git a/packages/app/e2e/regression/project-extensions.spec.ts b/packages/app/e2e/regression/project-extensions.spec.ts index fdd0070eec8f..818a46d140e5 100644 --- a/packages/app/e2e/regression/project-extensions.spec.ts +++ b/packages/app/e2e/regression/project-extensions.spec.ts @@ -66,7 +66,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag id, source: { type: "package", package: id }, status: "active", - tui: false, + 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..0571935efb38 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" }, + 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..188296058d4d 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" }, status: "active", features: { server: true } }, + { + id: "package-plugin", + source: { type: "package", package: "example" }, + status: "active", + features: { server: true }, + }, + { + id: "local-plugin", + source: { type: "local", path: "/tmp/plugin.ts" }, + status: "active", + features: { server: true }, + }, + { id: "sdk-plugin", source: { type: "sdk" }, 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 6e12d3ddf02e..e67196c78df7 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -51,7 +51,7 @@ export function format( .toSorted((a, b) => name(a).localeCompare(name(b))) .map((plugin) => `${name(plugin)} (${plugin.status})`) const advertised = plugins.flatMap((plugin) => - plugin.status !== "active" || !plugin.tui + plugin.status !== "active" || !plugin.features.tui ? [] : plugin.source.type === "package" ? [{ target: plugin.source.package, source: "advertised" as const }] diff --git a/packages/cli/test/plugin-list.test.ts b/packages/cli/test/plugin-list.test.ts index 1a14f24580ee..ee4ed8ab9176 100644 --- a/packages/cli/test/plugin-list.test.ts +++ b/packages/cli/test/plugin-list.test.ts @@ -6,24 +6,24 @@ 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" }, status: "active", features: { server: true } }, { id: "acme.dual", source: { type: "package", package: "acme-plugin@1.0.0" }, status: "active", - tui: true, + features: { server: true, tui: true }, }, { source: { type: "package", package: "broken-plugin" }, status: "failed", error: "broken", - tui: false, + features: { server: true }, }, { id: "local.dual", source: { type: "local", path: "/tmp/local/index.ts" }, status: "active", - tui: true, + features: { server: true, tui: true }, }, ], [ @@ -49,6 +49,10 @@ test("formats server and TUI plugins in sections without builtins", () => { 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" }, status: "active", features: { server: true } }], + [], + true, + ), ).toBe(["Server", "opencode.agent (active)"].join(EOL)) }) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d228103ad75c..cacc04acdee1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -16,6 +16,8 @@ export type PluginSource = | { type: "local"; path: string } | { type: "sdk" } +export type PluginFeatures = { server?: true; tui?: true; rpc?: true } + export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } export type MoneyUSD = number @@ -445,8 +447,8 @@ 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 } + | { id: string; source: PluginSource; status: "active"; features: PluginFeatures } + | { id?: string; source: PluginSource; status: "failed"; error: string; features: PluginFeatures } export type SessionMessageLocationSwitched = { id: string @@ -2504,7 +2506,7 @@ export const isRpcError = (value: unknown): value is RpcError => export type RpcInternalError = { readonly _tag: "RpcInternalError" - readonly type: "rpc.internal" + readonly type: "rpc.internal" | "rpc.invalid_output" readonly message: string readonly data?: unknown | undefined } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index bcd0e9a31b4c..3acb34f44a68 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -125,7 +125,7 @@ const layer = Layer.effect( source: definition.source ?? { type: "builtin" }, status: "failed", error: loaded.error, - tui: definition.tui ?? false, + features: { server: true, ...definition.features }, }) if (!previous) continue @@ -177,7 +177,7 @@ function activeInfo(plugin: Versioned): Plugin.Info { id: Plugin.ID.make(plugin.id), source: plugin.source ?? { type: "builtin" }, status: "active", - tui: plugin.tui ?? false, + features: { server: true, ...plugin.features }, } } diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 56cb837aaadc..4ee37f87398e 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -1,6 +1,7 @@ export * as PluginModule from "./module.js" import type { Plugin } from "@opencode-ai/plugin/effect/plugin" +import { Features } from "@opencode-ai/schema/plugin" import { Npm } from "@opencode-ai/util/npm" import { importModule } from "@opencode-ai/util/runtime-import" import { Effect, Schema } from "effect" @@ -14,18 +15,17 @@ 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), + features: Schema.optional(Features), 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), + features: Schema.optional(Features), vcs: Schema.optional(Discovery), setup: Schema.declare[0]["setup"]>( (input): input is Parameters[0]["setup"] => typeof input === "function", @@ -51,7 +51,7 @@ export const load = Effect.fn("PluginModule.load")(function* ( const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) return { id: plugin.id, - tui: plugin.tui, + features: plugin.features, vcs: plugin.vcs, version: JSON.stringify(operation), source: path.isAbsolute(operation.target) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index a656b4d42316..fed7b3a69dce 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -60,7 +60,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( source: pluginSource(operation.target), status: "failed", error: plugin.error, - tui: false, + features: { server: true }, }) continue } diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index ddb2064061c2..da141b1f46fb 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -124,7 +124,7 @@ describe("PluginSupervisor config", () => { path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"), }, status: "active", - tui: true, + features: { server: true, tui: true }, }) }), ), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index c0936569ca3d..288885874b6a 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -575,7 +575,7 @@ describe("LocationServiceMap", () => { source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") }, status: "failed", error: expect.stringContaining("plugin failed"), - tui: false, + features: { server: true }, }, ]) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index b73f8e311726..0be1d4a1b2e0 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -270,7 +270,7 @@ describe("Plugin", () => { source: { type: "package", package: "broken" }, status: "failed", error: "failed to resolve", - tui: false, + features: { server: true }, }, ], ) @@ -331,7 +331,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" }, status: "active", features: { server: true } }, + ]) + }), + ) + + it.effect("reports activated and declared 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" }, + status: "active", + features: { server: true, rpc: true }, + }, + ]) }), ) @@ -361,13 +381,13 @@ 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" }, status: "active", features: { server: true } }, { id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "failed", error: expect.stringContaining("materialization failed"), - tui: false, + features: { server: true }, }, ]) expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded") @@ -375,8 +395,8 @@ 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" }, status: "active", features: { server: true } }, + { id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", features: { server: true } }, ]) }), ) @@ -413,7 +433,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" }, + 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"]) @@ -459,7 +484,7 @@ describe("Plugin", () => { source: { type: "builtin" }, status: "failed", error: expect.stringContaining("replacement failed"), - tui: false, + features: { server: true }, }, ]) expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous") @@ -499,7 +524,7 @@ describe("Plugin", () => { source: { type: "builtin" }, status: "failed", error: expect.stringContaining("replacement failed"), - tui: false, + features: { server: true }, }, ]) expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index 1e8a0a9fdd64..1b4120038b41 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -2,7 +2,7 @@ import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ id: "config-promise-plugin", - tui: true, + features: { tui: true }, setup: async (ctx) => { await ctx.agent.transform((agents) => { agents.update("configured", (agent) => { diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 78bcb722f5e7..f6e8721d033e 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -1,5 +1,6 @@ import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/client/effect/api" import type { Location } from "@opencode-ai/schema/location" +import type { Features } from "@opencode-ai/schema/plugin" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { VcsDiscovery } from "../vcs.js" @@ -52,7 +53,7 @@ export interface Context { export interface Plugin { readonly id: string - readonly tui?: boolean + readonly features?: Features readonly vcs?: VcsDiscovery readonly effect: (context: Context) => Effect.Effect } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index b20d704b8309..1aed55eb677b 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -214,7 +214,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) { export function fromPromise(plugin: Plugin) { return define({ id: plugin.id, - tui: plugin.tui, + features: plugin.features, vcs: plugin.vcs, effect: (host) => Effect.gen(function* () { diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 5c20208ed135..c0dc556dc67d 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -1,6 +1,7 @@ import type { OpenCodeClient } from "@opencode-ai/client" import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api" import type { Location } from "@opencode-ai/schema/location" +import type { Features } from "@opencode-ai/schema/plugin" import type { PluginOptions } from "../options.js" import type { VcsDiscovery } from "../vcs.js" import type { App } from "../app.js" @@ -54,7 +55,7 @@ export type Cleanup = () => Promise | void export interface Plugin { readonly id: string - readonly tui?: boolean + readonly features?: Features readonly vcs?: VcsDiscovery readonly setup: (context: Context) => Promise | Cleanup | void } diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 23471fbc7582..d873a02972a2 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -16536,6 +16536,24 @@ "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": [ { @@ -16551,11 +16569,11 @@ "type": "string", "enum": ["active"] }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["id", "source", "status", "tui"], + "required": ["id", "source", "status", "features"], "additionalProperties": false }, { @@ -16574,11 +16592,11 @@ "error": { "type": "string" }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["source", "status", "error", "tui"], + "required": ["source", "status", "error", "features"], "additionalProperties": false } ] @@ -17156,7 +17174,7 @@ }, "type": { "type": "string", - "enum": ["rpc.internal"] + "enum": ["rpc.internal", "rpc.invalid_output"] }, "message": { "type": "string" diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 9d3c9e80320a..daff63d88ce7 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -15,19 +15,26 @@ export const Source = Schema.Union([ ]).annotate({ identifier: "Plugin.Source" }) export type Source = typeof Source.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 Info = Schema.Union([ Schema.Struct({ id: ID, source: Source, status: Schema.Literal("active"), - tui: Schema.Boolean, + features: Features, }), Schema.Struct({ id: ID.pipe(optional), source: Source, status: Schema.Literal("failed"), error: Schema.String, - tui: Schema.Boolean, + features: Features, }), ]).annotate({ identifier: "Plugin.Info" }) export type Info = typeof Info.Type diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 8a2b77a3c44f..d29f93b1abcd 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -511,7 +511,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d readonly source: { readonly type: "package" } | { readonly type: "local" } } => plugin.status === "active" && - plugin.tui && + plugin.features.tui === true && (plugin.source.type === "package" || plugin.source.type === "local"), ), ), diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index ddcfa68bae58..9e2e7b7835fa 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -123,7 +123,7 @@ 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, + features: { server: true, tui: true }, }, ], resolve: async (spec, install) => { @@ -158,7 +158,7 @@ test("loads an advertised local TUI entrypoint beside its server entrypoint", as id: "test.server", source: { type: "local", path: path.join(plugin, "index.ts") }, status: "active", - tui: true, + features: { server: true, tui: true }, }, ], }) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 23471fbc7582..d873a02972a2 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -16536,6 +16536,24 @@ "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": [ { @@ -16551,11 +16569,11 @@ "type": "string", "enum": ["active"] }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["id", "source", "status", "tui"], + "required": ["id", "source", "status", "features"], "additionalProperties": false }, { @@ -16574,11 +16592,11 @@ "error": { "type": "string" }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["source", "status", "error", "tui"], + "required": ["source", "status", "error", "features"], "additionalProperties": false } ] @@ -17156,7 +17174,7 @@ }, "type": { "type": "string", - "enum": ["rpc.internal"] + "enum": ["rpc.internal", "rpc.invalid_output"] }, "message": { "type": "string" diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 23471fbc7582..d873a02972a2 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -16536,6 +16536,24 @@ "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": [ { @@ -16551,11 +16569,11 @@ "type": "string", "enum": ["active"] }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["id", "source", "status", "tui"], + "required": ["id", "source", "status", "features"], "additionalProperties": false }, { @@ -16574,11 +16592,11 @@ "error": { "type": "string" }, - "tui": { - "type": "boolean" + "features": { + "$ref": "#/components/schemas/Plugin.Features" } }, - "required": ["source", "status", "error", "tui"], + "required": ["source", "status", "error", "features"], "additionalProperties": false } ] @@ -17156,7 +17174,7 @@ }, "type": { "type": "string", - "enum": ["rpc.internal"] + "enum": ["rpc.internal", "rpc.invalid_output"] }, "message": { "type": "string" diff --git a/packages/www/src/docs/content/build/plugins/cli.mdx b/packages/www/src/docs/content/build/plugins/cli.mdx index c2500a6aad24..3a6fb3e93ec0 100644 --- a/packages/www/src/docs/content/build/plugins/cli.mdx +++ b/packages/www/src/docs/content/build/plugins/cli.mdx @@ -436,14 +436,14 @@ 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. +Set `features.tui` on 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, + features: { 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 111dba2f98d1..62044a32df00 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -132,7 +132,11 @@ interface Context { interface Plugin { readonly id: string - readonly tui?: boolean + readonly features?: { + readonly server?: true + readonly tui?: true + readonly rpc?: true + } readonly effect: (context: Context) => Effect.Effect } ``` diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index bf1cf15c446e..e180b51a4670 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -637,6 +637,7 @@ import { Acme } from "./rpc.js" export default Plugin.define({ id: "acme-plugin", + features: { rpc: true }, async setup(ctx) { const registration = await ctx.rpc.register(Acme, { search: async ({ query }, context) => { From b40ad3b0908929e4748195f3d577e2992a45724b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:24:44 -0400 Subject: [PATCH 11/20] fix(plugin): infer available features --- packages/core/src/plugin.ts | 1 + packages/core/src/plugin/module.ts | 42 +++++++++++++++---- packages/core/test/plugin.test.ts | 2 +- .../plugin/fixtures/config-promise-plugin.ts | 1 - .../plugin/fixtures/config-promise/tui.ts | 1 + packages/core/test/plugin/module.test.ts | 9 +++- packages/plugin/src/effect/plugin.ts | 2 - packages/plugin/src/promise/adapter.ts | 1 - packages/plugin/src/promise/plugin.ts | 2 - .../src/docs/content/build/plugins/cli.mdx | 3 +- .../src/docs/content/build/plugins/effect.mdx | 5 --- .../src/docs/content/build/plugins/index.mdx | 1 - 12 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 packages/core/test/plugin/fixtures/config-promise/tui.ts diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 3acb34f44a68..cce74ac2f73f 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -39,6 +39,7 @@ export interface Interface { export type Versioned = PluginDefinition & { readonly version: string readonly source?: Plugin.Source + readonly features?: Plugin.Features } export class Service extends Context.Service()("@opencode/Plugin") {} diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 4ee37f87398e..aede3b5b791e 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -1,10 +1,10 @@ export * as PluginModule from "./module.js" import type { Plugin } from "@opencode-ai/plugin/effect/plugin" -import { Features } from "@opencode-ai/schema/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" @@ -19,13 +19,11 @@ const Definition = Schema.Struct({ default: Schema.Union([ Schema.Struct({ id: Schema.String, - features: Schema.optional(Features), vcs: Schema.optional(Discovery), effect: Schema.declare((input): input is Plugin["effect"] => typeof input === "function"), }), Schema.Struct({ id: Schema.String, - features: Schema.optional(Features), 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, - features: plugin.features, + 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/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 0be1d4a1b2e0..0e5b0eeefa72 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -337,7 +337,7 @@ describe("Plugin", () => { }), ) - it.effect("reports activated and declared plugin features", () => + it.effect("reports activated and discovered plugin features", () => Effect.gen(function* () { const plugins = yield* Plugin.Service yield* plugins.activate([ diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index 1b4120038b41..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", - features: { tui: true }, setup: async (ctx) => { await ctx.agent.transform((agents) => { agents.update("configured", (agent) => { 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/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/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index f6e8721d033e..5b0cea849a71 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -1,6 +1,5 @@ import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/client/effect/api" import type { Location } from "@opencode-ai/schema/location" -import type { Features } from "@opencode-ai/schema/plugin" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { VcsDiscovery } from "../vcs.js" @@ -53,7 +52,6 @@ export interface Context { export interface Plugin { readonly id: string - readonly features?: Features readonly vcs?: VcsDiscovery readonly effect: (context: Context) => Effect.Effect } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 1aed55eb677b..0eaa50270d90 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -214,7 +214,6 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) { export function fromPromise(plugin: Plugin) { return define({ id: plugin.id, - features: plugin.features, vcs: plugin.vcs, effect: (host) => Effect.gen(function* () { diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index c0dc556dc67d..362685299add 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -1,7 +1,6 @@ import type { OpenCodeClient } from "@opencode-ai/client" import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api" import type { Location } from "@opencode-ai/schema/location" -import type { Features } from "@opencode-ai/schema/plugin" import type { PluginOptions } from "../options.js" import type { VcsDiscovery } from "../vcs.js" import type { App } from "../app.js" @@ -55,7 +54,6 @@ export type Cleanup = () => Promise | void export interface Plugin { readonly id: string - readonly features?: Features readonly vcs?: VcsDiscovery readonly setup: (context: Context) => Promise | Cleanup | void } diff --git a/packages/www/src/docs/content/build/plugins/cli.mdx b/packages/www/src/docs/content/build/plugins/cli.mdx index 3a6fb3e93ec0..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 `features.tui` 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", - features: { 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 62044a32df00..398f0eda1af8 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -132,11 +132,6 @@ interface Context { interface Plugin { readonly id: string - readonly features?: { - readonly server?: true - readonly tui?: true - readonly rpc?: true - } readonly effect: (context: Context) => Effect.Effect } ``` diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index e180b51a4670..bf1cf15c446e 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -637,7 +637,6 @@ import { Acme } from "./rpc.js" export default Plugin.define({ id: "acme-plugin", - features: { rpc: true }, async setup(ctx) { const registration = await ctx.rpc.register(Acme, { search: async ({ query }, context) => { From 13b204ac2dc92bf30816b453522ea0c265314d13 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:34:09 -0400 Subject: [PATCH 12/20] refactor(rpc): replace namespace with id --- packages/client/src/effect/api/api.ts | 2 +- .../client/src/effect/generated/client.ts | 2 +- packages/client/src/effect/rpc.ts | 2 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 4 +- packages/client/src/promise/rpc.ts | 8 ++-- packages/client/src/rpc-runtime.ts | 4 +- packages/client/test/rpc-effect.test.ts | 10 ++--- packages/client/test/rpc-promise.test.ts | 24 ++++++------ packages/core/src/rpc.ts | 28 +++++++------- packages/core/test/plugin/rpc-effect.test.ts | 4 +- packages/core/test/plugin/rpc-promise.test.ts | 8 ++-- packages/core/test/rpc.test.ts | 38 +++++++++---------- packages/plugin/test/rpc-effect.types.ts | 4 +- packages/plugin/test/rpc-promise.types.ts | 18 ++++----- packages/plugin/test/rpc.fixture.ts | 4 +- packages/plugin/test/rpc.test.ts | 8 ++-- packages/protocol/openapi.json | 6 +-- packages/protocol/src/groups/rpc.ts | 6 +-- packages/protocol/test/rpc.test.ts | 6 +-- packages/schema/src/rpc.ts | 6 +-- packages/server/src/handlers/rpc.ts | 2 +- packages/server/test/rpc.test.ts | 10 ++--- packages/www/openapi.json | 6 +-- packages/www/public/openapi.json | 6 +-- .../src/docs/content/build/client/effect.mdx | 2 +- .../src/docs/content/build/client/index.mdx | 8 ++-- .../src/docs/content/build/plugins/index.mdx | 13 +++---- 28 files changed, 120 insertions(+), 121 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 6dd17e698bc0..0aedf0b0d71a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1574,7 +1574,7 @@ export interface SkillApi { } export type RpcCallInput = { - readonly namespace: string + readonly rpcID: string readonly method: string readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly input?: unknown | undefined diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index f6ecc3dbe352..3bda6a383e7b 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1171,7 +1171,7 @@ const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSki const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) => preserveEffect()( raw["rpc.call"]({ - params: { namespace: input["namespace"], method: input["method"] }, + params: { rpcID: input["rpcID"], method: input["method"] }, query: { location: input["location"] }, payload: { input: input["input"] }, }).pipe(Effect.mapError(mapClientError)), diff --git a/packages/client/src/effect/rpc.ts b/packages/client/src/effect/rpc.ts index 4603b008960f..b1933ba11bae 100644 --- a/packages/client/src/effect/rpc.ts +++ b/packages/client/src/effect/rpc.ts @@ -47,7 +47,7 @@ export function make( const result = Effect.gen(function* () { const response = yield* call( { - namespace: definition.namespace, + rpcID: definition.id, method: name, input, location: options?.location, diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 2fef3277f48f..8f05dded3638 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1601,7 +1601,7 @@ export function make(options: ClientOptions) { request( { method: "POST", - path: `/api/rpc/${encodeURIComponent(input.namespace)}/${encodeURIComponent(input.method)}`, + path: `/api/rpc/${encodeURIComponent(input.rpcID)}/${encodeURIComponent(input.method)}`, query: { location: input["location"] }, body: { input: input["input"] }, successStatus: 200, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index cacc04acdee1..57d344c6a724 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -5702,8 +5702,8 @@ export type SkillListOutput = { } export type RpcCallInput = { - readonly namespace: { readonly namespace: string; readonly method: string }["namespace"] - readonly method: { readonly namespace: string; readonly method: string }["method"] + 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"] diff --git a/packages/client/src/promise/rpc.ts b/packages/client/src/promise/rpc.ts index ba89315e995a..1d3c270bb5cf 100644 --- a/packages/client/src/promise/rpc.ts +++ b/packages/client/src/promise/rpc.ts @@ -37,7 +37,7 @@ type RpcEventPayloadFor< D extends Rpc.PortableDefinition, Name extends keyof D["events"] & string, > = Omit & { - type: `rpc.${D["namespace"]}.${Name}` + type: `rpc.${D["id"]}.${Name}` data: Rpc.EventData } @@ -59,7 +59,7 @@ export function makeRpc( name: string, options?: Pick, ): AsyncIterable> => { - if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`) + if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.id}.${name}`) const type = eventType(definition, name) return { [Symbol.asyncIterator]() { @@ -101,7 +101,7 @@ export function makeRpc( try { const result = await raw.rpc.call( { - namespace: definition.namespace, + 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 @@ -143,5 +143,5 @@ export function makeRpc( } function eventType(definition: Rpc.PortableDefinition, name: string) { - return `rpc.${definition.namespace}.${name}` as const + return `rpc.${definition.id}.${name}` as const } diff --git a/packages/client/src/rpc-runtime.ts b/packages/client/src/rpc-runtime.ts index 71ff31abd1ba..3b79ca933b42 100644 --- a/packages/client/src/rpc-runtime.ts +++ b/packages/client/src/rpc-runtime.ts @@ -55,6 +55,6 @@ export const event = Effect.fn("Client.Rpc.event")(function* < export function eventType( definition: D, name: Name, -): `rpc.${D["namespace"]}.${Name}` { - return `rpc.${definition.namespace}.${name}` +): `rpc.${D["id"]}.${Name}` { + return `rpc.${definition.id}.${name}` } diff --git a/packages/client/test/rpc-effect.test.ts b/packages/client/test/rpc-effect.test.ts index 5c15c1fec94e..f19ad0a2fa79 100644 --- a/packages/client/test/rpc-effect.test.ts +++ b/packages/client/test/rpc-effect.test.ts @@ -5,7 +5,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { OpenCode } from "../src/effect/index" const definition = Rpc.define({ - namespace: "example", + id: "example", methods: { count: { input: Schema.Struct({ count: Schema.FiniteFromString }), @@ -24,11 +24,11 @@ const definition = Rpc.define({ const connected = { id: "evt_connected", type: "server.connected", data: {} } -function rpcEvent(count: unknown, directory = "/project/one", namespace = "example", name = "progress") { +function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") { return { id: "evt_progress", created: 123, - type: `rpc.${namespace}.${name}`, + type: `rpc.${rpcID}.${name}`, location: { directory }, metadata: { origin: "test" }, data: { count }, @@ -93,7 +93,7 @@ test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw n 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({ namespace: "example", method: "count", input: null }) + 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) @@ -122,7 +122,7 @@ test("Effect RPC trusts server-side Standard Schema transforms for outputs and e }, } const service = Rpc.define({ - namespace: "standard", + id: "standard", methods: { transform: { input: standard, output: standard } }, events: { transformed: { diff --git a/packages/client/test/rpc-promise.test.ts b/packages/client/test/rpc-promise.test.ts index 4488ea3bc1d2..08ea70242699 100644 --- a/packages/client/test/rpc-promise.test.ts +++ b/packages/client/test/rpc-promise.test.ts @@ -11,7 +11,7 @@ afterEach(() => { }) const Echo = Rpc.define({ - namespace: "acme/jobs", + id: "acme/jobs", methods: { echo: { input: z.string(), @@ -26,10 +26,10 @@ const Echo = Rpc.define({ }, }) const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} } -const rpcEvent = (data: unknown, directory = "/first", namespace = Echo.namespace, name = "updated") => ({ +const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({ id: "evt_rpc", created: 10, - type: `rpc.${namespace}.${name}`, + type: `rpc.${rpcID}.${name}`, location: { directory }, metadata: { source: "test" }, data, @@ -115,7 +115,7 @@ test("rpc is callable, retains raw call, and routes method location, headers, an 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({ namespace: Echo.namespace, method: "echo", input: "raw" })).toEqual({ output: "raw" }) + 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") }) @@ -163,7 +163,7 @@ test("RPC Standard Schema results are already parsed and are not transformed aga }, } const definition = Rpc.define({ - namespace: "standard", + id: "standard", methods: { count: { input, output } }, events: { counted: { schema: eventOutput } }, }) @@ -175,7 +175,7 @@ test("RPC Standard Schema results are already parsed and are not transformed aga 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.namespace, "counted")) + 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 }) @@ -223,7 +223,7 @@ test("RPC method failures remove the generic transport wrapper", async () => { 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({ namespace: Echo.namespace, method: "echo", input: "hello" })).rejects.toEqual(response) + 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 () => { @@ -239,7 +239,7 @@ test("native events and multiple RPC clients share one lazy source across locati 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, namespace: "other" }) + 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() @@ -251,8 +251,8 @@ test("native events and multiple RPC clients share one lazy source across locati 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.namespace, "unknown")) - await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.namespace)) + 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" }, @@ -263,7 +263,7 @@ test("native events and multiple RPC clients share one lazy source across locati const expected = { id: "evt_rpc", created: 10, - type: `rpc.${Echo.namespace}.updated`, + type: `rpc.${Echo.id}.updated`, location: { directory: "/first" }, metadata: { source: "test" }, data: { count: 42 }, @@ -310,7 +310,7 @@ test("RPC callback subscriptions unsubscribe independently", async () => { 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.namespace}.updated` }) + expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` }) unsubscribe() unsubscribe() expect(source.requests[0].signal.aborted).toBe(false) diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index 3ce0f7002e37..1f306f61066b 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -16,7 +16,7 @@ import { optional, statics } from "./schema.js" export interface Interface { readonly register: RpcDomain["register"] readonly client: (definition: D) => RpcClient - readonly call: (namespace: string, method: string, input: unknown) => Effect.Effect + readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect } export class Service extends Context.Service()("@opencode/Rpc") {} @@ -70,16 +70,16 @@ const layer = Layer.effect( ) { const entry = { definition, handlers } const dispose = Effect.sync(() => { - const remaining = (registrations.get(definition.namespace) ?? []).filter((candidate) => candidate !== entry) + const remaining = (registrations.get(definition.id) ?? []).filter((candidate) => candidate !== entry) if (remaining.length === 0) { - registrations.delete(definition.namespace) + registrations.delete(definition.id) return } - registrations.set(definition.namespace, remaining) + registrations.set(definition.id, remaining) }) yield* Effect.acquireRelease( Effect.sync(() => - registrations.set(definition.namespace, [...(registrations.get(definition.namespace) ?? []), entry]), + registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry]), ), () => dispose, ) @@ -91,7 +91,7 @@ const layer = Layer.effect( 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.namespace}.${args[0]}`)) + 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 @@ -106,12 +106,12 @@ const layer = Layer.effect( } }) - const call = Effect.fn("Rpc.call")(function* (namespace: string, name: string, input: unknown) { - const entry = registrations.get(namespace)?.at(-1) + 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.namespace_unavailable", `RPC namespace is unavailable: ${namespace}`)) + 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: ${namespace}.${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( @@ -133,7 +133,7 @@ const layer = Layer.effect( Object.entries(definition.methods).map(([name, method]) => [ name, (input: unknown) => - call(definition.namespace, name, input).pipe( + 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)))), ), @@ -146,7 +146,7 @@ const layer = Layer.effect( events: { subscribe: (name: Name) => { const registered = events.get(name) - if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.namespace}.${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)), @@ -174,8 +174,8 @@ const jsonSchemas = new WeakMap>() function eventType( definition: D, name: Name, -): `rpc.${D["namespace"]}.${Name}` { - return `rpc.${definition.namespace}.${name}` +): `rpc.${D["id"]}.${Name}` { + return `rpc.${definition.id}.${name}` } function eventDefinition(definition: Rpc.Definition, name: string): Event.Definition { diff --git a/packages/core/test/plugin/rpc-effect.test.ts b/packages/core/test/plugin/rpc-effect.test.ts index d0ff0f826f25..f158bd78fd68 100644 --- a/packages/core/test/plugin/rpc-effect.test.ts +++ b/packages/core/test/plugin/rpc-effect.test.ts @@ -9,7 +9,7 @@ import { testEffect } from "../lib/effect" const it = testEffect(PluginTestLayer) const Echo = Rpc.define({ - namespace: "shared-echo", + id: "shared-echo", methods: { echo: { input: Schema.String, output: Schema.String }, fail: { @@ -21,7 +21,7 @@ const Echo = Rpc.define({ events: { updated: { schema: Schema.Struct({ text: Schema.String }) } }, }) -it.effect("Effect plugins register, call, and publish namespaces independently of plugin identity", () => +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 diff --git a/packages/core/test/plugin/rpc-promise.test.ts b/packages/core/test/plugin/rpc-promise.test.ts index f969d8f1179a..69ef2d25adc3 100644 --- a/packages/core/test/plugin/rpc-promise.test.ts +++ b/packages/core/test/plugin/rpc-promise.test.ts @@ -16,7 +16,7 @@ describe("Promise plugin RPC", () => { Effect.gen(function* () { const plugins = yield* Plugin.Service const service = Rpc.define({ - namespace: "promise-rpc-calls", + id: "promise-rpc-calls", methods: { standard: { input: z.string().transform(Number), output: z.number().transform(String) }, ping: { input: z.undefined(), output: z.null() }, @@ -93,7 +93,7 @@ describe("Promise plugin RPC", () => { Effect.gen(function* () { const plugins = yield* Plugin.Service const service = Rpc.define({ - namespace: "promise-rpc-cancel", + id: "promise-rpc-cancel", methods: { wait: { input: z.string(), output: z.string() } }, events: {}, }) @@ -150,7 +150,7 @@ describe("Promise plugin RPC", () => { Effect.gen(function* () { const plugins = yield* Plugin.Service const service = Rpc.define({ - namespace: "promise-rpc-async-listeners", + id: "promise-rpc-async-listeners", methods: {}, events: { updated: { schema: z.object({ value: z.number() }) } }, }) @@ -208,7 +208,7 @@ describe("Promise plugin RPC", () => { Effect.gen(function* () { const plugins = yield* Plugin.Service const service = Rpc.define({ - namespace: "promise-rpc-events", + id: "promise-rpc-events", methods: {}, events: { counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) }, diff --git a/packages/core/test/rpc.test.ts b/packages/core/test/rpc.test.ts index 25d9939af4a2..59b23a4e5667 100644 --- a/packages/core/test/rpc.test.ts +++ b/packages/core/test/rpc.test.ts @@ -19,7 +19,7 @@ const it = testEffect( ]), ) const Echo = Rpc.define({ - namespace: "test.rpc", + id: "test.rpc", methods: { echo: { input: z.string(), output: z.string() } }, events: { updated: { schema: z.object({ text: z.string() }) } }, }) @@ -31,19 +31,19 @@ describe("Rpc", () => { const client = rpc.client(Echo) const request = client.echo("hello") expect(yield* request.pipe(Effect.flip)).toEqual({ - type: "rpc.namespace_unavailable", - message: "RPC namespace is unavailable: test.rpc", + 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.namespace, "missing", "hello").pipe(Effect.flip)).toEqual({ + 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.namespace, "toString", "hello").pipe(Effect.flip)).toEqual({ + expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({ type: "rpc.method_not_found", message: "Unknown RPC method: test.rpc.toString", }) @@ -92,11 +92,11 @@ describe("Rpc", () => { return value }), }) - expect(Exit.isFailure(yield* rpc.call(Echo.namespace, "echo", 42).pipe(Effect.exit))).toBe(true) + expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true) expect(received).toEqual([]) const Checked = Rpc.define({ - namespace: "checked", + id: "checked", methods: { echo: { input: z.string(), output: z.string().min(3) } }, events: {}, }) @@ -109,7 +109,7 @@ describe("Rpc", () => { Effect.gen(function* () { const rpc = yield* Rpc.Service const Identity = Rpc.define({ - namespace: "identity", + id: "identity", methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } }, events: {}, }) @@ -124,7 +124,7 @@ describe("Rpc", () => { const rpc = yield* Rpc.Service const counts = { input: 0, output: 0, event: 0 } const Transformed = Rpc.define({ - namespace: "transformed", + id: "transformed", methods: { count: { input: z.string().transform((value) => { @@ -163,12 +163,12 @@ describe("Rpc", () => { Effect.gen(function* () { const rpc = yield* Rpc.Service const Codec = Rpc.define({ - namespace: "codec", + 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.namespace, "count", "41")).toBe("42") + 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) @@ -184,7 +184,7 @@ describe("Rpc", () => { Effect.gen(function* () { const rpc = yield* Rpc.Service const Failing = Rpc.define({ - namespace: "failing", + id: "failing", methods: { standard: { input: z.undefined(), @@ -205,7 +205,7 @@ describe("Rpc", () => { effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })), }) - expect(yield* rpc.call(Failing.namespace, "standard", undefined).pipe(Effect.flip)).toEqual({ + expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({ type: "missing", message: "Missing", data: { attempts: 2 }, @@ -215,7 +215,7 @@ describe("Rpc", () => { message: "Missing", data: { attempts: 2 }, }) - expect(yield* rpc.call(Failing.namespace, "effect", undefined).pipe(Effect.flip)).toEqual({ + expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({ type: "invalid", message: "Invalid", data: { count: "3" }, @@ -251,7 +251,7 @@ describe("Rpc", () => { Effect.gen(function* () { const rpc = yield* Rpc.Service const Raw = Rpc.define({ - namespace: "raw", + id: "raw", methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } }, events: { counted: { @@ -265,9 +265,9 @@ describe("Rpc", () => { }, }) const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) }) - expect(yield* rpc.call(Raw.namespace, "count", 42)).toBe(42) - expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "count", "42").pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "count", 0).pipe(Effect.exit))).toBe(true) + 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) }), @@ -277,7 +277,7 @@ describe("Rpc", () => { Effect.gen(function* () { const rpc = yield* Rpc.Service const Empty = Rpc.define({ - namespace: "empty", + id: "empty", methods: { ping: { input: z.undefined(), output: z.undefined() } }, events: {}, }) diff --git a/packages/plugin/test/rpc-effect.types.ts b/packages/plugin/test/rpc-effect.types.ts index 540795d60783..4c57e64d9d73 100644 --- a/packages/plugin/test/rpc-effect.types.ts +++ b/packages/plugin/test/rpc-effect.types.ts @@ -162,9 +162,9 @@ Stream.map(updates, (event) => { }) // @ts-expect-error Effect custom event data must also be an object. -Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } }) +Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } }) Rpc.define({ - namespace: "invalid-array-event", + 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 index 14c6589c27d1..caac2771612a 100644 --- a/packages/plugin/test/rpc-promise.types.ts +++ b/packages/plugin/test/rpc-promise.types.ts @@ -19,7 +19,7 @@ const raw = acme.raw({ value: "hello" }) const ping = acme.ping() export type Checks = [ - Assert>, + Assert>, Assert>, Assert>>, Assert>>, @@ -119,7 +119,7 @@ await ctx.rpc.register(Acme, { 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 namespace. +// @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 }) @@ -175,9 +175,9 @@ acme.events.subscribe("updated", { headers: { "x-test": "yes" } }) acme.events.on("updated", () => {}, { location: { directory: "/project" } }) // @ts-expect-error Every method requires an output schema. -Rpc.define({ namespace: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} }) +Rpc.define({ id: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} }) Rpc.define({ - namespace: "invalid-error", + id: "invalid-error", methods: { search: { input: z.string(), @@ -189,16 +189,16 @@ Rpc.define({ events: {}, }) // @ts-expect-error The subclient's events member is reserved, not an RPC method. -Rpc.define({ namespace: "invalid", methods: { events: Acme.methods.search }, events: {} }) +Rpc.define({ id: "invalid", methods: { events: Acme.methods.search }, events: {} }) // @ts-expect-error Custom event data must be an object. -Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } }) +Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } }) // @ts-expect-error Custom event data cannot be an array. -Rpc.define({ namespace: "invalid-array-event", methods: {}, events: { updated: { schema: z.array(z.string()) } } }) +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({ namespace: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } }) +Rpc.define({ id: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } }) const LocationInput = Rpc.define({ - namespace: "location-input", + id: "location-input", methods: { echo: { input: z.object({ location: z.string() }), diff --git a/packages/plugin/test/rpc.fixture.ts b/packages/plugin/test/rpc.fixture.ts index d955684a16ee..d32debc2d9b7 100644 --- a/packages/plugin/test/rpc.fixture.ts +++ b/packages/plugin/test/rpc.fixture.ts @@ -4,7 +4,7 @@ import type { Types } from "effect" import { z } from "zod" export const Acme = Rpc.define({ - namespace: "acme", + id: "acme", methods: { search: { input: z.object({ query: z.string() }), @@ -39,7 +39,7 @@ export const Acme = Rpc.define({ }) export const EffectAcme = Rpc.define({ - namespace: "effect-acme", + id: "effect-acme", methods: { codec: { input: Schema.Struct({ count: Schema.FiniteFromString }), diff --git a/packages/plugin/test/rpc.test.ts b/packages/plugin/test/rpc.test.ts index b74f9fc54d05..ed37b4af8d2d 100644 --- a/packages/plugin/test/rpc.test.ts +++ b/packages/plugin/test/rpc.test.ts @@ -3,9 +3,9 @@ import { Rpc } from "@opencode-ai/plugin/rpc" import { fileURLToPath } from "node:url" import { Acme } from "./rpc.fixture.js" -test("definitions preserve their schemas and namespace without registering anything", () => { +test("definitions preserve their schemas and ID without registering anything", () => { expect(Rpc.define(Acme)).toBe(Acme) - expect(Acme.namespace).toBe("acme") + expect(Acme.id).toBe("acme") expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted"]) }) @@ -20,7 +20,7 @@ test("defining an RPC contract does not invoke its schema parser", () => { }, } const definition = Rpc.define({ - namespace: "portable", + id: "portable", methods: { echo: { input: schema, output: schema, errors: { rejected: schema } } }, events: { updated: { schema } }, }) @@ -35,7 +35,7 @@ test("framework RPC error names are reserved", () => { const schema = { type: "null" } const errors = Object.fromEntries([["rpc.internal", schema]]) expect(() => - Rpc.define({ namespace: "reserved", methods: { call: { input: schema, output: schema, errors } }, events: {} }), + Rpc.define({ id: "reserved", methods: { call: { input: schema, output: schema, errors } }, events: {} }), ).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal') }) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index d873a02972a2..c19fb350267b 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -8950,13 +8950,13 @@ "summary": "List skills" } }, - "/api/rpc/{namespace}/{method}": { + "/api/rpc/{rpcID}/{method}": { "post": { "tags": ["rpc"], "operationId": "v2.rpc.call", "parameters": [ { - "name": "namespace", + "name": "rpcID", "in": "path", "schema": { "type": "string" @@ -9062,7 +9062,7 @@ } } }, - "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", + "description": "Dispatch a method to the currently registered RPC at the requested location.", "summary": "Call a plugin RPC", "requestBody": { "content": { diff --git a/packages/protocol/src/groups/rpc.ts b/packages/protocol/src/groups/rpc.ts index df9edc5c43ac..e291dce94a55 100644 --- a/packages/protocol/src/groups/rpc.ts +++ b/packages/protocol/src/groups/rpc.ts @@ -11,8 +11,8 @@ export const RpcOutput = Schema.Struct({ output: Schema.optionalKey(Schema.Unkno export const RpcGroup = HttpApiGroup.make("server.rpc") .add( - HttpApiEndpoint.post("rpc.call", "/api/rpc/:namespace/:method", { - params: { namespace: Schema.String, method: Schema.String }, + HttpApiEndpoint.post("rpc.call", "/api/rpc/:rpcID/:method", { + params: { rpcID: Schema.String, method: Schema.String }, query: LocationQuery, payload: RpcInput, success: RpcOutput, @@ -23,7 +23,7 @@ export const RpcGroup = HttpApiGroup.make("server.rpc") OpenApi.annotations({ identifier: "v2.rpc.call", summary: "Call a plugin RPC", - description: "Dispatch a method to the currently registered RPC namespace at the requested location.", + description: "Dispatch a method to the currently registered RPC at the requested location.", }), ), ) diff --git a/packages/protocol/test/rpc.test.ts b/packages/protocol/test/rpc.test.ts index 220e8b55f929..f3f9d25ad1b6 100644 --- a/packages/protocol/test/rpc.test.ts +++ b/packages/protocol/test/rpc.test.ts @@ -46,12 +46,12 @@ test("exposes one generic RPC operation with location routing and ordinary trans 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/{namespace}/{method}", + "/api/rpc/{rpcID}/{method}", ]) - const operation = document.paths["/api/rpc/{namespace}/{method}"]?.post + const operation = document.paths["/api/rpc/{rpcID}/{method}"]?.post expect(operation?.operationId).toBe("v2.rpc.call") expect(operation?.parameters).toContainEqual( - expect.objectContaining({ name: "namespace", in: "path", required: true }), + expect.objectContaining({ name: "rpcID", in: "path", required: true }), ) expect(operation?.parameters).toContainEqual(expect.objectContaining({ name: "method", in: "path", required: true })) expect(operation?.parameters).toContainEqual( diff --git a/packages/schema/src/rpc.ts b/packages/schema/src/rpc.ts index 4fac00abe9f0..2fed79a2cc5c 100644 --- a/packages/schema/src/rpc.ts +++ b/packages/schema/src/rpc.ts @@ -41,7 +41,7 @@ export interface EventDefinition { export type PortableEventDefinition = EventDefinition & { readonly schema: PortableEventValueSchema } export interface Definition { - readonly namespace: string + readonly id: string readonly methods: Readonly> & { readonly events?: never } readonly events: Readonly> } @@ -100,7 +100,7 @@ export interface Failure { } export type SystemError = Failure< - | "rpc.namespace_unavailable" + | "rpc.unavailable" | "rpc.method_not_found" | "rpc.invalid_input" | "rpc.invalid_output" @@ -157,7 +157,7 @@ type EventPayloadFor< D extends Definition, Name extends keyof D["events"] & string, > = Omit, "type" | "data" | "durable" | "location"> & { - readonly type: `rpc.${D["namespace"]}.${Name}` + readonly type: `rpc.${D["id"]}.${Name}` readonly data: EventData readonly location: Location.Ref } diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts index 661e6b559e1c..f6a80088171a 100644 --- a/packages/server/src/handlers/rpc.ts +++ b/packages/server/src/handlers/rpc.ts @@ -11,7 +11,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) => const supervisor = yield* PluginSupervisor.Service yield* supervisor.flush const rpc = yield* Rpc.Service - const output = yield* rpc.call(params.namespace, params.method, payload.input) + const output = yield* rpc.call(params.rpcID, params.method, payload.input) return output === undefined ? {} : { output } }).pipe( Effect.mapError( diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts index a37e25d1f61f..a8d388311b1d 100644 --- a/packages/server/test/rpc.test.ts +++ b/packages/server/test/rpc.test.ts @@ -73,7 +73,7 @@ const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) { it.live("dispatches RPC wrappers with query, header and default locations and generic failures", () => Effect.gen(function* () { const Echo = Rpc.define({ - namespace: "transport.echo", + id: "transport.echo", methods: { echo: { input: Schema.String, output: Schema.String }, json: { input: Schema.Json, output: Schema.Json }, @@ -146,7 +146,7 @@ it.live("dispatches RPC wrappers with query, header and default locations and ge { route: "missing/echo", body: {}, - error: { type: "rpc.namespace_unavailable", message: "RPC namespace is unavailable: missing" }, + error: { type: "rpc.unavailable", message: "RPC is unavailable: missing" }, }, { route: "transport.echo/missing", @@ -201,12 +201,12 @@ it.live("request cancellation interrupts Effect RPC handlers and signals Promise const promiseStarted = Promise.withResolvers() const promiseStopped = Promise.withResolvers() const Blocking = Rpc.define({ - namespace: "blocking", + id: "blocking", methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } }, events: {}, }) const PromiseBlocking = Rpc.define({ - namespace: "promise-blocking", + id: "promise-blocking", methods: { wait: { input: { type: "null" }, output: { type: "null" } } }, events: {}, }) @@ -281,7 +281,7 @@ it.live("request cancellation interrupts Effect RPC handlers and signals Promise it.live("public SSE and generic native plugin subscriptions receive RPC events across locations", () => Effect.gen(function* () { const Updates = Rpc.define({ - namespace: "updates", + id: "updates", methods: { emit: { input: Schema.String, output: Schema.Undefined } }, events: { updated: { schema: Schema.Struct({ text: Schema.String }) } }, }) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index d873a02972a2..c19fb350267b 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -8950,13 +8950,13 @@ "summary": "List skills" } }, - "/api/rpc/{namespace}/{method}": { + "/api/rpc/{rpcID}/{method}": { "post": { "tags": ["rpc"], "operationId": "v2.rpc.call", "parameters": [ { - "name": "namespace", + "name": "rpcID", "in": "path", "schema": { "type": "string" @@ -9062,7 +9062,7 @@ } } }, - "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", + "description": "Dispatch a method to the currently registered RPC at the requested location.", "summary": "Call a plugin RPC", "requestBody": { "content": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index d873a02972a2..c19fb350267b 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -8950,13 +8950,13 @@ "summary": "List skills" } }, - "/api/rpc/{namespace}/{method}": { + "/api/rpc/{rpcID}/{method}": { "post": { "tags": ["rpc"], "operationId": "v2.rpc.call", "parameters": [ { - "name": "namespace", + "name": "rpcID", "in": "path", "schema": { "type": "string" @@ -9062,7 +9062,7 @@ } } }, - "description": "Dispatch a method to the currently registered RPC namespace at the requested location.", + "description": "Dispatch a method to the currently registered RPC at the requested location.", "summary": "Call a plugin RPC", "requestBody": { "content": { diff --git a/packages/www/src/docs/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index 784e8d879101..93cac5580d93 100644 --- a/packages/www/src/docs/content/build/client/effect.mdx +++ b/packages/www/src/docs/content/build/client/effect.mdx @@ -102,7 +102,7 @@ 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 -namespace's events from all locations, each with required `location` and a normal +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. diff --git a/packages/www/src/docs/content/build/client/index.mdx b/packages/www/src/docs/content/build/client/index.mdx index 20bfda99201d..49aad6b3c89d 100644 --- a/packages/www/src/docs/content/build/client/index.mdx +++ b/packages/www/src/docs/content/build/client/index.mdx @@ -116,7 +116,7 @@ the typed subclient. Reserved `rpc.*` framework failures remain plain RPC failur while unrelated authentication, transport, and protocol errors keep their normal client representations. -RPC subscriptions use local names and receive that namespace's events across all +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: @@ -129,13 +129,13 @@ for await (const event of acme.events.subscribe("updated")) { `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 +`rpc..` envelope with direct object event data. Live subscriptions do not replay missed events. -The server plugin must be configured and implement the namespace; importing a +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/{namespace}/{method}` route with `{ "input": ... }` and receive +`POST /api/rpc/{rpcID}/{method}` route with `{ "input": ... }` and receive `{ "output": ... }`. Omitted input/output fields represent no value. ## Local background service diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index bf1cf15c446e..88774d0dbe2f 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -590,7 +590,7 @@ import { Rpc } from "@opencode-ai/plugin/rpc" import { z } from "zod" export const Acme = Rpc.define({ - namespace: "acme", + id: "acme", methods: { search: { input: z.object({ query: z.string() }), @@ -653,13 +653,12 @@ export default Plugin.define({ 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 -namespaces are independent of plugin IDs. One plugin -can implement several namespaces, and later registrations override earlier ones +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 namespace: +Other server plugins can obtain a handle without implementing the RPC: ```ts const acme = ctx.rpc(Acme) @@ -684,8 +683,8 @@ 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 namespace's events across all locations. The native `/api/event` stream and -typed subclients observe the same direct `rpc..` envelope. +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. From 72c8b8b2ea412c2cf2a0ceeb15d97bf6d5920734 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:35:44 -0400 Subject: [PATCH 13/20] fix(www): make docs toc scrollable --- packages/www/src/docs/styles/global.css | 2 ++ 1 file changed, 2 insertions(+) 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 { From b22c7f044bd10292af8e2cd15306c04ebe846a88 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:37:13 -0400 Subject: [PATCH 14/20] docs(plugin): move rpc after hooks --- .../src/docs/content/build/plugins/index.mdx | 218 +++++++++--------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 88774d0dbe2f..11e000cace77 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -579,115 +579,6 @@ interface PluginContext { } ``` -### 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. - ### References Read the references available at a location. @@ -1402,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 From c10f191aa8bf0c8806496260d88b5ff3e1680f56 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:37:51 -0400 Subject: [PATCH 15/20] docs: define documentation writing style --- packages/www/src/docs/AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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. From 27dd051f1c1d8fdfd33ee552a73c1e03d107ba97 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 19:50:50 -0400 Subject: [PATCH 16/20] refactor(plugin): embed inventory status --- .../e2e/regression/project-extensions.spec.ts | 2 +- .../e2e/regression/settings-loading.spec.ts | 2 +- .../app/src/providers/catalog/plugin.test.ts | 8 +- .../cli/src/commands/handlers/plugin/list.ts | 4 +- packages/cli/test/plugin-list.test.ts | 18 ++-- .../client/src/promise/generated/types.ts | 6 +- packages/core/src/plugin.ts | 13 +-- packages/core/src/plugin/supervisor.ts | 8 +- packages/core/test/config/plugin.test.ts | 4 +- packages/core/test/location-layer.test.ts | 5 +- packages/core/test/plugin.test.ts | 39 ++++++--- packages/protocol/openapi.json | 87 ++++++++++--------- packages/schema/src/plugin.ts | 29 +++---- packages/schema/test/plugin.test.ts | 21 +++++ .../src/feature-plugins/system/plugins.tsx | 5 +- packages/tui/src/plugin/context.tsx | 6 +- packages/tui/test/plugin-hot-reload.test.tsx | 4 +- packages/www/openapi.json | 87 ++++++++++--------- packages/www/public/openapi.json | 87 ++++++++++--------- 19 files changed, 238 insertions(+), 197 deletions(-) create mode 100644 packages/schema/test/plugin.test.ts diff --git a/packages/app/e2e/regression/project-extensions.spec.ts b/packages/app/e2e/regression/project-extensions.spec.ts index 818a46d140e5..e92ca1421749 100644 --- a/packages/app/e2e/regression/project-extensions.spec.ts +++ b/packages/app/e2e/regression/project-extensions.spec.ts @@ -65,7 +65,7 @@ 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", + status: { type: "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 0571935efb38..b9a382d0d5d9 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -86,7 +86,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) => { id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, - status: "active", + status: { type: "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 188296058d4d..0998ae967ac9 100644 --- a/packages/app/src/providers/catalog/plugin.test.ts +++ b/packages/app/src/providers/catalog/plugin.test.ts @@ -5,20 +5,20 @@ import { pluginLabels } from "./plugin" describe("pluginLabels", () => { test("omits built-in plugins", () => { const plugins: PluginInfo[] = [ - { id: "opencode.internal", source: { type: "builtin" }, status: "active", features: { server: true } }, + { id: "opencode.internal", source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, { id: "package-plugin", source: { type: "package", package: "example" }, - status: "active", + status: { type: "active" }, features: { server: true }, }, { id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, - status: "active", + status: { type: "active" }, features: { server: true }, }, - { id: "sdk-plugin", source: { type: "sdk" }, status: "active", features: { server: true } }, + { id: "sdk-plugin", source: { type: "sdk" }, status: { type: "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 e67196c78df7..568d0599117b 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -49,9 +49,9 @@ 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.status.type})`) const advertised = plugins.flatMap((plugin) => - plugin.status !== "active" || !plugin.features.tui + plugin.status.type !== "active" || !plugin.features.tui ? [] : plugin.source.type === "package" ? [{ target: plugin.source.package, source: "advertised" as const }] diff --git a/packages/cli/test/plugin-list.test.ts b/packages/cli/test/plugin-list.test.ts index ee4ed8ab9176..69be0abf9631 100644 --- a/packages/cli/test/plugin-list.test.ts +++ b/packages/cli/test/plugin-list.test.ts @@ -6,23 +6,22 @@ test("formats server and TUI plugins in sections without builtins", () => { expect( format( [ - { id: "opencode.agent", source: { type: "builtin" }, status: "active", features: { server: true } }, + { id: "opencode.agent", source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, { id: "acme.dual", source: { type: "package", package: "acme-plugin@1.0.0" }, - status: "active", + status: { type: "active" }, features: { server: true, tui: true }, }, { source: { type: "package", package: "broken-plugin" }, - status: "failed", - error: "broken", + status: { type: "failed", error: "broken" }, features: { server: true }, }, { id: "local.dual", source: { type: "local", path: "/tmp/local/index.ts" }, - status: "active", + status: { type: "active" }, features: { server: true, tui: true }, }, ], @@ -50,7 +49,14 @@ test("formats server and TUI plugins in sections without builtins", () => { test("includes builtins when requested", () => { expect( format( - [{ id: "opencode.agent", source: { type: "builtin" }, status: "active", features: { server: true } }], + [ + { + id: "opencode.agent", + source: { type: "builtin" }, + status: { type: "active" }, + features: { server: true }, + }, + ], [], true, ), diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 57d344c6a724..888f12e81daa 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -18,6 +18,8 @@ export type PluginSource = export type PluginFeatures = { server?: true; tui?: true; rpc?: true } +export type PluginStatus = { type: "active" } | { type: "failed"; error: string } + export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } export type MoneyUSD = number @@ -446,9 +448,7 @@ export type ProviderRequest = { export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } -export type PluginInfo = - | { id: string; source: PluginSource; status: "active"; features: PluginFeatures } - | { id?: string; source: PluginSource; status: "failed"; error: string; features: PluginFeatures } +export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; status: PluginStatus } export type SessionMessageLocationSwitched = { id: string diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index cce74ac2f73f..b754d570aa8e 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, Status } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" @@ -31,11 +31,13 @@ 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 status: Extract } + export type Versioned = PluginDefinition & { readonly version: string readonly source?: Plugin.Source @@ -82,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() @@ -124,8 +126,7 @@ const layer = Layer.effect( nextInventory.push({ id: definition.id, source: definition.source ?? { type: "builtin" }, - status: "failed", - error: loaded.error, + status: { type: "failed", error: loaded.error }, features: { server: true, ...definition.features }, }) @@ -177,7 +178,7 @@ function activeInfo(plugin: Versioned): Plugin.Info { return { id: Plugin.ID.make(plugin.id), source: plugin.source ?? { type: "builtin" }, - status: "active", + status: { type: "active" }, features: { server: true, ...plugin.features }, } } diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index fed7b3a69dce..4b8cf89e3f17 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 status: Extract } + >() const plugins = () => [...definitions, ...packages.values()] for (const operation of operations) { @@ -58,8 +61,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( if ("error" in plugin) { failures.set(operation.target, { source: pluginSource(operation.target), - status: "failed", - error: plugin.error, + status: { type: "failed", error: plugin.error }, features: { server: true }, }) continue diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index da141b1f46fb..e18849ce8a07 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -123,7 +123,7 @@ describe("PluginSupervisor config", () => { type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"), }, - status: "active", + status: { type: "active" }, features: { server: true, tui: true }, }) }), @@ -210,7 +210,7 @@ describe("PluginSupervisor config", () => { 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.status.type === "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/index.ts") }, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 288885874b6a..c6aebd07d041 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -566,15 +566,14 @@ describe("LocationServiceMap", () => { ), ) for (let attempt = 0; attempt < 100; attempt++) { - if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break + if ((yield* registry.list()).some((plugin) => plugin.status.type === "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/index.ts") }, - status: "failed", - error: expect.stringContaining("plugin failed"), + status: { type: "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 0e5b0eeefa72..190cdd4d7d03 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -268,8 +268,7 @@ describe("Plugin", () => { [ { source: { type: "package", package: "broken" }, - status: "failed", - error: "failed to resolve", + status: { type: "failed", error: "failed to resolve" }, features: { server: true }, }, ], @@ -332,7 +331,7 @@ describe("Plugin", () => { expect(Exit.isFailure(result)).toBe(true) expect(yield* plugins.list()).toEqual([ - { id: active, source: { type: "builtin" }, status: "active", features: { server: true } }, + { id: active, source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, ]) }), ) @@ -348,7 +347,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("rpc-plugin"), source: { type: "builtin" }, - status: "active", + status: { type: "active" }, features: { server: true, rpc: true }, }, ]) @@ -381,12 +380,16 @@ describe("Plugin", () => { yield* plugins.activate([versioned(good), versioned(bad)]) expect(yield* plugins.list()).toEqual([ - { id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", features: { server: true } }, + { + id: Plugin.ID.make("good"), + source: { type: "builtin" }, + status: { type: "active" }, + features: { server: true }, + }, { id: Plugin.ID.make("bad"), source: { type: "builtin" }, - status: "failed", - error: expect.stringContaining("materialization failed"), + status: { type: "failed", error: expect.stringContaining("materialization failed") }, features: { server: true }, }, ]) @@ -395,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", features: { server: true } }, - { id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", features: { server: true } }, + { + id: Plugin.ID.make("good"), + source: { type: "builtin" }, + status: { type: "active" }, + features: { server: true }, + }, + { + id: Plugin.ID.make("bad"), + source: { type: "builtin" }, + status: { type: "active" }, + features: { server: true }, + }, ]) }), ) @@ -436,7 +449,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, - status: "active", + status: { type: "active" }, features: { server: true }, }, ]) @@ -482,8 +495,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("managed"), source: { type: "builtin" }, - status: "failed", - error: expect.stringContaining("replacement failed"), + status: { type: "failed", error: expect.stringContaining("replacement failed") }, features: { server: true }, }, ]) @@ -522,8 +534,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("managed"), source: { type: "builtin" }, - status: "failed", - error: expect.stringContaining("replacement failed"), + status: { type: "failed", error: expect.stringContaining("replacement failed") }, features: { server: true }, }, ]) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index c19fb350267b..7f2ee14c4db9 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -16555,51 +16555,23 @@ "additionalProperties": false }, "Plugin.Info": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Plugin.Source" - }, - "status": { - "type": "string", - "enum": ["active"] - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["id", "source", "status", "features"], - "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" - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["source", "status", "error", "features"], - "additionalProperties": false + "source": { + "$ref": "#/components/schemas/Plugin.Source" + }, + "features": { + "$ref": "#/components/schemas/Plugin.Features" + }, + "status": { + "$ref": "#/components/schemas/Plugin.Status" } - ] + }, + "required": ["source", "features", "status"], + "additionalProperties": false }, "Plugin.Source": { "anyOf": [ @@ -16655,6 +16627,35 @@ } ] }, + "Plugin.Status": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["active"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["type", "error"], + "additionalProperties": false + } + ] + }, "Project": { "type": "object", "properties": { diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index daff63d88ce7..3a5619a1354a 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -22,22 +22,19 @@ export const Features = Schema.Struct({ }).annotate({ identifier: "Plugin.Features" }) export type Features = typeof Features.Type -export const Info = Schema.Union([ - Schema.Struct({ - id: ID, - source: Source, - status: Schema.Literal("active"), - features: Features, - }), - Schema.Struct({ - id: ID.pipe(optional), - source: Source, - status: Schema.Literal("failed"), - error: Schema.String, - features: Features, - }), -]).annotate({ identifier: "Plugin.Info" }) -export type Info = typeof Info.Type +export const Status = Schema.Union([ + Schema.Struct({ type: Schema.Literal("active") }), + Schema.Struct({ type: Schema.Literal("failed"), error: Schema.String }), +]).annotate({ identifier: "Plugin.Status" }) +export type Status = typeof Status.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID.pipe(optional), + source: Source, + features: Features, + status: Status, +}).annotate({ identifier: "Plugin.Info" }) const Added = ephemeral({ type: "plugin.added", diff --git a/packages/schema/test/plugin.test.ts b/packages/schema/test/plugin.test.ts new file mode 100644 index 000000000000..dda5275672a2 --- /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 status in one info schema", () => { + 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, status: { type: "active" } })).toEqual({ + id: Plugin.ID.make("acme"), + source, + features, + status: { type: "active" }, + }) + expect(decode({ source, features, status: { type: "failed", error: "broken" } })).toEqual({ + source, + features, + status: { type: "failed", error: "broken" }, + }) +}) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index f32d9f1c0602..8b413757806a 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.status.type 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.status.type === "failed" ? entry.plugin.status.error : undefined return entry?.error } diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index d29f93b1abcd..f223d3608976 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -101,7 +101,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d const data = useData() const [serverPlugins, setServerPlugins] = createSignal< ReadonlyArray< - Extract & { + PluginInfo & { readonly status: { readonly type: "active" } } & { readonly source: { readonly type: "package" } | { readonly type: "local" } } > @@ -507,10 +507,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d response.data.filter( ( plugin, - ): plugin is Extract & { + ): plugin is PluginInfo & { readonly status: { readonly type: "active" } } & { readonly source: { readonly type: "package" } | { readonly type: "local" } } => - plugin.status === "active" && + plugin.status.type === "active" && plugin.features.tui === true && (plugin.source.type === "package" || plugin.source.type === "local"), ), diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 9e2e7b7835fa..7c8eb5efb4ee 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -122,7 +122,7 @@ 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", + status: { type: "active" }, features: { server: true, tui: true }, }, ], @@ -157,7 +157,7 @@ test("loads an advertised local TUI entrypoint beside its server entrypoint", as { id: "test.server", source: { type: "local", path: path.join(plugin, "index.ts") }, - status: "active", + status: { type: "active" }, features: { server: true, tui: true }, }, ], diff --git a/packages/www/openapi.json b/packages/www/openapi.json index c19fb350267b..7f2ee14c4db9 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -16555,51 +16555,23 @@ "additionalProperties": false }, "Plugin.Info": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Plugin.Source" - }, - "status": { - "type": "string", - "enum": ["active"] - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["id", "source", "status", "features"], - "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" - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["source", "status", "error", "features"], - "additionalProperties": false + "source": { + "$ref": "#/components/schemas/Plugin.Source" + }, + "features": { + "$ref": "#/components/schemas/Plugin.Features" + }, + "status": { + "$ref": "#/components/schemas/Plugin.Status" } - ] + }, + "required": ["source", "features", "status"], + "additionalProperties": false }, "Plugin.Source": { "anyOf": [ @@ -16655,6 +16627,35 @@ } ] }, + "Plugin.Status": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["active"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["type", "error"], + "additionalProperties": false + } + ] + }, "Project": { "type": "object", "properties": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index c19fb350267b..7f2ee14c4db9 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -16555,51 +16555,23 @@ "additionalProperties": false }, "Plugin.Info": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Plugin.Source" - }, - "status": { - "type": "string", - "enum": ["active"] - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["id", "source", "status", "features"], - "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" - }, - "features": { - "$ref": "#/components/schemas/Plugin.Features" - } - }, - "required": ["source", "status", "error", "features"], - "additionalProperties": false + "source": { + "$ref": "#/components/schemas/Plugin.Source" + }, + "features": { + "$ref": "#/components/schemas/Plugin.Features" + }, + "status": { + "$ref": "#/components/schemas/Plugin.Status" } - ] + }, + "required": ["source", "features", "status"], + "additionalProperties": false }, "Plugin.Source": { "anyOf": [ @@ -16655,6 +16627,35 @@ } ] }, + "Plugin.Status": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["active"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["type", "error"], + "additionalProperties": false + } + ] + }, "Project": { "type": "object", "properties": { From 716904212dc135186e8463496732fb6e74a475dd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 20:00:09 -0400 Subject: [PATCH 17/20] refactor(plugin): name inventory state --- .../e2e/regression/project-extensions.spec.ts | 2 +- .../e2e/regression/settings-loading.spec.ts | 2 +- .../app/src/providers/catalog/plugin.test.ts | 8 ++++---- .../cli/src/commands/handlers/plugin/list.ts | 4 ++-- packages/cli/test/plugin-list.test.ts | 10 +++++----- .../client/src/promise/generated/types.ts | 4 ++-- packages/core/src/plugin.ts | 8 ++++---- packages/core/src/plugin/supervisor.ts | 4 ++-- packages/core/test/config/plugin.test.ts | 4 ++-- packages/core/test/location-layer.test.ts | 4 ++-- packages/core/test/plugin.test.ts | 20 +++++++++---------- packages/protocol/openapi.json | 16 +++++++-------- packages/schema/src/plugin.ts | 12 +++++------ packages/schema/test/plugin.test.ts | 10 +++++----- .../src/feature-plugins/system/plugins.tsx | 4 ++-- packages/tui/src/plugin/context.tsx | 6 +++--- packages/tui/test/plugin-hot-reload.test.tsx | 4 ++-- packages/www/openapi.json | 16 +++++++-------- packages/www/public/openapi.json | 16 +++++++-------- 19 files changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/app/e2e/regression/project-extensions.spec.ts b/packages/app/e2e/regression/project-extensions.spec.ts index e92ca1421749..9825fa6402d9 100644 --- a/packages/app/e2e/regression/project-extensions.spec.ts +++ b/packages/app/e2e/regression/project-extensions.spec.ts @@ -65,7 +65,7 @@ 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: { type: "active" }, + 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 b9a382d0d5d9..8e5c44a5aebb 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -86,7 +86,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) => { id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, - status: { type: "active" }, + 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 0998ae967ac9..06159ee2584d 100644 --- a/packages/app/src/providers/catalog/plugin.test.ts +++ b/packages/app/src/providers/catalog/plugin.test.ts @@ -5,20 +5,20 @@ import { pluginLabels } from "./plugin" describe("pluginLabels", () => { test("omits built-in plugins", () => { const plugins: PluginInfo[] = [ - { id: "opencode.internal", source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, + { id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } }, { id: "package-plugin", source: { type: "package", package: "example" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, { id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, - { id: "sdk-plugin", source: { type: "sdk" }, status: { type: "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 568d0599117b..0b052dfd3054 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -49,9 +49,9 @@ 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.type})`) + .map((plugin) => `${name(plugin)} (${plugin.state.status})`) const advertised = plugins.flatMap((plugin) => - plugin.status.type !== "active" || !plugin.features.tui + plugin.state.status !== "active" || !plugin.features.tui ? [] : plugin.source.type === "package" ? [{ target: plugin.source.package, source: "advertised" as const }] diff --git a/packages/cli/test/plugin-list.test.ts b/packages/cli/test/plugin-list.test.ts index 69be0abf9631..536a08fe8fe6 100644 --- a/packages/cli/test/plugin-list.test.ts +++ b/packages/cli/test/plugin-list.test.ts @@ -6,22 +6,22 @@ test("formats server and TUI plugins in sections without builtins", () => { expect( format( [ - { id: "opencode.agent", source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, + { 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: { type: "active" }, + state: { status: "active" }, features: { server: true, tui: true }, }, { source: { type: "package", package: "broken-plugin" }, - status: { type: "failed", error: "broken" }, + state: { status: "failed", error: "broken" }, features: { server: true }, }, { id: "local.dual", source: { type: "local", path: "/tmp/local/index.ts" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true, tui: true }, }, ], @@ -53,7 +53,7 @@ test("includes builtins when requested", () => { { id: "opencode.agent", source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, ], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 888f12e81daa..3c4bb6e9cf49 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -18,7 +18,7 @@ export type PluginSource = export type PluginFeatures = { server?: true; tui?: true; rpc?: true } -export type PluginStatus = { type: "active" } | { type: "failed"; error: string } +export type PluginState = { status: "active" } | { status: "failed"; error: string } export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } @@ -448,7 +448,7 @@ export type ProviderRequest = { export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } -export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; status: PluginStatus } +export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; state: PluginState } export type SessionMessageLocationSwitched = { id: string diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index b754d570aa8e..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, Status } 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" @@ -36,7 +36,7 @@ export interface Interface { readonly list: () => Effect.Effect } -type Failure = Plugin.Info & { readonly status: Extract } +type Failure = Plugin.Info & { readonly state: Extract } export type Versioned = PluginDefinition & { readonly version: string @@ -126,7 +126,7 @@ const layer = Layer.effect( nextInventory.push({ id: definition.id, source: definition.source ?? { type: "builtin" }, - status: { type: "failed", error: loaded.error }, + state: { status: "failed", error: loaded.error }, features: { server: true, ...definition.features }, }) @@ -178,7 +178,7 @@ function activeInfo(plugin: Versioned): Plugin.Info { return { id: Plugin.ID.make(plugin.id), source: plugin.source ?? { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true, ...plugin.features }, } } diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4b8cf89e3f17..e33383136eff 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -27,7 +27,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( const packages = new Map() const failures = new Map< string, - Plugin.Info & { readonly status: Extract } + Plugin.Info & { readonly state: Extract } >() const plugins = () => [...definitions, ...packages.values()] @@ -61,7 +61,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( if ("error" in plugin) { failures.set(operation.target, { source: pluginSource(operation.target), - status: { type: "failed", error: plugin.error }, + state: { status: "failed", error: plugin.error }, features: { server: true }, }) continue diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index e18849ce8a07..2cf7a9847998 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -123,7 +123,7 @@ describe("PluginSupervisor config", () => { type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"), }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true, tui: true }, }) }), @@ -210,7 +210,7 @@ describe("PluginSupervisor config", () => { path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"), ]) expect( - (yield* plugins.list()).filter((plugin) => plugin.status.type === "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/index.ts") }, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index c6aebd07d041..0a8f4b1e9c2d 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -566,14 +566,14 @@ describe("LocationServiceMap", () => { ), ) for (let attempt = 0; attempt < 100; attempt++) { - if ((yield* registry.list()).some((plugin) => plugin.status.type === "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/index.ts") }, - status: { type: "failed", error: expect.stringContaining("plugin failed") }, + 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 190cdd4d7d03..d99a98bec3a3 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -268,7 +268,7 @@ describe("Plugin", () => { [ { source: { type: "package", package: "broken" }, - status: { type: "failed", error: "failed to resolve" }, + state: { status: "failed", error: "failed to resolve" }, features: { server: true }, }, ], @@ -331,7 +331,7 @@ describe("Plugin", () => { expect(Exit.isFailure(result)).toBe(true) expect(yield* plugins.list()).toEqual([ - { id: active, source: { type: "builtin" }, status: { type: "active" }, features: { server: true } }, + { id: active, source: { type: "builtin" }, state: { status: "active" }, features: { server: true } }, ]) }), ) @@ -347,7 +347,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("rpc-plugin"), source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true, rpc: true }, }, ]) @@ -383,13 +383,13 @@ describe("Plugin", () => { { id: Plugin.ID.make("good"), source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, { id: Plugin.ID.make("bad"), source: { type: "builtin" }, - status: { type: "failed", error: expect.stringContaining("materialization failed") }, + state: { status: "failed", error: expect.stringContaining("materialization failed") }, features: { server: true }, }, ]) @@ -401,13 +401,13 @@ describe("Plugin", () => { { id: Plugin.ID.make("good"), source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, { id: Plugin.ID.make("bad"), source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, ]) @@ -449,7 +449,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true }, }, ]) @@ -495,7 +495,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("managed"), source: { type: "builtin" }, - status: { type: "failed", error: expect.stringContaining("replacement failed") }, + state: { status: "failed", error: expect.stringContaining("replacement failed") }, features: { server: true }, }, ]) @@ -534,7 +534,7 @@ describe("Plugin", () => { { id: Plugin.ID.make("managed"), source: { type: "builtin" }, - status: { type: "failed", error: expect.stringContaining("replacement failed") }, + state: { status: "failed", error: expect.stringContaining("replacement failed") }, features: { server: true }, }, ]) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 7f2ee14c4db9..b771af062774 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -16566,11 +16566,11 @@ "features": { "$ref": "#/components/schemas/Plugin.Features" }, - "status": { - "$ref": "#/components/schemas/Plugin.Status" + "state": { + "$ref": "#/components/schemas/Plugin.State" } }, - "required": ["source", "features", "status"], + "required": ["source", "features", "state"], "additionalProperties": false }, "Plugin.Source": { @@ -16627,23 +16627,23 @@ } ] }, - "Plugin.Status": { + "Plugin.State": { "anyOf": [ { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["active"] } }, - "required": ["type"], + "required": ["status"], "additionalProperties": false }, { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["failed"] }, @@ -16651,7 +16651,7 @@ "type": "string" } }, - "required": ["type", "error"], + "required": ["status", "error"], "additionalProperties": false } ] diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 3a5619a1354a..f7c24c59ef9b 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -22,18 +22,18 @@ export const Features = Schema.Struct({ }).annotate({ identifier: "Plugin.Features" }) export type Features = typeof Features.Type -export const Status = Schema.Union([ - Schema.Struct({ type: Schema.Literal("active") }), - Schema.Struct({ type: Schema.Literal("failed"), error: Schema.String }), -]).annotate({ identifier: "Plugin.Status" }) -export type Status = typeof Status.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, - status: Status, + state: State, }).annotate({ identifier: "Plugin.Info" }) const Added = ephemeral({ diff --git a/packages/schema/test/plugin.test.ts b/packages/schema/test/plugin.test.ts index dda5275672a2..94e082ba0901 100644 --- a/packages/schema/test/plugin.test.ts +++ b/packages/schema/test/plugin.test.ts @@ -2,20 +2,20 @@ import { expect, test } from "bun:test" import { Schema } from "effect" import { Plugin } from "../src/plugin.js" -test("embeds plugin status in one info schema", () => { +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, status: { type: "active" } })).toEqual({ + expect(decode({ id: "acme", source, features, state: { status: "active" } })).toEqual({ id: Plugin.ID.make("acme"), source, features, - status: { type: "active" }, + state: { status: "active" }, }) - expect(decode({ source, features, status: { type: "failed", error: "broken" } })).toEqual({ + expect(decode({ source, features, state: { status: "failed", error: "broken" } })).toEqual({ source, features, - status: { type: "failed", error: "broken" }, + state: { status: "failed", error: "broken" }, }) }) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 8b413757806a..a0170d0c1f2e 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -198,13 +198,13 @@ function source(plugin: PluginInfo, context: Plugin.Context) { } function status(entry: Entry) { - if (entry.runtime === "server") return entry.plugin.status.type + 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.type === "failed" ? entry.plugin.status.error : undefined + 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 f223d3608976..408e3bf8bc35 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -101,7 +101,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d const data = useData() const [serverPlugins, setServerPlugins] = createSignal< ReadonlyArray< - PluginInfo & { readonly status: { readonly type: "active" } } & { + PluginInfo & { readonly state: { readonly status: "active" } } & { readonly source: { readonly type: "package" } | { readonly type: "local" } } > @@ -507,10 +507,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d response.data.filter( ( plugin, - ): plugin is PluginInfo & { readonly status: { readonly type: "active" } } & { + ): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & { readonly source: { readonly type: "package" } | { readonly type: "local" } } => - plugin.status.type === "active" && + plugin.state.status === "active" && plugin.features.tui === true && (plugin.source.type === "package" || plugin.source.type === "local"), ), diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 7c8eb5efb4ee..b78e703dce59 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -122,7 +122,7 @@ 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: { type: "active" }, + state: { status: "active" }, features: { server: true, tui: true }, }, ], @@ -157,7 +157,7 @@ test("loads an advertised local TUI entrypoint beside its server entrypoint", as { id: "test.server", source: { type: "local", path: path.join(plugin, "index.ts") }, - status: { type: "active" }, + state: { status: "active" }, features: { server: true, tui: true }, }, ], diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 7f2ee14c4db9..b771af062774 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -16566,11 +16566,11 @@ "features": { "$ref": "#/components/schemas/Plugin.Features" }, - "status": { - "$ref": "#/components/schemas/Plugin.Status" + "state": { + "$ref": "#/components/schemas/Plugin.State" } }, - "required": ["source", "features", "status"], + "required": ["source", "features", "state"], "additionalProperties": false }, "Plugin.Source": { @@ -16627,23 +16627,23 @@ } ] }, - "Plugin.Status": { + "Plugin.State": { "anyOf": [ { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["active"] } }, - "required": ["type"], + "required": ["status"], "additionalProperties": false }, { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["failed"] }, @@ -16651,7 +16651,7 @@ "type": "string" } }, - "required": ["type", "error"], + "required": ["status", "error"], "additionalProperties": false } ] diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 7f2ee14c4db9..b771af062774 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -16566,11 +16566,11 @@ "features": { "$ref": "#/components/schemas/Plugin.Features" }, - "status": { - "$ref": "#/components/schemas/Plugin.Status" + "state": { + "$ref": "#/components/schemas/Plugin.State" } }, - "required": ["source", "features", "status"], + "required": ["source", "features", "state"], "additionalProperties": false }, "Plugin.Source": { @@ -16627,23 +16627,23 @@ } ] }, - "Plugin.Status": { + "Plugin.State": { "anyOf": [ { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["active"] } }, - "required": ["type"], + "required": ["status"], "additionalProperties": false }, { "type": "object", "properties": { - "type": { + "status": { "type": "string", "enum": ["failed"] }, @@ -16651,7 +16651,7 @@ "type": "string" } }, - "required": ["type", "error"], + "required": ["status", "error"], "additionalProperties": false } ] From c909e3b715571b5b4904e3b188050f26bff209de Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 20:08:36 -0400 Subject: [PATCH 18/20] test(plugin): update rpc inventory assertions --- packages/core/test/plugin/rpc-promise.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/test/plugin/rpc-promise.test.ts b/packages/core/test/plugin/rpc-promise.test.ts index 69ef2d25adc3..7bf64a7746f8 100644 --- a/packages/core/test/plugin/rpc-promise.test.ts +++ b/packages/core/test/plugin/rpc-promise.test.ts @@ -85,7 +85,7 @@ describe("Promise plugin RPC", () => { ) yield* plugins.activate([{ ...adapted, version: "1" }]) - expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }]) + expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }]) }), ) @@ -142,7 +142,7 @@ describe("Promise plugin RPC", () => { ) yield* plugins.activate([{ ...adapted, version: "1" }]) - expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }]) + expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }]) }), ) @@ -199,7 +199,7 @@ describe("Promise plugin RPC", () => { yield* plugins .activate([{ ...adapted, version: "1" }]) .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))) - expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }]) + expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }]) yield* plugins.activate([]) }), ) @@ -280,7 +280,7 @@ describe("Promise plugin RPC", () => { ) yield* plugins.activate([{ ...adapted, version: "1" }]) - expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }]) + 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) From 0808f5d606066d4785b7f4443d1b8faf9cf94cd5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 21:10:04 -0400 Subject: [PATCH 19/20] test(server): allow rpc integration startup --- packages/server/test/rpc.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts index a8d388311b1d..dede9bd666c8 100644 --- a/packages/server/test/rpc.test.ts +++ b/packages/server/test/rpc.test.ts @@ -360,4 +360,5 @@ it.live("public SSE and generic native plugin subscriptions receive RPC events a ]) expect(received).toEqual(events) }), + 15_000, ) From a4f98d01137a60af7ae3803b15ca91115d161616 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 30 Aug 2026 21:17:52 -0400 Subject: [PATCH 20/20] test(server): handle windows rpc fixture paths --- packages/server/test/rpc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/test/rpc.test.ts b/packages/server/test/rpc.test.ts index dede9bd666c8..33b2b0b736c2 100644 --- a/packages/server/test/rpc.test.ts +++ b/packages/server/test/rpc.test.ts @@ -304,7 +304,7 @@ it.live("public SSE and generic native plugin subscriptions receive RPC events a 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 (!directory.endsWith("/first")) return + 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),