import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import type { JsonValue, Tool, ToolCall } from "../src/types.ts"; import { validateToolArguments } from "../src/utils/validation.ts"; function createToolCallWithPlainSchema( schema: Tool["parameters"], value: unknown, ): { tool: Tool; toolCall: ToolCall; } { const tool: Tool = { name: "echo", description: "Echo tool", parameters: { type: "object", properties: { value: schema, }, required: ["value"], } as Tool["parameters"], }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: value as JsonValue }, }; return { tool, toolCall }; } describe("validateToolArguments", () => { it("still validates when Function constructor is unavailable", () => { const originalFunction = globalThis.Function; const tool: Tool = { name: "echo", description: "Echo tool", parameters: Type.Object({ count: Type.Number(), }), }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: { count: "42" as unknown as number }, }; globalThis.Function = (() => { throw new EvalError("Code generation from strings disallowed for this context"); }) as unknown as FunctionConstructor; try { expect(validateToolArguments(tool, toolCall)).toEqual({ count: 42 }); } finally { globalThis.Function = originalFunction; } }); it("coerces serialized plain JSON schemas like TypeBox schemas", () => { const passingCases: Array<{ schema: Tool["parameters"]; input: unknown; expected: unknown; }> = [ { schema: { type: "number" } as Tool["parameters"], input: "42", expected: 42 }, { schema: { type: "number" } as Tool["parameters"], input: true, expected: 1 }, { schema: { type: "number" } as Tool["parameters"], input: null, expected: 0 }, { schema: { type: "integer" } as Tool["parameters"], input: "42", expected: 42 }, { schema: { type: "boolean" } as Tool["parameters"], input: "true", expected: true }, { schema: { type: "boolean" } as Tool["parameters"], input: "false", expected: false }, { schema: { type: "boolean" } as Tool["parameters"], input: 1, expected: true }, { schema: { type: "boolean" } as Tool["parameters"], input: 0, expected: false }, // TypeBox's conversion rules come first: null is "null" for a required string. { schema: { type: "string" } as Tool["parameters"], input: null, expected: "null" }, { schema: { type: "string" } as Tool["parameters"], input: true, expected: "true" }, { schema: { type: "null" } as Tool["parameters"], input: "", expected: null }, { schema: { type: "null" } as Tool["parameters"], input: 0, expected: null }, { schema: { type: "null" } as Tool["parameters"], input: false, expected: null }, { schema: { type: ["number", "string"] } as Tool["parameters"], input: "1", expected: "1", }, { schema: { type: ["boolean", "number"] } as Tool["parameters"], input: "1", expected: 1, }, { schema: { type: "boolean" } as Tool["parameters"], input: "1", expected: true }, { schema: { type: "boolean" } as Tool["parameters"], input: "0", expected: false }, { schema: { type: "null" } as Tool["parameters"], input: "null", expected: null }, { schema: { type: "integer" } as Tool["parameters"], input: "42.1", expected: 42 }, { schema: { type: "array", items: { type: "number" } } as Tool["parameters"], input: "7", expected: [7] }, ]; for (const testCase of passingCases) { const { tool, toolCall } = createToolCallWithPlainSchema(testCase.schema, testCase.input); expect(validateToolArguments(tool, toolCall)).toEqual({ value: testCase.expected }); } }); it("treats null as omission for optional non-nullable properties", () => { const tool: Tool = { name: "echo", description: "Echo tool", parameters: Type.Object({ path: Type.String(), offset: Type.Optional(Type.Number()), nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])), metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }), }), }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: { path: "file.txt", offset: null, nullable: null, metadata: { enabled: null } }, }; expect(validateToolArguments(tool, toolCall)).toEqual({ path: "file.txt", nullable: null, metadata: {}, }); }); it("preserves optional nulls whose referenced schema is nullable", () => { const tool: Tool = { name: "echo", description: "Echo tool", parameters: { type: "object", properties: { value: { $ref: "#/$defs/value" } }, $defs: { value: { anyOf: [{ type: "number" }, { type: "null" }] } }, } as Tool["parameters"], }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: null }, }; expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); }); it("preserves a value that already matches a nullable union arm", () => { const tool: Tool = { name: "echo", description: "Echo tool", parameters: Type.Object({ value: Type.Union([Type.Number(), Type.Null()]), }), }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: null }, }; expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); }); it("preserves a value that already matches a oneOf nullable union arm", () => { const { tool, toolCall } = createToolCallWithPlainSchema( { oneOf: [{ type: "number" }, { type: "null" }] } as Tool["parameters"], null, ); expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); }); it("still coerces nullable unions when the original value does not match any arm", () => { const { tool, toolCall } = createToolCallWithPlainSchema( { anyOf: [{ type: "number" }, { type: "null" }] } as Tool["parameters"], "42", ); expect(validateToolArguments(tool, toolCall)).toEqual({ value: 42 }); }); it("accepts null for nullable array schemas with items", () => { const { tool, toolCall } = createToolCallWithPlainSchema( { type: ["array", "null"], items: { type: "string" } } as Tool["parameters"], null, ); expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); }); it("rejects invalid coercions for serialized plain JSON schemas", () => { const failingCases: Array<{ schema: Tool["parameters"]; input: unknown; }> = [ { schema: { type: "boolean" } as Tool["parameters"], input: "yes" }, { schema: { type: "number" } as Tool["parameters"], input: "abc" }, { schema: { type: "null" } as Tool["parameters"], input: "nil" }, { schema: { type: "integer" } as Tool["parameters"], input: "4x" }, ]; for (const testCase of failingCases) { const { tool, toolCall } = createToolCallWithPlainSchema(testCase.schema, testCase.input); expect(() => validateToolArguments(tool, toolCall)).toThrow("Validation failed"); } }); it("lists pi-schema's errors, then the arguments it received", () => { const { tool, toolCall } = createToolCallWithPlainSchema({ type: "number" } as Tool["parameters"], "many"); expect(() => validateToolArguments(tool, toolCall)).toThrow( 'Validation failed for tool "echo":\n - value: expected number, got string\n\nReceived arguments:\n{\n "value": "many"\n}', ); }); it("reports a missing required property at the property", () => { const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x"); expect(() => validateToolArguments(tool, { ...toolCall, arguments: {} })).toThrow(" - value: missing"); }); it("returns a copy of the arguments, also when nothing is coerced", () => { const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x"); const args = validateToolArguments(tool, toolCall); expect(args).toEqual({ value: "x" }); expect(args).not.toBe(toolCall.arguments); }); // #10897 it("reports malformed arguments JSON instead of validating empty arguments", () => { const tool: Tool = { name: "echo", description: "Echo tool", parameters: Type.Object({ value: Type.Optional(Type.String()) }), }; const toolCall: ToolCall = { type: "toolCall", id: "tool-1", name: "echo", arguments: {}, argumentsError: "Unexpected token", }; expect(() => validateToolArguments(tool, toolCall)).toThrow( 'Invalid arguments for tool "echo": Unexpected token', ); }); });