Skip to content

Commit b62b384

Browse files
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)即停止,不再跳过。
1 parent d7001b8 commit b62b384

2 files changed

Lines changed: 190 additions & 9 deletions

File tree

src/utils/__tests__/messages.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,179 @@ describe('ensureToolResultPairing', () => {
610610
expect(lastMsg.type).toBe('user')
611611
})
612612
})
613+
614+
// ─── CC-1215: normalizeMessagesForAPI must not merge assistants across tool_results ──
615+
616+
describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)', () => {
617+
test('does not merge same-id assistants across a tool_result boundary', () => {
618+
// Simulate the streaming sequence when extended thinking + tool_use appear
619+
// in the same turn, and StreamingToolExecutor inserts a tool_result
620+
// between the two assistant content-block messages.
621+
const sharedMessageId = 'msg_shared_001'
622+
const toolUseId = 'toolu_cc1215'
623+
624+
// assistant[thinking] — first content_block_stop yield
625+
const thinkingMsg = createAssistantMessage({
626+
content: [
627+
{ type: 'thinking', thinking: 'Let me think...', signature: 'sig1' },
628+
],
629+
})
630+
thinkingMsg.message.id = sharedMessageId
631+
632+
// user[tool_result] — from StreamingToolExecutor completing fast
633+
const toolResultMsg = createUserMessage({
634+
content: [
635+
{
636+
type: 'tool_result',
637+
tool_use_id: toolUseId,
638+
content: '/home/user',
639+
},
640+
],
641+
})
642+
643+
// assistant[tool_use] — second content_block_stop yield
644+
const toolUseMsg = createAssistantMessage({
645+
content: [
646+
{
647+
type: 'tool_use',
648+
id: toolUseId,
649+
name: 'Bash',
650+
input: { command: 'pwd' },
651+
},
652+
],
653+
})
654+
toolUseMsg.message.id = sharedMessageId
655+
656+
const messages: Message[] = [
657+
makeUserMsg('Run pwd'),
658+
thinkingMsg,
659+
toolResultMsg,
660+
toolUseMsg,
661+
]
662+
663+
const result = normalizeMessagesForAPI(messages)
664+
665+
// Before the fix, the backward walk would skip the tool_result and merge
666+
// thinking + tool_use into one assistant. This produced duplicate tool_use
667+
// IDs after ensureToolResultPairing ran, leading to orphaned tool_results
668+
// and consecutive user messages → API 400.
669+
//
670+
// After the fix, the backward walk stops at the tool_result, so the two
671+
// assistants remain separate. The result should have 4 messages:
672+
// user, assistant[thinking], user[tool_result], assistant[tool_use]
673+
expect(result).toHaveLength(4)
674+
expect(result[0]!.type).toBe('user')
675+
expect(result[1]!.type).toBe('assistant')
676+
expect(result[2]!.type).toBe('user')
677+
expect(result[3]!.type).toBe('assistant')
678+
679+
// The thinking assistant should NOT have been merged with the tool_use one
680+
const thinkingAssistant = result[1] as AssistantMessage
681+
const thinkingContent = thinkingAssistant.message.content as Array<{
682+
type: string
683+
}>
684+
expect(thinkingContent.some(b => b.type === 'tool_use')).toBe(false)
685+
686+
const toolUseAssistant = result[3] as AssistantMessage
687+
const toolUseContent = toolUseAssistant.message.content as Array<{
688+
type: string
689+
}>
690+
expect(toolUseContent.some(b => b.type === 'tool_use')).toBe(true)
691+
})
692+
693+
test('still merges consecutive same-id assistants without intervening tool_result', () => {
694+
const sharedMessageId = 'msg_shared_002'
695+
696+
const thinkingMsg = createAssistantMessage({
697+
content: [{ type: 'thinking', thinking: 'Hmm', signature: 'sig2' }],
698+
})
699+
thinkingMsg.message.id = sharedMessageId
700+
701+
const toolUseMsg = createAssistantMessage({
702+
content: [
703+
{
704+
type: 'tool_use',
705+
id: 'toolu_merge',
706+
name: 'Bash',
707+
input: { command: 'ls' },
708+
},
709+
],
710+
})
711+
toolUseMsg.message.id = sharedMessageId
712+
713+
// No tool_result between them — they should still be merged
714+
const messages: Message[] = [
715+
makeUserMsg('List files'),
716+
thinkingMsg,
717+
toolUseMsg,
718+
]
719+
720+
const result = normalizeMessagesForAPI(messages)
721+
722+
// Should be: user, assistant[thinking + tool_use]
723+
expect(result).toHaveLength(2)
724+
expect(result[0]!.type).toBe('user')
725+
726+
const merged = result[1] as AssistantMessage
727+
const content = merged.message.content as Array<{ type: string }>
728+
expect(content.some(b => b.type === 'thinking')).toBe(true)
729+
expect(content.some(b => b.type === 'tool_use')).toBe(true)
730+
})
731+
732+
test('full pipeline: normalize + ensureToolResultPairing produces valid role alternation', () => {
733+
const sharedMessageId = 'msg_shared_003'
734+
const toolUseId = 'toolu_pipeline'
735+
736+
const thinkingMsg = createAssistantMessage({
737+
content: [
738+
{ type: 'thinking', thinking: 'Planning...', signature: 'sig3' },
739+
],
740+
})
741+
thinkingMsg.message.id = sharedMessageId
742+
743+
const toolResultMsg = createUserMessage({
744+
content: [
745+
{
746+
type: 'tool_result',
747+
tool_use_id: toolUseId,
748+
content: 'file.txt',
749+
},
750+
],
751+
})
752+
753+
const toolUseMsg = createAssistantMessage({
754+
content: [
755+
{
756+
type: 'tool_use',
757+
id: toolUseId,
758+
name: 'Bash',
759+
input: { command: 'ls' },
760+
},
761+
],
762+
})
763+
toolUseMsg.message.id = sharedMessageId
764+
765+
// Full pipeline: normalize → ensureToolResultPairing
766+
const normalized = normalizeMessagesForAPI([
767+
makeUserMsg('Run ls'),
768+
thinkingMsg,
769+
toolResultMsg,
770+
toolUseMsg,
771+
])
772+
const result = ensureToolResultPairing(normalized)
773+
774+
// Verify strict role alternation: user → assistant → user → assistant → ...
775+
for (let i = 1; i < result.length; i++) {
776+
const prev = result[i - 1]!
777+
const curr = result[i]!
778+
if (prev.type === 'user' && curr.type === 'user') {
779+
expect.unreachable(`Consecutive user messages at index ${i - 1}-${i}`)
780+
}
781+
if (prev.type === 'assistant' && curr.type === 'assistant') {
782+
expect.unreachable(
783+
`Consecutive assistant messages at index ${i - 1}-${i}`,
784+
)
785+
}
786+
}
787+
})
788+
})

src/utils/messages.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2541,21 +2541,26 @@ export function normalizeMessagesForAPI(
25412541
}
25422542

25432543
// Find a previous assistant message with the same message ID and merge.
2544-
// Walk backwards, skipping tool results and different-ID assistants,
2545-
// since concurrent agents (teammates) can interleave streaming content
2546-
// blocks from multiple API responses with different message IDs.
2544+
// Walk backwards, skipping different-ID assistants, since concurrent
2545+
// agents (teammates) can interleave streaming content blocks from
2546+
// multiple API responses with different message IDs.
2547+
//
2548+
// Do NOT skip tool_result messages — when claude.ts yields separate
2549+
// AssistantMessages for thinking and tool_use blocks (same message.id),
2550+
// a StreamingToolExecutor tool_result can land between them. Merging
2551+
// across that boundary produces duplicate tool_use IDs that downstream
2552+
// ensureToolResultPairing strips, leaving orphaned tool_results and
2553+
// ultimately consecutive user messages → API 400 (CC-1215).
25472554
for (let i = result.length - 1; i >= 0; i--) {
25482555
const msg = result[i]!
25492556

2550-
if (msg.type !== 'assistant' && !isToolResultMessage(msg)) {
2557+
if (msg.type !== 'assistant') {
25512558
break
25522559
}
25532560

2554-
if (msg.type === 'assistant') {
2555-
if (msg.message.id === normalizedMessage.message.id) {
2556-
result[i] = mergeAssistantMessages(msg, normalizedMessage)
2557-
return
2558-
}
2561+
if (msg.message.id === normalizedMessage.message.id) {
2562+
result[i] = mergeAssistantMessages(msg, normalizedMessage)
2563+
return
25592564
}
25602565
}
25612566

0 commit comments

Comments
 (0)