Remove effect-zod bridge (#26956)

This commit is contained in:
Kit Langton
2026-05-11 21:14:55 -04:00
committed by GitHub
parent abb1ee6278
commit e5aa5161f2
21 changed files with 425 additions and 1266 deletions

View File

@@ -45,6 +45,7 @@ Output: Creates directory 'foo'"
"description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer",
},
"workdir": {
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
"type": "string",
},
},
"ref": "QuestionOption",
"required": [
"label",
"description",
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
"type": "string",
},
},
"ref": "QuestionPrompt",
"required": [
"question",
"header",
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"format": {
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
"anyOf": [
{
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
],
"type": "string",
},
{
"type": "null",
},
],
"type": "string",
},
"timeout": {
"description": "Optional timeout in seconds (max 120)",

View File

@@ -1,13 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { toJsonSchema } from "@opencode-ai/core/effect-zod"
import { ToolJsonSchema } from "../../src/tool/json-schema"
// Each tool exports its parameters schema at module scope so this test can
// import them without running the tool's Effect-based init. The JSON Schema
// snapshot captures what the LLM sees; the parse assertions pin down the
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
// byte-identical regardless of whether a tool has migrated from zod to Schema.
// provider-compatible while tools use Effect Schema internally.
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
import { Parameters as Edit } from "../../src/tool/edit"
@@ -32,6 +32,8 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
const toJsonSchema = ToolJsonSchema.fromSchema
describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
test("inlines named child schemas for provider compatibility", () => {
const schema = toJsonSchema(Question)
expect(schema).not.toHaveProperty("$defs")
expect(schema).toMatchObject({
properties: {
questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
},
})
})
test("preserves required nullable fields", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
})
})
test("keeps repeated allOf constraints instead of dropping duplicates", () => {
expect(
toJsonSchema(
Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
),
).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
})
test("bounds bare integer fields to safe integer range", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
})
})
})
describe("apply_patch", () => {

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
Layer.provide(Truncate.defaultLayer),
)
const it = testEffect(Layer.mergeAll(registryLayer, node))
const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
@@ -141,6 +144,89 @@ describe("tool.registry", () => {
}),
)
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "sql.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'query database',",
" args: { query: tool.schema.string().describe('SQL query to execute') },",
" execute: async ({ query }) => query,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({
type: "object",
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service
const promptTools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
}),
)
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "legacy.ts"),
[
"export default {",
" description: 'legacy schema tool',",
" args: { text: { type: 'string', description: 'Text to render' } },",
" execute: async ({ text }) => text,",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object",
properties: {
text: { type: "string", description: "Text to render" },
},
required: ["text"],
})
}),
)
it.instance("loads tools with external dependencies without crashing", () =>
Effect.gen(function* () {
const test = yield* TestInstance