diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..684d437d17a5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -146,7 +146,21 @@ const layer = Layer.effect( directory: ctx.directory, worktree: ctx.worktree, } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const coerced = (args ?? {}) as Record + const validated = yield* Effect.try({ + try: () => (zodParams ? zodParams.parse(coerced) : coerced), + catch: (error) => + new Tool.InvalidArgumentsError({ + tool: id, + detail: + error instanceof z.ZodError + ? error.issues + .map((i) => `${i.path.join(".") || ""}: ${i.message}`) + .join("; ") + : String(error), + }), + }) + const result = yield* Effect.promise(() => def.execute(validated as any, pluginCtx)) const output = typeof result === "string" ? result : result.output const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) const attachments = typeof result === "string" ? undefined : result.attachments diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac59559..8128f02b9699 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -569,4 +569,95 @@ describe("tool.registry", () => { expect(ids).toContain("cowsay") }), ) + + // Regression: custom tools with optional args must tolerate + // undefined args from the AI SDK without crashing (#30219, #20019). + it.instance("custom tool with optional Zod args executes with undefined input", () => + 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, "optional.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'echo optional text',", + " args: { text: tool.schema.string().optional().describe('Some text') },", + " execute: async (args) => ({ output: JSON.stringify(args), metadata: {} }),", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((t) => t.id === "optional") + if (!loaded) throw new Error("optional tool was not loaded") + + const agents = yield* Agent.Service + const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context + + // Execute with undefined args — should not crash + const result = yield* loaded.execute(undefined as any, ctx) + // undefined is coerced to {} and parsed through optional schema, yielding empty object + expect(result.output).toBe("{}") + + // Execute with valid args + const result2 = yield* loaded.execute({ text: "hello" }, ctx) + expect(result2.output).toBe('{"text":"hello"}') + }), + ) + + it.instance("custom tool with required Zod args rejects undefined input", () => + 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, "required.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'echo required text',", + " args: { text: tool.schema.string().describe('Required text') },", + " execute: async (args) => ({ output: JSON.stringify(args), metadata: {} }),", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((t) => t.id === "required") + if (!loaded) throw new Error("required tool was not loaded") + + const agents = yield* Agent.Service + const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context + + // Execute with undefined args — should fail with validation error + const exit = yield* loaded.execute(undefined as any, ctx).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ) })