use crate::model::ChatMessage;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SanitizeDiagnostics {
pub repaired_missing_tool_call_id: usize,
pub synthetic_tool_results: usize,
pub dropped_stray_tools: usize,
}
impl SanitizeDiagnostics {
pub fn is_clean(&self) -> bool {
self.repaired_missing_tool_call_id == 0
&& self.synthetic_tool_results == 0
&& self.dropped_stray_tools == 0
}
}
const MISSING_TOOL_RESULT_CONTENT: &str = r#"{"error":"tool result missing from restored history (turn likely interrupted)","code":"missing_tool_result_recovered"}"#;
pub fn sanitize_history(messages: Vec<ChatMessage>) -> (Vec<ChatMessage>, SanitizeDiagnostics) {
let mut diag = SanitizeDiagnostics::default();
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
let mut i = 0;
while i < messages.len() {
match &messages[i] {
ChatMessage::Tool { .. } => {
diag.dropped_stray_tools += 1;
i += 1;
}
ChatMessage::Assistant {
tool_calls,
text,
thinking,
} if !tool_calls.is_empty() => {
let mut repaired_calls = tool_calls.clone();
for (call_idx, call) in repaired_calls.iter_mut().enumerate() {
if call.id.trim().is_empty() {
call.id = format!("synthetic_call_{i}_{call_idx}");
diag.repaired_missing_tool_call_id += 1;
}
}
let expected_ids: Vec<String> =
repaired_calls.iter().map(|c| c.id.clone()).collect();
out.push(ChatMessage::Assistant {
text: text.clone(),
tool_calls: repaired_calls,
thinking: thinking.clone(),
});
let mut next = i + 1;
for id in &expected_ids {
if let Some(ChatMessage::Tool {
tool_call_id,
content,
is_error,
attachments,
}) = messages.get(next)
{
let tid = tool_call_id.trim();
if tid == id {
out.push(messages[next].clone());
next += 1;
continue;
}
if tid.is_empty() {
out.push(ChatMessage::Tool {
tool_call_id: id.clone(),
content: content.clone(),
is_error: *is_error,
attachments: attachments.clone(),
});
diag.repaired_missing_tool_call_id += 1;
next += 1;
continue;
}
}
out.push(synthetic_tool_result(id));
diag.synthetic_tool_results += 1;
}
while let Some(ChatMessage::Tool { .. }) = messages.get(next) {
diag.dropped_stray_tools += 1;
next += 1;
}
i = next;
}
_ => {
out.push(messages[i].clone());
i += 1;
}
}
}
(out, diag)
}
fn synthetic_tool_result(id: &str) -> ChatMessage {
ChatMessage::Tool {
tool_call_id: id.to_string(),
content: MISSING_TOOL_RESULT_CONTENT.to_string(),
is_error: true,
attachments: vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolInvocation;
fn user(s: &str) -> ChatMessage {
ChatMessage::User {
content: s.into(),
attachments: vec![],
}
}
fn assistant_calls(ids: &[&str]) -> ChatMessage {
ChatMessage::Assistant {
text: None,
tool_calls: ids
.iter()
.map(|id| ToolInvocation {
id: (*id).into(),
name: "bash".into(),
input: serde_json::json!({}),
})
.collect(),
thinking: None,
}
}
fn tool(id: &str) -> ChatMessage {
ChatMessage::Tool {
tool_call_id: id.into(),
content: "ok".into(),
is_error: false,
attachments: vec![],
}
}
fn ids_of(m: &ChatMessage) -> Vec<String> {
match m {
ChatMessage::Assistant { tool_calls, .. } => {
tool_calls.iter().map(|c| c.id.clone()).collect()
}
_ => vec![],
}
}
#[test]
fn well_formed_history_is_untouched() {
let msgs = vec![
user("hi"),
assistant_calls(&["c1"]),
tool("c1"),
ChatMessage::Assistant {
text: Some("done".into()),
tool_calls: vec![],
thinking: None,
},
];
let (out, diag) = sanitize_history(msgs.clone());
assert!(diag.is_clean());
assert_eq!(out, msgs);
}
#[test]
fn synthesizes_missing_tool_result() {
let msgs = vec![user("hi"), assistant_calls(&["c1", "c2"]), tool("c1")];
let (out, diag) = sanitize_history(msgs);
assert_eq!(diag.synthetic_tool_results, 1);
assert_eq!(diag.dropped_stray_tools, 0);
assert_eq!(out.len(), 4);
match &out[3] {
ChatMessage::Tool {
tool_call_id,
is_error,
..
} => {
assert_eq!(tool_call_id, "c2");
assert!(is_error);
}
other => panic!("expected synthetic Tool, got {other:?}"),
}
}
#[test]
fn missing_assistant_means_all_results_synthesized() {
let msgs = vec![user("hi"), assistant_calls(&["c1", "c2"])];
let (out, diag) = sanitize_history(msgs);
assert_eq!(diag.synthetic_tool_results, 2);
assert_eq!(out.len(), 4); }
#[test]
fn drops_stray_tool_without_preceding_call() {
let msgs = vec![user("hi"), tool("orphan"), user("again")];
let (out, diag) = sanitize_history(msgs);
assert_eq!(diag.dropped_stray_tools, 1);
assert_eq!(out.len(), 2);
assert!(matches!(out[1], ChatMessage::User { .. }));
}
#[test]
fn drops_extra_results_beyond_calls() {
let msgs = vec![assistant_calls(&["c1"]), tool("c1"), tool("c1")];
let (out, diag) = sanitize_history(msgs);
assert_eq!(diag.dropped_stray_tools, 1);
assert_eq!(diag.synthetic_tool_results, 0);
assert_eq!(out.len(), 2);
}
#[test]
fn repairs_blank_tool_call_id() {
let msgs = vec![assistant_calls(&[""]), tool("")];
let (out, diag) = sanitize_history(msgs);
assert_eq!(diag.repaired_missing_tool_call_id, 2); let call_ids = ids_of(&out[0]);
assert_eq!(call_ids.len(), 1);
assert!(!call_ids[0].is_empty());
match &out[1] {
ChatMessage::Tool { tool_call_id, .. } => assert_eq!(tool_call_id, &call_ids[0]),
other => panic!("expected Tool, got {other:?}"),
}
}
#[test]
fn assistant_without_tool_calls_passes_through_with_thinking() {
let msg = ChatMessage::Assistant {
text: Some("final".into()),
tool_calls: vec![],
thinking: Some(crate::model::AssistantThinking {
text: "ponder".into(),
signature: Some("sig".into()),
}),
};
let (out, diag) = sanitize_history(vec![msg.clone()]);
assert!(diag.is_clean());
assert_eq!(out, vec![msg]);
}
}