From 10aad3be6c45afca65b572dc6d9d8808957e9a68 Mon Sep 17 00:00:00 2001 From: czhcc Date: Fri, 9 Oct 2026 15:54:12 +0800 Subject: [PATCH] fix(chat): keep ordinary MCP results out of final answers (#661) Contributed by czhcc (@czhcc). Display only explicit rich UI payloads below final answers while retaining ordinary MCP output in tool details. Verified with 21 related frontend tests and a full production build. --- .../src/components/chat/MessageBubble.vue | 3 +- .../MessageBubble.structuredHistory.test.ts | 37 +++++++++++++++++++ .../components/chat/tool-results/registry.ts | 8 +++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index bc85180f..5a2a8f41 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -603,6 +603,7 @@ import { openFilePreview } from './preview/previewBus' import BrowserTimeline from './BrowserTimeline.vue' import ToolCallSegment from './ToolCallSegment.vue' import ToolResultView from './tool-results/ToolResultView.vue' +import { hasToolResultUi } from './tool-results/registry' import ThinkingSegment from './ThinkingSegment.vue' import ContentSegment from './ContentSegment.vue' import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue' @@ -1224,7 +1225,7 @@ const answerToolResults = computed(() => { const seen = new Set() return segments.value.filter(seg => { if (seg.type !== 'tool_call' || seg.status !== 'completed' - || seg.toolSuccess === false || !seg.structuredContent) return false + || seg.toolSuccess === false || !hasToolResultUi(seg.structuredContent)) return false const key = seg.toolCallId || seg.id if (seen.has(key)) return false seen.add(key) diff --git a/mateclaw-ui/src/components/chat/__tests__/MessageBubble.structuredHistory.test.ts b/mateclaw-ui/src/components/chat/__tests__/MessageBubble.structuredHistory.test.ts index a01c0d69..51c0af97 100644 --- a/mateclaw-ui/src/components/chat/__tests__/MessageBubble.structuredHistory.test.ts +++ b/mateclaw-ui/src/components/chat/__tests__/MessageBubble.structuredHistory.test.ts @@ -4,6 +4,7 @@ import { createPinia } from 'pinia' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Message } from '@/types' +vi.mock('/logo/mateclaw_logo_s.png', () => ({ default: '/logo/mateclaw_logo_s.png' })) vi.mock('@/composables/useStreamingMarkdown', () => ({ useStreamingMarkdown: (content: unknown) => ({ renderedContent: content, @@ -123,6 +124,42 @@ const answerMessage = (status = 'completed', metadata: unknown = {}) => ({ contentParts: [], status, metadata, }) as Message +const weatherResult = { + success: true, + result: { city: '广州', weather: '阵雨', temperature: 28, humidity: 85 }, + error: null, +} + +it('does not append ordinary MCP JSON when a live turn completes, while retaining tool details', async () => { + const result = JSON.stringify(weatherResult) + const tool = { ...successfulTool('weather'), toolName: 'get_weather', + toolResult: result, structuredContent: weatherResult } + const message = reactive(answerMessage('generating', { segments: [tool, + { id: 'text', type: 'content', status: 'running', text: '广州有阵雨,28°C。' }], + })) + const host = mountMessage(message) + message.status = 'completed' + await nextTick() + expect(host.querySelector('.segments-view')?.textContent).toContain('广州有阵雨,28°C。') + expect(host.querySelector('.answer-tool-results')).toBeNull() + host.querySelector('.seg-tool__header')!.click() + await nextTick() + expect(host.querySelector('.seg-tool pre')?.textContent).toBe(result) +}) + +it.each(['segments', 'toolCalls'])('does not append ordinary MCP JSON from saved %s alongside rich output', (source) => { + const metadata = source === 'segments' + ? { segments: [{ ...successfulTool('weather'), structuredContent: weatherResult }, successfulTool('rich')] } + : { toolCalls: [ + { name: 'get_weather', toolCallId: 'weather', status: 'completed', success: true, structuredContent: weatherResult }, + { name: 'sales', toolCallId: 'rich', status: 'completed', success: true, structuredContent: richResult }, + ] } + const host = mountMessage(answerMessage('completed', JSON.stringify(metadata))) + expect(host.querySelectorAll('.answer-tool-results .tool-result-view')).toHaveLength(1) + expect(host.querySelector('.answer-tool-results td')?.textContent).toBe('Quarterly sales') + expect(host.querySelector('.answer-tool-results pre')).toBeNull() +}) + it('shows rich results once after the final content when a live turn completes', async () => { const message = reactive(answerMessage('generating', { segments: [successfulTool('a'), { id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }], diff --git a/mateclaw-ui/src/components/chat/tool-results/registry.ts b/mateclaw-ui/src/components/chat/tool-results/registry.ts index bb7ac7a8..37a52339 100644 --- a/mateclaw-ui/src/components/chat/tool-results/registry.ts +++ b/mateclaw-ui/src/components/chat/tool-results/registry.ts @@ -12,6 +12,12 @@ export interface ResolvedToolBlock { title?: string; component?: Component; data const registry = new Map() const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value) +/** Only explicit UI payloads belong below the assistant answer; ordinary MCP data stays in tool details. */ +export function hasToolResultUi(input: unknown): boolean { + const ui = record(input) ? input.mateclawUi : undefined + return record(ui) && ui.version === 1 && Array.isArray(ui.blocks) && ui.blocks.length <= 8 +} + /** Registration is limited to trusted application code; payloads only name a renderer. */ export function registerToolResultRenderer(type: string, renderer: Renderer): void { registry.set(type, { ...renderer, component: markRaw(renderer.component) }) @@ -24,7 +30,7 @@ export function resolveToolResult(input: unknown): ResolvedToolBlock[] { const fallback = () => [{ fallback: bounded(json) }] if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback() const ui = record(input) ? input.mateclawUi : undefined - if (!record(ui) || ui.version !== 1 || !Array.isArray(ui.blocks) || ui.blocks.length > 8) return fallback() + if (!hasToolResultUi(input)) return fallback() return ui.blocks.map((block: unknown) => { if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) } const title = typeof block.title === 'string' ? block.title : undefined