Fix Anthropic tool result request ordering

Sigrid Jin relayed an adamantium Discord field report: Anthropic rejected requests with invalid_request_error when messages contained tool_use ids without immediately following tool_result blocks.

Coalesce consecutive tool-result messages after assistant tool_use blocks into one Anthropic user message, and drop orphan tool_use/tool_result blocks before dispatch so resume/edit/compaction boundary damage cannot reach the provider as a 400.

Tests cover parallel tool results and orphaned resume-boundary history.
This commit is contained in:
Yeachan-Heo 2026-04-26 02:18:50 +00:00
parent 62adbf49d1
commit 56f7f2e600

View File

@ -9192,44 +9192,136 @@ fn permission_policy(
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
let mut converted = Vec::new();
let mut index = 0;
while index < messages.len() {
let message = &messages[index];
match message.role {
MessageRole::Assistant => {
let tool_use_ids = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect::<Vec<_>>();
let (tool_result_blocks, next_index) = if tool_use_ids.is_empty() {
(Vec::new(), index + 1)
} else {
collect_immediate_tool_results(messages, index + 1)
};
let has_all_tool_results = !tool_use_ids.is_empty()
&& tool_use_ids.iter().all(|id| {
tool_result_blocks.iter().any(|block| {
matches!(block, InputContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id)
})
});
let paired_tool_result_blocks = if has_all_tool_results {
tool_result_blocks
.into_iter()
.filter(|block| {
matches!(block, InputContentBlock::ToolResult { tool_use_id, .. } if tool_use_ids.contains(tool_use_id))
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let content = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(InputContentBlock::Text {
text: text.clone(),
}),
ContentBlock::ToolUse { id, name, input } if has_all_tool_results => {
Some(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
})
}
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => None,
})
.collect::<Vec<_>>();
if !content.is_empty() {
converted.push(InputMessage {
role: "assistant".to_string(),
content,
});
}
if has_all_tool_results && !paired_tool_result_blocks.is_empty() {
converted.push(InputMessage {
role: "user".to_string(),
content: paired_tool_result_blocks,
});
index = next_index;
} else {
index += 1;
}
}
MessageRole::Tool => {
// Anthropic requires tool_result blocks to appear in the user message
// immediately following their assistant tool_use. A bare Tool-role
// message here is orphaned (for example after a resume/edit/compaction
// boundary) and would be rejected with a provider 400.
index += 1;
}
MessageRole::System | MessageRole::User => {
let content = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(InputContentBlock::Text {
text: text.clone(),
}),
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => None,
})
.collect::<Vec<_>>();
if !content.is_empty() {
converted.push(InputMessage {
role: "user".to_string(),
content,
});
}
index += 1;
}
}
}
converted
}
fn collect_immediate_tool_results(
messages: &[ConversationMessage],
start: usize,
) -> (Vec<InputContentBlock>, usize) {
let mut blocks = Vec::new();
let mut index = start;
while let Some(message) = messages.get(index) {
if message.role != MessageRole::Tool {
break;
}
blocks.extend(message.blocks.iter().filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => Some(InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
ContentBlock::Text { .. } | ContentBlock::ToolUse { .. } => None,
}));
index += 1;
}
(blocks, index)
}
#[allow(clippy::too_many_lines)]
@ -9433,7 +9525,7 @@ mod tests {
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use api::{ApiError, InputContentBlock, MessageResponse, OutputContentBlock, Usage};
use plugins::{
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
@ -12900,6 +12992,93 @@ UU conflicted.rs",
assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user");
}
#[test]
fn converts_parallel_tool_results_into_immediate_single_user_message_256() {
let messages = vec![
ConversationMessage::assistant(vec![
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read".to_string(),
input: "{\"path\":\"a\"}".to_string(),
},
ContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "read".to_string(),
input: "{\"path\":\"b\"}".to_string(),
},
]),
ConversationMessage::tool_result(
"tool-1".to_string(),
"read".to_string(),
"a".to_string(),
false,
),
ConversationMessage::tool_result(
"tool-2".to_string(),
"read".to_string(),
"b".to_string(),
false,
),
];
let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 2);
assert_eq!(converted[0].role, "assistant");
assert_eq!(converted[1].role, "user");
assert!(matches!(
converted[0].content.as_slice(),
[
InputContentBlock::ToolUse { id: id1, .. },
InputContentBlock::ToolUse { id: id2, .. }
] if id1 == "tool-1" && id2 == "tool-2"
));
assert!(matches!(
converted[1].content.as_slice(),
[
InputContentBlock::ToolResult { tool_use_id: id1, .. },
InputContentBlock::ToolResult { tool_use_id: id2, .. }
] if id1 == "tool-1" && id2 == "tool-2"
));
}
#[test]
fn drops_orphan_tool_use_and_tool_result_before_anthropic_dispatch_256() {
let messages = vec![
ConversationMessage::assistant(vec![
ContentBlock::Text {
text: "before tool".to_string(),
},
ContentBlock::ToolUse {
id: "orphan".to_string(),
name: "bash".to_string(),
input: "{\"command\":\"pwd\"}".to_string(),
},
]),
ConversationMessage::user_text("resume prompt"),
ConversationMessage::tool_result(
"orphan".to_string(),
"bash".to_string(),
"late".to_string(),
false,
),
];
let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 2);
assert_eq!(converted[0].role, "assistant");
assert!(matches!(
converted[0].content.as_slice(),
[InputContentBlock::Text { text }] if text == "before tool"
));
assert_eq!(converted[1].role, "user");
assert!(matches!(
converted[1].content.as_slice(),
[InputContentBlock::Text { text }] if text == "resume prompt"
));
}
#[test]
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();