diff --git a/.changeset/tool-args-type-coercion.md b/.changeset/tool-args-type-coercion.md new file mode 100644 index 0000000000..3ec3cb1563 --- /dev/null +++ b/.changeset/tool-args-type-coercion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix tool calls failing validation when a model sends numeric or boolean arguments as strings. diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index c0d3a6946c..242a1160fe 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -7,7 +7,10 @@ * through the ordered `onDidExecuteTool` hook, publishes tool lifecycle * events through `event`, records telemetry through `telemetry`, truncates * oversized outputs through `toolResultTruncation`, and logs parse - * diagnostics through `log`. Bound at Agent scope. + * diagnostics through `log`. Non-conforming argument types are normalized + * through the `tool` domain's args normalization before validation, with + * coercions surfaced as model-visible notes on the tool result. Bound at + * Agent scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -23,6 +26,7 @@ import { type JsonType, type ToolArgsValidator, } from '#/tool/args-validator'; +import { normalizeToolArgs, type ArgCoercion } from '#/tool/args-normalize'; import { PathSecurityError } from '#/tool/path-access'; import { isAbortError, isUserCancellation } from '#/_base/utils/abort'; import { IEventBus } from '#/app/event/eventBus'; @@ -600,7 +604,10 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { const coercedResult = coerceToolResult(didCtx.result, call.toolName); const effectiveResult = normalizeToolResult(coercedResult); - const finalResult: ToolResult = { + const mergedNote = [call.normalizationNote, effectiveResult.note] + .filter((part): part is string => typeof part === 'string' && part.length > 0) + .join('\n'); + const baseResult = { ...effectiveResult, description: result.description, display: result.display, @@ -612,6 +619,8 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { stopBatchAfterThis: result.stopBatchAfterThis, delivery: coercedResult.delivery, }; + const finalResult: ToolResult = + mergedNote.length > 0 ? { ...baseResult, note: mergedNote } : baseResult; return this.resultTruncation.truncateForModel({ toolName: call.toolName, toolCallId: call.toolCall.id, @@ -626,6 +635,7 @@ interface RunnableToolCall { readonly toolName: string; readonly tool: ExecutableTool; readonly args: unknown; + readonly normalizationNote?: string; } interface RejectedToolCall { @@ -715,17 +725,37 @@ function preflightToolCall( output: unavailable, }; } - const validationError = validateExecutableToolArgs(tool, parsedArgs.data); + const normalization = normalizeToolArgs(tool.parameters, parsedArgs.data); + if (normalization.coercions.length > 0) { + log?.debug('tool args coerced to match schema', { + toolName, + toolCallId: toolCall.id, + coercions: normalization.coercions + .map((coercion) => `${coercion.path}: ${coercion.received} -> ${coercion.expected}`) + .join('; '), + }); + } + const validationError = validateExecutableToolArgs(tool, normalization.args); if (validationError !== null) { return { kind: 'rejected', toolCall, toolName, - args: parsedArgs.data, + args: normalization.args, output: `Invalid args for tool "${toolName}": ${validationError}`, }; } - return { kind: 'runnable', toolCall, toolName, tool, args: parsedArgs.data }; + return { + kind: 'runnable', + toolCall, + toolName, + tool, + args: normalization.args, + normalizationNote: + normalization.coercions.length > 0 + ? coercionNote(toolName, normalization.coercions) + : undefined, + }; } export function parseToolCallArguments(raw: unknown): { @@ -746,6 +776,13 @@ export function parseToolCallArguments(raw: unknown): { } } +function coercionNote(toolName: string, coercions: readonly ArgCoercion[]): string { + const details = coercions + .map((coercion) => `${coercion.path}: ${coercion.received} -> ${coercion.expected}`) + .join('; '); + return `Note: tool "${toolName}" received argument type(s) that did not match its schema (${details}). They were coerced to the declared types before validation. Emit argument types exactly as the tool schema declares.`; +} + function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { let validator = validators.get(tool); if (validator === undefined) { diff --git a/packages/agent-core-v2/src/tool/args-normalize.ts b/packages/agent-core-v2/src/tool/args-normalize.ts new file mode 100644 index 0000000000..cd0b4bdb84 --- /dev/null +++ b/packages/agent-core-v2/src/tool/args-normalize.ts @@ -0,0 +1,206 @@ +/** + * `tool` domain (L3) — pre-validation tool-args normalization. + * + * Model tool-call output is untrusted input: some providers emit primitive + * arguments as JSON strings (`"3"` for an integer) instead of the declared + * types. The harness absorbs that at the parse/validation boundary — the + * earliest point it controls — while keeping validation itself strict. + * + * The rules are deliberately generic and bounded so this layer stays a single + * schema-driven rule instead of growing into a catalog of model-specific + * hacks: + * - only string → integer / number / boolean coercions are attempted + * - only when the coercion is lossless and the schema does not already + * accept strings at that position + * - `$ref` nodes and ambiguous (string-accepting) schemas are left alone + * - composition keywords are consulted for type discovery only, never as a + * proof of validity; under-coercion is deliberate, since whatever this + * layer misses still faces strict validation below + * + * Whatever cannot be normalized still fails validation afterwards, where the + * error message reports what was actually received. Pure helper; no scoped + * service. + */ + +export type CoercionTarget = 'integer' | 'number' | 'boolean'; + +export interface ArgCoercion { + readonly path: string; + readonly received: string; + readonly expected: CoercionTarget; +} + +export interface ArgsNormalization { + readonly args: unknown; + readonly coercions: readonly ArgCoercion[]; +} + +const INTEGER_TEXT = /^[+-]?\d+$/; +const NUMBER_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; + +type PrimitiveType = 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array' | 'null'; + +function collectPrimitiveTypes(node: Record, into: Set): void { + const type = node['type']; + if (typeof type === 'string') into.add(type as PrimitiveType); + if (Array.isArray(type)) { + for (const entry of type) { + if (typeof entry === 'string') into.add(entry as PrimitiveType); + } + } + for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) { + const branches = node[keyword]; + if (Array.isArray(branches)) { + for (const branch of branches) { + if (branch !== null && typeof branch === 'object' && !Array.isArray(branch)) { + collectPrimitiveTypes(branch as Record, into); + } + } + } + } +} + +function isSchemaNode(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isUnconstrained(node: Record): boolean { + return ( + node['type'] === undefined && + node['const'] === undefined && + node['enum'] === undefined && + node['$ref'] === undefined + ); +} + +function stringAlreadyValid(node: Record, raw: string): boolean { + if (node['const'] === raw) return true; + const enumValues = node['enum']; + if (Array.isArray(enumValues) && enumValues.includes(raw)) return true; + for (const keyword of ['anyOf', 'oneOf'] as const) { + const branches = node[keyword]; + if (!Array.isArray(branches)) continue; + for (const branch of branches) { + if (!isSchemaNode(branch)) continue; + if (isUnconstrained(branch) || stringAlreadyValid(branch, raw)) return true; + } + } + return false; +} + +function coerceString( + raw: string, + expected: ReadonlySet, +): { readonly value: number | boolean; readonly target: CoercionTarget } | undefined { + if (expected.has('integer') && INTEGER_TEXT.test(raw)) { + const parsed = Number(raw); + if (Number.isSafeInteger(parsed)) return { value: parsed, target: 'integer' }; + return undefined; + } + if (expected.has('number') && NUMBER_TEXT.test(raw)) { + const parsed = Number(raw); + const lossless = INTEGER_TEXT.test(raw) ? Number.isSafeInteger(parsed) : Number.isFinite(parsed); + if (lossless) return { value: parsed, target: 'number' }; + return undefined; + } + if (expected.has('boolean') && (raw === 'true' || raw === 'false')) { + return { value: raw === 'true', target: 'boolean' }; + } + return undefined; +} + +export function describeReceivedValue(value: unknown): string { + if (typeof value === 'string') return `string ${JSON.stringify(value)}`; + if (typeof value === 'number') return `number ${String(value)}`; + if (typeof value === 'boolean') return `boolean ${String(value)}`; + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return 'array'; + return 'object'; +} + +function normalizeValue( + node: Record, + value: unknown, + path: string, + coercions: ArgCoercion[], +): unknown { + if (typeof value === 'string') { + const expected = new Set(); + collectPrimitiveTypes(node, expected); + if (!expected.has('string') && !stringAlreadyValid(node, value)) { + const coerced = coerceString(value, expected); + if (coerced !== undefined) { + coercions.push({ + path, + received: describeReceivedValue(value), + expected: coerced.target, + }); + return coerced.value; + } + } + return value; + } + + if (Array.isArray(value)) { + const items = node['items']; + if (items !== null && typeof items === 'object' && !Array.isArray(items)) { + const itemSchema = items as Record; + if (itemSchema['$ref'] === undefined) { + let changed: unknown[] | undefined; + for (let index = 0; index < value.length; index += 1) { + const normalized = normalizeValue( + itemSchema, + value[index], + `${path}/${String(index)}`, + coercions, + ); + if (normalized !== value[index]) { + changed ??= [...value]; + changed[index] = normalized; + } + } + return changed ?? value; + } + } + return value; + } + + if (value !== null && typeof value === 'object') { + const properties = node['properties']; + if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) { + const record = value as Record; + let changed: Record | undefined; + for (const [key, child] of Object.entries(properties)) { + if (child === null || typeof child !== 'object' || Array.isArray(child)) continue; + const childSchema = child as Record; + if (childSchema['$ref'] !== undefined) continue; + if (!(key in record)) continue; + const normalized = normalizeValue( + childSchema, + record[key], + `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`, + coercions, + ); + if (normalized !== record[key]) { + changed ??= { ...record }; + changed[key] = normalized; + } + } + return changed ?? value; + } + return value; + } + + return value; +} + +export function normalizeToolArgs( + schema: Record, + args: unknown, +): ArgsNormalization { + const coercions: ArgCoercion[] = []; + if (schema['$ref'] !== undefined) return { args, coercions }; + const normalized = normalizeValue(schema, args, '', coercions); + return { args: normalized, coercions }; +} diff --git a/packages/agent-core-v2/src/tool/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts index 3d8b33b3b0..1c9ee5ea4e 100644 --- a/packages/agent-core-v2/src/tool/args-validator.ts +++ b/packages/agent-core-v2/src/tool/args-validator.ts @@ -3,9 +3,10 @@ * * Compiles tool-parameter JSON Schemas into AJV validators (draft-07 / * 2019-09 / 2020-12 detected per schema) and formats validation failures - * into model-readable messages. Only the executor path loads this module, - * so the AJV instances are paid for once, at execution time. Pure helper; - * no scoped service. + * into model-readable messages — type failures report the value that was + * actually received, deduplicated across union branches. Only the executor + * path loads this module, so the AJV instances are paid for once, at + * execution time. Pure helper; no scoped service. */ import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; @@ -13,6 +14,8 @@ import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; +import { describeReceivedValue } from '#/tool/args-normalize'; + const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true }); addFormats(DRAFT_07_AJV); @@ -67,7 +70,18 @@ export interface JsonObject extends Record {} export type ToolArgsValidator = ValidateFunction; -function formatValidationError(error: ErrorObject): string { +function valueAtInstancePath(root: JsonType, instancePath: string): unknown { + if (instancePath.length === 0) return root; + let current: unknown = root; + for (const segment of instancePath.split('/').slice(1)) { + const key = segment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (current === null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +function formatValidationError(error: ErrorObject, args: JsonType): string { if (error.keyword === 'required' && 'missingProperty' in error.params) { return `must have required property '${String(error.params['missingProperty'])}'`; } @@ -77,7 +91,11 @@ function formatValidationError(error: ErrorObject): string { } const path = error.instancePath ? `${error.instancePath} ` : ''; - return `${path}${error.message ?? 'is invalid'}`; + const received = + error.keyword === 'type' + ? ` (received ${describeReceivedValue(valueAtInstancePath(args, error.instancePath))})` + : ''; + return `${path}${error.message ?? 'is invalid'}${received}`; } export function compileToolArgsValidator(schema: Record): ToolArgsValidator { @@ -95,5 +113,5 @@ export function validateToolArgs(validator: ToolArgsValidator, args: JsonType): return 'Tool parameter validation failed'; } - return errors.map((error) => formatValidationError(error)).join('; '); + return [...new Set(errors.map((error) => formatValidationError(error, args)))].join('; '); } diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 6eedeaa02a..72053987b1 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -941,6 +941,92 @@ describe('parseToolCallArguments', () => { }); }); +describe('tool args normalization at the parse boundary', () => { + const READ_LIKE_SCHEMA = { + type: 'object', + properties: { + path: { type: 'string' }, + line_offset: { + anyOf: [ + { type: 'integer', minimum: 1 }, + { type: 'integer', minimum: -1000, maximum: -1 }, + ], + }, + }, + required: ['path'], + additionalProperties: false, + }; + + it('coerces schema-violating string primitives and warns via the note channel', async () => { + const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA }); + registry.register(tool); + + const results = await execute([ + toolCall('call_coerced', 'read_like', { path: 'a.ts', line_offset: '3' }), + ]); + + expect(results).toHaveLength(1); + expect(results[0]).not.toMatchObject({ isError: true }); + expect(tool.calls[0]?.args).toEqual({ path: 'a.ts', line_offset: 3 }); + expect(results[0]?.note).toContain( + 'tool "read_like" received argument type(s) that did not match its schema', + ); + expect(results[0]?.note).toContain('/line_offset: string "3" -> integer'); + expect(results[0]?.note).toContain('coerced to the declared types before validation'); + expect(results[0]?.note).not.toContain('call succeeded'); + }); + + it('keeps the coercion warning ahead of the tool own note', async () => { + const tool = new TestTool('read_like', { + parameters: READ_LIKE_SCHEMA, + result: { output: 'contents', note: 'tool status.' }, + }); + registry.register(tool); + + const results = await execute([ + toolCall('call_notes', 'read_like', { path: 'a.ts', line_offset: '-5' }), + ]); + + const note = results[0]?.note ?? ''; + expect(note).toContain('did not match its schema'); + expect(note).toContain('tool status.'); + expect(note.indexOf('did not match its schema')).toBeLessThan( + note.indexOf('tool status.'), + ); + }); + + it('rejects with an honest received-type message when coercion cannot fix the args', async () => { + const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA }); + registry.register(tool); + + const results = await execute([ + toolCall('call_rejected', 'read_like', { path: 'a.ts', line_offset: 'three' }), + ]); + + expect(results).toEqual([ + expect.objectContaining({ + isError: true, + output: + 'Invalid args for tool "read_like": /line_offset must be integer (received string "three"); /line_offset must match a schema in anyOf', + }), + ]); + expect(tool.calls).toEqual([]); + }); + + it('passes conforming args through without a note', async () => { + const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA }); + registry.register(tool); + + const results = await execute([ + toolCall('call_clean', 'read_like', { path: 'a.ts', line_offset: 3 }), + ]); + + expect(results[0]).not.toMatchObject({ isError: true }); + expect(results[0]?.note).toBeUndefined(); + expect(tool.calls[0]?.args).toEqual({ path: 'a.ts', line_offset: 3 }); + }); +}); + async function execute( calls: ToolCall[], signal?: AbortSignal, diff --git a/packages/agent-core-v2/test/tool/args-normalize.test.ts b/packages/agent-core-v2/test/tool/args-normalize.test.ts new file mode 100644 index 0000000000..fcb273f226 --- /dev/null +++ b/packages/agent-core-v2/test/tool/args-normalize.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { describeReceivedValue, normalizeToolArgs } from '#/tool/args-normalize'; + +describe('normalizeToolArgs', () => { + it('coerces integer strings, including negatives', () => { + const schema = { type: 'object', properties: { line_offset: { type: 'integer' } } }; + expect(normalizeToolArgs(schema, { line_offset: '3' })).toEqual({ + args: { line_offset: 3 }, + coercions: [{ path: '/line_offset', received: 'string "3"', expected: 'integer' }], + }); + expect(normalizeToolArgs(schema, { line_offset: '-12' }).args).toEqual({ line_offset: -12 }); + }); + + it('coerces through anyOf branches (Read-style union)', () => { + const schema = { + type: 'object', + properties: { + line_offset: { + anyOf: [ + { type: 'integer', minimum: 1 }, + { type: 'integer', minimum: -1000, maximum: -1 }, + ], + }, + }, + }; + const result = normalizeToolArgs(schema, { line_offset: '-50' }); + expect(result.args).toEqual({ line_offset: -50 }); + expect(result.coercions).toHaveLength(1); + }); + + it('coerces number and boolean strings', () => { + const schema = { + type: 'object', + properties: { temperature: { type: 'number' }, verbose: { type: 'boolean' } }, + }; + const result = normalizeToolArgs(schema, { temperature: '0.5', verbose: 'true' }); + expect(result.args).toEqual({ temperature: 0.5, verbose: true }); + expect(result.coercions.map((c) => c.expected)).toEqual(['number', 'boolean']); + }); + + it('does not coerce lossy or malformed strings', () => { + const schema = { + type: 'object', + properties: { + a: { type: 'integer' }, + b: { type: 'integer' }, + c: { type: 'number' }, + d: { type: 'boolean' }, + }, + }; + const result = normalizeToolArgs(schema, { a: '3.5', b: '3px', c: '', d: 'yes' }); + expect(result.args).toEqual({ a: '3.5', b: '3px', c: '', d: 'yes' }); + expect(result.coercions).toHaveLength(0); + }); + + it('does not coerce beyond the safe-integer range', () => { + const schema = { type: 'object', properties: { big: { type: 'integer' } } }; + const result = normalizeToolArgs(schema, { big: '9007199254740993' }); + expect(result.args).toEqual({ big: '9007199254740993' }); + expect(result.coercions).toHaveLength(0); + + const numberSchema = { type: 'object', properties: { big: { type: 'number' } } }; + expect(normalizeToolArgs(numberSchema, { big: '9007199254740993' }).args).toEqual({ + big: '9007199254740993', + }); + }); + + it('leaves values alone when the schema already accepts strings', () => { + const schema = { + type: 'object', + properties: { id: { anyOf: [{ type: 'string' }, { type: 'integer' }] } }, + }; + const result = normalizeToolArgs(schema, { id: '3' }); + expect(result.args).toEqual({ id: '3' }); + expect(result.coercions).toHaveLength(0); + }); + + it('does not coerce strings already valid via const, enum, or unconstrained branches', () => { + const constSchema = { + type: 'object', + properties: { v: { oneOf: [{ type: 'integer' }, { const: '3' }] } }, + }; + expect(normalizeToolArgs(constSchema, { v: '3' })).toEqual({ args: { v: '3' }, coercions: [] }); + + const enumSchema = { type: 'object', properties: { v: { enum: ['3', 3] } } }; + expect(normalizeToolArgs(enumSchema, { v: '3' }).args).toEqual({ v: '3' }); + + const openBranchSchema = { + type: 'object', + properties: { v: { anyOf: [{ type: 'integer' }, {}] } }, + }; + expect(normalizeToolArgs(openBranchSchema, { v: '3' }).args).toEqual({ v: '3' }); + }); + + it('discovers primitive types composed with allOf', () => { + const schema = { + type: 'object', + properties: { v: { allOf: [{ type: 'integer' }, { minimum: 1 }] } }, + }; + const result = normalizeToolArgs(schema, { v: '3' }); + expect(result.args).toEqual({ v: 3 }); + expect(result.coercions).toEqual([ + { path: '/v', received: 'string "3"', expected: 'integer' }, + ]); + }); + + it('leaves non-string values and $ref nodes untouched', () => { + const schema = { + type: 'object', + properties: { a: { type: 'integer' }, b: { $ref: '#/definitions/x' } }, + }; + const result = normalizeToolArgs(schema, { a: 3, b: '3' }); + expect(result.args).toEqual({ a: 3, b: '3' }); + expect(result.coercions).toHaveLength(0); + }); + + it('recurses into nested objects and array items', () => { + const schema = { + type: 'object', + properties: { + range: { + type: 'object', + properties: { start: { type: 'integer' }, end: { type: 'integer' } }, + }, + offsets: { type: 'array', items: { type: 'integer' } }, + }, + }; + const result = normalizeToolArgs(schema, { + range: { start: '1', end: 10 }, + offsets: ['1', '2', 'x'], + }); + expect(result.args).toEqual({ range: { start: 1, end: 10 }, offsets: [1, 2, 'x'] }); + expect(result.coercions.map((c) => c.path)).toEqual([ + '/range/start', + '/offsets/0', + '/offsets/1', + ]); + }); + + it('returns the original reference when nothing was coerced', () => { + const schema = { + type: 'object', + properties: { n: { type: 'integer' }, offsets: { type: 'array', items: { type: 'integer' } } }, + }; + const args = { n: 3, offsets: [1, 2, 3], extra: 'kept' }; + const result = normalizeToolArgs(schema, args); + expect(result.args).toBe(args); + expect((result.args as typeof args).offsets).toBe(args.offsets); + expect(normalizeToolArgs(schema, {}).coercions).toHaveLength(0); + }); + + it('under-coerces ambiguous allOf compositions instead of guessing', () => { + const schema = { + type: 'object', + properties: { + v: { allOf: [{ type: 'integer' }, { anyOf: [{ type: 'string' }, { type: 'number' }] }] }, + }, + }; + const result = normalizeToolArgs(schema, { v: '3' }); + expect(result.args).toEqual({ v: '3' }); + expect(result.coercions).toHaveLength(0); + }); +}); + +describe('describeReceivedValue', () => { + it('describes JSON values for model-facing errors', () => { + expect(describeReceivedValue('3')).toBe('string "3"'); + expect(describeReceivedValue(1.5)).toBe('number 1.5'); + expect(describeReceivedValue(false)).toBe('boolean false'); + expect(describeReceivedValue(null)).toBe('null'); + expect(describeReceivedValue(undefined)).toBe('undefined'); + expect(describeReceivedValue([])).toBe('array'); + expect(describeReceivedValue({})).toBe('object'); + }); +}); diff --git a/packages/agent-core-v2/test/tool/args-validator.test.ts b/packages/agent-core-v2/test/tool/args-validator.test.ts index 0615db51e7..0642a4ca28 100644 --- a/packages/agent-core-v2/test/tool/args-validator.test.ts +++ b/packages/agent-core-v2/test/tool/args-validator.test.ts @@ -43,3 +43,39 @@ describe('args-validator (Ajv, format support)', () => { expect(validate({ const: 'x' }, 'y')).toContain('constant'); }); }); + +describe('args-validator (honest type errors)', () => { + it('reports the received value on type failures', () => { + expect(validate({ type: 'object', properties: { n: { type: 'integer' } } }, { n: '3' })).toBe( + '/n must be integer (received string "3")', + ); + expect(validate({ type: 'boolean' }, 'true')).toBe('must be boolean (received string "true")'); + expect(validate({ type: 'object', properties: { n: { type: 'number' } } }, { n: null })).toBe( + '/n must be number (received null)', + ); + }); + + it('dedupes identical type failures from union branches', () => { + const schema = { + type: 'object', + properties: { + line_offset: { + anyOf: [ + { type: 'integer', minimum: 1 }, + { type: 'integer', minimum: -1000, maximum: -1 }, + ], + }, + }, + }; + const message = validate(schema, { line_offset: '3' }); + expect(message).toBe( + '/line_offset must be integer (received string "3"); /line_offset must match a schema in anyOf', + ); + }); + + it('keeps required / additionalProperties messages free of received details', () => { + expect(validate({ type: 'object', required: ['a'] }, {})).toBe( + "must have required property 'a'", + ); + }); +});