From 87a8faf34d918917547ea9b39e2992f5f7129728 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 16:41:47 +0200 Subject: [PATCH 01/18] Extract MCP content-to-tool-result mapping into a pure function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim move of the content.map body of callMCPTool into an exported mcpContentToToolResultOutputs function. callMCPTool now delegates to it. No behavior change; this makes the conversion rules unit-testable without a live MCP client, in preparation for regression tests around resource handling. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- common/src/mcp/client.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts index 5a5608d57f..9540d9fc75 100644 --- a/common/src/mcp/client.ts +++ b/common/src/mcp/client.ts @@ -181,18 +181,14 @@ function getResourceData( return '' } -export async function callMCPTool( - clientId: string, - ...args: Parameters -): Promise { - const client = runningClients[clientId] - if (!client) { - throw new Error(`callTool: client not found with id: ${clientId}`) - } - const callResult = await client.callTool(...args) - const result = callResult as CallToolResult - const content = result.content - +/** + * Convert MCP tool-result content blocks into codebuff tool-result outputs. + * Pure function (no client access) so conversion rules are testable in + * isolation. No behavior change from the previous inline map. + */ +export function mcpContentToToolResultOutputs( + content: CallToolResult['content'], +): ToolResultOutput[] { return content.map((c: (typeof content)[number]) => { if (c.type === 'text') { return { @@ -231,3 +227,18 @@ export async function callMCPTool( } satisfies ToolResultOutput }) } + +export async function callMCPTool( + clientId: string, + ...args: Parameters +): Promise { + const client = runningClients[clientId] + if (!client) { + throw new Error(`callTool: client not found with id: ${clientId}`) + } + const callResult = await client.callTool(...args) + const result = callResult as CallToolResult + const content = result.content + + return mcpContentToToolResultOutputs(content) +} From 7b8dab75221c51f432ffcdef7372c0f941ec6569 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:23:09 +0200 Subject: [PATCH 02/18] Fix zod schema amputation by lodash cloneDeep (schema._zod.parent crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lodash cloneDeep drops zod v4's non-enumerable _zod engine, leaving clones that look like schemas (safeParse, def, shape all present) but detonate on first internal use. The visible symptom was MCP/custom tool input schemas randomly arriving at the model as empty {} - the ensureJsonSchemaCompatible fallback silently consumed the amputated schema, and whether a boot got healthy schemas depended on which call sites happened to trip the clone. cloneDeepKeepingZod passes zod instances through by reference while cloning surrounding plain data. Applied at the three clone sites that touch tool definitions: run-agent-step (custom tool defs), tools/prompts getToolSet, and tool-executor writeTo. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- packages/agent-runtime/src/run-agent-step.ts | 6 ++-- packages/agent-runtime/src/tools/prompts.ts | 4 +-- .../agent-runtime/src/tools/tool-executor.ts | 4 +-- .../agent-runtime/src/util/zod-safe-clone.ts | 34 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 packages/agent-runtime/src/util/zod-safe-clone.ts diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 9a97508e26..c0b5323417 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -22,9 +22,11 @@ import { userMessage, } from '@codebuff/common/util/messages' import { type ToolSet } from 'ai' -import { cloneDeep, mapValues } from 'lodash' +import { mapValues } from 'lodash' import z from 'zod/v4' +import { cloneDeepKeepingZod } from './util/zod-safe-clone' + import { maybeCompactHistory } from './compact-history' import { CACHE_DEBUG_FULL_LOGGING } from './constants' import { getMCPToolData } from './mcp' @@ -151,7 +153,7 @@ async function additionalToolDefinitions( ): Promise { const { agentTemplate, fileContext } = params - const defs = cloneDeep( + const defs = cloneDeepKeepingZod( Object.fromEntries( Object.entries(fileContext.customToolDefinitions).filter(([toolName]) => agentTemplate!.toolNames.includes(toolName), diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index d3d9110665..aca40e36ae 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -8,7 +8,7 @@ import { getToolCallString } from '@codebuff/common/tools/utils' import { buildArray } from '@codebuff/common/util/array' import { formatAvailableSkillsXml } from '@codebuff/common/util/skills' import { pluralize } from '@codebuff/common/util/string' -import { cloneDeep } from 'lodash' +import { cloneDeepKeepingZod } from '../util/zod-safe-clone' import z from 'zod/v4' import { convertJsonSchemaToZod } from 'zod-from-json-schema' @@ -430,7 +430,7 @@ export async function getToolSet(params: { const toolDefinitions = await additionalToolDefinitions() for (const [toolName, toolDefinition] of Object.entries(toolDefinitions)) { - const clonedDef = cloneDeep(toolDefinition) + const clonedDef = cloneDeepKeepingZod(toolDefinition) // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP) // Ensure it's a Zod schema for the AI SDK const zodSchema = ensureZodSchema(clonedDef.inputSchema) diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 36c4708752..d3abd7c6f0 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -1,7 +1,7 @@ import { endsAgentStepParam, toolNames } from '@codebuff/common/tools/constants' import { toolParams } from '@codebuff/common/tools/list' import { generateCompactId } from '@codebuff/common/util/string' -import { cloneDeep } from 'lodash' +import { cloneDeepKeepingZod } from '../util/zod-safe-clone' import { getMCPToolData } from '../mcp' import { MCP_TOOL_SEPARATOR } from '../mcp-constants' @@ -675,7 +675,7 @@ export async function executeCustomToolCall( ...params, toolNames: agentTemplate.toolNames, mcpServers: agentTemplate.mcpServers, - writeTo: cloneDeep(fileContext.customToolDefinitions), + writeTo: cloneDeepKeepingZod(fileContext.customToolDefinitions), }), rawToolCall: { toolName, diff --git a/packages/agent-runtime/src/util/zod-safe-clone.ts b/packages/agent-runtime/src/util/zod-safe-clone.ts new file mode 100644 index 0000000000..6e680895c4 --- /dev/null +++ b/packages/agent-runtime/src/util/zod-safe-clone.ts @@ -0,0 +1,34 @@ +import { cloneDeepWith } from 'lodash' + +/** + * lodash cloneDeep destroys zod v4 schema instances. + * + * zod v4 stores its engine on a non-enumerable `_zod` property, and lodash + * only copies enumerable own properties. The clone therefore looks like a + * schema (has safeParse/def/type) but has no `_zod` internals, and any zod + * internal that touches `schema._zod.*` detonates with: + * "undefined is not an object (evaluating 'schema._zod.def')" + * + * This deep-clones plain data (descriptions, maps, arrays) exactly like + * cloneDeep, but passes zod schema instances through by reference so their + * internals survive. + */ +export function cloneDeepKeepingZod(value: T): T { + const cloned = cloneDeepWith(value, (node) => { + if (isZodSchemaInstance(node)) { + // Pass the live schema through untouched. + return node as T + } + // Fall through to lodash's default deep clone. + return undefined + }) + return cloned as T +} + +function isZodSchemaInstance(node: unknown): boolean { + if (typeof node !== 'object' || node === null) { + return false + } + const candidate = node as { _zod?: unknown } + return typeof candidate._zod === 'object' && candidate._zod !== null +} From 01db5f9a9567adf604251ed6d93c6dacafd8ea87 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 16:46:34 +0200 Subject: [PATCH 03/18] Add regression tests for zod-safe clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Characterizes the bug (lodash cloneDeep strips zod v4's non-enumerable _zod engine; the amputated clone then throws in z.toJSONSchema) and pins cloneDeepKeepingZod behavior: schemas pass through by reference with a working engine, plain data still deep-clones, nested schemas survive. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../src/util/__tests__/zod-safe-clone.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts new file mode 100644 index 0000000000..44077d8225 --- /dev/null +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'bun:test' +import { cloneDeep } from 'lodash' +import { z } from 'zod/v4' + +import { cloneDeepKeepingZod } from '../zod-safe-clone' + +describe('lodash cloneDeep zod amputation (the bug)', () => { + test('cloneDeep strips the zod engine, so the clone detonates on use', () => { + const schema = z.object({ q: z.string() }) + const cloned = cloneDeep(schema) + + // zod v4 keeps its engine on a non-enumerable own property; lodash only + // copies enumerable own properties, so the clone looks like a schema... + expect(typeof cloned.safeParse).toBe('function') + // ...but has no internals, and every zod internal that touches _zod dies: + expect('_zod' in cloned).toBe(false) + expect(() => z.toJSONSchema(cloned as never)).toThrow() + }) +}) + +describe('cloneDeepKeepingZod', () => { + test('passes zod schemas through by reference, engine intact', () => { + const schema = z.object({ q: z.string().describe('query') }) + const input = { cfg: schema, note: 'plain', nested: { arr: [1, 2] } } + + const out = cloneDeepKeepingZod(input) + + expect(out.note).toBe('plain') + expect(out.nested).not.toBe(input.nested) + expect(out.nested.arr).toEqual([1, 2]) + // Same live instance, so the engine survives: + expect(out.cfg).toBe(schema) + const jsonSchema = z.toJSONSchema(out.cfg) + expect(jsonSchema.type).toBe('object') + expect(jsonSchema.properties.q.type).toBe('string') + }) + + test('deep-clones plain structures exactly like cloneDeep', () => { + const input = { a: { b: [1, { c: 'd' }] }, e: null } + const out = cloneDeepKeepingZod(input) + expect(out).toEqual(input) + expect(out.a).not.toBe(input.a) + expect(out.a.b[1]).not.toBe(input.a.b[1]) + }) + + test('handles schemas nested inside collections', () => { + const schema = z.object({ id: z.number() }) + const out = cloneDeepKeepingZod({ tools: [{ name: 'x', inputSchema: schema }] }) + expect(out.tools[0].inputSchema).toBe(schema) + expect(() => z.toJSONSchema(out.tools[0].inputSchema)).not.toThrow() + }) +}) From 99a5b5bdc46b81469a7c8cddf620ff0f7884d488 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:24:35 +0200 Subject: [PATCH 04/18] Store plain JSON Schema in persisted agent state, not live zod schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool definitions land in agent state, which is snapshotted and persisted every turn. Storing the live zod schema (as getMCPToolData did for MCP tools, and as mapValues shipped into toolDefinitions) made any JSON.stringify over that state throw "cannot serialize cyclic structures" - visible as a hard session death from turn 2 onward. The fix moves toTokenCountInputSchema into util/to-json-schema.ts (also removes an import cycle) and stores plain JSON Schema at the two state boundaries: loopAgentSteps' toolDefinitions and spawn-agent-inline's parent tool definitions. MCP schemas are now stored exactly as the server sent them; zod conversion happens at point of use via ensureZodSchema / toTokenCountInputSchema. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- packages/agent-runtime/src/mcp.ts | 8 ++- packages/agent-runtime/src/run-agent-step.ts | 55 ++++--------------- .../tools/handlers/tool/spawn-agent-inline.ts | 4 +- .../agent-runtime/src/util/to-json-schema.ts | 43 +++++++++++++++ 4 files changed, 63 insertions(+), 47 deletions(-) create mode 100644 packages/agent-runtime/src/util/to-json-schema.ts diff --git a/packages/agent-runtime/src/mcp.ts b/packages/agent-runtime/src/mcp.ts index a7390f219c..716ba901d4 100644 --- a/packages/agent-runtime/src/mcp.ts +++ b/packages/agent-runtime/src/mcp.ts @@ -1,5 +1,4 @@ import { getErrorObject } from '@codebuff/common/util/error' -import { convertJsonSchemaToZod } from 'zod-from-json-schema' import { MCP_TOOL_SEPARATOR } from './mcp-constants' @@ -55,8 +54,13 @@ export async function getMCPToolData( }) for (const { name, description, inputSchema } of mcpData) { + // Store the raw JSON Schema from the server, NOT the converted Zod + // schema. Tool definitions are persisted in run state / session + // state and must stay JSON-serializable; Zod instances are cyclic + // and make any JSON.stringify over that state detonate. Consumers + // convert at point of use (ensureZodSchema / toTokenCountInputSchema). writeTo[mcpName + MCP_TOOL_SEPARATOR + name] = { - inputSchema: convertJsonSchemaToZod(inputSchema as any) as any, + inputSchema: inputSchema as {}, endsAgentStep: true, description, } diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index c0b5323417..ce18cffc8b 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -26,6 +26,7 @@ import { mapValues } from 'lodash' import z from 'zod/v4' import { cloneDeepKeepingZod } from './util/zod-safe-clone' +import { toTokenCountInputSchema } from './util/to-json-schema' import { maybeCompactHistory } from './compact-history' import { CACHE_DEBUG_FULL_LOGGING } from './constants' @@ -100,47 +101,9 @@ import type { ProjectFileContext, } from '@codebuff/common/util/file' -// Convert a tool's stored inputSchema into JSON Schema suitable for Anthropic's -// count_tokens API. Built-in and MCP tools store a Zod schema here; serializing -// it raw ships Zod internals (`def`/`shape`) instead of JSON Schema, so token -// counts are computed against garbage and any schema whose top-level isn't an -// object (e.g. a union → `anyOf`) arrives without `type`, which the API rejects -// with `tools.N.custom.input_schema.type: Field required`. We convert to JSON -// Schema and guarantee a top-level `type: 'object'`. -export function toTokenCountInputSchema( - inputSchema: unknown, -): Record | undefined { - if (inputSchema == null) return undefined - - let jsonSchema: Record - if ( - typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' - ) { - try { - jsonSchema = z.toJSONSchema(inputSchema as z.ZodType, { - io: 'input', - }) as Record - } catch { - jsonSchema = { type: 'object', properties: {} } - } - } else if (typeof inputSchema === 'object' && !Array.isArray(inputSchema)) { - // Already a plain object (e.g. a pre-serialized JSON Schema) — copy it. - jsonSchema = { ...(inputSchema as Record) } - } else { - return undefined - } - - // `$schema` is meaningless to count_tokens; drop it to keep the payload lean. - delete jsonSchema['$schema'] - // Anthropic requires a top-level `type: 'object'`. Object schemas already - // carry it; union/intersection schemas (anyOf/allOf) don't — backfill it. - // Treat missing / null / empty-string as absent (valid JSON Schema `type` is - // always a non-empty string or array). - if (jsonSchema.type == null || jsonSchema.type === '') { - jsonSchema.type = 'object' - } - return jsonSchema -} +// Moved to util/to-json-schema.ts so spawn-agent-inline can use it without an +// import cycle through run-agent-step. Re-exported here for existing importers. +export { toTokenCountInputSchema } from './util/to-json-schema' async function additionalToolDefinitions( params: { @@ -969,11 +932,14 @@ export async function loopAgentSteps( }), ) - // Convert tools to a serializable format for context-pruner token counting + // Convert tool definitions to a JSON-serializable format. These live in + // agent state (persisted, snapshotted, shipped over the wire), so every + // inputSchema must be plain JSON Schema — Zod instances are cyclic and + // detonate any JSON.stringify over the state (turn 2+ would die). const toolDefinitions = mapValues(tools, (tool) => ({ description: typeof tool.description === 'string' ? tool.description : undefined, - inputSchema: tool.inputSchema as {}, + inputSchema: toTokenCountInputSchema(tool.inputSchema) ?? {}, })) const additionalToolDefinitionsWithCache = async () => { @@ -996,7 +962,8 @@ export async function loopAgentSteps( // Convert tool definitions to Anthropic format for accurate token counting. // Tool definitions are stored as { [name]: { description, inputSchema } }, - // where inputSchema is a Zod schema. Anthropic's count_tokens API expects + // with inputSchema as plain JSON Schema (see toolDefinitions above). + // Anthropic's count_tokens API expects // [{ name, description, input_schema }] with input_schema being real JSON // Schema (with a top-level `type: 'object'`) — see toTokenCountInputSchema. const toolsForTokenCount = Object.entries(toolDefinitions).map( diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts index 3b996cdb87..a80ccaec5a 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts @@ -1,5 +1,7 @@ import { mapValues } from 'lodash' +import { toTokenCountInputSchema } from '../../../util/to-json-schema' + import { validateAndGetAgentTemplate, validateAgentInput, @@ -114,7 +116,7 @@ export const handleSpawnAgentInline = (async ( toolDefinitions: mapValues(parentTools, (tool) => ({ description: typeof tool.description === 'string' ? tool.description : undefined, - inputSchema: tool.inputSchema as {}, + inputSchema: toTokenCountInputSchema(tool.inputSchema) ?? {}, })), } diff --git a/packages/agent-runtime/src/util/to-json-schema.ts b/packages/agent-runtime/src/util/to-json-schema.ts new file mode 100644 index 0000000000..fcf8744634 --- /dev/null +++ b/packages/agent-runtime/src/util/to-json-schema.ts @@ -0,0 +1,43 @@ +import z from 'zod/v4' + +// Convert a tool's stored inputSchema into plain JSON Schema. Built-in and MCP +// tools convert from a Zod schema; plain objects (e.g. a pre-serialized JSON +// Schema) are copied. Serializing a Zod schema raw would ship Zod internals +// (`def`/`shape`, non-enumerable `_zod`) instead of JSON Schema — which breaks +// JSON.stringify (zod schemas are cyclic) and makes token counts computed +// against garbage. Any schema whose top-level isn't an object (e.g. a union → +// `anyOf`) is backfilled with `type: 'object'`, which Anthropic requires. +export function toTokenCountInputSchema( + inputSchema: unknown, +): Record | undefined { + if (inputSchema == null) return undefined + + let jsonSchema: Record + if ( + typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' + ) { + try { + jsonSchema = z.toJSONSchema(inputSchema as z.ZodType, { + io: 'input', + }) as Record + } catch { + jsonSchema = { type: 'object', properties: {} } + } + } else if (typeof inputSchema === 'object' && !Array.isArray(inputSchema)) { + // Already a plain object (e.g. a pre-serialized JSON Schema) — copy it. + jsonSchema = { ...(inputSchema as Record) } + } else { + return undefined + } + + // `$schema` is meaningless to count_tokens; drop it to keep the payload lean. + delete jsonSchema['$schema'] + // Anthropic requires a top-level `type: 'object'`. Object schemas already + // carry it; union/intersection schemas (anyOf/allOf) don't — backfill it. + // Treat missing / null / empty-string as absent (valid JSON Schema `type` is + // always a non-empty string or array). + if (jsonSchema.type == null || jsonSchema.type === '') { + jsonSchema.type = 'object' + } + return jsonSchema +} From b02bd7d0f2cdc89c8f3aa2f51857cda43ef93bb0 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:01:42 +0200 Subject: [PATCH 05/18] Add regression tests for JSON Schema state storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the state contract: getMCPToolData must store the server's JSON Schema verbatim (JSON round-trip equality), because tool definitions are persisted and replayed every turn. Includes a characterization of the failure mode (a zod instance round-trips to def/shape internals, not the server schema) and covers toTokenCountInputSchema: zod conversion, the Anthropic type:object backfill for unions, plain passthrough, and $schema stripping. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../src/__tests__/mcp-schema-store.test.ts | 86 +++++++++++++++++++ .../src/util/__tests__/to-json-schema.test.ts | 42 +++++++++ .../src/util/__tests__/zod-safe-clone.test.ts | 2 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts create mode 100644 packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts diff --git a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts new file mode 100644 index 0000000000..e9a035661d --- /dev/null +++ b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from 'bun:test' +import { z } from 'zod/v4' + +import { getMCPToolData } from '../mcp' +import { MCP_TOOL_SEPARATOR } from '../mcp-constants' + +describe('getMCPToolData schema storage (the bug: live zod in persisted state)', () => { + test('stores the server JSON Schema verbatim, JSON-serializable by contract', async () => { + const serverSchema = { + type: 'object', + properties: { + location: { type: 'string', enum: ['NYC', 'LA'] }, + units: { type: 'string', description: 'metric or imperial' }, + }, + required: ['location'], + } + const writeTo: Record = {} + await getMCPToolData({ + toolNames: ['weather/get_forecast'], + mcpServers: { + weather: { command: 'echo', args: [] }, + } as never, + writeTo: writeTo as never, + requestMcpToolData: async () => [ + { + name: 'get_forecast', + description: 'Get the forecast', + inputSchema: serverSchema, + }, + ], + }) + + const stored = writeTo[`weather${MCP_TOOL_SEPARATOR}get_forecast`] + expect(stored).toBeDefined() + + // THE CONTRACT: tool definitions are persisted, snapshotted, and shipped + // over the wire every turn, so the stored schema must round-trip JSON as + // the exact schema the server sent. Storing a live zod instance here + // instead serializes zod internals (def/shape) and can carry cycles that + // detonate JSON.stringify over the whole run state ("cannot serialize + // cyclic structures", session death from turn 2 onward). + const roundTripped = JSON.parse(JSON.stringify(stored.inputSchema)) + expect(roundTripped).toEqual(serverSchema) + }) + + test('stores schemas for multiple tools and servers without conversion', async () => { + const schemaA = { type: 'object', properties: { a: { type: 'number' } } } + const schemaB = { type: 'string' } + const writeTo: Record = {} + await getMCPToolData({ + toolNames: [], + mcpServers: { + alpha: { command: 'echo', args: [] }, + beta: { command: 'echo', args: [] }, + } as never, + writeTo: writeTo as never, + requestMcpToolData: async ({ toolNames }: { toolNames: unknown }) => { + void toolNames + return [ + { name: 't1', description: 'A', inputSchema: schemaA }, + { name: 't2', description: 'B', inputSchema: schemaB }, + ] + }, + }) + + for (const [server, schema] of [ + ['alpha', schemaA], + ['beta', schemaB], + ] as const) { + const toolName = server === 'alpha' ? 't1' : 't2' + const stored = writeTo[`${server}${MCP_TOOL_SEPARATOR}${toolName}`] + expect(JSON.parse(JSON.stringify(stored.inputSchema))).toEqual(schema) + } + expect(writeTo[`beta${MCP_TOOL_SEPARATOR}t2`].description).toBe('B') + }) + + test('a zod schema stored in state is the failure mode this guards against', () => { + // Documents what the old code did: storing convertJsonSchemaToZod output + // in state. It round-trips to garbage, not the server's schema. + const serverSchema = { type: 'object', properties: { q: { type: 'string' } } } + const zodInstance = z.object({ q: z.string() }) + const roundTripped = JSON.parse(JSON.stringify(zodInstance)) + expect(roundTripped).not.toEqual(serverSchema) + expect(roundTripped.def).toBeDefined() // zod internals leaked into state + }) +}) diff --git a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts new file mode 100644 index 0000000000..d4f499447b --- /dev/null +++ b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from 'bun:test' +import { z } from 'zod/v4' + +import { toTokenCountInputSchema } from '../to-json-schema' + +describe('toTokenCountInputSchema', () => { + test('converts a zod schema to JSON Schema with a top-level type', () => { + const schema = z.object({ + q: z.string().describe('query'), + n: z.number().optional(), + }) + const out = toTokenCountInputSchema(schema) as Record | undefined + expect(out?.type).toBe('object') + expect(out?.properties.q.type).toBe('string') + }) + + test('backfills type:object for union schemas (anyOf)', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]) + const out = toTokenCountInputSchema(schema) as Record | undefined + // Anthropic's count_tokens rejects a schema with no top-level type + expect(out?.type).toBe('object') + expect(out?.anyOf).toBeDefined() + }) + + test('copies an already-plain JSON Schema object as-is', () => { + const jsonSchema = { + type: 'object', + properties: { location: { type: 'string', enum: ['NYC', 'LA'] } }, + required: ['location'], + } + const out = toTokenCountInputSchema(jsonSchema) + expect(out).toEqual(jsonSchema) + }) + + test('drops $schema and survives nullish input', () => { + expect(toTokenCountInputSchema(undefined)).toBeUndefined() + expect(toTokenCountInputSchema(null)).toBeUndefined() + const out = toTokenCountInputSchema({ $schema: 'https://json-schema.org/x', type: 'object' }) + expect(out?.$schema).toBeUndefined() + expect(out?.type).toBe('object') + }) +}) diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts index 44077d8225..5d3f3911e1 100644 --- a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -32,7 +32,7 @@ describe('cloneDeepKeepingZod', () => { expect(out.cfg).toBe(schema) const jsonSchema = z.toJSONSchema(out.cfg) expect(jsonSchema.type).toBe('object') - expect(jsonSchema.properties.q.type).toBe('string') + expect((jsonSchema.properties as { q: { type: string } }).q.type).toBe('string') }) test('deep-clones plain structures exactly like cloneDeep', () => { From 6ae8dae8f417fe28ded06c4960e93c2742a26513 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:25:22 +0200 Subject: [PATCH 06/18] Treat text MCP resources as text, not base64 media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resource whose contents are text was wrapped as a media part with the prose in the data field. When the AI SDK rebuilds the prompt on any later turn, a file part's data that is not a URL gets base64-decoded - and English prose is not base64, so it died with "The string contains invalid characters". Because the poisoned message stays in message history, the session never recovers. Text contents now flow as a plain json tool result. Binary (blob) resources keep the existing media path. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- common/src/mcp/client.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts index 9540d9fc75..90f2af3da6 100644 --- a/common/src/mcp/client.ts +++ b/common/src/mcp/client.ts @@ -211,6 +211,16 @@ export function mcpContentToToolResultOutputs( } satisfies ToolResultOutput } if (c.type === 'resource') { + // A resource with text contents is text, not media. Wrapping prose as + // media makes the AI SDK base64-decode it when rebuilding the prompt on + // every later turn, which dies with "The string contains invalid + // characters" forever, since the poisoned message replays from history. + if ('text' in c.resource) { + return { + type: 'json', + value: c.resource.text, + } satisfies ToolResultOutput + } return { type: 'media', data: getResourceData(c.resource), From 2cc793bfa9ec99cf0a406e5519fbd2267e3a7f0e Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:05:11 +0200 Subject: [PATCH 07/18] Add regression tests for text MCP resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the resource mapping contract at the extracted pure function: a text resource must reach the model as a text value, never as media (media triggers base64-decoding of the prose on every later prompt build); an image resource stays media. Characterizes the pre-fix behavior that permanently poisoned session history. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../mcp/__tests__/mcp-content-mapping.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 common/src/mcp/__tests__/mcp-content-mapping.test.ts diff --git a/common/src/mcp/__tests__/mcp-content-mapping.test.ts b/common/src/mcp/__tests__/mcp-content-mapping.test.ts new file mode 100644 index 0000000000..385d1a1d37 --- /dev/null +++ b/common/src/mcp/__tests__/mcp-content-mapping.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'bun:test' + +import { mcpContentToToolResultOutputs } from '../client' + +describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 media)', () => { + test('a text resource becomes a text value, NOT media', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///notes.txt', + mimeType: 'text/plain', + text: 'Resource 1: This is a plain text resource.', + }, + }, + ] as never) + + // The bug: this prose was wrapped as media, and on every later turn the + // AI SDK base64-decodes file data - "The string contains invalid + // characters", forever, since the message replays from history. + expect(outputs).toEqual([ + { + type: 'json', + value: 'Resource 1: This is a plain text resource.', + }, + ]) + }) + + test('an image resource stays media', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///logo.png', + mimeType: 'image/png', + blob: 'aGVsbG8=', + }, + }, + ] as never) + + expect(outputs).toHaveLength(1) + expect(outputs[0].type).toBe('media') + expect((outputs[0] as { mediaType?: string }).mediaType).toBe('image/png') + }) + + test('plain text content still maps to a json value', () => { + const outputs = mcpContentToToolResultOutputs([ + { type: 'text', text: 'Echo: hello' }, + ] as never) + expect(outputs).toEqual([{ type: 'json', value: 'Echo: hello' }]) + }) +}) From 917539a21f2921659154b5e2249f0b1a1d221434 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:26:21 +0200 Subject: [PATCH 08/18] Degrade non-image MCP resources and file parts instead of poisoning sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers for the same failure mode: the OpenAI-compatible chat converter (GLM and other OpenAI-compatible providers) accepts only image file parts and threw on anything else. An application/gzip MCP resource converted to media, the converter threw during the next prompt build, and - because the message replays from history every turn - the session was dead permanently. Ingestion (mcp/client.ts): only image/* resources stay media; other binary resources become a descriptive text result the model can read. Defense (convert-to-openai-compatible-chat-messages.ts): non-image file parts degrade to a text placeholder with an approximate byte size instead of throwing. Image data URIs are unchanged. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- common/src/mcp/client.ts | 20 +++++++++-- ...vert-to-openai-compatible-chat-messages.ts | 33 +++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts index 90f2af3da6..fe2b526cbc 100644 --- a/common/src/mcp/client.ts +++ b/common/src/mcp/client.ts @@ -221,10 +221,24 @@ export function mcpContentToToolResultOutputs( value: c.resource.text, } satisfies ToolResultOutput } + const mimeType = c.resource.mimeType ?? 'application/octet-stream' + // Only images stay media: every provider path (including the + // OpenAI-compatible chat converter used by GLM) accepts image file + // parts but throws on anything else — and a thrown converter poisons + // the whole session, since the message replays on every later turn. + if (mimeType.startsWith('image/')) { + return { + type: 'media', + data: getResourceData(c.resource), + mediaType: mimeType, + } satisfies ToolResultOutput + } + // Other binary resources (gzip, PDF, ...): surface metadata instead of + // undecodable bytes. + const blobData = getResourceData(c.resource) return { - type: 'media', - data: getResourceData(c.resource), - mediaType: c.resource.mimeType ?? 'text/plain', + type: 'json', + value: `[Binary resource ${c.resource.uri}: ${mimeType}, ~${Math.round((blobData.length * 3) / 4)} bytes, not displayable]`, } satisfies ToolResultOutput } const fallbackValue = diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts index ead5daab11..4491f8dfaa 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts @@ -14,6 +14,25 @@ function getOpenAIMetadata(message: { return message?.providerOptions?.openaiCompatible ?? {} } +/** Approximate payload size of a file part's data, for placeholder text. */ +function filePartByteLength(data: unknown): number { + let value = data + if (value && typeof value === 'object' && 'type' in value) { + if (value.type === 'data' && 'data' in value) { + value = value.data + } else if (value.type === 'url' && 'url' in value) { + value = value.url + } + } + if (typeof value === 'string') { + return Math.round((value.length * 3) / 4) + } + if (value instanceof Uint8Array) { + return value.byteLength + } + return 0 +} + function imageUrlFromData(data: unknown, mediaType: string): string { // AI SDK 7 adapts this v2 provider to v4, whose file data is tagged. The // compatibility proxy passes that v4 shape through to the v2 implementation. @@ -89,9 +108,17 @@ export function convertToOpenAICompatibleChatMessages( ...partMetadata, } } else { - throw new UnsupportedFunctionalityError({ - functionality: `file part media type ${part.mediaType}`, - }) + // Non-image file parts (e.g. application/gzip from an MCP + // resource) have no OpenAI-compatible representation. + // Degrade to a text placeholder instead of throwing: a + // throw here fails the entire prompt build and, because + // the message stays in history, kills the session on every + // subsequent turn. + return { + type: 'text', + text: `[${part.mediaType} file part not displayable (~${filePartByteLength(part.data)} bytes)]`, + ...partMetadata, + } } } } From 19036099a6965fd57f8a796964daf79472e3f1b1 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:08:10 +0200 Subject: [PATCH 09/18] Add regression tests for non-image resource and file-part handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins both halves of the degrade contract: a non-image binary MCP resource maps to descriptive text (never media) at ingestion, and the OpenAI-compatible converter degrades any non-image file part to a text placeholder with an approximate byte size instead of throwing during prompt build - the throw was what permanently killed sessions. Image file parts must still convert to image_url data URIs. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../mcp/__tests__/mcp-content-mapping.test.ts | 21 ++++++++ ...to-openai-compatible-chat-messages.test.ts | 52 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/common/src/mcp/__tests__/mcp-content-mapping.test.ts b/common/src/mcp/__tests__/mcp-content-mapping.test.ts index 385d1a1d37..d6709fa5e2 100644 --- a/common/src/mcp/__tests__/mcp-content-mapping.test.ts +++ b/common/src/mcp/__tests__/mcp-content-mapping.test.ts @@ -43,6 +43,27 @@ describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 med expect((outputs[0] as { mediaType?: string }).mediaType).toBe('image/png') }) + test('a non-image binary resource becomes descriptive text, NOT media', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///archive.gz', + mimeType: 'application/gzip', + blob: 'aGVsbG8=', + }, + }, + ] as never) + + // The bug: application/gzip media killed the OpenAI-compatible converter + // at prompt build on every later turn (session death). Only images may + // travel as media through ingestion. + expect(outputs[0].type).toBe('json') + const value = (outputs[0] as { value: string }).value + expect(value).toContain('application/gzip') + expect(value).toContain('not displayable') + }) + test('plain text content still maps to a json value', () => { const outputs = mcpContentToToolResultOutputs([ { type: 'text', text: 'Echo: hello' }, diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index 195d63b819..175956ad60 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -1083,3 +1083,55 @@ describe('consecutive assistant messages', () => { ]) }) }) + +describe('non-image file parts (the bug: application/gzip threw at prompt build)', () => { + it('degrades a non-image file part to a text placeholder instead of throwing', () => { + // The bug: this threw UnsupportedFunctionalityError during prompt build. + // Because the message stays in history, the session died on every + // subsequent turn. + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'file', + data: Buffer.from('Hello freebuff!').toString('base64'), + mediaType: 'application/gzip', + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'text', + text: '[application/gzip file part not displayable (~15 bytes)]', + }, + ], + }, + ]) + }) + + it('still converts image file parts to image_url data URIs', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'file', + data: Buffer.from([0, 1, 2, 3]).toString('base64'), + mediaType: 'image/png', + }, + ], + }, + ]) + + expect(result[0].content[0]).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,AAECAw==' }, + }) + }) +}) From 5b969a2ae2e292e8365e326be55dd34c4db595cc Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:35:18 +0200 Subject: [PATCH 10/18] Log schema-conversion fallbacks and MCP tool loads instead of failing silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensureJsonSchemaCompatible fallback (duplicated in tools/prompts.ts and templates/prompts.ts) converts any schema that fails JSON Schema conversion into an empty permissive schema without a trace. During the zod-clone incident this fallback was the masking layer that turned a broken schema into a silent empty tool schema at the model; loud logging here would have surfaced it in minutes. Both copies now accept an optional logger and warn on fallback with the tool name and error. getToolSet and buildAgentToolSet thread an optional logger through; loopAgentSteps passes the one it already holds. getMCPToolData gains a debug receipt per server (tool count) so missing tools are attributable to a specific server load. Logger writes go to the CLI's file sink only, never the TUI. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- packages/agent-runtime/src/mcp.ts | 4 +++ packages/agent-runtime/src/run-agent-step.ts | 1 + .../agent-runtime/src/templates/prompts.ts | 22 ++++++++++++-- packages/agent-runtime/src/tools/prompts.ts | 29 +++++++++++++++++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/agent-runtime/src/mcp.ts b/packages/agent-runtime/src/mcp.ts index 716ba901d4..c797d3fe60 100644 --- a/packages/agent-runtime/src/mcp.ts +++ b/packages/agent-runtime/src/mcp.ts @@ -65,6 +65,10 @@ export async function getMCPToolData( description, } } + logger?.debug( + { mcpServer: mcpName, toolCount: mcpData.length }, + `Loaded ${mcpData.length} tool(s) from MCP server "${mcpName}".`, + ) } catch (error) { // A failed MCP server (e.g. a stdio server that can't be spawned) // should disable just its own tools, not abort the whole turn. The diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index ce18cffc8b..3030366933 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -877,6 +877,7 @@ export async function loopAgentSteps( windowedFileReads: agentTemplate.windowedFileReads === true, suppressCommitAttribution: agentTemplate.suppressCommitAttribution === true, + logger, additionalToolDefinitions: async () => { if (!cachedAdditionalToolDefinitions) { cachedAdditionalToolDefinitions = await additionalToolDefinitions({ diff --git a/packages/agent-runtime/src/templates/prompts.ts b/packages/agent-runtime/src/templates/prompts.ts index d4e96faa03..51c55608d7 100644 --- a/packages/agent-runtime/src/templates/prompts.ts +++ b/packages/agent-runtime/src/templates/prompts.ts @@ -10,11 +10,26 @@ import type { ParamsExcluding } from '@codebuff/common/types/function-params' import type { AgentTemplateType } from '@codebuff/common/types/session-state' import type { ToolSet } from 'ai' -function ensureJsonSchemaCompatible(schema: z.ZodType): z.ZodType { +function ensureJsonSchemaCompatible( + schema: z.ZodType, + opts?: { logger?: Logger; name?: string }, +): z.ZodType { try { z.toJSONSchema(schema, { io: 'input' }) return schema - } catch { + } catch (error) { + // Same silent-fallback hazard as the copy in tools/prompts.ts: this once + // consumed amputated zod schemas without a trace. Log it loudly. + opts?.logger?.warn( + { + toolName: opts.name, + error: String(error), + schemaConstructor: schema?.constructor?.name, + }, + `input schema failed JSON Schema conversion; serving empty schema${ + opts.name ? ` for '${opts.name}'` : '' + }`, + ) const fallback = z.object({}).passthrough() return schema.description ? fallback.describe(schema.description) : fallback } @@ -81,7 +96,7 @@ export async function buildAgentToolSet( 'agentId' | 'localAgentTemplates' >, ): Promise { - const { spawnableAgents, agentTemplates } = params + const { spawnableAgents, agentTemplates, logger } = params const toolSet: ToolSet = {} @@ -97,6 +112,7 @@ export async function buildAgentToolSet( const toolName = getAgentToolName(agentType) const inputSchema = ensureJsonSchemaCompatible( buildAgentToolInputSchema(agentTemplate), + { logger, name: toolName }, ) // Use the same structure as other tools in toolParams diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index aca40e36ae..d10662be63 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -14,6 +14,7 @@ import { convertJsonSchemaToZod } from 'zod-from-json-schema' import type { ToolName } from '@codebuff/common/tools/constants' import type { SkillsMap } from '@codebuff/common/types/skill' +import type { Logger } from '@codebuff/common/types/contracts/logger' import type { CustomToolDefinitions, customToolDefinitionsSchema, @@ -38,11 +39,28 @@ export function ensureZodSchema( return convertJsonSchemaToZod(schema as Record) } -function ensureJsonSchemaCompatible(schema: z.ZodType): z.ZodType { +function ensureJsonSchemaCompatible( + schema: z.ZodType, + opts?: { logger?: Logger; name?: string }, +): z.ZodType { try { z.toJSONSchema(schema, { io: 'input' }) return schema - } catch { + } catch (error) { + // This fallback once silently consumed amputated zod schemas (lodash + // cloneDeep drops zod v4's non-enumerable _zod), turning a broken schema + // into an empty tool schema at the model. Loud failure here would have + // surfaced that bug in minutes instead of sessions. + opts?.logger?.warn( + { + toolName: opts.name, + error: String(error), + schemaConstructor: schema?.constructor?.name, + }, + `input schema failed JSON Schema conversion; serving empty schema${ + opts.name ? ` for '${opts.name}'` : '' + }`, + ) const fallback = z.object({}).passthrough() return schema.description ? fallback.describe(schema.description) : fallback } @@ -369,6 +387,7 @@ export async function getToolSet(params: { additionalToolDefinitions: () => Promise agentTools: ToolSet skills: SkillsMap + logger?: Logger }): Promise { const { toolNames, @@ -377,6 +396,7 @@ export async function getToolSet(params: { additionalToolDefinitions, agentTools, skills, + logger, } = params // Generate available skills XML for the skill tool description @@ -434,7 +454,10 @@ export async function getToolSet(params: { // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP) // Ensure it's a Zod schema for the AI SDK const zodSchema = ensureZodSchema(clonedDef.inputSchema) - const safeSchema = ensureJsonSchemaCompatible(zodSchema) + const safeSchema = ensureJsonSchemaCompatible(zodSchema, { + logger, + name: toolName, + }) toolSet[toolName] = { ...clonedDef, inputSchema: safeSchema, From b249decd4dd044ed9655e80e45f0f6db57d02e78 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:59:35 +0200 Subject: [PATCH 11/18] Polish regression tests: Given/When/Then docstrings and contractual names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No assertion changes. Every test now carries a docstring stating the Given/When/Then contract, names follow trigger-outcome form, arrange/ act/assert stages are visually demarcated, narration comments moved into the docstrings, and the converter test's byte estimate is a named constant carrying its derivation instead of a bare literal. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../mcp/__tests__/mcp-content-mapping.test.ts | 52 +++++++++++--- .../src/__tests__/mcp-schema-store.test.ts | 69 ++++++++++++------- .../src/util/__tests__/to-json-schema.test.ts | 61 +++++++++++++--- .../src/util/__tests__/zod-safe-clone.test.ts | 55 ++++++++++++--- ...to-openai-compatible-chat-messages.test.ts | 28 +++++--- 5 files changed, 204 insertions(+), 61 deletions(-) diff --git a/common/src/mcp/__tests__/mcp-content-mapping.test.ts b/common/src/mcp/__tests__/mcp-content-mapping.test.ts index d6709fa5e2..6d79cc8923 100644 --- a/common/src/mcp/__tests__/mcp-content-mapping.test.ts +++ b/common/src/mcp/__tests__/mcp-content-mapping.test.ts @@ -2,8 +2,24 @@ import { describe, test, expect } from 'bun:test' import { mcpContentToToolResultOutputs } from '../client' -describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 media)', () => { - test('a text resource becomes a text value, NOT media', () => { +/** + * Regression tests for MCP tool-result content mapping. + * + * Given: tool results live in message history and are replayed into every + * later prompt build. + * When: MCP content blocks are mapped to codebuff tool-result outputs. + * Then: text content never travels as media. The AI SDK base64-decodes + * file-part data at prompt build, so prose stored as media died with + * "The string contains invalid characters" on every subsequent turn, + * permanently, because the poisoned message replays from history. + */ +describe('mcpContentToToolResultOutputs resources', () => { + /** + * Given: an MCP resource whose contents are plain text. + * When: it is mapped. + * Then: the output is a json value carrying that text - never media. + */ + test('maps text resource to json value not media', () => { const outputs = mcpContentToToolResultOutputs([ { type: 'resource', @@ -15,9 +31,6 @@ describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 med }, ] as never) - // The bug: this prose was wrapped as media, and on every later turn the - // AI SDK base64-decodes file data - "The string contains invalid - // characters", forever, since the message replays from history. expect(outputs).toEqual([ { type: 'json', @@ -26,7 +39,13 @@ describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 med ]) }) - test('an image resource stays media', () => { + /** + * Given: an MCP resource carrying binary image data. + * When: it is mapped. + * Then: the output stays media with the server's mime type, because + * every provider path accepts image file parts. + */ + test('keeps image resource as media with server mime type', () => { const outputs = mcpContentToToolResultOutputs([ { type: 'resource', @@ -43,7 +62,13 @@ describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 med expect((outputs[0] as { mediaType?: string }).mediaType).toBe('image/png') }) - test('a non-image binary resource becomes descriptive text, NOT media', () => { + /** + * Given: an MCP resource carrying non-image binary data. + * When: it is mapped. + * Then: the output is descriptive text, not media - media here killed + * the OpenAI-compatible converter at prompt build (session death). + */ + test('maps non-image binary resource to descriptive text not media', () => { const outputs = mcpContentToToolResultOutputs([ { type: 'resource', @@ -55,19 +80,24 @@ describe('mcpContentToToolResultOutputs: resources (the bug: prose as base64 med }, ] as never) - // The bug: application/gzip media killed the OpenAI-compatible converter - // at prompt build on every later turn (session death). Only images may - // travel as media through ingestion. expect(outputs[0].type).toBe('json') + const value = (outputs[0] as { value: string }).value expect(value).toContain('application/gzip') expect(value).toContain('not displayable') }) - test('plain text content still maps to a json value', () => { + /** + * Given: an ordinary MCP text content block (no resource involved). + * When: it is mapped. + * Then: it stays a json value - the extraction must not alter the + * pre-existing text mapping. + */ + test('maps plain text content to json value', () => { const outputs = mcpContentToToolResultOutputs([ { type: 'text', text: 'Echo: hello' }, ] as never) + expect(outputs).toEqual([{ type: 'json', value: 'Echo: hello' }]) }) }) diff --git a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts index e9a035661d..9e2bdc2146 100644 --- a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts +++ b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts @@ -4,8 +4,26 @@ import { z } from 'zod/v4' import { getMCPToolData } from '../mcp' import { MCP_TOOL_SEPARATOR } from '../mcp-constants' -describe('getMCPToolData schema storage (the bug: live zod in persisted state)', () => { - test('stores the server JSON Schema verbatim, JSON-serializable by contract', async () => { +/** + * Regression tests for MCP tool-schema storage. + * + * Given: tool definitions returned by getMCPToolData are written into + * project file context and persisted in run/session state, which is + * snapshotted and JSON-serialized on every turn. + * When: an MCP server reports a tool's input schema. + * Then: that schema is stored verbatim. Storing a converted live zod + * instance instead round-trips to def/shape internals and can carry + * cycles that detonate JSON.stringify over the whole run state ("cannot + * serialize cyclic structures", session death from turn 2 onward). + */ +describe('getMCPToolData schema storage', () => { + /** + * Given: one MCP server reporting one tool with a JSON Schema. + * When: getMCPToolData stores it. + * Then: the stored schema round-trips through JSON as the exact schema + * the server sent - the persisted-state contract. + */ + test('stores the server JSON Schema verbatim and JSON round-trips it', async () => { const serverSchema = { type: 'object', properties: { @@ -15,6 +33,7 @@ describe('getMCPToolData schema storage (the bug: live zod in persisted state)', required: ['location'], } const writeTo: Record = {} + await getMCPToolData({ toolNames: ['weather/get_forecast'], mcpServers: { @@ -31,22 +50,21 @@ describe('getMCPToolData schema storage (the bug: live zod in persisted state)', }) const stored = writeTo[`weather${MCP_TOOL_SEPARATOR}get_forecast`] - expect(stored).toBeDefined() - - // THE CONTRACT: tool definitions are persisted, snapshotted, and shipped - // over the wire every turn, so the stored schema must round-trip JSON as - // the exact schema the server sent. Storing a live zod instance here - // instead serializes zod internals (def/shape) and can carry cycles that - // detonate JSON.stringify over the whole run state ("cannot serialize - // cyclic structures", session death from turn 2 onward). const roundTripped = JSON.parse(JSON.stringify(stored.inputSchema)) expect(roundTripped).toEqual(serverSchema) }) - test('stores schemas for multiple tools and servers without conversion', async () => { + /** + * Given: two servers each reporting one tool with a distinct schema. + * When: getMCPToolData stores both. + * Then: each server's tool carries its own schema, namespaced with the + * internal separator, verbatim and JSON-serializable. + */ + test('stores distinct schemas per server without conversion', async () => { const schemaA = { type: 'object', properties: { a: { type: 'number' } } } const schemaB = { type: 'string' } const writeTo: Record = {} + await getMCPToolData({ toolNames: [], mcpServers: { @@ -63,24 +81,27 @@ describe('getMCPToolData schema storage (the bug: live zod in persisted state)', }, }) - for (const [server, schema] of [ - ['alpha', schemaA], - ['beta', schemaB], - ] as const) { - const toolName = server === 'alpha' ? 't1' : 't2' - const stored = writeTo[`${server}${MCP_TOOL_SEPARATOR}${toolName}`] - expect(JSON.parse(JSON.stringify(stored.inputSchema))).toEqual(schema) - } - expect(writeTo[`beta${MCP_TOOL_SEPARATOR}t2`].description).toBe('B') + const alphaStored = writeTo[`alpha${MCP_TOOL_SEPARATOR}t1`] + const betaStored = writeTo[`beta${MCP_TOOL_SEPARATOR}t2`] + expect(JSON.parse(JSON.stringify(alphaStored.inputSchema))).toEqual(schemaA) + expect(JSON.parse(JSON.stringify(betaStored.inputSchema))).toEqual(schemaB) + expect(betaStored.description).toBe('B') }) - test('a zod schema stored in state is the failure mode this guards against', () => { - // Documents what the old code did: storing convertJsonSchemaToZod output - // in state. It round-trips to garbage, not the server's schema. + /** + * Given: the old implementation stored convertJsonSchemaToZod output. + * When: a live zod instance is round-tripped through JSON. + * Then: the result is zod internals (def/shape), not the server schema - + * the failure mode this contract guards against, kept here as a + * characterization so a regression to zod storage cannot pass silently. + */ + test('keeps the zod storage failure mode characterized as non passing', () => { const serverSchema = { type: 'object', properties: { q: { type: 'string' } } } const zodInstance = z.object({ q: z.string() }) + const roundTripped = JSON.parse(JSON.stringify(zodInstance)) + expect(roundTripped).not.toEqual(serverSchema) - expect(roundTripped.def).toBeDefined() // zod internals leaked into state + expect(roundTripped.def).toBeDefined() }) }) diff --git a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts index d4f499447b..9073671668 100644 --- a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts +++ b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts @@ -3,40 +3,83 @@ import { z } from 'zod/v4' import { toTokenCountInputSchema } from '../to-json-schema' +/** + * Regression tests for the persisted-state schema conversion. + * + * Given: tool inputSchemas are persisted into agent state, snapshotted and + * replayed on every turn, and shipped to Anthropic's count_tokens API. + * When: toTokenCountInputSchema converts them. + * Then: every output is plain JSON Schema with a top-level type, and zod + * internals never leak into state. + */ describe('toTokenCountInputSchema', () => { - test('converts a zod schema to JSON Schema with a top-level type', () => { + /** + * Given: a zod object schema with an optional field. + * When: it is converted. + * Then: the result is JSON Schema with type object and the field mapped, + * not a serialized zod instance. + */ + test('converts zod object schema to JSON Schema with top level type object', () => { const schema = z.object({ q: z.string().describe('query'), n: z.number().optional(), }) + const out = toTokenCountInputSchema(schema) as Record | undefined + expect(out?.type).toBe('object') expect(out?.properties.q.type).toBe('string') }) - test('backfills type:object for union schemas (anyOf)', () => { + /** + * Given: a union schema, which JSON Schema represents as anyOf with no + * top-level type. + * When: it is converted. + * Then: type object is backfilled, because Anthropic's count_tokens + * rejects input_schema values without a top-level type. + */ + test('backfills type object for union schemas represented as anyOf', () => { const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]) + const out = toTokenCountInputSchema(schema) as Record | undefined - // Anthropic's count_tokens rejects a schema with no top-level type + expect(out?.type).toBe('object') expect(out?.anyOf).toBeDefined() }) - test('copies an already-plain JSON Schema object as-is', () => { + /** + * Given: a schema that is already a plain JSON Schema object (the shape + * MCP servers and the SDK send). + * When: it is converted. + * Then: it is copied as-is - conversion must not mangle foreign schemas. + */ + test('copies an already plain JSON Schema object unchanged', () => { const jsonSchema = { type: 'object', properties: { location: { type: 'string', enum: ['NYC', 'LA'] } }, required: ['location'], } + const out = toTokenCountInputSchema(jsonSchema) + expect(out).toEqual(jsonSchema) }) - test('drops $schema and survives nullish input', () => { - expect(toTokenCountInputSchema(undefined)).toBeUndefined() + /** + * Given: nullish input and a schema carrying a $schema key. + * When: they are converted. + * Then: nullish input yields undefined, and the meaningless $schema key + * is dropped to keep the token-count payload lean. + */ + test('returns undefined for nullish input and strips the schema meta key', () => { + const withMeta = { $schema: 'https://json-schema.org/x', type: 'object' } + + const nullishOut = toTokenCountInputSchema(undefined) + const metaOut = toTokenCountInputSchema(withMeta) + + expect(nullishOut).toBeUndefined() expect(toTokenCountInputSchema(null)).toBeUndefined() - const out = toTokenCountInputSchema({ $schema: 'https://json-schema.org/x', type: 'object' }) - expect(out?.$schema).toBeUndefined() - expect(out?.type).toBe('object') + expect(metaOut?.$schema).toBeUndefined() + expect(metaOut?.type).toBe('object') }) }) diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts index 5d3f3911e1..3aebcea206 100644 --- a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -4,22 +4,43 @@ import { z } from 'zod/v4' import { cloneDeepKeepingZod } from '../zod-safe-clone' +/** + * Regression tests for tool-schema cloning. + * + * Given: tool definitions carry live zod v4 schemas whose engine lives on + * the non-enumerable _zod property. + * When: surrounding plain data is deep-cloned at a state boundary. + * Then: the clone must keep schemas alive by reference. An amputated clone + * still looks like a schema (safeParse, def, shape all present) but + * throws the first time zod internals touch it - which is how MCP and + * custom tool schemas silently became empty {} at the model. + */ describe('lodash cloneDeep zod amputation (the bug)', () => { - test('cloneDeep strips the zod engine, so the clone detonates on use', () => { + /** + * Given: a zod v4 schema. + * When: it is cloned with lodash cloneDeep. + * Then: the clone retains safeParse but loses _zod, and z.toJSONSchema + * throws on it - the production failure behind the empty-schema bug. + */ + test('cloneDeep strips the zod engine so toJSONSchema throws on the clone', () => { const schema = z.object({ q: z.string() }) + const cloned = cloneDeep(schema) - // zod v4 keeps its engine on a non-enumerable own property; lodash only - // copies enumerable own properties, so the clone looks like a schema... expect(typeof cloned.safeParse).toBe('function') - // ...but has no internals, and every zod internal that touches _zod dies: expect('_zod' in cloned).toBe(false) expect(() => z.toJSONSchema(cloned as never)).toThrow() }) }) describe('cloneDeepKeepingZod', () => { - test('passes zod schemas through by reference, engine intact', () => { + /** + * Given: a plain structure with a live zod schema nested inside. + * When: it is cloned with cloneDeepKeepingZod. + * Then: plain data is deep-cloned (new references), the schema is the + * same live instance, and its engine still converts to JSON Schema. + */ + test('cloneDeepKeepingZod passes schemas through by reference so the engine survives', () => { const schema = z.object({ q: z.string().describe('query') }) const input = { cfg: schema, note: 'plain', nested: { arr: [1, 2] } } @@ -28,24 +49,40 @@ describe('cloneDeepKeepingZod', () => { expect(out.note).toBe('plain') expect(out.nested).not.toBe(input.nested) expect(out.nested.arr).toEqual([1, 2]) - // Same live instance, so the engine survives: expect(out.cfg).toBe(schema) + const jsonSchema = z.toJSONSchema(out.cfg) expect(jsonSchema.type).toBe('object') expect((jsonSchema.properties as { q: { type: string } }).q.type).toBe('string') }) - test('deep-clones plain structures exactly like cloneDeep', () => { + /** + * Given: a plain (schema-free) nested structure. + * When: it is cloned with cloneDeepKeepingZod. + * Then: the result matches cloneDeep exactly, including fresh nested + * references - the clone helper must not change plain-data semantics. + */ + test('cloneDeepKeepingZod deep-clones plain structures exactly like cloneDeep', () => { const input = { a: { b: [1, { c: 'd' }] }, e: null } + const out = cloneDeepKeepingZod(input) + expect(out).toEqual(input) expect(out.a).not.toBe(input.a) expect(out.a.b[1]).not.toBe(input.a.b[1]) }) - test('handles schemas nested inside collections', () => { + /** + * Given: a zod schema nested inside a collection, the shape custom tool * definitions actually arrive in. + * When: the containing structure is cloned. + * Then: the schema survives as a live instance usable by zod internals. + */ + test('cloneDeepKeepingZod preserves schemas nested inside collections', () => { const schema = z.object({ id: z.number() }) - const out = cloneDeepKeepingZod({ tools: [{ name: 'x', inputSchema: schema }] }) + const input = { tools: [{ name: 'x', inputSchema: schema }] } + + const out = cloneDeepKeepingZod(input) + expect(out.tools[0].inputSchema).toBe(schema) expect(() => z.toJSONSchema(out.tools[0].inputSchema)).not.toThrow() }) diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index 175956ad60..b2c774c0b4 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -1084,18 +1084,30 @@ describe('consecutive assistant messages', () => { }) }) -describe('non-image file parts (the bug: application/gzip threw at prompt build)', () => { - it('degrades a non-image file part to a text placeholder instead of throwing', () => { - // The bug: this threw UnsupportedFunctionalityError during prompt build. - // Because the message stays in history, the session died on every - // subsequent turn. +/** + * Regression tests for non-image file parts. + * + * Given: MCP resources can put non-image file parts (e.g. gzip) into + * message history, which is replayed into every later prompt build. + * When: the OpenAI-compatible converter meets such a part. + * Then: it must degrade to a text placeholder. Throwing here failed the + * entire prompt build and, because the message stays in history, killed + * the session on every subsequent turn. + */ +describe('non-image file parts', () => { + // The fixture's base64 string is 20 chars; the placeholder estimates raw + // bytes as round(20 * 3 / 4) = 15. + const GZIP_FIXTURE_BASE64 = Buffer.from('Hello freebuff!').toString('base64') + const EXPECTED_BYTE_ESTIMATE = 15 + + it('degrades non-image file part to text placeholder instead of throwing', () => { const result = convertToOpenAICompatibleChatMessages([ { role: 'user', content: [ { type: 'file', - data: Buffer.from('Hello freebuff!').toString('base64'), + data: GZIP_FIXTURE_BASE64, mediaType: 'application/gzip', }, ], @@ -1108,14 +1120,14 @@ describe('non-image file parts (the bug: application/gzip threw at prompt build) content: [ { type: 'text', - text: '[application/gzip file part not displayable (~15 bytes)]', + text: `[application/gzip file part not displayable (~${EXPECTED_BYTE_ESTIMATE} bytes)]`, }, ], }, ]) }) - it('still converts image file parts to image_url data URIs', () => { + it('converts image file parts to image_url data URIs unchanged', () => { const result = convertToOpenAICompatibleChatMessages([ { role: 'user', From 1a04ce60a82e245acc59a6e9c246e66eebed2506 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 18:50:02 +0200 Subject: [PATCH 12/18] Rewrite test module headers as plain context prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Given/When/Then format is a scenario shape and belongs in per-test docstrings only; module headers describe the shared contract the family of tests protects, so they now carry that context without the labels. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../src/mcp/__tests__/mcp-content-mapping.test.ts | 13 ++++++------- .../src/__tests__/mcp-schema-store.test.ts | 14 ++++++-------- .../src/util/__tests__/to-json-schema.test.ts | 10 +++++----- .../src/util/__tests__/zod-safe-clone.test.ts | 13 ++++++------- ...vert-to-openai-compatible-chat-messages.test.ts | 12 ++++++------ 5 files changed, 29 insertions(+), 33 deletions(-) diff --git a/common/src/mcp/__tests__/mcp-content-mapping.test.ts b/common/src/mcp/__tests__/mcp-content-mapping.test.ts index 6d79cc8923..1ded5258a4 100644 --- a/common/src/mcp/__tests__/mcp-content-mapping.test.ts +++ b/common/src/mcp/__tests__/mcp-content-mapping.test.ts @@ -5,13 +5,12 @@ import { mcpContentToToolResultOutputs } from '../client' /** * Regression tests for MCP tool-result content mapping. * - * Given: tool results live in message history and are replayed into every - * later prompt build. - * When: MCP content blocks are mapped to codebuff tool-result outputs. - * Then: text content never travels as media. The AI SDK base64-decodes - * file-part data at prompt build, so prose stored as media died with - * "The string contains invalid characters" on every subsequent turn, - * permanently, because the poisoned message replays from history. + * Tool results live in message history and are replayed into every later + * prompt build, and the AI SDK base64-decodes file-part data at prompt + * build. Text content therefore never travels as media: prose stored as + * media died with "The string contains invalid characters" on every + * subsequent turn, permanently, because the poisoned message replays from + * history. */ describe('mcpContentToToolResultOutputs resources', () => { /** diff --git a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts index 9e2bdc2146..c73fb1d5e9 100644 --- a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts +++ b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts @@ -7,14 +7,12 @@ import { MCP_TOOL_SEPARATOR } from '../mcp-constants' /** * Regression tests for MCP tool-schema storage. * - * Given: tool definitions returned by getMCPToolData are written into - * project file context and persisted in run/session state, which is - * snapshotted and JSON-serialized on every turn. - * When: an MCP server reports a tool's input schema. - * Then: that schema is stored verbatim. Storing a converted live zod - * instance instead round-trips to def/shape internals and can carry - * cycles that detonate JSON.stringify over the whole run state ("cannot - * serialize cyclic structures", session death from turn 2 onward). + * Tool definitions returned by getMCPToolData are persisted in run/session + * state, which is snapshotted and JSON-serialized on every turn. Schemas + * must be stored verbatim: storing converted live zod instances instead + * round-trips to def/shape internals and can carry cycles that detonate + * JSON.stringify over the whole run state ("cannot serialize cyclic + * structures", session death from turn 2 onward). */ describe('getMCPToolData schema storage', () => { /** diff --git a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts index 9073671668..a5cc67e1dd 100644 --- a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts +++ b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts @@ -6,11 +6,11 @@ import { toTokenCountInputSchema } from '../to-json-schema' /** * Regression tests for the persisted-state schema conversion. * - * Given: tool inputSchemas are persisted into agent state, snapshotted and - * replayed on every turn, and shipped to Anthropic's count_tokens API. - * When: toTokenCountInputSchema converts them. - * Then: every output is plain JSON Schema with a top-level type, and zod - * internals never leak into state. + * Tool inputSchemas are persisted into agent state, snapshotted and replayed + * on every turn, and shipped to Anthropic's count_tokens API. Every stored + * schema must therefore be plain JSON Schema with a top-level type: zod + * internals never leak into state, and foreign (already-JSON) schemas pass + * through unmangled. */ describe('toTokenCountInputSchema', () => { /** diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts index 3aebcea206..a05f71287b 100644 --- a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -7,13 +7,12 @@ import { cloneDeepKeepingZod } from '../zod-safe-clone' /** * Regression tests for tool-schema cloning. * - * Given: tool definitions carry live zod v4 schemas whose engine lives on - * the non-enumerable _zod property. - * When: surrounding plain data is deep-cloned at a state boundary. - * Then: the clone must keep schemas alive by reference. An amputated clone - * still looks like a schema (safeParse, def, shape all present) but - * throws the first time zod internals touch it - which is how MCP and - * custom tool schemas silently became empty {} at the model. + * Tool definitions carry live zod v4 schemas, and state boundaries + * deep-clone the surrounding data. lodash cloneDeep strips zod's + * non-enumerable _zod engine: the amputated clone still looks like a schema + * (safeParse, def, shape all present) but throws the first time zod + * internals touch it - which is how MCP and custom tool schemas silently + * became empty {} at the model. cloneDeepKeepingZod is the fix pinned here. */ describe('lodash cloneDeep zod amputation (the bug)', () => { /** diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index b2c774c0b4..9ac4bd904c 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -1087,12 +1087,12 @@ describe('consecutive assistant messages', () => { /** * Regression tests for non-image file parts. * - * Given: MCP resources can put non-image file parts (e.g. gzip) into - * message history, which is replayed into every later prompt build. - * When: the OpenAI-compatible converter meets such a part. - * Then: it must degrade to a text placeholder. Throwing here failed the - * entire prompt build and, because the message stays in history, killed - * the session on every subsequent turn. + * MCP resources can put non-image file parts (e.g. gzip) into message + * history, which is replayed into every later prompt build. The + * OpenAI-compatible converter must degrade such parts to a text + * placeholder: throwing here failed the entire prompt build and, because + * the message stays in history, killed the session on every subsequent + * turn. */ describe('non-image file parts', () => { // The fixture's base64 string is 20 chars; the placeholder estimates raw From 4243a2cd8ab00a085694575d122c3f624d1e20b5 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 06:10:23 +0200 Subject: [PATCH 13/18] Add red tests: loose MCP input schemas lose named properties through the zod round-trip --- .../__tests__/prompts-schema-handling.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index d3ad20b276..151cb073eb 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -510,3 +510,132 @@ describe('getToolSet: commit-attribution suppression', () => { ) }) }) + +// An MCP server declares a tool's arguments as a JSON Schema, and that schema +// is forwarded to the LLM — the model reads it to decide what arguments to +// emit. MCP allows these schemas to be vague: SEP-2106 requires only +// `type: "object"` +// (https://modelcontextprotocol.io/seps/2106-json-schema-2020-12), so a +// property may be a bare `{ "type": "object" }` with no named fields. +// The conversion to zod and back used to strip such schemas down to an empty +// object schema, and a model that reads an empty argument schema calls the +// tool with `{}` — no arguments at all. These tests pin the contract: what +// the server declared is what the model must see. +describe('getToolSet: loose MCP schemas survive the point-of-use round-trip', () => { + // One tight field, one loose field, both required. + const LOOSE_MCP_SCHEMA = { + type: 'object', + properties: { + project_id: { type: 'string' }, + payload: { type: 'object' }, + }, + required: ['project_id', 'payload'], + } + + // The AI SDK Schema contract getToolSet serves for JSON-Schema inputs: + // the raw schema passes to providers verbatim; args validate via callback. + type ServedSchema = { + jsonSchema: Record + validate: (value: unknown) => { success: boolean; value?: unknown } + } + + const buildWithCustomTool = async (inputSchema: unknown) => + getToolSet({ + toolNames: [], + windowedFileReads: false, + additionalToolDefinitions: async () => ({ + loose_schema_tool: { + description: 'Tool with a loose schema', + inputSchema: inputSchema as z.ZodType, + endsAgentStep: false, + }, + }), + agentTools: {}, + skills: {}, + }) + + test('a loose MCP schema reaches the model with its named properties intact', async () => { + // Given a custom tool whose JSON Schema contains a bare + // `{ type: 'object' }` property (unconvertible to a named zod shape), + // when getToolSet serves the tool's inputSchema, + // then the model-facing JSON Schema round-trip succeeds and still names + // both properties and both required fields - the served schema must not + // be the empty passthrough fallback. + + // Arrange + const toolSet = await buildWithCustomTool(LOOSE_MCP_SCHEMA) + const servedSchema = toolSet['loose_schema_tool']?.inputSchema as unknown as ServedSchema + + // Act + const modelFacing = servedSchema.jsonSchema + + // Assert: the raw JSON Schema must reach the model unamputated - + // both named properties and the required list, no passthrough fallback. + const properties = modelFacing.properties as + | Record + | undefined + expect(properties).toBeDefined() + expect(properties).toHaveProperty('project_id') + expect(properties).toHaveProperty('payload') + expect(modelFacing.required).toEqual( + expect.arrayContaining(['project_id', 'payload']), + ) + }) + + test('the served loose schema accepts arbitrary payloads but still rejects missing required fields', async () => { + // Given the same loose-schema tool served by getToolSet, + // when arguments are validated against the served inputSchema, + // then both sides of the validation contract hold: + // (a) the loose payload accepts arbitrary nested data - the served schema + // must not become stricter than what the MCP server declared, and + // (b) calls with missing required fields fail - the served schema must not + // become the old empty passthrough fallback, which accepted anything, + // including calls the MCP server declared invalid. + + // Arrange + const toolSet = await buildWithCustomTool(LOOSE_MCP_SCHEMA) + const servedSchema = toolSet['loose_schema_tool']?.inputSchema as unknown as ServedSchema + + // Act (a): both required fields present; payload is arbitrary nested data, + // which the server deliberately left unconstrained. + const validArgs = servedSchema.validate({ + project_id: 'p1', + payload: { anything: { deep: true } }, + }) + + // Act (b): no arguments at all, so both required fields are missing. + const missingRequired = servedSchema.validate({}) + + // Assert: (a) accepted, (b) rejected. + expect(validArgs.success).toBe(true) + expect(missingRequired.success).toBe(false) + }) + + test('a tight MCP schema is unaffected by the loose-schema path', async () => { + // Given a fully named (tight) MCP schema - every property a concrete + // scalar type, the pattern served by e.g. the MCP reference "everything" + // server (@modelcontextprotocol/server-everything) - + // when getToolSet serves it, + // then its properties round-trip intact. Control test: the loose-schema + // fix must not degrade the tight path that already worked. + + // Arrange + const tightSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + } + const toolSet = await buildWithCustomTool(tightSchema) + const servedSchema = toolSet['loose_schema_tool']?.inputSchema as unknown as ServedSchema + + // Act + const modelFacing = servedSchema.jsonSchema + + // Assert + expect(modelFacing.properties).toHaveProperty('name') + expect(modelFacing.required).toEqual(['name']) + }) +}) From 747de13f93d6cc0ee597934d771c68de4116cd98 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 06:10:24 +0200 Subject: [PATCH 14/18] Serve MCP input schemas verbatim via ai jsonSchema() with zod-backed validation --- packages/agent-runtime/src/tools/prompts.ts | 78 ++++++++++++++++--- .../agent-runtime/src/util/to-json-schema.ts | 13 ++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index d10662be63..42bd41980f 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -9,6 +9,7 @@ import { buildArray } from '@codebuff/common/util/array' import { formatAvailableSkillsXml } from '@codebuff/common/util/skills' import { pluralize } from '@codebuff/common/util/string' import { cloneDeepKeepingZod } from '../util/zod-safe-clone' +import { jsonSchema as wrapJsonSchema } from 'ai' import z from 'zod/v4' import { convertJsonSchemaToZod } from 'zod-from-json-schema' @@ -35,10 +36,68 @@ export function ensureZodSchema( ) { return schema as z.ZodType } - // JSON Schema object - convert to Zod + // JSON Schema object - convert to Zod for validating tool-call arguments. + // This zod schema is never converted back to JSON Schema: the copy sent to + // the LLM is the MCP server's original (see serveInputSchema), because a + // round-trip through zod drops information for schemas zod cannot express + // (e.g. a property typed only `{ type: 'object' }`). return convertJsonSchemaToZod(schema as Record) } +/** + * Prepares a custom tool's inputSchema for the AI SDK. The schema ends up in + * two places, with different fidelity requirements: + * + * 1. The tool definition sent to the LLM provider. The model reads this to + * decide what arguments to emit, so it must match what the MCP server + * declared. JSON Schema inputs are therefore passed through verbatim, + * wrapped in ai's jsonSchema() (a pass-through container). + * 2. Argument validation at call time (the validate callback below). + * Approximation is acceptable here — a wrong rejection is recoverable, + * the model can retry — so the zod conversion does this job. + * + * Converting the schema to zod and back would be lossy: schemas zod cannot + * represent (e.g. a property typed only `{ "type": "object" }`) come back + * as an empty object schema, and a model reading an empty argument schema + * emits `{}` — a tool call with no arguments. Zod-typed inputSchemas + * (internal tools defined in TypeScript) keep the + * ensureJsonSchemaCompatible path, which converts in one direction only. + */ +function serveInputSchema( + inputSchema: z.ZodType | Record, + opts: { logger?: Logger; name?: string }, +): z.ZodType | ReturnType { + if ( + inputSchema && + typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' + ) { + return ensureJsonSchemaCompatible(inputSchema as z.ZodType, opts) + } + const rawJsonSchema = inputSchema as Record + // Validation only. The zod conversion handles checking arguments fine; + // its weakness is serializing back to JSON Schema, which we never do here. + const validationSchema = ensureZodSchema(rawJsonSchema) + const served = wrapJsonSchema( + rawJsonSchema as unknown as Parameters[0], + { + validate: (value: unknown) => { + const result = validationSchema.safeParse(value) + return result.success + ? { success: true as const, value: result.data } + : { success: false as const, error: result.error } + }, + }, + ) + if ( + typeof rawJsonSchema.description === 'string' && + rawJsonSchema.description.length > 0 + ) { + ;(served as { description?: string }).description ??= + rawJsonSchema.description + } + return served +} + function ensureJsonSchemaCompatible( schema: z.ZodType, opts?: { logger?: Logger; name?: string }, @@ -47,10 +106,11 @@ function ensureJsonSchemaCompatible( z.toJSONSchema(schema, { io: 'input' }) return schema } catch (error) { - // This fallback once silently consumed amputated zod schemas (lodash - // cloneDeep drops zod v4's non-enumerable _zod), turning a broken schema - // into an empty tool schema at the model. Loud failure here would have - // surfaced that bug in minutes instead of sessions. + // This fallback once silently consumed zod schemas whose internals had + // been stripped by a shallow clone (lodash cloneDeep drops zod v4's + // non-enumerable _zod), turning a broken schema into an empty tool + // schema for the model. Loud failure here would have surfaced that bug + // in minutes instead of sessions. opts?.logger?.warn( { toolName: opts.name, @@ -451,10 +511,10 @@ export async function getToolSet(params: { const toolDefinitions = await additionalToolDefinitions() for (const [toolName, toolDefinition] of Object.entries(toolDefinitions)) { const clonedDef = cloneDeepKeepingZod(toolDefinition) - // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP) - // Ensure it's a Zod schema for the AI SDK - const zodSchema = ensureZodSchema(clonedDef.inputSchema) - const safeSchema = ensureJsonSchemaCompatible(zodSchema, { + // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP). + // JSON Schema is served verbatim (see serveInputSchema); the former + // unconditional zod round-trip amputated loose schemas. + const safeSchema = serveInputSchema(clonedDef.inputSchema, { logger, name: toolName, }) diff --git a/packages/agent-runtime/src/util/to-json-schema.ts b/packages/agent-runtime/src/util/to-json-schema.ts index fcf8744634..b129fd39c2 100644 --- a/packages/agent-runtime/src/util/to-json-schema.ts +++ b/packages/agent-runtime/src/util/to-json-schema.ts @@ -13,7 +13,20 @@ export function toTokenCountInputSchema( if (inputSchema == null) return undefined let jsonSchema: Record + // AI SDK Schema objects (served by getToolSet for raw JSON Schema inputs, + // e.g. loose MCP schemas) already carry the model-facing JSON Schema — + // use it directly. Converting it through zod again would count tokens + // against a lossy round-tripped copy instead of the real schema. + const asSchema = inputSchema as { jsonSchema?: unknown; validate?: unknown } if ( + typeof asSchema.validate === 'function' && + asSchema.jsonSchema != null && + typeof asSchema.jsonSchema === 'object' + ) { + jsonSchema = { + ...(asSchema.jsonSchema as Record), + } + } else if ( typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' ) { try { From efe386e12aa26613dc14d8067aaf5fbfbd479320 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 07:39:18 +0200 Subject: [PATCH 15/18] Replace debugging-session metaphors in comments with their mechanisms --- .../src/__tests__/prompts-schema-handling.test.ts | 2 +- packages/agent-runtime/src/templates/prompts.ts | 3 ++- packages/agent-runtime/src/tools/prompts.ts | 3 ++- .../agent-runtime/src/util/__tests__/zod-safe-clone.test.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 151cb073eb..5a53b393ac 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -569,7 +569,7 @@ describe('getToolSet: loose MCP schemas survive the point-of-use round-trip', () // Act const modelFacing = servedSchema.jsonSchema - // Assert: the raw JSON Schema must reach the model unamputated - + // Assert: the raw JSON Schema must reach the model intact - // both named properties and the required list, no passthrough fallback. const properties = modelFacing.properties as | Record diff --git a/packages/agent-runtime/src/templates/prompts.ts b/packages/agent-runtime/src/templates/prompts.ts index 51c55608d7..07bdb680c9 100644 --- a/packages/agent-runtime/src/templates/prompts.ts +++ b/packages/agent-runtime/src/templates/prompts.ts @@ -19,7 +19,8 @@ function ensureJsonSchemaCompatible( return schema } catch (error) { // Same silent-fallback hazard as the copy in tools/prompts.ts: this once - // consumed amputated zod schemas without a trace. Log it loudly. + // consumed zod schemas whose internals a shallow clone had stripped, + // without a trace. Log it loudly. opts?.logger?.warn( { toolName: opts.name, diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index 42bd41980f..18aed0b1c4 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -513,7 +513,8 @@ export async function getToolSet(params: { const clonedDef = cloneDeepKeepingZod(toolDefinition) // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP). // JSON Schema is served verbatim (see serveInputSchema); the former - // unconditional zod round-trip amputated loose schemas. + // unconditional zod round-trip stripped loose schemas to an empty + // object schema at the model. const safeSchema = serveInputSchema(clonedDef.inputSchema, { logger, name: toolName, diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts index a05f71287b..7cdd1ffbad 100644 --- a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -9,7 +9,7 @@ import { cloneDeepKeepingZod } from '../zod-safe-clone' * * Tool definitions carry live zod v4 schemas, and state boundaries * deep-clone the surrounding data. lodash cloneDeep strips zod's - * non-enumerable _zod engine: the amputated clone still looks like a schema + * non-enumerable _zod engine: the stripped clone still looks like a schema * (safeParse, def, shape all present) but throws the first time zod * internals touch it - which is how MCP and custom tool schemas silently * became empty {} at the model. cloneDeepKeepingZod is the fix pinned here. From c6e71a95eadc5e4c6409117acf272f8a77ad970b Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 08:12:41 +0200 Subject: [PATCH 16/18] Repair string-encoded union members in custom tool call inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-boot live testing against an echo MCP server showed that when a tool schema declares a param as a union with an object variant (anyOf/oneOf), the model may emit the object as a JSON-encoded string - unambiguously valid for the union, so nothing downstream fails, and the server receives a string where the model meant an object. The repair is schema-guided: only params whose declared union includes an object variant, and whose value parses as JSON, are decoded; plain strings and string-typed params containing JSON (script sources, file contents) are untouched. The parse result now returns the validated parameters rather than the raw input, so repairs reach the handler (this also stops a latent crash when input is absent). Tests use quwin's loose-schema shape from PR #1259 follow-up discussion. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../__tests__/prompts-schema-handling.test.ts | 104 +++++++++++++++++- .../agent-runtime/src/tools/tool-executor.ts | 51 ++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 5a53b393ac..54023a9098 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -7,7 +7,7 @@ import { buildAgentToolInputSchema, buildAgentToolSet, } from '../templates/prompts' -import { tryTransformAgentToolCall } from '../tools/tool-executor' +import { parseRawCustomToolCall, tryTransformAgentToolCall } from '../tools/tool-executor' import { handleLookupAgentInfo } from '../tools/handlers/tool/lookup-agent-info' import { ensureZodSchema, @@ -639,3 +639,105 @@ describe('getToolSet: loose MCP schemas survive the point-of-use round-trip', () expect(modelFacing.required).toEqual(['name']) }) }) + +// Some models hedge on union-typed parameters: when a schema says a param may +// be a string OR an object (anyOf), the model sometimes emits the object as a +// JSON-encoded string, because a string is unambiguously valid for the union. +// The whole pipeline preserves that string faithfully, so the MCP server +// receives a string where an object was meant - and since the union accepts +// strings, nothing fails loudly. The tool-executor already repairs +// double-encoded arguments at the top level; these tests pin the same repair +// for nested, schema-guided cases. +describe('parseRawCustomToolCall: schema-guided repair of string-encoded union members', () => { + const buildWithCustomTool = (inputSchema: unknown) => ({ + customToolDefs: { + 'loose-server__loose_union': { + description: 'Echoes back exactly the arguments it received.', + inputSchema: inputSchema as never, + endsAgentStep: false, + }, + }, + rawToolCall: { + toolName: 'loose-server__loose_union', + toolCallId: 'probe-1', + }, + }) + + const unionSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + spec: { + anyOf: [{ type: 'string' }, { type: 'object', properties: { kind: { type: 'string' } }, additionalProperties: true }], + description: 'A string or an object. Either is accepted.', + }, + }, + required: ['spec'], + additionalProperties: true, + } + + test('a JSON-encoded string for a union param with an object variant is decoded to an object', () => { + // Given a union schema (string | object) and a raw tool call whose + // union-typed parameter arrived as a JSON-encoded string, + // when the custom tool call is parsed, + // then the parameter is decoded to the object the model meant - + // matching what the same model emits for unambiguous object params. + + // Arrange + const { customToolDefs, rawToolCall } = buildWithCustomTool(unionSchema) + const withInput = { ...rawToolCall, input: { spec: '{"kind": "unhinged-union-spec", "extra": 42}' } } + + // Act + const result = parseRawCustomToolCall({ customToolDefs, rawToolCall: withInput }) + + // Assert + expect(result).toHaveProperty('input') + expect((result as { input: { spec: unknown } }).input.spec).toEqual({ + kind: 'unhinged-union-spec', + extra: 42, + }) + }) + + test('a non-JSON string for a union param stays a string', () => { + // Given the same union schema and a parameter that is a plain string + // (not JSON-encoded), + // when parsed, + // then the string is preserved - the string branch of the union is a + // legitimate choice and must not be mangled. + + // Arrange + const { customToolDefs, rawToolCall } = buildWithCustomTool(unionSchema) + const withInput = { ...rawToolCall, input: { spec: 'plain-string-variant' } } + + // Act + const result = parseRawCustomToolCall({ customToolDefs, rawToolCall: withInput }) + + // Assert + expect((result as { input: { spec: unknown } }).input.spec).toBe('plain-string-variant') + }) + + test('a JSON-encoded string for a plain string-typed param is NOT decoded', () => { + // Given a schema whose param is a plain string (no object variant) and a + // value that happens to be JSON-encoded, + // when parsed, + // then the string stays a string - the repair must be guided by the + // schema, or tools whose string params legitimately contain JSON (like + // evaluate_script source) would be corrupted. + + // Arrange + const stringOnlySchema = { + type: 'object', + properties: { code: { type: 'string' } }, + required: ['code'], + additionalProperties: false, + } + const { customToolDefs, rawToolCall } = buildWithCustomTool(stringOnlySchema) + const withInput = { ...rawToolCall, input: { code: '{"looks": "like json"}' } } + + // Act + const result = parseRawCustomToolCall({ customToolDefs, rawToolCall: withInput }) + + // Assert + expect((result as { input: { code: unknown } }).input.code).toBe('{"looks": "like json"}') + }) +}) diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index d3abd7c6f0..5d4b108aea 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -571,6 +571,52 @@ export async function executeToolCall( }) } +/** + * Repairs values the model string-encoded against its schema. When a + * parameter's declared schema is a union containing an object variant, a + * model may emit the object as a JSON-encoded string (a string is + * unambiguously valid for the union, so nothing downstream fails). The + * schema-guided decode below restores the object the model meant; plain + * strings and params without an object variant are never touched, so + * tools whose string parameters legitimately contain JSON (script + * sources, file contents) are unaffected. + */ +function repairStringEncodedUnionMembers( + parameters: Record, + rawSchema: unknown, +): void { + if (!rawSchema || typeof rawSchema !== 'object') return + const properties = (rawSchema as { properties?: Record }) + .properties + if (!properties) return + for (const [param, value] of Object.entries(parameters)) { + if (typeof value !== 'string') continue + const propSchema = properties[param] + if (!propSchema || typeof propSchema !== 'object') continue + const union = + (propSchema as { anyOf?: unknown[] }).anyOf ?? + (propSchema as { oneOf?: unknown[] }).oneOf + if (!Array.isArray(union)) continue + const hasObjectVariant = union.some( + (variant) => + variant && + typeof variant === 'object' && + (variant as { type?: unknown }).type === 'object', + ) + if (!hasObjectVariant) continue + const trimmed = value.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue + try { + const decoded = JSON.parse(trimmed) + if (decoded && typeof decoded === 'object') { + parameters[param] = decoded + } + } catch { + // Not JSON after all - the string is a legitimate value. + } + } +} + export function parseRawCustomToolCall(params: { customToolDefs: CustomToolDefinitions rawToolCall: { @@ -618,6 +664,7 @@ export function parseRawCustomToolCall(params: { const rawSchema = customToolDefs?.[toolName]?.inputSchema if (rawSchema) { + repairStringEncodedUnionMembers(processedParameters, rawSchema) const paramsSchema = ensureZodSchema(rawSchema) const result = paramsSchema.safeParse(processedParameters) @@ -635,7 +682,9 @@ export function parseRawCustomToolCall(params: { } } - const input = JSON.parse(JSON.stringify(parsedInput.input)) + // processedParameters is what the schema saw (including the union repair + // above), so it - not the untouched raw input - is what the handler gets. + const input = JSON.parse(JSON.stringify(processedParameters)) if (endsAgentStepParam in input) { delete input[endsAgentStepParam] } From e5768532221cf593ab8ea60dc083051c129348f1 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 15:09:24 +0200 Subject: [PATCH 17/18] Remove redundant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests duplicated coverage already provided by existing sibling tests and were removed: - mcp-schema-store: the zod-storage characterization test inspected zod's .def internals and duplicated the verbatim round-trip test, which already fails if storage ever regresses to a live zod instance. - zod-safe-clone: the by-reference clone test duplicated the collection-shape test (schema survival) and the cloneDeep-parity test (plain-data deep-cloning); the amputation test now asserts the bug behaviorally instead of checking a private field. - openai-compatible converter: an image-conversion test was byte-for-byte identical to one already present in the user-messages block. Tests: 37 passing across the three touched files (was 40). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../src/__tests__/mcp-schema-store.test.ts | 18 ------------ .../src/util/__tests__/zod-safe-clone.test.ts | 29 +++---------------- ...to-openai-compatible-chat-messages.test.ts | 20 ------------- 3 files changed, 4 insertions(+), 63 deletions(-) diff --git a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts index c73fb1d5e9..814a1597d1 100644 --- a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts +++ b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts @@ -1,5 +1,4 @@ import { describe, test, expect } from 'bun:test' -import { z } from 'zod/v4' import { getMCPToolData } from '../mcp' import { MCP_TOOL_SEPARATOR } from '../mcp-constants' @@ -85,21 +84,4 @@ describe('getMCPToolData schema storage', () => { expect(JSON.parse(JSON.stringify(betaStored.inputSchema))).toEqual(schemaB) expect(betaStored.description).toBe('B') }) - - /** - * Given: the old implementation stored convertJsonSchemaToZod output. - * When: a live zod instance is round-tripped through JSON. - * Then: the result is zod internals (def/shape), not the server schema - - * the failure mode this contract guards against, kept here as a - * characterization so a regression to zod storage cannot pass silently. - */ - test('keeps the zod storage failure mode characterized as non passing', () => { - const serverSchema = { type: 'object', properties: { q: { type: 'string' } } } - const zodInstance = z.object({ q: z.string() }) - - const roundTripped = JSON.parse(JSON.stringify(zodInstance)) - - expect(roundTripped).not.toEqual(serverSchema) - expect(roundTripped.def).toBeDefined() - }) }) diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts index 7cdd1ffbad..fbf378c49c 100644 --- a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -18,43 +18,22 @@ describe('lodash cloneDeep zod amputation (the bug)', () => { /** * Given: a zod v4 schema. * When: it is cloned with lodash cloneDeep. - * Then: the clone retains safeParse but loses _zod, and z.toJSONSchema - * throws on it - the production failure behind the empty-schema bug. + * Then: the clone still looks like a schema (safeParse present) but its + * engine is gone: z.toJSONSchema throws on it - the production failure + * behind the empty-schema bug, and the reason the helper below exists. */ test('cloneDeep strips the zod engine so toJSONSchema throws on the clone', () => { const schema = z.object({ q: z.string() }) const cloned = cloneDeep(schema) + // Asserted behaviorally: the clone still parses, but conversion fails. expect(typeof cloned.safeParse).toBe('function') - expect('_zod' in cloned).toBe(false) expect(() => z.toJSONSchema(cloned as never)).toThrow() }) }) describe('cloneDeepKeepingZod', () => { - /** - * Given: a plain structure with a live zod schema nested inside. - * When: it is cloned with cloneDeepKeepingZod. - * Then: plain data is deep-cloned (new references), the schema is the - * same live instance, and its engine still converts to JSON Schema. - */ - test('cloneDeepKeepingZod passes schemas through by reference so the engine survives', () => { - const schema = z.object({ q: z.string().describe('query') }) - const input = { cfg: schema, note: 'plain', nested: { arr: [1, 2] } } - - const out = cloneDeepKeepingZod(input) - - expect(out.note).toBe('plain') - expect(out.nested).not.toBe(input.nested) - expect(out.nested.arr).toEqual([1, 2]) - expect(out.cfg).toBe(schema) - - const jsonSchema = z.toJSONSchema(out.cfg) - expect(jsonSchema.type).toBe('object') - expect((jsonSchema.properties as { q: { type: string } }).q.type).toBe('string') - }) - /** * Given: a plain (schema-free) nested structure. * When: it is cloned with cloneDeepKeepingZod. diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index 9ac4bd904c..9827f7314d 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -1126,24 +1126,4 @@ describe('non-image file parts', () => { }, ]) }) - - it('converts image file parts to image_url data URIs unchanged', () => { - const result = convertToOpenAICompatibleChatMessages([ - { - role: 'user', - content: [ - { - type: 'file', - data: Buffer.from([0, 1, 2, 3]).toString('base64'), - mediaType: 'image/png', - }, - ], - }, - ]) - - expect(result[0].content[0]).toEqual({ - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - }) - }) }) From bc5d8e991aadcb31da96b9e6931137aa5b842233 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Sat, 5 Sep 2026 15:51:20 +0200 Subject: [PATCH 18/18] Credit quwin's repro in the schema regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose-schema fixture is his minimal repro verbatim, and the union repair was found while digging into his report - both now say so. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../src/__tests__/prompts-schema-handling.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 54023a9098..a68e1cd32e 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -522,7 +522,8 @@ describe('getToolSet: commit-attribution suppression', () => { // tool with `{}` — no arguments at all. These tests pin the contract: what // the server declared is what the model must see. describe('getToolSet: loose MCP schemas survive the point-of-use round-trip', () => { - // One tight field, one loose field, both required. + // quwin's minimal repro from the issue #912 follow-up, verbatim: + // one tight field, one loose field, both required. const LOOSE_MCP_SCHEMA = { type: 'object', properties: { @@ -647,7 +648,8 @@ describe('getToolSet: loose MCP schemas survive the point-of-use round-trip', () // receives a string where an object was meant - and since the union accepts // strings, nothing fails loudly. The tool-executor already repairs // double-encoded arguments at the top level; these tests pin the same repair -// for nested, schema-guided cases. +// for nested, schema-guided cases. Found while digging into quwin's report +// on issue #912. describe('parseRawCustomToolCall: schema-guided repair of string-encoded union members', () => { const buildWithCustomTool = (inputSchema: unknown) => ({ customToolDefs: {