Remove effect-zod bridge (#26956)
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user