Compare commits

...
Author SHA1 Message Date
Kit Langton b7d0582a1f fix(plugin): own effect plugin runtime 2026-08-24 09:17:45 -04:00
13 changed files with 233 additions and 14 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Export the Effect runtime used by Effect plugins and safely adapt its tool schemas across host module instances.
@@ -3,12 +3,16 @@ import {
Command,
Connection,
Credential,
Effect,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Schema,
Skill,
WebSearch,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
@@ -18,11 +22,15 @@ const key = Symbol.for("opencode.plugin.v2.effect")
Command,
Connection,
Credential,
Effect,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Schema,
Skill,
WebSearch,
Tool: { Error: Tool.Error },
}
@@ -4,11 +4,13 @@ import {
Connection,
Credential,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Skill,
WebSearch,
} from "@opencode-ai/plugin"
const key = Symbol.for("opencode.plugin.v2.promise")
@@ -18,9 +20,11 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Connection,
Credential,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Skill,
WebSearch,
}
+4 -1
View File
@@ -127,14 +127,17 @@ export const Command = sdk.Command
export const Connection = sdk.Connection
export const Credential = sdk.Credential
export const Integration = sdk.Integration
export const Mcp = sdk.Mcp
export const Model = sdk.Model
export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
export const Skill = sdk.Skill
export const WebSearch = sdk.WebSearch`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
.concat("\nexport const Effect = sdk.Effect\nexport const Schema = sdk.Schema")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
+2 -1
View File
@@ -2,6 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { instanceSafeContext } from "@opencode-ai/plugin/effect/tool-schema"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import path from "path"
@@ -126,7 +127,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
effect: (host) => plugin.effect(instanceSafeContext({ ...host, options: operation.options })),
} satisfies Plugin.Versioned
})
@@ -1,5 +1,4 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
import { Effect, Plugin } from "@opencode-ai/plugin/effect"
export default Plugin.define({
id: "config-effect-plugin",
+1 -2
View File
@@ -8,8 +8,7 @@ The Effect plugin API grants plugins two in-process capabilities:
## Defining A Plugin
```ts
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
import { Effect, Plugin } from "@opencode-ai/plugin/effect"
export default Plugin.define({
id: "example",
+1
View File
@@ -1,5 +1,6 @@
export * as Plugin from "./plugin.js"
export type { StorageEntry, StorageScanOptions, StorageScanResult } from "../storage.js"
export { Effect, Schema } from "effect"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
+1 -1
View File
@@ -43,6 +43,6 @@ export interface Plugin<R = Scope.Scope> {
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
export function define<R = Scope.Scope>(plugin: Plugin<R>): Plugin<R> {
return plugin
}
+48
View File
@@ -0,0 +1,48 @@
import type { Tool } from "@opencode-ai/schema/tool"
import { Schema, SchemaAST } from "effect"
import type { Context } from "./plugin.js"
export function instanceSafeContext(context: Context): Context {
return {
...context,
tool: {
...context.tool,
transform: (callback) =>
context.tool.transform((draft) => callback({ add: (tool) => draft.add(instanceSafeTool(tool)) })),
},
}
}
export function instanceSafeTool(tool: Tool.Info<any, any>): Tool.Info<any, any> {
const input = instanceSafeValueSchema(tool.input, "input")
const output = tool.output === undefined ? undefined : instanceSafeValueSchema(tool.output, "output")
if (input === tool.input && output === tool.output) return tool
return { ...tool, input, ...(output === undefined ? {} : { output }) }
}
function instanceSafeValueSchema(schema: Tool.ValueSchema<any>, direction: "input" | "output"): Tool.ValueSchema<any> {
if (typeof schema === "object" && schema !== null && "~standard" in schema) {
const standard = schema["~standard"] as Record<string, any>
if (
typeof standard.validate !== "function" ||
typeof standard.jsonSchema?.input !== "function" ||
typeof standard.jsonSchema?.output !== "function"
)
throw new Error("Tool schemas must implement Standard Schema validation and JSON Schema generation")
return { "~standard": standard } as Tool.ValueSchema<any>
}
if (!Schema.isSchema(schema)) return schema
// Native codecs can only be compiled by the Effect instance that authored their AST.
if (!(schema.ast instanceof SchemaAST.Base)) {
throw new Error(
"Effect tool schemas must use Schema from @opencode-ai/plugin/effect or be converted to Standard Schema by their authoring Effect instance",
)
}
const codec = schema as Schema.Codec<unknown, unknown>
// Standard Schema validates Encoded -> Type, so flip outputs to run Type -> Encoded.
const oriented = direction === "input" ? codec : Schema.flip(codec)
const augmented = Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(oriented)) as unknown as {
readonly "~standard": Record<string, unknown>
}
return { "~standard": augmented["~standard"] } as Tool.ValueSchema<any>
}
+31 -1
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
@@ -30,7 +31,10 @@ test.each([
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
})
test("promise entrypoint exposes its public contract", () => {
expect(Object.keys(PromisePlugin).sort()).toEqual([
"Agent",
"Command",
"Connection",
@@ -46,6 +50,32 @@ test.each([
])
})
test("effect entrypoint owns its Effect runtime", () => {
expect(Plugin.Effect).toBe(Effect)
expect(Plugin.Schema).toBe(Schema)
expect(Object.keys(Plugin).sort()).toEqual([
"Agent",
"Command",
"Connection",
"Credential",
"Effect",
"Integration",
"Mcp",
"Model",
"Plugin",
"Provider",
"Reference",
"Schema",
"Skill",
"WebSearch",
])
})
test("effect plugin definition preserves identity", () => {
const definition = { id: "demo", effect: () => Effect.void }
expect(Plugin.Plugin.define(definition)).toBe(definition)
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
@@ -0,0 +1,121 @@
import { afterAll, expect, test } from "bun:test"
import { cp, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, Schema } from "effect"
import { instanceSafeContext } from "../src/effect/tool-schema.js"
const fixture = prepareAdapter()
afterAll(async () => rm((await fixture).directory, { recursive: true, force: true }))
test("prepares tools at the host plugin boundary", async () => {
const registered: Array<Tool.Info> = []
const context = {
tool: {
transform: (callback: (draft: { add: (tool: Tool.Info) => void }) => void) => {
callback({ add: (tool) => registered.push(tool) })
return Effect.succeed({ dispose: Effect.void })
},
},
} as unknown as Parameters<typeof instanceSafeContext>[0]
await Effect.runPromise(
Effect.scoped(
instanceSafeContext(context).tool.transform((draft) =>
draft.add({
name: "create",
description: "Create",
input: Schema.Struct({ title: Schema.String }),
execute: () => Effect.succeed({ content: "ok" }),
}),
),
),
)
expect(registered).toHaveLength(1)
expect(Schema.isSchema(registered[0]?.input)).toBe(false)
})
test("converts schemas authored by the plugin Effect runtime", async () => {
const prepared = await fixture
const tool = prepared.instanceSafeTool({
name: "create",
description: "Create",
input: prepared.Schema.FiniteFromString,
output: prepared.Schema.FiniteFromString,
execute: () => prepared.Effect.succeed({ output: 42 }),
})
expect(Schema.isSchema(tool.input)).toBe(false)
const input = tool.input as StandardSchemaV1
expect(await input["~standard"].validate("42")).toEqual({ value: 42 })
expect(await input["~standard"].validate(42)).toHaveProperty("issues")
expect((input as StandardJSONSchemaV1)["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({
type: "string",
})
const output = tool.output as StandardSchemaV1 & StandardJSONSchemaV1
expect(await output["~standard"].validate(42)).toEqual({ value: "42" })
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({ type: "string" })
})
test("rejects an unprepared schema from another Effect runtime", async () => {
const prepared = await fixture
expect(() =>
prepared.instanceSafeTool({
name: "create",
description: "Create",
input: Schema.Struct({ title: Schema.String }),
execute: () => prepared.Effect.succeed({ content: "ok" }),
}),
).toThrow("must use Schema from @opencode-ai/plugin/effect")
})
test("accepts a foreign schema prepared by its authoring runtime", async () => {
const prepared = await fixture
const schema = Schema.Struct({ title: Schema.String })
const augmented = Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema))
const detached = { "~standard": augmented["~standard"] }
const tool = prepared.instanceSafeTool({
name: "create",
description: "Create",
input: detached,
execute: () => prepared.Effect.succeed({ content: "ok" }),
})
expect(Schema.isSchema(tool.input)).toBe(false)
const input = tool.input as StandardSchemaV1 & StandardJSONSchemaV1
expect(await input["~standard"].validate({ title: "probe" })).toEqual({ value: { title: "probe" } })
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "object" })
})
test("rejects incomplete Standard Schema implementations", async () => {
const prepared = await fixture
expect(() =>
prepared.instanceSafeTool({
name: "create",
description: "Create",
input: { "~standard": { version: 1, vendor: "test", validate: () => ({ value: {} }) } } as Tool.ValueSchema,
execute: () => prepared.Effect.succeed({ content: "ok" }),
}),
).toThrow("must implement Standard Schema validation and JSON Schema generation")
})
async function prepareAdapter() {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-plugin-effect-"))
const source = path.dirname(fileURLToPath(import.meta.resolve("effect/package.json")))
await cp(source, path.join(directory, "node_modules", "effect"), { recursive: true })
await cp(new URL("../src/effect/tool-schema.ts", import.meta.url), path.join(directory, "tool-schema.ts"))
const adapter = (await import(pathToFileURL(path.join(directory, "tool-schema.ts")).href)) as {
instanceSafeTool: (tool: Tool.Info) => Tool.Info
}
const runtime = (await import(
pathToFileURL(path.join(directory, "node_modules", "effect", "dist", "index.js")).href
)) as {
Effect: typeof Effect
Schema: typeof Schema
}
return { directory, instanceSafeTool: adapter.instanceSafeTool, Effect: runtime.Effect, Schema: runtime.Schema }
}
@@ -459,16 +459,16 @@ being resolved.
## Effect
OpenCode provides a first-class Effect API for plugins through the
`@opencode-ai/plugin/effect` entrypoint. Install `effect` alongside the
plugin package and export an `effect` function instead of `setup`:
`@opencode-ai/plugin/effect` entrypoint. It exports the compatible `Effect` and
`Schema` modules alongside the plugin API. Use those exports instead of a separate
`effect` installation so values crossing the plugin boundary share one runtime:
```sh
bun add @opencode-ai/plugin@beta effect
bun add @opencode-ai/plugin@beta
```
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
import { Effect, Plugin } from "@opencode-ai/plugin/effect"
export default Plugin.define({
id: "acme.reviewer-effect",
@@ -489,6 +489,6 @@ fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect`. Effect and Promise plugins use the
Typed tools can use `Schema` from `@opencode-ai/plugin/effect`. Effect and Promise plugins use the
same `tools.add(name, tool, options?)` registration shape. Effect executors
return an Effect and may fail with the typed tool failure channel.