diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5869b50a2a..8d4dd5440a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -145,7 +145,14 @@ export const layer: Layer.Layer< const entries = Object.entries(def.args) const allZod = entries.every((entry) => isZodType(entry[1])) const zodParams = allZod ? z.object(def.args) : undefined - const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) + // Newer @opencode-ai/plugin versions precompute JSON Schema with the + // Zod instance that owns arg metadata. Fall back for older/manual + // custom tools that only expose raw Zod args. + const jsonSchema = zodParams + ? isJsonSchemaDefinition(def.jsonSchema) + ? (def.jsonSchema as JSONSchema7) + : zodJsonSchema(zodParams) + : legacyJsonSchema(entries) const parameters = zodParams ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) : Schema.Unknown diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0a96a689cd..84d2ff8a39 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { pathToFileURL } from "url" +import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" @@ -263,6 +263,71 @@ describe("tool.registry", () => { }), ) + it.instance("preserves Zod arg descriptions from config-scoped plugin packages", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const customTools = path.join(opencode, "tools") + const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") + yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { + dereference: true, + recursive: true, + }), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "dist", "index.js"), + [ + "import { z } from 'zod'", + "export function tool(input) {", + " return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }", + "}", + "tool.schema = z", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "addition.ts"), + [ + 'import { tool } from "@opencode-ai/plugin"', + "export default tool({", + " description: 'Use this tool to add two numbers and return their sum.',", + " args: {", + " left: tool.schema.number().describe('The first number to add'),", + " right: tool.schema.number().describe('The second number to add'),", + " },", + " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") + if (!loaded) throw new Error("custom addition tool was not loaded") + + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + properties: { + left: { type: "number", description: "The first number to add" }, + right: { type: "number", description: "The second number to add" }, + }, + }) + }), + 20_000, + ) + it.instance("preserves attachments from structured custom tool results", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index b8a634c796..daf6b0bbd7 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -48,7 +48,13 @@ export function tool(input: { args: Args execute(args: z.infer>, context: ToolContext): Promise }) { - return input + return { + ...input, + // Generate JSON Schema here with the same Zod instance that created + // `tool.schema` args. Zod metadata such as `.describe()` is stored in a + // module-local registry, so converting later from opencode can lose it. + jsonSchema: z.toJSONSchema(z.object(input.args), { target: "draft-7", io: "input" }), + } } tool.schema = z