Files
opencode/packages/plugin/test/instance-safe-tool.test.ts
T
Kit Langton 58a27e7555 fix(plugin): keep tool schema validation in the authoring effect instance
Plugins load their own copy of effect, so their live Effect schemas cannot be
interpreted by the host instance: parser sentinels and AST class identity are
per-instance, making checks false-fail on valid input (bogus minLength errors)
and branded IDs die as defects surfaced as bare 'Tool execution failed'.

Plugin.define now converts tool input/output schemas to detached Standard
Schema wrappers at the draft.add boundary, so validation and JSON Schema
generation run as closures bound to the instance that created the schema. The
core tool runtime detects still-foreign live schemas from older plugin
packages and skips validation with a warning instead of misvalidating, and
standard-schema validation errors now include the issue path.
2026-08-19 18:30:02 -04:00

72 lines
2.9 KiB
TypeScript

import { expect, test } from "bun:test"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, Schema } from "effect"
import { Plugin } from "../src/effect/index.js"
import type { Tool } from "@opencode-ai/schema/tool"
// `define` must hand the host detached Standard Schema wrappers instead of live
// Effect schemas: hosts may run a different `effect` instance, which cannot
// interpret foreign schemas (checks false-fail and branded types die as defects).
const collectTool = async (tool: Tool.Info<any, any>) => {
const added: Array<Tool.Info<any, any>> = []
const context = {
tool: {
transform: (callback: (draft: { add: (tool: Tool.Info<any, any>) => void }) => void) => {
callback({ add: (item) => added.push(item) })
return Effect.succeed({ dispose: Effect.void })
},
},
} as unknown as Plugin.Context
const plugin = Plugin.define({
id: "test.instance-safe",
effect: (ctx) => ctx.tool.transform((draft) => draft.add(tool)).pipe(Effect.asVoid),
})
await Effect.runPromise(Effect.scoped(plugin.effect(context)))
expect(added).toHaveLength(1)
return added[0]
}
type StandardValue = StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>
test("define converts Effect schemas to detached standard wrappers", async () => {
const execute = (input: { title?: string }) => Effect.succeed({ output: { id: `ses_${input.title}` } })
const registered = await collectTool({
name: "create",
description: "Create",
input: Schema.Struct({ title: Schema.optional(Schema.String.check(Schema.isMinLength(1))) }),
output: Schema.Struct({ id: Schema.String }),
execute,
})
expect(registered.execute).toBe(execute)
expect(Schema.isSchema(registered.input)).toBe(false)
expect(Schema.isSchema(registered.output)).toBe(false)
const input = registered.input as StandardValue
expect(await input["~standard"].validate({ title: "probe" })).toEqual({ value: { title: "probe" } })
const invalid = await input["~standard"].validate({ title: "" })
expect(invalid.issues?.[0]?.message).toContain("a value with a length of at least 1")
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "object" })
// Outputs validate in the encode direction (Type -> Encoded) and describe the
// encoded shape.
const output = registered.output as StandardValue
expect(await output["~standard"].validate({ id: "ses_x" })).toEqual({ value: { id: "ses_x" } })
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({
type: "object",
required: ["id"],
})
})
test("define leaves non-Effect schemas untouched", async () => {
const input = { type: "object" as const }
const registered = await collectTool({
name: "raw",
description: "Raw",
input,
execute: () => Effect.succeed({ content: "ok" }),
})
expect(registered.input).toBe(input)
expect(registered.output).toBeUndefined()
})