fix: ACP 模式下 extended thinking + tool_use 触发连续 user 消息导致 400 (CC-1215)

This commit is contained in:
claude-code-best
2026-05-22 21:24:11 +08:00
parent f91060836f
commit b1c4f40f90
2 changed files with 102 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ import {
AUTO_REJECT_MESSAGE, AUTO_REJECT_MESSAGE,
DONT_ASK_REJECT_MESSAGE, DONT_ASK_REJECT_MESSAGE,
SYNTHETIC_MODEL, SYNTHETIC_MODEL,
ensureToolResultPairing,
} from '../messages' } from '../messages'
import type { import type {
Message, Message,
@@ -516,3 +517,96 @@ describe('normalizeMessagesForAPI', () => {
expect(block._geminiThoughtSignature).toBe('sig-123') 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')
})
})

View File

@@ -5829,11 +5829,15 @@ export function ensureToolResultPairing(
) )
} else { } else {
// Content is empty after stripping orphaned tool_results. We still // Content is empty after stripping orphaned tool_results. We still
// need a user message here to maintain role alternation — otherwise // need a user message here to maintain role alternation — unless the
// the assistant placeholder we just pushed would be immediately // previous result entry is already a user message, in which case
// followed by the NEXT assistant message, which the API rejects with // inserting another user placeholder creates consecutive-user messages
// a role-alternation 400 (not the duplicate-id 400 we handle). // that Anthropic rejects with a misleading "tool_use without
// tool_result" 400 (CC-1215).
i++ i++
if (result.at(-1)?.type === 'user') {
continue
}
result.push( result.push(
createUserMessage({ createUserMessage({
content: NO_CONTENT_MESSAGE, content: NO_CONTENT_MESSAGE,