fix(chat): keep ordinary MCP results out of final answers (#661)

Contributed by czhcc <cui.zihui@trs-dsj.com.cn> (@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.
This commit is contained in:
czhcc 2026-10-09 15:54:12 +08:00 committed by GitHub
parent 61b7f56bca
commit 10aad3be6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 46 additions and 2 deletions

View File

@ -603,6 +603,7 @@ import { openFilePreview } from './preview/previewBus'
import BrowserTimeline from './BrowserTimeline.vue' import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue' import ToolCallSegment from './ToolCallSegment.vue'
import ToolResultView from './tool-results/ToolResultView.vue' import ToolResultView from './tool-results/ToolResultView.vue'
import { hasToolResultUi } from './tool-results/registry'
import ThinkingSegment from './ThinkingSegment.vue' import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue' import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue' import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
@ -1224,7 +1225,7 @@ const answerToolResults = computed(() => {
const seen = new Set<string>() const seen = new Set<string>()
return segments.value.filter(seg => { return segments.value.filter(seg => {
if (seg.type !== 'tool_call' || seg.status !== 'completed' 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 const key = seg.toolCallId || seg.id
if (seen.has(key)) return false if (seen.has(key)) return false
seen.add(key) seen.add(key)

View File

@ -4,6 +4,7 @@ import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '@/types' import type { Message } from '@/types'
vi.mock('/logo/mateclaw_logo_s.png', () => ({ default: '/logo/mateclaw_logo_s.png' }))
vi.mock('@/composables/useStreamingMarkdown', () => ({ vi.mock('@/composables/useStreamingMarkdown', () => ({
useStreamingMarkdown: (content: unknown) => ({ useStreamingMarkdown: (content: unknown) => ({
renderedContent: content, renderedContent: content,
@ -123,6 +124,42 @@ const answerMessage = (status = 'completed', metadata: unknown = {}) => ({
contentParts: [], status, metadata, contentParts: [], status, metadata,
}) as Message }) 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<HTMLElement>('.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 () => { it('shows rich results once after the final content when a live turn completes', async () => {
const message = reactive(answerMessage('generating', { segments: [successfulTool('a'), const message = reactive(answerMessage('generating', { segments: [successfulTool('a'),
{ id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }], { id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }],

View File

@ -12,6 +12,12 @@ export interface ResolvedToolBlock { title?: string; component?: Component; data
const registry = new Map<string, Renderer>() const registry = new Map<string, Renderer>()
const record = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value) const record = (value: unknown): value is Record<string, any> => !!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. */ /** Registration is limited to trusted application code; payloads only name a renderer. */
export function registerToolResultRenderer(type: string, renderer: Renderer): void { export function registerToolResultRenderer(type: string, renderer: Renderer): void {
registry.set(type, { ...renderer, component: markRaw(renderer.component) }) registry.set(type, { ...renderer, component: markRaw(renderer.component) })
@ -24,7 +30,7 @@ export function resolveToolResult(input: unknown): ResolvedToolBlock[] {
const fallback = () => [{ fallback: bounded(json) }] const fallback = () => [{ fallback: bounded(json) }]
if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback() if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback()
const ui = record(input) ? input.mateclawUi : undefined 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) => { return ui.blocks.map((block: unknown) => {
if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) } if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) }
const title = typeof block.title === 'string' ? block.title : undefined const title = typeof block.title === 'string' ? block.title : undefined