use crate::types::{ContentBlock, Message, Role};
fn is_tool_loop_assistant(msg: &Message) -> bool {
msg.role == Role::Assistant
&& msg
.content_blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }))
}
fn is_tool_result_only(msg: &Message) -> bool {
msg.role == Role::User
&& !msg.content_blocks.is_empty()
&& msg
.content_blocks
.iter()
.all(|b| matches!(b, ContentBlock::ToolResult { .. }))
}
fn derive_content_text_only(blocks: &[ContentBlock]) -> String {
super::types::derive_content_from_blocks_with(blocks, false)
}
fn collect_round(assistant: &Message, results: Option<&Message>, out: &mut Vec<ContentBlock>) {
for block in &assistant.content_blocks {
match block {
ContentBlock::ToolUse { id, .. } => {
out.push(block.clone());
if let Some(result_msg) = results {
if let Some(tr) = result_msg.content_blocks.iter().find(|b| {
matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id)
}) {
out.push(tr.clone());
}
}
}
ContentBlock::Text { text } if text.trim().is_empty() => {
}
ContentBlock::Text { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::ToolResult { .. } => {
out.push(block.clone());
}
}
}
}
pub fn merge_tool_loop_messages(messages: &[Message]) -> Vec<Message> {
let mut output: Vec<Message> = Vec::new();
let mut i = 0;
while let Some(msg) = messages.get(i) {
if msg.role != Role::Assistant {
if !is_tool_result_only(msg) {
output.push(msg.clone());
}
i = i.saturating_add(1);
continue;
}
if !is_tool_loop_assistant(msg) {
output.push(msg.clone());
i = i.saturating_add(1);
continue;
}
let mut merged_blocks: Vec<ContentBlock> = Vec::new();
let mut last_assistant = msg;
while let Some(current) = messages.get(i) {
let results = i
.checked_add(1)
.and_then(|next_index| messages.get(next_index))
.filter(|next| is_tool_result_only(next));
let next_is_result = results.is_some();
collect_round(current, results, &mut merged_blocks);
last_assistant = current;
if next_is_result {
i = i.saturating_add(2);
} else {
i = i.saturating_add(1);
}
if i >= messages.len() {
break; }
let Some(next) = messages.get(i) else {
break;
};
if next.role == Role::Assistant && is_tool_loop_assistant(next) {
continue; }
if next.role == Role::Assistant {
merged_blocks.extend(next.content_blocks.iter().cloned());
last_assistant = next;
i = i.saturating_add(1);
break;
}
break;
}
let content = derive_content_text_only(&merged_blocks);
output.push(Message {
msg_id: last_assistant.msg_id.clone(),
role: Role::Assistant,
content,
images: last_assistant.images.clone(),
content_blocks: merged_blocks,
alt_index: last_assistant.alt_index,
alt_count: last_assistant.alt_count,
alternatives: last_assistant.alternatives.clone(),
timestamp: last_assistant.timestamp.clone(),
provider_key: last_assistant.provider_key.clone(),
});
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_msg(id: &str, role: Role, content: &str, blocks: Vec<ContentBlock>) -> Message {
Message {
msg_id: id.into(),
role,
content: content.into(),
images: vec![],
content_blocks: blocks,
alt_index: None,
alt_count: None,
alternatives: vec![],
provider_key: None,
timestamp: "2026-01-01T00:00:00Z".into(),
}
}
fn user_msg(id: &str, content: &str) -> Message {
make_msg(id, Role::User, content, vec![])
}
fn assistant_text(id: &str, text: &str) -> Message {
make_msg(
id,
Role::Assistant,
text,
vec![ContentBlock::Text { text: text.into() }],
)
}
fn assistant_tool_use(id: &str, tools: Vec<(&str, &str)>) -> Message {
let blocks: Vec<ContentBlock> = tools
.into_iter()
.map(|(tid, name)| ContentBlock::ToolUse {
id: tid.into(),
name: name.into(),
input: json!({}),
})
.collect();
make_msg(id, Role::Assistant, "", blocks)
}
fn assistant_thinking_and_tool_use(
id: &str,
thinking: &str,
tools: Vec<(&str, &str)>,
) -> Message {
let mut blocks = vec![ContentBlock::Thinking {
thinking: thinking.into(),
signature: None,
}];
for (tid, name) in tools {
blocks.push(ContentBlock::ToolUse {
id: tid.into(),
name: name.into(),
input: json!({}),
});
}
make_msg(id, Role::Assistant, "", blocks)
}
fn user_tool_results(id: &str, results: Vec<(&str, &str, bool)>) -> Message {
let blocks: Vec<ContentBlock> = results
.into_iter()
.map(|(tid, content, is_error)| ContentBlock::ToolResult {
tool_use_id: tid.into(),
content: content.into(),
is_error,
})
.collect();
let content = crate::types::derive_content_from_blocks(&blocks);
make_msg(id, Role::User, &content, blocks)
}
fn item<T>(items: &[T], index: usize) -> &T {
items.get(index).expect("expected item")
}
#[test]
fn empty_conversation() {
assert!(merge_tool_loop_messages(&[]).is_empty());
}
#[test]
fn no_tool_use() {
let msgs = vec![user_msg("u1", "hello"), assistant_text("a1", "hi there")];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 0).msg_id, "u1");
assert_eq!(item(&merged, 1).msg_id, "a1");
}
#[test]
fn whitespace_text_block_does_not_prevent_merge() {
let mut asst = assistant_thinking_and_tool_use(
"a1",
"Let me check",
vec![("t1", "memory_search"), ("t2", "check_time")],
);
asst.content_blocks.insert(
0,
ContentBlock::Text {
text: "\n\n".into(),
},
);
let msgs = vec![
user_msg("u1", "hello"),
asst,
user_tool_results(
"u2",
vec![("t1", "mem result", false), ("t2", "3:22 PM", false)],
),
assistant_text("a2", "Hey there!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2, "should merge into user + assistant");
let assistant = item(&merged, 1);
assert_eq!(assistant.msg_id, "a2");
assert_eq!(assistant.content, "Hey there!");
let blocks = &assistant.content_blocks;
assert_eq!(blocks.len(), 6);
assert!(matches!(item(blocks, 0), ContentBlock::Thinking { .. }));
assert!(
matches!(item(blocks, 1), ContentBlock::ToolUse { name, .. } if name == "memory_search")
);
assert!(matches!(item(blocks, 2), ContentBlock::ToolResult { .. }));
assert!(
matches!(item(blocks, 3), ContentBlock::ToolUse { name, .. } if name == "check_time")
);
assert!(matches!(item(blocks, 4), ContentBlock::ToolResult { .. }));
assert!(matches!(item(blocks, 5), ContentBlock::Text { text } if text == "Hey there!"));
}
#[test]
fn text_before_tool_calls_merged() {
let mut asst =
assistant_tool_use("a1", vec![("t1", "memory_search"), ("t2", "check_time")]);
asst.content_blocks.insert(
0,
ContentBlock::Text {
text: "let me look that up!".into(),
},
);
let msgs = vec![
user_msg("u1", "what do you know about me?"),
asst,
user_tool_results(
"u2",
vec![("t1", "Trevor", false), ("t2", "3:22 PM", false)],
),
assistant_text("a2", "You're Trevor!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2, "should merge into user + assistant");
let assistant = item(&merged, 1);
assert_eq!(assistant.msg_id, "a2");
let blocks = &assistant.content_blocks;
assert_eq!(blocks.len(), 6);
assert!(
matches!(item(blocks, 0), ContentBlock::Text { text } if text == "let me look that up!")
);
assert!(
matches!(item(blocks, 1), ContentBlock::ToolUse { name, .. } if name == "memory_search")
);
assert!(matches!(item(blocks, 2), ContentBlock::ToolResult { .. }));
assert!(
matches!(item(blocks, 3), ContentBlock::ToolUse { name, .. } if name == "check_time")
);
assert!(matches!(item(blocks, 4), ContentBlock::ToolResult { .. }));
assert!(matches!(item(blocks, 5), ContentBlock::Text { text } if text == "You're Trevor!"));
assert!(assistant.content.contains("let me look that up!"));
assert!(assistant.content.contains("You're Trevor!"));
}
#[test]
fn single_tool_round() {
let msgs = vec![
user_msg("u1", "what time is it?"),
assistant_tool_use("a1", vec![("t1", "check_time")]),
user_tool_results("u2", vec![("t1", "3:22 PM", false)]),
assistant_text("a2", "It's 3:22 PM!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 0).msg_id, "u1");
let assistant = item(&merged, 1);
assert_eq!(assistant.msg_id, "a2");
assert_eq!(assistant.content, "It's 3:22 PM!");
let blocks = &assistant.content_blocks;
assert_eq!(blocks.len(), 3);
assert!(
matches!(item(blocks, 0), ContentBlock::ToolUse { name, .. } if name == "check_time")
);
assert!(
matches!(item(blocks, 1), ContentBlock::ToolResult { content, .. } if content == "3:22 PM")
);
assert!(matches!(item(blocks, 2), ContentBlock::Text { text } if text == "It's 3:22 PM!"));
}
#[test]
fn multiple_tools_single_round() {
let msgs = vec![
user_msg("u1", "time and save"),
assistant_tool_use("a1", vec![("t1", "check_time"), ("t2", "memory_write")]),
user_tool_results("u2", vec![("t1", "3:22 PM", false), ("t2", "saved", false)]),
assistant_text("a2", "Done!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
let blocks = &item(&merged, 1).content_blocks;
assert_eq!(blocks.len(), 5); assert!(matches!(item(blocks, 0), ContentBlock::ToolUse { id, .. } if id == "t1"));
assert!(
matches!(item(blocks, 1), ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
);
assert!(matches!(item(blocks, 2), ContentBlock::ToolUse { id, .. } if id == "t2"));
assert!(
matches!(item(blocks, 3), ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "t2")
);
assert!(matches!(item(blocks, 4), ContentBlock::Text { .. }));
}
#[test]
fn multi_round_tool_loop() {
let msgs = vec![
user_msg("u1", "do stuff"),
assistant_tool_use("a1", vec![("t1", "search")]),
user_tool_results("u2", vec![("t1", "result A", false)]),
assistant_tool_use("a2", vec![("t2", "fetch")]),
user_tool_results("u3", vec![("t2", "result B", false)]),
assistant_text("a3", "Here you go."),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 1).msg_id, "a3");
let blocks = &item(&merged, 1).content_blocks;
assert_eq!(blocks.len(), 5); }
#[test]
fn thinking_preserved() {
let msgs = vec![
user_msg("u1", "remember me?"),
assistant_thinking_and_tool_use(
"a1",
"Let me search memory",
vec![("t1", "memory_search")],
),
user_tool_results("u2", vec![("t1", "Trevor", false)]),
assistant_text("a2", "Yes, you're Trevor!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
let blocks = &item(&merged, 1).content_blocks;
assert_eq!(blocks.len(), 4); assert!(
matches!(item(blocks, 0), ContentBlock::Thinking { thinking, .. } if thinking == "Let me search memory")
);
assert!(matches!(item(blocks, 1), ContentBlock::ToolUse { .. }));
assert!(matches!(item(blocks, 2), ContentBlock::ToolResult { .. }));
assert!(matches!(item(blocks, 3), ContentBlock::Text { .. }));
}
#[test]
fn redacted_thinking_preserved() {
let mut msgs = vec![user_msg("u1", "test")];
let blocks = vec![
ContentBlock::RedactedThinking {
data: "opaque".into(),
},
ContentBlock::ToolUse {
id: "t1".into(),
name: "search".into(),
input: json!({}),
},
];
msgs.push(make_msg("a1", Role::Assistant, "", blocks.clone()));
msgs.push(user_tool_results("u2", vec![("t1", "found", false)]));
msgs.push(assistant_text("a2", "Result"));
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert!(matches!(
item(&item(&merged, 1).content_blocks, 0),
ContentBlock::RedactedThinking { .. }
));
}
#[test]
fn incomplete_loop_no_result() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search")]),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
let assistant = item(&merged, 1);
assert_eq!(assistant.msg_id, "a1");
assert_eq!(assistant.content_blocks.len(), 1);
assert!(matches!(
item(&assistant.content_blocks, 0),
ContentBlock::ToolUse { .. }
));
}
#[test]
fn incomplete_loop_mid_chain() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search")]),
user_tool_results("u2", vec![("t1", "result", false)]),
assistant_tool_use("a2", vec![("t2", "fetch")]),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 1).msg_id, "a2");
let blocks = &item(&merged, 1).content_blocks;
assert_eq!(blocks.len(), 3); }
#[test]
fn content_field_text_only() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "check_time")]),
user_tool_results("u2", vec![("t1", "3:22 PM", false)]),
assistant_text("a2", "The time is 3:22 PM."),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(item(&merged, 1).content, "The time is 3:22 PM.");
}
#[test]
fn content_field_empty_for_incomplete() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search")]),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(item(&merged, 1).content, "");
}
#[test]
fn merged_msg_inherits_final_metadata() {
let mut final_msg = assistant_text("a_final", "Done");
final_msg.timestamp = "2026-03-29T15:30:00Z".into();
final_msg.alt_index = Some(1);
final_msg.alt_count = Some(3);
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search")]),
user_tool_results("u2", vec![("t1", "found", false)]),
final_msg,
];
let merged = merge_tool_loop_messages(&msgs);
let assistant = item(&merged, 1);
assert_eq!(assistant.msg_id, "a_final");
assert_eq!(assistant.timestamp, "2026-03-29T15:30:00Z");
assert_eq!(assistant.alt_index, Some(1));
assert_eq!(assistant.alt_count, Some(3));
}
#[test]
fn orphan_tool_result_user_filtered() {
let msgs = vec![
user_msg("u1", "hello"),
user_tool_results("u2", vec![("t1", "orphan result", false)]),
assistant_text("a1", "hi"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 0).msg_id, "u1");
assert_eq!(item(&merged, 1).msg_id, "a1");
}
#[test]
fn normal_user_messages_preserved() {
let msgs = vec![
user_msg("u1", "first"),
assistant_text("a1", "reply 1"),
user_msg("u2", "second"),
assistant_text("a2", "reply 2"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 4);
}
#[test]
fn system_messages_preserved() {
let msgs = vec![
make_msg("s1", Role::System, "system prompt", vec![]),
user_msg("u1", "hello"),
assistant_text("a1", "hi"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 3);
assert_eq!(item(&merged, 0).role, Role::System);
}
#[test]
fn multiple_exchanges_only_first_has_tools() {
let msgs = vec![
user_msg("u1", "time?"),
assistant_tool_use("a1", vec![("t1", "check_time")]),
user_tool_results("u2", vec![("t1", "3:22 PM", false)]),
assistant_text("a2", "It's 3:22 PM"),
user_msg("u3", "thanks"),
assistant_text("a3", "You're welcome!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 4);
assert_eq!(item(&merged, 0).msg_id, "u1");
assert_eq!(item(&merged, 1).msg_id, "a2"); assert_eq!(item(&merged, 2).msg_id, "u3");
assert_eq!(item(&merged, 3).msg_id, "a3"); }
#[test]
fn both_exchanges_have_tools() {
let msgs = vec![
user_msg("u1", "time?"),
assistant_tool_use("a1", vec![("t1", "check_time")]),
user_tool_results("u2", vec![("t1", "3:22 PM", false)]),
assistant_text("a2", "3:22"),
user_msg("u3", "remember me"),
assistant_tool_use("a3", vec![("t2", "memory_search")]),
user_tool_results("u4", vec![("t2", "Trevor", false)]),
assistant_text("a4", "Hi Trevor!"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 4);
assert_eq!(item(&merged, 1).content_blocks.len(), 3); assert_eq!(item(&merged, 3).content_blocks.len(), 3); }
#[test]
fn legacy_messages_without_content_blocks() {
let msgs = vec![
make_msg("u1", Role::User, "old message", vec![]),
make_msg("a1", Role::Assistant, "old reply", vec![]),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
assert_eq!(item(&merged, 0).content, "old message");
assert_eq!(item(&merged, 1).content, "old reply");
}
#[test]
fn unmatched_tool_use_id_no_result_paired() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search")]),
user_tool_results("u2", vec![("t999", "orphan result", false)]),
assistant_text("a2", "Done"),
];
let merged = merge_tool_loop_messages(&msgs);
assert_eq!(merged.len(), 2);
let blocks = &item(&merged, 1).content_blocks;
assert!(matches!(item(blocks, 0), ContentBlock::ToolUse { id, .. } if id == "t1"));
assert!(
!blocks.iter().any(
|b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
),
"should not have a tool result for t1"
);
}
#[test]
fn multiple_tool_uses_partial_results() {
let msgs = vec![
user_msg("u1", "test"),
assistant_tool_use("a1", vec![("t1", "search"), ("t2", "fetch")]),
user_tool_results("u2", vec![("t1", "found it", false)]),
assistant_text("a2", "Result"),
];
let merged = merge_tool_loop_messages(&msgs);
let blocks = &item(&merged, 1).content_blocks;
assert!(matches!(item(blocks, 0), ContentBlock::ToolUse { id, .. } if id == "t1"));
assert!(
matches!(item(blocks, 1), ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
);
assert!(matches!(item(blocks, 2), ContentBlock::ToolUse { id, .. } if id == "t2"));
assert!(!blocks.iter().any(
|b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "t2")
));
}
#[test]
fn tool_error_results_preserved() {
let msgs = vec![
user_msg("u1", "search something"),
assistant_tool_use("a1", vec![("t1", "web_search")]),
user_tool_results("u2", vec![("t1", "Connection refused", true)]),
assistant_text("a2", "Sorry, the search failed."),
];
let merged = merge_tool_loop_messages(&msgs);
let blocks = &item(&merged, 1).content_blocks;
assert!(matches!(
item(blocks, 1),
ContentBlock::ToolResult { is_error, content, .. }
if *is_error && content == "Connection refused"
));
}
}