diff --git a/src/plugin/normalize-tool-arg-schemas.test.ts b/src/plugin/normalize-tool-arg-schemas.test.ts new file mode 100644 index 0000000000..27f1489958 --- /dev/null +++ b/src/plugin/normalize-tool-arg-schemas.test.ts @@ -0,0 +1,97 @@ +/// + +import { afterEach, describe, expect, it } from "bun:test" +import { cpSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { pathToFileURL } from "node:url" +import { tool } from "@opencode-ai/plugin" +import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas" + +const tempDirectories: string[] = [] + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getNestedRecord(record: Record, key: string): Record | undefined { + const value = record[key] + return isRecord(value) ? value : undefined +} + +async function loadSeparateHostZodModule(): Promise { + const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir)) + const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod") + const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-")) + const copiedZodDirectory = join(tempDirectory, "zod") + + cpSync(sourceZodDirectory, copiedZodDirectory, { recursive: true }) + tempDirectories.push(tempDirectory) + + return await import(pathToFileURL(join(copiedZodDirectory, "index.js")).href) +} + +function serializeWithHostZod( + hostZod: typeof import("zod"), + args: Record, +): Record { + return hostZod.z.toJSONSchema(Reflect.apply(hostZod.z.object, hostZod.z, [args])) +} + +describe("normalizeToolArgSchemas", () => { + afterEach(() => { + for (const tempDirectory of tempDirectories.splice(0)) { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + + it("preserves nested descriptions and metadata across zod instances", async () => { + // given + const hostZod = await loadSeparateHostZodModule() + const toolDefinition = tool({ + description: "Search tool", + args: { + filters: tool.schema + .object({ + query: tool.schema + .string() + .describe("Free-text search query") + .meta({ title: "Query", examples: ["issue 2314"] }), + }) + .describe("Filter options") + .meta({ title: "Filters" }), + }, + async execute(): Promise { + return "ok" + }, + }) + + // when + const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args) + const beforeProperties = getNestedRecord(beforeSchema, "properties") + const beforeFilters = beforeProperties ? getNestedRecord(beforeProperties, "filters") : undefined + const beforeFilterProperties = beforeFilters ? getNestedRecord(beforeFilters, "properties") : undefined + const beforeQuery = beforeFilterProperties ? getNestedRecord(beforeFilterProperties, "query") : undefined + + normalizeToolArgSchemas(toolDefinition) + + const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args) + const afterProperties = getNestedRecord(afterSchema, "properties") + const afterFilters = afterProperties ? getNestedRecord(afterProperties, "filters") : undefined + const afterFilterProperties = afterFilters ? getNestedRecord(afterFilters, "properties") : undefined + const afterQuery = afterFilterProperties ? getNestedRecord(afterFilterProperties, "query") : undefined + + // then + expect(beforeFilters?.description).toBeUndefined() + expect(beforeFilters?.title).toBeUndefined() + expect(beforeQuery?.description).toBeUndefined() + expect(beforeQuery?.title).toBeUndefined() + expect(beforeQuery?.examples).toBeUndefined() + + expect(afterFilters?.description).toBe("Filter options") + expect(afterFilters?.title).toBe("Filters") + expect(afterQuery?.description).toBe("Free-text search query") + expect(afterQuery?.title).toBe("Query") + expect(afterQuery?.examples).toEqual(["issue 2314"]) + }) +}) diff --git a/src/plugin/normalize-tool-arg-schemas.ts b/src/plugin/normalize-tool-arg-schemas.ts new file mode 100644 index 0000000000..4e8182b637 --- /dev/null +++ b/src/plugin/normalize-tool-arg-schemas.ts @@ -0,0 +1,42 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolDefinition } from "@opencode-ai/plugin" + +type ToolArgSchema = ToolDefinition["args"][string] + +type SchemaWithJsonSchemaOverride = ToolArgSchema & { + _zod: ToolArgSchema["_zod"] & { + toJSONSchema?: () => unknown + } +} + +function stripRootJsonSchemaFields(jsonSchema: Record): Record { + const { $schema: _schema, ...rest } = jsonSchema + return rest +} + +function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void { + if (schema._zod.toJSONSchema) { + return + } + + schema._zod.toJSONSchema = (): Record => { + const originalOverride = schema._zod.toJSONSchema + delete schema._zod.toJSONSchema + + try { + return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema)) + } finally { + schema._zod.toJSONSchema = originalOverride + } + } +} + +export function normalizeToolArgSchemas>( + toolDefinition: TDefinition, +): TDefinition { + for (const schema of Object.values(toolDefinition.args)) { + attachJsonSchemaOverride(schema) + } + + return toolDefinition +} diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 3b441c1970..a08b9bd33a 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -32,6 +32,7 @@ import { log } from "../shared" import type { Managers } from "../create-managers" import type { SkillContext } from "./skill-context" +import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas" export type ToolRegistryResult = { filteredTools: ToolsRecord @@ -139,6 +140,10 @@ export function createToolRegistry(args: { ...hashlineToolsRecord, } + for (const toolDefinition of Object.values(allTools)) { + normalizeToolArgSchemas(toolDefinition) + } + const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools) return {