mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
fix: normalizeMessagesForAPI 不再跨 tool_result 边界合并同 ID assistant 消息 (CC-1215)
ACP 模式下 extended thinking + tool_use 同一 turn 时,StreamingToolExecutor 在两个同 message.id 的 AssistantMessage 之间插入 tool_result,导致向后遍历 合并跨越边界,产生重复 tool_use ID → 孤立 tool_result → 连续 user 消息 → 400。 修改向后遍历停止条件:遇到非 assistant 消息(含 tool_result)即停止,不再跳过。
This commit is contained in:
@@ -610,3 +610,179 @@ describe('ensureToolResultPairing', () => {
|
||||
expect(lastMsg.type).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CC-1215: normalizeMessagesForAPI must not merge assistants across tool_results ──
|
||||
|
||||
describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)', () => {
|
||||
test('does not merge same-id assistants across a tool_result boundary', () => {
|
||||
// Simulate the streaming sequence when extended thinking + tool_use appear
|
||||
// in the same turn, and StreamingToolExecutor inserts a tool_result
|
||||
// between the two assistant content-block messages.
|
||||
const sharedMessageId = 'msg_shared_001'
|
||||
const toolUseId = 'toolu_cc1215'
|
||||
|
||||
// assistant[thinking] — first content_block_stop yield
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Let me think...', signature: 'sig1' },
|
||||
],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
// user[tool_result] — from StreamingToolExecutor completing fast
|
||||
const toolResultMsg = createUserMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: '/home/user',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// assistant[tool_use] — second content_block_stop yield
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
const messages: Message[] = [
|
||||
makeUserMsg('Run pwd'),
|
||||
thinkingMsg,
|
||||
toolResultMsg,
|
||||
toolUseMsg,
|
||||
]
|
||||
|
||||
const result = normalizeMessagesForAPI(messages)
|
||||
|
||||
// Before the fix, the backward walk would skip the tool_result and merge
|
||||
// thinking + tool_use into one assistant. This produced duplicate tool_use
|
||||
// IDs after ensureToolResultPairing ran, leading to orphaned tool_results
|
||||
// and consecutive user messages → API 400.
|
||||
//
|
||||
// After the fix, the backward walk stops at the tool_result, so the two
|
||||
// assistants remain separate. The result should have 4 messages:
|
||||
// user, assistant[thinking], user[tool_result], assistant[tool_use]
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]!.type).toBe('user')
|
||||
expect(result[1]!.type).toBe('assistant')
|
||||
expect(result[2]!.type).toBe('user')
|
||||
expect(result[3]!.type).toBe('assistant')
|
||||
|
||||
// The thinking assistant should NOT have been merged with the tool_use one
|
||||
const thinkingAssistant = result[1] as AssistantMessage
|
||||
const thinkingContent = thinkingAssistant.message.content as Array<{
|
||||
type: string
|
||||
}>
|
||||
expect(thinkingContent.some(b => b.type === 'tool_use')).toBe(false)
|
||||
|
||||
const toolUseAssistant = result[3] as AssistantMessage
|
||||
const toolUseContent = toolUseAssistant.message.content as Array<{
|
||||
type: string
|
||||
}>
|
||||
expect(toolUseContent.some(b => b.type === 'tool_use')).toBe(true)
|
||||
})
|
||||
|
||||
test('still merges consecutive same-id assistants without intervening tool_result', () => {
|
||||
const sharedMessageId = 'msg_shared_002'
|
||||
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [{ type: 'thinking', thinking: 'Hmm', signature: 'sig2' }],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_merge',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
// No tool_result between them — they should still be merged
|
||||
const messages: Message[] = [
|
||||
makeUserMsg('List files'),
|
||||
thinkingMsg,
|
||||
toolUseMsg,
|
||||
]
|
||||
|
||||
const result = normalizeMessagesForAPI(messages)
|
||||
|
||||
// Should be: user, assistant[thinking + tool_use]
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]!.type).toBe('user')
|
||||
|
||||
const merged = result[1] as AssistantMessage
|
||||
const content = merged.message.content as Array<{ type: string }>
|
||||
expect(content.some(b => b.type === 'thinking')).toBe(true)
|
||||
expect(content.some(b => b.type === 'tool_use')).toBe(true)
|
||||
})
|
||||
|
||||
test('full pipeline: normalize + ensureToolResultPairing produces valid role alternation', () => {
|
||||
const sharedMessageId = 'msg_shared_003'
|
||||
const toolUseId = 'toolu_pipeline'
|
||||
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Planning...', signature: 'sig3' },
|
||||
],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
const toolResultMsg = createUserMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: 'file.txt',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
// Full pipeline: normalize → ensureToolResultPairing
|
||||
const normalized = normalizeMessagesForAPI([
|
||||
makeUserMsg('Run ls'),
|
||||
thinkingMsg,
|
||||
toolResultMsg,
|
||||
toolUseMsg,
|
||||
])
|
||||
const result = ensureToolResultPairing(normalized)
|
||||
|
||||
// Verify strict role alternation: user → assistant → user → assistant → ...
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prev = result[i - 1]!
|
||||
const curr = result[i]!
|
||||
if (prev.type === 'user' && curr.type === 'user') {
|
||||
expect.unreachable(`Consecutive user messages at index ${i - 1}-${i}`)
|
||||
}
|
||||
if (prev.type === 'assistant' && curr.type === 'assistant') {
|
||||
expect.unreachable(
|
||||
`Consecutive assistant messages at index ${i - 1}-${i}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user