mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
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:
parent
61b7f56bca
commit
10aad3be6c
@ -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<string>()
|
||||
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)
|
||||
|
||||
@ -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<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 () => {
|
||||
const message = reactive(answerMessage('generating', { segments: [successfulTool('a'),
|
||||
{ id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }],
|
||||
|
||||
@ -12,6 +12,12 @@ export interface ResolvedToolBlock { title?: string; component?: Component; data
|
||||
const registry = new Map<string, Renderer>()
|
||||
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. */
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user