Browse Source

fix(tool): preserve custom tool arg descriptions (#27750)

Co-authored-by: khimaros <231498+khimaros@users.noreply.github.com>
Aiden Cline 3 tháng trước cách đây
mục cha
commit
ef7d801271

+ 8 - 1
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<unknown>((u): u is unknown => zodParams.safeParse(u).success)
             : Schema.Unknown

+ 66 - 1
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

+ 7 - 1
packages/plugin/src/tool.ts

@@ -48,7 +48,13 @@ export function tool<Args extends z.ZodRawShape>(input: {
   args: Args
   execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>
 }) {
-  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