use crate::llm::types::ChatMessage;
use std::collections::HashSet;
pub const INTERRUPTED_RESULT: &str =
"[tool execution was interrupted before a result was recorded (task \
cancelled, timeout, or crash); treat this call as not executed]";
pub fn sanitize_history(messages: &mut Vec<ChatMessage>) -> usize {
let mut repairs = 0usize;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
let mut i = 0;
while i < messages.len() {
let msg = messages[i].clone();
if msg.role == "tool" {
repairs += 1;
i += 1;
continue;
}
let has_tool_calls = msg
.tool_calls
.as_ref()
.map(|t| !t.is_empty())
.unwrap_or(false);
if !has_tool_calls {
out.push(msg);
i += 1;
continue;
}
let expected: Vec<String> = msg
.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|tc| tc.id.clone())
.collect();
let expected_set: HashSet<&str> = expected.iter().map(|s| s.as_str()).collect();
out.push(msg);
i += 1;
let mut answered: HashSet<String> = HashSet::new();
let mut displaced: Vec<ChatMessage> = Vec::new();
let mut tool_block: Vec<ChatMessage> = Vec::new();
while i < messages.len() {
let next = &messages[i];
match next.role.as_str() {
"tool" => {
let id = next.tool_call_id.clone().unwrap_or_default();
if expected_set.contains(id.as_str()) && !answered.contains(&id) {
answered.insert(id);
tool_block.push(next.clone());
} else {
repairs += 1;
}
i += 1;
}
"system" if answered.len() < expected.len() => {
displaced.push(next.clone());
i += 1;
}
_ => break, }
}
for id in &expected {
if !answered.contains(id) {
repairs += 1;
tool_block.push(ChatMessage::tool_result(
id.clone(),
INTERRUPTED_RESULT.to_string(),
));
}
}
tool_block.sort_by_key(|t| {
let id = t.tool_call_id.as_deref().unwrap_or("");
expected.iter().position(|e| e == id).unwrap_or(usize::MAX)
});
out.extend(tool_block);
out.extend(displaced);
}
*messages = out;
repairs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::ToolCall;
fn asst_with_tools(ids: &[&str]) -> ChatMessage {
ChatMessage::assistant_with_tools(
String::new(),
ids.iter()
.map(|id| ToolCall {
id: id.to_string(),
name: "run_shell".into(),
arguments: "{}".into(),
})
.collect(),
)
}
fn tool(id: &str) -> ChatMessage {
ChatMessage::tool_result(id.to_string(), "ok".to_string())
}
#[test]
fn valid_history_untouched() {
let mut m = vec![
ChatMessage::system("s"),
ChatMessage::user("u"),
asst_with_tools(&["a", "b"]),
tool("a"),
tool("b"),
ChatMessage {
role: "assistant".into(),
content: "done".into(),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
];
let before = m.clone();
assert_eq!(sanitize_history(&mut m), 0);
assert_eq!(m.len(), before.len());
}
#[test]
fn dangling_tool_calls_get_synthetic_results() {
let mut m = vec![
ChatMessage::user("u"),
asst_with_tools(&["call_1", "call_2"]),
ChatMessage::user("continue please"),
];
let repairs = sanitize_history(&mut m);
assert_eq!(repairs, 2);
assert_eq!(m[2].role, "tool");
assert_eq!(m[2].tool_call_id.as_deref(), Some("call_1"));
assert!(m[2].content.contains("interrupted"));
assert_eq!(m[3].tool_call_id.as_deref(), Some("call_2"));
assert_eq!(m[4].role, "user");
}
#[test]
fn partially_answered_tool_calls_completed() {
let mut m = vec![
asst_with_tools(&["a", "b", "c"]),
tool("a"),
ChatMessage::user("next"),
];
let repairs = sanitize_history(&mut m);
assert_eq!(repairs, 2);
let ids: Vec<_> = m
.iter()
.filter(|x| x.role == "tool")
.map(|x| x.tool_call_id.clone().unwrap())
.collect();
assert_eq!(ids, ["a", "b", "c"]);
assert_eq!(m.last().unwrap().role, "user");
}
#[test]
fn orphaned_tool_messages_dropped() {
let mut m = vec![
ChatMessage::user("u"),
tool("ghost"), ChatMessage::user("v"),
];
let repairs = sanitize_history(&mut m);
assert_eq!(repairs, 1);
assert!(m.iter().all(|x| x.role != "tool"));
}
#[test]
fn interleaved_system_note_moved_after_tool_block() {
let mut m = vec![
asst_with_tools(&["a"]),
ChatMessage::system("[SYSTEM WARNING]: stale"),
tool("a"),
];
sanitize_history(&mut m);
assert_eq!(m[0].role, "assistant");
assert_eq!(m[1].role, "tool");
assert_eq!(m[2].role, "system");
}
#[test]
fn duplicate_tool_result_dropped() {
let mut m = vec![asst_with_tools(&["a"]), tool("a"), tool("a")];
let repairs = sanitize_history(&mut m);
assert_eq!(repairs, 1);
assert_eq!(m.iter().filter(|x| x.role == "tool").count(), 1);
}
#[test]
fn foreign_tool_id_dropped_and_missing_injected() {
let mut m = vec![asst_with_tools(&["a"]), tool("zzz")];
let repairs = sanitize_history(&mut m);
assert_eq!(repairs, 2); let tools: Vec<_> = m.iter().filter(|x| x.role == "tool").collect();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].tool_call_id.as_deref(), Some("a"));
}
#[test]
fn sanitize_is_idempotent_and_leaves_no_duplicate_results() {
let mut m = vec![
ChatMessage::user("u"),
asst_with_tools(&["a", "b"]),
tool("a"),
tool("a"), ChatMessage::user("next"),
];
let _ = sanitize_history(&mut m);
let after_first = m.clone();
let ids: Vec<String> = m
.iter()
.filter(|x| x.role == "tool")
.map(|x| x.tool_call_id.clone().unwrap())
.collect();
let mut sorted = ids.clone();
sorted.sort();
sorted.dedup();
assert_eq!(ids.len(), sorted.len(), "no duplicate results: {ids:?}");
assert_eq!(ids, ["a", "b"], "missing result injected in order");
assert_eq!(sanitize_history(&mut m), 0, "second pass must be a no-op");
assert_eq!(m.len(), after_first.len(), "length stable across passes");
let roles = |v: &[ChatMessage]| v.iter().map(|x| x.role.clone()).collect::<Vec<_>>();
assert_eq!(roles(&m), roles(&after_first), "roles stable across passes");
}
}