From b62b384e36f263417bf2f9127bd942393f19fc8d Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 4 Jun 2026 15:41:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20normalizeMessagesForAPI=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=B7=A8=20tool=5Fresult=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=90=8C=20ID=20assistant=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=20(CC-1215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP 模式下 extended thinking + tool_use 同一 turn 时,StreamingToolExecutor 在两个同 message.id 的 AssistantMessage 之间插入 tool_result,导致向后遍历 合并跨越边界,产生重复 tool_use ID → 孤立 tool_result → 连续 user 消息 → 400。 修改向后遍历停止条件:遇到非 assistant 消息(含 tool_result)即停止,不再跳过。 --- src/utils/__tests__/messages.test.ts | 176 +++++++++++++++++++++++++++ src/utils/messages.ts | 23 ++-- 2 files changed, 190 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index c9ac5f2ab..d143df681 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -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}`, + ) + } + } + }) +}) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index e6a0b2204..8b1bec0a2 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -2541,21 +2541,26 @@ export function normalizeMessagesForAPI( } // Find a previous assistant message with the same message ID and merge. - // Walk backwards, skipping tool results and different-ID assistants, - // since concurrent agents (teammates) can interleave streaming content - // blocks from multiple API responses with different message IDs. + // Walk backwards, skipping different-ID assistants, since concurrent + // agents (teammates) can interleave streaming content blocks from + // multiple API responses with different message IDs. + // + // Do NOT skip tool_result messages — when claude.ts yields separate + // AssistantMessages for thinking and tool_use blocks (same message.id), + // a StreamingToolExecutor tool_result can land between them. Merging + // across that boundary produces duplicate tool_use IDs that downstream + // ensureToolResultPairing strips, leaving orphaned tool_results and + // ultimately consecutive user messages → API 400 (CC-1215). for (let i = result.length - 1; i >= 0; i--) { const msg = result[i]! - if (msg.type !== 'assistant' && !isToolResultMessage(msg)) { + if (msg.type !== 'assistant') { break } - if (msg.type === 'assistant') { - if (msg.message.id === normalizedMessage.message.id) { - result[i] = mergeAssistantMessages(msg, normalizedMessage) - return - } + if (msg.message.id === normalizedMessage.message.id) { + result[i] = mergeAssistantMessages(msg, normalizedMessage) + return } }