fix(chat): dedupe tool-call segments by toolCallId, not name+args

A tool/MCP call could render 2+ times in the timeline (issue #521). The
tool is invoked once — this is a display artifact. The segment de-dup in
MessageBubble keyed on `toolName::toolArgs`, which fails two ways:

- The same logical call rendered on both the live SSE stream and the
  reloaded/persisted path can carry differing toolArgs strings
  (whitespace / key-order from re-serialization), so the two are NOT
  de-duplicated and both survive → the reported duplicate.
- Genuine repeated calls of the same tool with identical args (e.g. shell
  / python retries) share the key and get wrongly collapsed to one.

Prefer the LLM-provided toolCallId (carried end-to-end on both live and
persisted segments, stable across serialization) and fall back to
toolName::toolArgs only for legacy segments without an id. This fixes
both the visible duplication and the over-collapse.

Adds pure-function tests for the de-dup logic.
This commit is contained in:
倪程伟 2026-07-15 14:53:22 +08:00 committed by GitHub
parent fc3d84d6c2
commit 97a040aa89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 97 additions and 2 deletions

View File

@ -1082,11 +1082,19 @@ const segments = computed<MessageSegment[]>(() => {
}
}
// toolName + toolArgs tool_call segment
// tool_call segment LLM toolCallId
// live useChat handleToolCallStarted /
// accumulator" live+reload "
// toolArgs / toolName::toolArgs
// issue #521"" shell/python
// toolCallId/ segment退
// toolName::toolArgs
const seenToolCalls = new Set<string>()
const deduped = segs.filter(seg => {
if (seg.type !== 'tool_call') return true
const key = `${seg.toolName}::${seg.toolArgs || ''}`
const key = seg.toolCallId
? `id::${seg.toolCallId}`
: `na::${seg.toolName}::${seg.toolArgs || ''}`
if (seenToolCalls.has(key)) return false
seenToolCalls.add(key)
return true

View File

@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import type { MessageSegment } from '@/types'
/**
* MessageBubble.vue segments computed tool_call
*
* bugissue #521/MCP 2
* 1 key `toolName::toolArgs` live
* reload toolArgs /
* key "同名同参的多次真实调用"
*
* toolCallId id 退 toolName::toolArgs
*/
function dedupeToolCalls(segs: MessageSegment[]): MessageSegment[] {
const seen = new Set<string>()
return segs.filter(seg => {
if (seg.type !== 'tool_call') return true
const key = seg.toolCallId
? `id::${seg.toolCallId}`
: `na::${seg.toolName}::${seg.toolArgs || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function toolCall(id: string | undefined, name: string, args: string): MessageSegment {
return { id: `seg-${Math.random()}`, type: 'tool_call', status: 'completed',
toolName: name, toolArgs: args, toolCallId: id }
}
describe('dedupeToolCalls — 按 toolCallId 去重', () => {
it('同一 toolCallId、args 序列化不同live vs reload→ 合并为一个(修复重复显示)', () => {
const segs = [
toolCall('call_1', 'wiki_search', '{"query":"a"}'),
toolCall('call_1', 'wiki_search', '{ "query": "a" }'), // 空白差异
]
const out = dedupeToolCalls(segs)
expect(out).toHaveLength(1)
expect(out[0].toolArgs).toBe('{"query":"a"}')
})
it('同名同参但 toolCallId 不同(真实重试)→ 全部保留(修复误合并)', () => {
const segs = [
toolCall('call_1', 'execute_shell', '{"cmd":"ls"}'),
toolCall('call_2', 'execute_shell', '{"cmd":"ls"}'),
]
const out = dedupeToolCalls(segs)
expect(out).toHaveLength(2)
})
it('无 toolCallId 的遗留 segment → 退回 toolName::toolArgs 去重', () => {
const segs = [
toolCall(undefined, 'wiki_read_page', '{"slug":"x"}'),
toolCall(undefined, 'wiki_read_page', '{"slug":"x"}'),
toolCall(undefined, 'wiki_read_page', '{"slug":"y"}'),
]
const out = dedupeToolCalls(segs)
expect(out).toHaveLength(2)
expect(out.map(s => s.toolArgs)).toEqual(['{"slug":"x"}', '{"slug":"y"}'])
})
it('混合有/无 id有 id 按 id、无 id 按 name+args互不干扰', () => {
const segs = [
toolCall('call_1', 'search', '{"q":"1"}'),
toolCall('call_1', 'search', '{"q":"1"}'), // dup by id
toolCall(undefined, 'search', '{"q":"1"}'), // 无 id保留key 前缀不同)
toolCall(undefined, 'search', '{"q":"1"}'), // dup of 上一条
]
const out = dedupeToolCalls(segs)
expect(out).toHaveLength(2)
})
it('非 tool_call segment 一律保留', () => {
const segs: MessageSegment[] = [
{ id: 't1', type: 'thinking', status: 'completed', thinkingText: '...' },
{ id: 'c1', type: 'content', status: 'completed', text: 'hello' },
toolCall('call_1', 'search', '{}'),
toolCall('call_1', 'search', '{}'),
]
const out = dedupeToolCalls(segs)
expect(out).toHaveLength(3)
expect(out.filter(s => s.type === 'tool_call')).toHaveLength(1)
})
})