From b1c4f40f90048a0bd91859d773424e87d33dee59 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 22 May 2026 21:24:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20ACP=20=E6=A8=A1=E5=BC=8F=E4=B8=8B=20exte?= =?UTF-8?q?nded=20thinking=20+=20tool=5Fuse=20=E8=A7=A6=E5=8F=91=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=20user=20=E6=B6=88=E6=81=AF=E5=AF=BC=E8=87=B4=20400?= =?UTF-8?q?=20(CC-1215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/__tests__/messages.test.ts | 94 ++++++++++++++++++++++++++++ src/utils/messages.ts | 12 ++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index dbf529bf6..c9ac5f2ab 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -27,6 +27,7 @@ import { AUTO_REJECT_MESSAGE, DONT_ASK_REJECT_MESSAGE, SYNTHETIC_MODEL, + ensureToolResultPairing, } from '../messages' import type { Message, @@ -516,3 +517,96 @@ describe('normalizeMessagesForAPI', () => { expect(block._geminiThoughtSignature).toBe('sig-123') }) }) + +describe('ensureToolResultPairing', () => { + test('does not produce consecutive user messages when orphaned tool_result is stripped after an existing user message (CC-1215)', () => { + // Reproduce the scenario from the bug report: + // Streaming yields assistant[thinking] and assistant[tool_use] separately. + // normalizeMessagesForAPI merges them, but if the merge fails (e.g. intervening + // user message breaks backward walk), ensureToolResultPairing sees duplicate + // tool_use ID, strips it, leaving empty content in the next user message, + // which becomes NO_CONTENT_MESSAGE. If the previous result entry is already + // user, this must NOT create consecutive user messages. + const toolUseId = 'toolu_test_dup_001' + + const messages: (UserMessage | AssistantMessage)[] = [ + // Previous turn: user with tool_result + createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: 'previous result', + }, + ], + }), + // Current turn: assistant with thinking only (tool_use was deduped away) + makeAssistantMsg([{ type: 'thinking', thinking: 'let me think...' }]), + // Current turn: assistant with tool_use (second streaming yield, same ID) + makeAssistantMsg([ + { + type: 'tool_use', + id: toolUseId, + name: 'Bash', + input: { command: 'pwd' }, + }, + ]), + // Tool result for the tool_use + createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: '/home/user', + }, + ], + }), + ] + + const result = ensureToolResultPairing(messages) + + // Verify no consecutive user messages + for (let i = 1; i < result.length; i++) { + if (result[i - 1]!.type === 'user') { + expect(result[i]!.type).not.toBe('user') + } + } + }) + + test('inserts NO_CONTENT_MESSAGE when previous result entry is assistant', () => { + // When the orphan strip empties a user message and the previous entry is + // assistant, the placeholder should still be inserted to maintain alternation. + const toolUseId = 'toolu_test_orphan_001' + + const messages: (UserMessage | AssistantMessage)[] = [ + makeAssistantMsg([{ type: 'text', text: 'hello' }]), + // This assistant has a tool_use with an ID that won't match any result + makeAssistantMsg([ + { + type: 'tool_use', + id: toolUseId, + name: 'Bash', + input: { command: 'ls' }, + }, + ]), + // User message with ONLY a tool_result for a non-existent tool_use + // After orphan stripping, content becomes empty + createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: 'nonexistent_id', + content: 'orphan', + }, + ], + }), + ] + + const result = ensureToolResultPairing(messages) + + // Should have assistant, [possibly modified assistant], user placeholder + // The key assertion: last message should be a user placeholder + const lastMsg = result[result.length - 1]! + expect(lastMsg.type).toBe('user') + }) +}) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 2a3afd27f..e6a0b2204 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -5829,11 +5829,15 @@ export function ensureToolResultPairing( ) } else { // Content is empty after stripping orphaned tool_results. We still - // need a user message here to maintain role alternation — otherwise - // the assistant placeholder we just pushed would be immediately - // followed by the NEXT assistant message, which the API rejects with - // a role-alternation 400 (not the duplicate-id 400 we handle). + // need a user message here to maintain role alternation — unless the + // previous result entry is already a user message, in which case + // inserting another user placeholder creates consecutive-user messages + // that Anthropic rejects with a misleading "tool_use without + // tool_result" 400 (CC-1215). i++ + if (result.at(-1)?.type === 'user') { + continue + } result.push( createUserMessage({ content: NO_CONTENT_MESSAGE,