mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55: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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2541,23 +2541,28 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(normalizedMessage)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user