mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 18:46:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d0582a1f |
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user