use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::{ChatMessage, ChatRole, Role};
const DUMP_DIR_NAME: &str = ".compaction";
pub(crate) async fn dump_deleted_messages(
agent_id: &str,
old_history: &[ChatMessage],
retained_window: &[ChatMessage],
role: &Role,
user_name: &str,
) -> Option<PathBuf> {
let deleted = deleted_conversation_messages(old_history, retained_window);
if deleted.is_empty() {
return None;
}
let body = render_dump(&deleted);
let dir = dump_dir(role, user_name).await?;
let path = next_dump_path(&dir, agent_id).await?;
if let Err(e) = tokio::fs::write(&path, &body).await {
tracing::warn!(
agent_id = %agent_id,
path = %path.display(),
error = %e,
"Failed to write compaction dump — continuing without it"
);
return None;
}
Some(path)
}
fn deleted_conversation_messages<'a>(
old_history: &'a [ChatMessage],
retained_window: &[ChatMessage],
) -> Vec<&'a ChatMessage> {
let mut consumed = vec![false; retained_window.len()];
let mut deleted = Vec::new();
for msg in old_history {
if msg.role == ChatRole::System {
continue;
}
if let Some(slot) = retained_window
.iter()
.zip(&consumed)
.position(|(retained, &used)| !used && retained == msg)
{
consumed[slot] = true;
} else {
deleted.push(msg);
}
}
deleted
}
fn render_dump(messages: &[&ChatMessage]) -> String {
let mut out = String::new();
for msg in messages {
let _ = writeln!(out, "=== {} ===", msg.role);
match super::decode_native_history_message(msg) {
Some(super::DecodedNativeHistoryMessage::Assistant {
content,
tool_calls,
..
}) => {
if let Some(content) = content.filter(|c| !c.is_empty()) {
let _ = writeln!(out, "{}", crate::util::strip_data_uris(&content));
}
if let Some(calls) = tool_calls {
for call in calls {
let _ = writeln!(
out,
"[tool_call {}] {}: {}",
call.id,
call.name,
crate::util::strip_data_uris(&call.arguments.to_string())
);
}
}
}
Some(super::DecodedNativeHistoryMessage::ToolResult {
tool_call_id,
content,
}) => {
let _ = writeln!(out, "[result for tool_call {tool_call_id}]");
let _ = writeln!(out, "{}", crate::util::strip_data_uris(&content));
}
None => {
let _ = writeln!(out, "{}", crate::util::strip_data_uris(&msg.content));
}
}
out.push('\n');
}
crate::util::scrub_credentials(out.trim_end())
}
async fn dump_dir(role: &Role, user_name: &str) -> Option<PathBuf> {
let dir = if matches!(role, Role::Assistant) {
if !crate::users::is_valid_personal_user_name(user_name) {
return None;
}
crate::users::personal_workspace_path(user_name).join(DUMP_DIR_NAME)
} else {
crate::tools::shell::agent_temp_dir()?.join("compaction")
};
tokio::fs::create_dir_all(&dir).await.ok()?;
Some(dir)
}
async fn next_dump_path(dir: &Path, agent_id: &str) -> Option<PathBuf> {
let sanitized: String = agent_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
let prefix = format!("compaction-{sanitized}-");
let mut existing = tokio::fs::read_dir(dir).await.ok()?;
let mut count = 0usize;
while let Ok(Some(entry)) = existing.next_entry().await {
if entry.file_name().to_string_lossy().starts_with(&prefix) {
count += 1;
}
}
let millis = chrono::Utc::now().timestamp_millis();
Some(dir.join(format!("{prefix}{count:03}-{millis}.md")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ChatRole;
#[test]
fn deleted_set_keeps_window_and_drops_older_turns() {
let system = ChatMessage::system("fresh system prompt");
let history = vec![
system,
ChatMessage::user("u1"),
ChatMessage::assistant("a1"),
ChatMessage::user("u2"),
ChatMessage::assistant("a2"),
ChatMessage::user("u3"),
ChatMessage::assistant("a3"),
ChatMessage::user("u4"),
ChatMessage::assistant("a4"),
];
let retained: Vec<ChatMessage> = history[3..].to_vec();
let deleted = deleted_conversation_messages(&history, &retained);
let contents: Vec<&str> = deleted.iter().map(|m| m.content.as_str()).collect();
assert_eq!(contents, vec!["u1", "a1"]);
}
#[test]
fn deleted_set_handles_duplicate_messages_as_multiset() {
let system = ChatMessage::system("sys");
let history = vec![
system,
ChatMessage::user("same text"),
ChatMessage::user("same text"),
ChatMessage::assistant("answer"),
];
let retained = vec![ChatMessage::user("same text")];
let deleted = deleted_conversation_messages(&history, &retained);
assert_eq!(deleted.len(), 2);
assert_eq!(deleted[0].content, "same text");
assert_eq!(deleted[1].content, "answer");
let retained = vec![
ChatMessage::user("same text"),
ChatMessage::user("same text"),
];
let deleted = deleted_conversation_messages(&history, &retained);
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0].content, "answer");
}
#[test]
fn render_dump_renders_native_frames_and_scrubs_credentials() {
let frame = ChatMessage {
role: ChatRole::Assistant,
content: serde_json::json!({
"content": "checking api_key: supersecretvalue123",
"tool_calls": [
{"id": "call_1", "name": "read", "arguments": {"path": "x.md"}}
]
})
.to_string(),
};
let result = ChatMessage::tool_result("call_1", "file contents here");
let out = render_dump(&[&frame, &result]);
assert!(out.contains("=== assistant ==="));
assert!(out.contains("checking api_key: supe*[REDACTED]"));
assert!(!out.contains("supersecretvalue123"));
assert!(out.contains("[tool_call call_1] read: {\"path\":\"x.md\"}"));
assert!(out.contains("=== tool ==="));
assert!(out.contains("[result for tool_call call_1]"));
assert!(out.contains("file contents here"));
}
}