diff --git a/.changeset/sharp-array-schemas.md b/.changeset/sharp-array-schemas.md new file mode 100644 index 00000000..35b06a42 --- /dev/null +++ b/.changeset/sharp-array-schemas.md @@ -0,0 +1,5 @@ +--- +"@caplets/opencode": patch +--- + +Preserve JSON Schema array item types when registering direct native tools with OpenCode. diff --git a/packages/opencode/src/schema.ts b/packages/opencode/src/schema.ts index 180ea3a6..9c3f2252 100644 --- a/packages/opencode/src/schema.ts +++ b/packages/opencode/src/schema.ts @@ -1,6 +1,8 @@ import { tool } from "@opencode-ai/plugin"; import { operations } from "@caplets/core/generated-tool-input-schema"; +type OpenCodeSchema = Parameters[0]["args"][string]; + export function capletsOpenCodeArgs(operationNames: string[] = [...operations]) { const enumValues = operationNames.length > 0 ? operationNames : [...operations]; return { @@ -52,28 +54,65 @@ export function capletsOpenCodeJsonSchemaArgs(schema: Record | return {}; } return Object.fromEntries( - Object.entries(properties).map(([key, value]) => [key, jsonSchemaPropertyToOpenCode(value)]), + Object.entries(properties).map(([key, value]) => [ + key, + jsonSchemaPropertyToOpenCode(value, { optional: true }), + ]), ); } -function jsonSchemaPropertyToOpenCode(value: unknown) { +function jsonSchemaPropertyToOpenCode( + value: unknown, + options: { optional: boolean }, +): OpenCodeSchema { if (!value || typeof value !== "object" || Array.isArray(value)) return tool.schema.unknown(); const schema = value as Record; if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) { - return tool.schema.enum(schema.enum as [string, ...string[]]); + const enumSchema = tool.schema.enum(schema.enum as [string, ...string[]]); + return options.optional ? enumSchema.optional() : enumSchema; + } + if (schema.type === "string") { + const stringSchema = tool.schema.string(); + return options.optional ? stringSchema.optional() : stringSchema; } - if (schema.type === "string") return tool.schema.string().optional(); if (schema.type === "number" || schema.type === "integer") { - return tool.schema.number().int().positive().optional(); + const numberSchema = tool.schema.number().int().positive(); + return options.optional ? numberSchema.optional() : numberSchema; } if (schema.type === "boolean" && "boolean" in tool.schema) { - return (tool.schema as typeof tool.schema & { boolean: () => unknown }).boolean(); + const booleanSchema = ( + tool.schema as typeof tool.schema & { + boolean: () => OpenCodeSchema & { optional: () => OpenCodeSchema }; + } + ).boolean(); + return options.optional ? booleanSchema.optional() : booleanSchema; } if (schema.type === "object") { - return tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(); + const objectSchema = tool.schema.record(tool.schema.string(), tool.schema.unknown()); + return options.optional ? objectSchema.optional() : objectSchema; } if (schema.type === "array") { - return tool.schema.array(tool.schema.unknown()).min(1).optional(); + const itemSchema = isSupportedOpenCodeJsonSchema(schema.items) + ? jsonSchemaPropertyToOpenCode(schema.items, { optional: false }) + : tool.schema.string(); + const arraySchema = tool.schema.array(itemSchema).min(1); + return options.optional ? arraySchema.optional() : arraySchema; } return tool.schema.unknown(); } + +function isSupportedOpenCodeJsonSchema(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const schema = value as Record; + if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) { + return schema.enum.length > 0; + } + return ( + schema.type === "string" || + schema.type === "number" || + schema.type === "integer" || + schema.type === "boolean" || + schema.type === "object" || + schema.type === "array" + ); +} diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index b2a62686..5a159ce8 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -3,13 +3,20 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@opencode-ai/plugin", () => ({ tool: Object.assign((definition: unknown) => definition, { schema: { - enum: () => ({ type: "enum" }), + enum: (values: string[]) => ({ + type: "enum", + values, + optional: () => ({ type: "enum", values, optional: true }), + }), string: () => ({ type: "string", optional: () => ({ type: "string", optional: true }), min: () => ({ type: "string" }), }), - boolean: () => ({ type: "boolean" }), + boolean: () => ({ + type: "boolean", + optional: () => ({ type: "boolean", optional: true }), + }), number: () => ({ int: () => ({ positive: () => ({ optional: () => ({ type: "number", optional: true }) }) }), }), @@ -31,7 +38,9 @@ vi.mock("@opencode-ai/plugin", () => ({ options, optional: () => ({ type: "union", options, optional: true }), }), - array: () => ({ min: () => ({ optional: () => ({ type: "array", optional: true }) }) }), + array: (item: unknown) => ({ + min: () => ({ optional: () => ({ type: "array", item, optional: true }) }), + }), }, }), })); @@ -184,7 +193,13 @@ describe("@caplets/opencode", () => { promptGuidance: ["Use caplets__status__ping."], inputSchema: { type: "object", - properties: { verbose: { type: "boolean" } }, + properties: { + mode: { enum: ["fast", "safe"] }, + verbose: { type: "boolean" }, + tags: { type: "array", items: { type: "string" } }, + priorityTags: { type: "array", items: { enum: ["high", "low"] } }, + looseTags: { type: "array", items: { anyOf: [{ type: "string" }] } }, + }, }, }, ], @@ -200,7 +215,17 @@ describe("@caplets/opencode", () => { execute(args: unknown, context: unknown): Promise; }; - expect(directTool.args).toEqual({ verbose: { type: "boolean" } }); + expect(directTool.args).toMatchObject({ + mode: { type: "enum", values: ["fast", "safe"], optional: true }, + verbose: { type: "boolean", optional: true }, + tags: { type: "array", item: { type: "string" }, optional: true }, + priorityTags: { + type: "array", + item: { type: "enum", values: ["high", "low"] }, + optional: true, + }, + looseTags: { type: "array", item: { type: "string" }, optional: true }, + }); await directTool.execute({ verbose: true }, {} as never); expect(service.execute).toHaveBeenCalledWith("status__ping", { verbose: true }); });