diff --git a/.changeset/mcp-structured-content-fallback.md b/.changeset/mcp-structured-content-fallback.md new file mode 100644 index 0000000000..593e088331 --- /dev/null +++ b/.changeset/mcp-structured-content-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Send MCP structuredContent to the model only when the tool result has no usable content, avoiding duplicate tool output. diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 996ca4ae26..97330de2b2 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -118,8 +118,11 @@ export async function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); + const hasUsableContent = converted.some((part) => + part.type === 'text' ? part.text.trim().length > 0 : true, + ); const structuredExtras: Record = {}; - if (result.structuredContent !== undefined) { + if (result.structuredContent !== undefined && !hasUsableContent) { structuredExtras['structuredContent'] = result.structuredContent; } if (result._meta !== undefined) { @@ -133,7 +136,7 @@ export async function mcpResultToExecutableOutput( if (serialized !== undefined) { wrapped.push({ type: 'text', - text: `\n\n${serialized}\n`, + text: `\n\n${serialized}\n`, }); } } @@ -167,7 +170,7 @@ export async function mcpResultToExecutableOutput( function serializeStructuredExtras(extras: Record): string | undefined { try { - return JSON.stringify(extras).replaceAll('', ''); + return JSON.stringify(extras).replaceAll('', ''); } catch { return undefined; } diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index c1425f77eb..09c63d4ce6 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -9,6 +9,9 @@ import { describe, expect, test } from 'vitest'; import type { ITelemetryService, TelemetryProperties } from '#/app/telemetry/telemetry'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '#/agent/mcp/output'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; +import { StdioMcpClient } from '#/mcpCore/client-stdio'; +import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; +import { FakeRuntime } from '#/runtime/fakeRuntime'; import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; import type { ToolExecution } from '#/tool/toolContract'; import { sniffImageDimensions } from '#/agent/media/file-type'; @@ -265,10 +268,10 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'oops', isError: true }); }); - test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => { + test('omits structuredContent when a text block already carries its serialization', async () => { const out = await mcpResultToExecutableOutput( { - content: [{ type: 'text', text: 'ok' }], + content: [{ type: 'text', text: '{"foo":1}' }], isError: false, structuredContent: { foo: 1 }, _meta: { bar: 2 }, @@ -277,13 +280,82 @@ describe('mcpResultToExecutableOutput', () => { ); const parts = out.output as ContentPart[]; const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); - expect(joined).toContain(''); - expect(joined).toContain('"structuredContent":{"foo":1}'); + expect(joined).not.toContain('"structuredContent"'); + expect(joined).toContain(''); expect(joined).toContain('"_meta":{"bar":2}'); expect(out.isError).toBe(false); }); - test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => { + test('omits structuredContent for dual-emit servers even when the serialized text is reformatted', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: '{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}' }], + isError: false, + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }, + 'mcp__s__t', + ); + expect(out.output).toBe('{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}'); + }); + + test('omits structuredContent when content is a faithful rendering of similar size', async () => { + const text = + 'Project: Central Macaw [d594e625]\n' + + 'Description: none\n' + + 'Timeline: 1920x1080 @ 30fps | durationInFrames=0\n' + + 'Assets: total=0'; + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text }], + isError: false, + structuredContent: { + project: { id: 'd594e625', name: 'Central Macaw', description: null }, + timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 }, + assets: { total: 0 }, + }, + }, + 'mcp__s__t', + ); + expect(out.output).toBe(text); + }); + + test('suppresses structuredContent whenever content carries usable text', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'list_projects returned 6 item(s).' }], + isError: false, + structuredContent: { + projects: [ + { id: 'p1', name: 'Alpha' }, + { id: 'p2', name: 'Beta' }, + { id: 'p3', name: 'Gamma' }, + { id: 'p4', name: 'Delta' }, + { id: 'p5', name: 'Epsilon' }, + { id: 'p6', name: 'Zeta' }, + ], + }, + }, + 'mcp__s__t', + ); + expect(out.output).toBe('list_projects returned 6 item(s).'); + }); + + test('falls back to structuredContent when content carries no usable text', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: ' ' }], + isError: false, + structuredContent: { foo: 1 }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain(''); + expect(joined).toContain('"structuredContent":{"foo":1}'); + }); + + test('keeps the mcp_tool_result wrap for media-only results and suppresses structuredContent', async () => { const out = await mcpResultToExecutableOutput( { content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }], @@ -294,9 +366,9 @@ describe('mcpResultToExecutableOutput', () => { ); const parts = out.output as ContentPart[]; expect(parts[0]).toEqual({ type: 'text', text: '' }); - expect(parts.at(-2)).toEqual({ type: 'text', text: '' }); - const last = parts.at(-1); - expect(last?.type === 'text' && last.text.includes('')).toBe(true); + expect(parts.at(-1)).toEqual({ type: 'text', text: '' }); + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).not.toContain(''); }); test('strips literal closing tags inside the structured payload', async () => { @@ -304,14 +376,14 @@ describe('mcpResultToExecutableOutput', () => { { content: [{ type: 'text', text: 'ok' }], isError: false, - _meta: { evil: 'ab' }, + _meta: { evil: 'ab' }, }, 'mcp__s__t', ); const parts = out.output as ContentPart[]; const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); expect(joined).toContain('"evil":"ab"'); - expect(joined.split('')).toHaveLength(2); + expect(joined.split('')).toHaveLength(2); }); test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => { @@ -642,3 +714,85 @@ describe('createMcpTool', () => { expect(result).not.toHaveProperty('truncated'); }); }); + +describe('mcpResultToExecutableOutput over a real stdio server', () => { + const fixture = join(import.meta.dirname, '../../mcpCore/fixtures/structured-content-stdio-server.mjs'); + + async function callFixtureTool(name: string) { + const runtime = Object.assign( + new FakeRuntime( + { workspaceId: 'workspace', runtimeId: 'local', generation: 'test' }, + { capabilities: ['process'] }, + ), + { process: new HostProcessService() }, + ); + const client = new StdioMcpClient( + { + transport: 'stdio', + command: process.execPath, + args: [fixture], + }, + { + runtimeResolver: { + _serviceBrand: undefined, + inspect: () => runtime, + acquire: () => ({ + runtime, + track: (resource) => resource, + dispose: () => {}, + }), + }, + workspaceId: 'workspace', + runtimeId: 'local', + defaultCwd: process.cwd(), + }, + ); + try { + await client.connect(); + return await mcpResultToExecutableOutput(await client.callTool(name, {}), 'mcp__mock__t'); + } finally { + await client.close(); + } + } + + function joinedText(output: string | ContentPart[]): string { + return typeof output === 'string' + ? output + : output.map((p) => (p.type === 'text' ? p.text : '')).join(''); + } + + test('dual-emitting servers reach the model once, through content', async () => { + const out = await callFixtureTool('dual_emit'); + const text = joinedText(out.output); + expect(text).toContain('"rows"'); + expect(text).not.toContain(''); + }, 15000); + + test('structuredContent-only results still reach the model as a fallback block', async () => { + const out = await callFixtureTool('structured_only'); + const text = joinedText(out.output); + expect(text).toContain(''); + expect(text).toContain('"structuredContent":{"rows":[{"id":1}],"total":1}'); + }, 15000); + + test('a prose summary suppresses the structured payload', async () => { + const out = await callFixtureTool('prose_plus_structured'); + const text = joinedText(out.output); + expect(text).toContain('Found 1 row.'); + expect(text).not.toContain(''); + }, 15000); + + test('a faithful rendering of similar size suppresses the structured copy', async () => { + const out = await callFixtureTool('faithful_rendering'); + const text = joinedText(out.output); + expect(text).toContain('Project: Central Macaw'); + expect(text).not.toContain(''); + }, 15000); + + test('vendor _meta keys pass through alongside content text', async () => { + const out = await callFixtureTool('meta_vendor'); + const text = joinedText(out.output); + expect(text).toContain('done'); + expect(text).toContain('"_meta":{"example.com/trace":"abc123"}'); + }, 15000); +}); diff --git a/packages/agent-core-v2/test/mcpCore/fixtures/structured-content-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/structured-content-stdio-server.mjs new file mode 100644 index 0000000000..7eaf3507c3 --- /dev/null +++ b/packages/agent-core-v2/test/mcpCore/fixtures/structured-content-stdio-server.mjs @@ -0,0 +1,75 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ name: 'mock-structured-content', version: '0.0.1' }); + +server.registerTool( + 'dual_emit', + { + description: + 'Returns the same JSON as a text block (pretty-printed, different key order) and as structuredContent', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: '{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}' }], + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'structured_only', + { + description: 'Returns structuredContent without any text content', + inputSchema: {}, + }, + () => ({ + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'prose_plus_structured', + { + description: 'Returns a prose summary in content plus distinct structuredContent', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: 'Found 1 row.' }], + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'meta_vendor', + { + description: 'Returns text content plus a vendor-namespaced _meta key', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: 'done' }], + _meta: { 'example.com/trace': 'abc123' }, + }), +); + +server.registerTool( + 'faithful_rendering', + { + description: 'content is a faithful human rendering of structuredContent at similar size', + inputSchema: {}, + }, + () => ({ + content: [ + { + type: 'text', + text: 'Project: Central Macaw [d594e625]\nDescription: none\nTimeline: 1920x1080 @ 30fps | durationInFrames=0\nAssets: total=0', + }, + ], + structuredContent: { + project: { id: 'd594e625', name: 'Central Macaw', description: null }, + timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 }, + assets: { total: 0 }, + }, + }), +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 00e64f407a..27d68c21f1 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -195,8 +195,18 @@ export async function mcpResultToExecutableOutput( // payload are stripped so server data cannot fake an early end of the // block. Protocol-reserved _meta keys are dropped first: those carry // host/protocol plumbing, not model-facing data. + // + // content and structuredContent are alternatives — never both forwarded. + // content wins whenever it carries anything usable (a media block or + // non-whitespace text): there is no reliable signal that the structured + // payload is richer than what the server already rendered into content, + // so the only case structuredContent fills in is an empty content array. + // _meta has no such overlap and always passes through. + const hasUsableContent = converted.some((part) => + part.type === 'text' ? part.text.trim().length > 0 : true, + ); const structuredExtras: Record = {}; - if (result.structuredContent !== undefined) { + if (result.structuredContent !== undefined && !hasUsableContent) { structuredExtras['structuredContent'] = result.structuredContent; } if (result._meta !== undefined) { @@ -208,12 +218,12 @@ export async function mcpResultToExecutableOutput( if (Object.keys(structuredExtras).length > 0) { try { const serialized = JSON.stringify(structuredExtras).replaceAll( - '', + '', '', ); wrapped.push({ type: 'text', - text: `\n\n${serialized}\n`, + text: `\n\n${serialized}\n`, }); } catch { // Non-serialisable payloads are dropped rather than failing the call. diff --git a/packages/agent-core/test/mcp/fixtures/structured-content-stdio-server.mjs b/packages/agent-core/test/mcp/fixtures/structured-content-stdio-server.mjs new file mode 100644 index 0000000000..01d262511b --- /dev/null +++ b/packages/agent-core/test/mcp/fixtures/structured-content-stdio-server.mjs @@ -0,0 +1,80 @@ +// MCP stdio server fixture covering structured-content result shapes, mirroring +// what real servers put on the wire (the Google Workspace servers dual-emit per +// the spec's backwards-compatibility SHOULD; others return structuredContent +// only). + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ name: 'mock-structured-content', version: '0.0.1' }); + +server.registerTool( + 'dual_emit', + { + description: + 'Returns the same JSON as a text block (pretty-printed, different key order) and as structuredContent', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: '{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}' }], + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'structured_only', + { + description: 'Returns structuredContent without any text content', + inputSchema: {}, + }, + () => ({ + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'prose_plus_structured', + { + description: 'Returns a prose summary in content plus distinct structuredContent', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: 'Found 1 row.' }], + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }), +); + +server.registerTool( + 'meta_vendor', + { + description: 'Returns text content plus a vendor-namespaced _meta key', + inputSchema: {}, + }, + () => ({ + content: [{ type: 'text', text: 'done' }], + _meta: { 'example.com/trace': 'abc123' }, + }), +); + +server.registerTool( + 'faithful_rendering', + { + description: 'content is a faithful human rendering of structuredContent at similar size', + inputSchema: {}, + }, + () => ({ + content: [ + { + type: 'text', + text: 'Project: Central Macaw [d594e625]\nDescription: none\nTimeline: 1920x1080 @ 30fps | durationInFrames=0\nAssets: total=0', + }, + ], + structuredContent: { + project: { id: 'd594e625', name: 'Central Macaw', description: null }, + timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 }, + assets: { total: 0 }, + }, + }), +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 9fca2499f2..d085b50c90 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { describe, expect, test } from 'vitest'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '../../src/mcp/output'; +import { StdioMcpClient } from '../../src/mcp/client-stdio'; import type { MCPContentBlock, MCPToolResult } from '../../src/mcp/types'; import type { TelemetryClient } from '../../src/telemetry'; import { sniffImageDimensions } from '../../src/tools/support/file-type'; @@ -264,10 +265,10 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'oops', isError: true }); }); - test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => { + test('omits structuredContent when a text block already carries its serialization', async () => { const out = await mcpResultToExecutableOutput( { - content: [{ type: 'text', text: 'ok' }], + content: [{ type: 'text', text: '{"foo":1}' }], isError: false, structuredContent: { foo: 1 }, _meta: { bar: 2 }, @@ -276,13 +277,93 @@ describe('mcpResultToExecutableOutput', () => { ); const parts = out.output as ContentPart[]; const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); - expect(joined).toContain(''); - expect(joined).toContain('"structuredContent":{"foo":1}'); + // Dual-emitting servers already place the serialized JSON in a + // TextContent block per the MCP spec, so forwarding structuredContent + // too would send the same data to the model twice. _meta has no such + // overlap and still passes through. + expect(joined).not.toContain('"structuredContent"'); + expect(joined).toContain(''); expect(joined).toContain('"_meta":{"bar":2}'); expect(out.isError).toBe(false); }); - test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => { + test('omits structuredContent for dual-emit servers even when the serialized text is reformatted', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: '{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}' }], + isError: false, + structuredContent: { rows: [{ id: 1 }], total: 1 }, + }, + 'mcp__s__t', + ); + // Nothing appended: the lone text block collapses to a plain string. + expect(out.output).toBe('{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}'); + }); + + test('omits structuredContent when content is a faithful rendering of similar size', async () => { + // The common well-behaved pattern: content reorganises the same data + // into human-readable text, so the payload adds nothing but tokens. + const text = + 'Project: Central Macaw [d594e625]\n' + + 'Description: none\n' + + 'Timeline: 1920x1080 @ 30fps | durationInFrames=0\n' + + 'Assets: total=0'; + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text }], + isError: false, + structuredContent: { + project: { id: 'd594e625', name: 'Central Macaw', description: null }, + timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 }, + assets: { total: 0 }, + }, + }, + 'mcp__s__t', + ); + expect(out.output).toBe(text); + }); + + test('suppresses structuredContent whenever content carries usable text', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'list_projects returned 6 item(s).' }], + isError: false, + structuredContent: { + projects: [ + { id: 'p1', name: 'Alpha' }, + { id: 'p2', name: 'Beta' }, + { id: 'p3', name: 'Gamma' }, + { id: 'p4', name: 'Delta' }, + { id: 'p5', name: 'Epsilon' }, + { id: 'p6', name: 'Zeta' }, + ], + }, + }, + 'mcp__s__t', + ); + // By design content and structuredContent are alternatives: there is no + // reliable signal that the payload is richer than the server's own + // rendering, so even a terse summary wins. The lone text block + // collapses to a plain string. + expect(out.output).toBe('list_projects returned 6 item(s).'); + }); + + test('falls back to structuredContent when content carries no usable text', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: ' ' }], + isError: false, + structuredContent: { foo: 1 }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain(''); + expect(joined).toContain('"structuredContent":{"foo":1}'); + }); + + test('keeps the mcp_tool_result wrap for media-only results and suppresses structuredContent', async () => { const out = await mcpResultToExecutableOutput( { content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }], @@ -292,12 +373,12 @@ describe('mcpResultToExecutableOutput', () => { 'mcp__s__shot', ); const parts = out.output as ContentPart[]; - // The structured block sits OUTSIDE the media wrap, after the closing - // tag, so the image keeps its tool attribution. + // Media already counts as usable content, so the structured payload is + // not forwarded; the media wrap is the only surrounding text. expect(parts[0]).toEqual({ type: 'text', text: '' }); - expect(parts.at(-2)).toEqual({ type: 'text', text: '' }); - const last = parts.at(-1); - expect(last?.type === 'text' && last.text.includes('')).toBe(true); + expect(parts.at(-1)).toEqual({ type: 'text', text: '' }); + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).not.toContain(''); }); test('strips literal closing tags inside the structured payload', async () => { @@ -305,7 +386,7 @@ describe('mcpResultToExecutableOutput', () => { { content: [{ type: 'text', text: 'ok' }], isError: false, - _meta: { evil: 'ab' }, + _meta: { evil: 'ab' }, }, 'mcp__s__t', ); @@ -313,7 +394,7 @@ describe('mcpResultToExecutableOutput', () => { const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); expect(joined).toContain('"evil":"ab"'); // Exactly one closing tag survives: the wrapper's own. - expect(joined.split('')).toHaveLength(2); + expect(joined.split('')).toHaveLength(2); }); test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => { @@ -786,3 +867,66 @@ describe('mcpResultToExecutableOutput', () => { await rm(dir, { recursive: true, force: true }); }); }); + +// Round-trip over a real stdio MCP server: exercises the SDK wire format, +// toMcpToolResult normalisation, and the output pipeline together, so the +// structuredContent fallback is verified against real protocol bytes rather +// than hand-built result objects. +describe('mcpResultToExecutableOutput over a real stdio server', () => { + const fixture = join(import.meta.dirname, 'fixtures', 'structured-content-stdio-server.mjs'); + + async function callFixtureTool(name: string) { + const client = new StdioMcpClient({ + transport: 'stdio', + command: process.execPath, + args: [fixture], + }); + try { + await client.connect(); + return await mcpResultToExecutableOutput(await client.callTool(name, {}), 'mcp__mock__t'); + } finally { + await client.close(); + } + } + + function joinedText(output: string | ContentPart[]): string { + return typeof output === 'string' + ? output + : output.map((p) => (p.type === 'text' ? p.text : '')).join(''); + } + + test('dual-emitting servers reach the model once, through content', async () => { + const out = await callFixtureTool('dual_emit'); + const text = joinedText(out.output); + expect(text).toContain('"rows"'); + expect(text).not.toContain(''); + }, 15000); + + test('structuredContent-only results still reach the model as a fallback block', async () => { + const out = await callFixtureTool('structured_only'); + const text = joinedText(out.output); + expect(text).toContain(''); + expect(text).toContain('"structuredContent":{"rows":[{"id":1}],"total":1}'); + }, 15000); + + test('a prose summary suppresses the structured payload', async () => { + const out = await callFixtureTool('prose_plus_structured'); + const text = joinedText(out.output); + expect(text).toContain('Found 1 row.'); + expect(text).not.toContain(''); + }, 15000); + + test('a faithful rendering of similar size suppresses the structured copy', async () => { + const out = await callFixtureTool('faithful_rendering'); + const text = joinedText(out.output); + expect(text).toContain('Project: Central Macaw'); + expect(text).not.toContain(''); + }, 15000); + + test('vendor _meta keys pass through alongside content text', async () => { + const out = await callFixtureTool('meta_vendor'); + const text = joinedText(out.output); + expect(text).toContain('done'); + expect(text).toContain('"_meta":{"example.com/trace":"abc123"}'); + }, 15000); +});