Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/plugin/normalize-tool-arg-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/// <reference types="bun-types" />

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<string, unknown> {
return typeof value === "object" && value !== null
}

function getNestedRecord(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const value = record[key]
return isRecord(value) ? value : undefined
}

async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not hardcode node_modules paths, as dependency hoisting can cause this directory to not exist. Resolve the zod package location dynamically instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/plugin/normalize-tool-arg-schemas.test.ts, line 24:

<comment>Do not hardcode `node_modules` paths, as dependency hoisting can cause this directory to not exist. Resolve the `zod` package location dynamically instead.</comment>

<file context>
@@ -0,0 +1,97 @@
+
+async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
+  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")
</file context>
Fix with Cubic

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<string, object>,
): Record<string, unknown> {
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<string> {
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"])
})
})
42 changes: 42 additions & 0 deletions src/plugin/normalize-tool-arg-schemas.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
const { $schema: _schema, ...rest } = jsonSchema
return rest
}

function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
if (schema._zod.toJSONSchema) {
return
}

schema._zod.toJSONSchema = (): Record<string, unknown> => {
const originalOverride = schema._zod.toJSONSchema
delete schema._zod.toJSONSchema

try {
return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
} finally {
schema._zod.toJSONSchema = originalOverride
}
}
}

export function normalizeToolArgSchemas<TDefinition extends Pick<ToolDefinition, "args">>(
toolDefinition: TDefinition,
): TDefinition {
for (const schema of Object.values(toolDefinition.args)) {
attachJsonSchemaOverride(schema)
}

return toolDefinition
}
5 changes: 5 additions & 0 deletions src/plugin/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down