From 91002a6385541cfbe117a074aea63ca017dbafeb Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 16:41:47 +0200 Subject: [PATCH 01/12] 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 d45e316a29c1216ad5a203602fc4abb8b1f95f74 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:23:09 +0200 Subject: [PATCH 02/12] 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 d3e3579c61d863e7464b266e5bf5f1f4c79fcab4 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 16:46:34 +0200 Subject: [PATCH 03/12] 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 20098ddb11dec3584c99b7681a5cca95e2c44577 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:24:35 +0200 Subject: [PATCH 04/12] 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 a509fcf3f77a3e20456f2241d0da918b3667f7a6 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:01:42 +0200 Subject: [PATCH 05/12] 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 80d20e5533c92a21e9a697a63e3f66b76a12da4a Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:25:22 +0200 Subject: [PATCH 06/12] 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 d9d032f52967a38350174b050d98c55b91be57f8 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:05:11 +0200 Subject: [PATCH 07/12] 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 6a3ab2e15a35650ab828ed9174e1e01c9b3753e1 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:26:21 +0200 Subject: [PATCH 08/12] 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 29250a3e7be0dff779e2206bf178ef1f3f98c4a9 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:08:10 +0200 Subject: [PATCH 09/12] 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 be29269cfa6c9884e3adda2d7f785ab3d429905f Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 14:35:18 +0200 Subject: [PATCH 10/12] 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 7eb0da82939c9e2e3332e2af062f72a8f8e773b6 Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 17:59:35 +0200 Subject: [PATCH 11/12] 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 780db6f1b8601e8b0823cfe887c6f0354c2a8cea Mon Sep 17 00:00:00 2001 From: hsm207 Date: Thu, 3 Sep 2026 18:50:02 +0200 Subject: [PATCH 12/12] 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