use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
use crate::{component, frontmatter, snapshot};
#[derive(Debug)]
struct Exchange {
user: String,
assistant: String,
}
pub fn run(
file: &Path,
keep: Option<usize>,
component_name: Option<&str>,
message: Option<&str>,
tag: Option<&str>,
) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
if tag != Some("skip")
&& let Err(e) = create_pre_compact_tag(file, tag)
{
eprintln!("[compact] Warning: could not create pre-compact tag: {}", e);
}
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (fm, body) = frontmatter::parse(&content)?;
let resolved = fm.resolve_mode();
if resolved.is_template() {
if resolved.is_crdt()
&& let Ok(Some(crdt_state)) = snapshot::load_crdt(file)
{
let compacted_crdt = crate::crdt::compact(&crdt_state)?;
snapshot::save_crdt(file, &compacted_crdt)?;
eprintln!(
"[compact] CRDT state compacted: {} → {} bytes",
crdt_state.len(),
compacted_crdt.len()
);
}
let target = component_name.unwrap_or("exchange");
let is_crdt = resolved.is_crdt();
return match keep {
Some(n) => run_component_compact_partial(file, &content, target, n, message, is_crdt),
None => run_component_compact(file, &content, target, message, is_crdt),
};
}
let keep_n = keep.unwrap_or(2);
let exchanges = parse_exchanges(body);
if exchanges.len() <= keep_n {
eprintln!(
"[compact] Only {} exchange(s) found, keeping all (threshold: {})",
exchanges.len(),
keep_n
);
return Ok(());
}
let to_archive = &exchanges[..exchanges.len() - keep_n];
let to_keep = &exchanges[exchanges.len() - keep_n..];
let archive_content = build_archive(&content, to_archive);
let archive_path = save_archive(file, &archive_content)?;
let compacted = build_compacted(&content, body, to_keep, &archive_path, to_archive.len());
crate::write::atomic_write_pub(file, &compacted)?;
snapshot::save(file, &compacted)?;
eprintln!(
"[compact] Archived {} exchange(s) to {}",
to_archive.len(),
archive_path.display()
);
eprintln!(
"[compact] {} exchange(s) remain in {}",
to_keep.len(),
file.display()
);
Ok(())
}
fn run_component_compact(
file: &Path,
content: &str,
target: &str,
message: Option<&str>,
is_crdt: bool,
) -> Result<()> {
let components = component::parse(content)?;
let comp = components
.iter()
.find(|c| c.name == target)
.ok_or_else(|| anyhow::anyhow!("component '{}' not found in document", target))?;
let old_content = comp.content(content);
let trimmed = old_content.trim();
if trimmed.is_empty() {
eprintln!("[compact] Component '{}' is already empty", target);
return Ok(());
}
let archive_path = save_archive(file, &build_component_archive(content, target, old_content))?;
let summary = match message {
Some(msg) => format!("{}\n", msg),
None => format!(
"*Compacted. Content archived to `{}`*\n",
archive_path.display()
),
};
let compacted = comp.replace_content(content, &summary);
crate::write::atomic_write_pub(file, &compacted)?;
snapshot::save(file, &compacted)?;
if is_crdt {
let new_crdt = crate::crdt::CrdtDoc::from_text(&compacted).encode_state();
snapshot::save_crdt(file, &new_crdt)?;
eprintln!("[compact] CRDT state refreshed from post-compact content");
}
let line_count = old_content.lines().count();
eprintln!(
"[compact] Archived {} lines from component '{}' to {}",
line_count,
target,
archive_path.display()
);
Ok(())
}
fn run_component_compact_partial(
file: &Path,
content: &str,
target: &str,
keep: usize,
message: Option<&str>,
is_crdt: bool,
) -> Result<()> {
let components = component::parse(content)?;
let comp = components
.iter()
.find(|c| c.name == target)
.ok_or_else(|| anyhow::anyhow!("component '{}' not found in document", target))?;
let old_content = comp.content(content);
let (preamble, sections) = parse_topic_sections(old_content);
if sections.len() <= keep {
eprintln!(
"[compact] Only {} topic section(s) found, keeping all (threshold: {})",
sections.len(),
keep
);
return Ok(());
}
let to_archive = §ions[..sections.len() - keep];
let to_keep = §ions[sections.len() - keep..];
let mut archive_body = String::new();
if !preamble.trim().is_empty() {
archive_body.push_str(preamble.trim_end());
archive_body.push_str("\n\n");
}
for section in to_archive {
archive_body.push_str(section.trim_end());
archive_body.push_str("\n\n");
}
let archive_path = save_archive(
file,
&build_component_archive(content, target, &archive_body),
)?;
let mut new_content = String::new();
match message {
Some(msg) => {
new_content.push_str(msg.trim_end());
new_content.push('\n');
}
None => {
if !preamble.trim().is_empty() {
new_content.push_str(preamble.trim_end());
new_content.push('\n');
}
}
}
new_content.push_str(&format!(
"\n*{} earlier topic(s) archived to `{}`*\n",
to_archive.len(),
archive_path.display()
));
for section in to_keep {
new_content.push('\n');
new_content.push_str(section.trim_end());
new_content.push('\n');
}
let compacted = comp.replace_content(content, &new_content);
crate::write::atomic_write_pub(file, &compacted)?;
snapshot::save(file, &compacted)?;
if is_crdt {
let new_crdt = crate::crdt::CrdtDoc::from_text(&compacted).encode_state();
snapshot::save_crdt(file, &new_crdt)?;
eprintln!("[compact] CRDT state refreshed from post-compact content");
}
eprintln!(
"[compact] Archived {} topic(s) from component '{}' to {}",
to_archive.len(),
target,
archive_path.display()
);
eprintln!(
"[compact] {} topic(s) remain in {}",
to_keep.len(),
file.display()
);
Ok(())
}
pub fn parse_topic_sections(content: &str) -> (String, Vec<String>) {
let mut preamble = String::new();
let mut sections: Vec<String> = Vec::new();
let mut current: Option<String> = None;
let mut found_first = false;
for line in content.lines() {
if line.starts_with("<!-- agent:boundary:") {
continue;
}
if line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:") {
if let Some(prev) = current.take() {
sections.push(prev);
}
found_first = true;
current = Some(format!("{}\n", line));
} else if found_first {
let section = current.get_or_insert_with(String::new);
section.push_str(line);
section.push('\n');
} else {
preamble.push_str(line);
preamble.push('\n');
}
}
if let Some(last) = current {
sections.push(last);
}
(preamble, sections)
}
fn build_component_archive(original: &str, component_name: &str, content: &str) -> String {
let mut archive = String::new();
archive.push_str("---\n");
archive.push_str("archived_from: compact\n");
archive.push_str(&format!("archived_at: {}\n", chrono_timestamp()));
archive.push_str(&format!("component: {}\n", component_name));
if let Ok((fm, _)) = frontmatter::parse(original)
&& let Some(session) = &fm.session
{
archive.push_str(&format!("session: {}\n", session));
}
archive.push_str("---\n\n");
archive.push_str(content.trim());
archive.push('\n');
archive
}
fn parse_exchanges(body: &str) -> Vec<Exchange> {
let mut exchanges = Vec::new();
let mut sections: Vec<(&str, String)> = Vec::new();
let mut current_type = "";
let mut current_content = String::new();
let mut in_code_block = false;
for line in body.lines() {
if line.starts_with("```") {
in_code_block = !in_code_block;
}
if !in_code_block {
if line == "## User" {
if !current_type.is_empty() {
sections.push((current_type, current_content.clone()));
}
current_type = "user";
current_content.clear();
continue;
} else if line == "## Assistant" {
if !current_type.is_empty() {
sections.push((current_type, current_content.clone()));
}
current_type = "assistant";
current_content.clear();
continue;
}
}
if !current_type.is_empty() {
current_content.push_str(line);
current_content.push('\n');
}
}
if !current_type.is_empty() {
sections.push((current_type, current_content));
}
let mut i = 0;
while i < sections.len() {
if sections[i].0 == "user" {
let user = sections[i].1.trim().to_string();
let assistant = if i + 1 < sections.len() && sections[i + 1].0 == "assistant" {
i += 1;
sections[i].1.trim().to_string()
} else {
String::new()
};
if !assistant.is_empty() {
exchanges.push(Exchange { user, assistant });
}
}
i += 1;
}
exchanges
}
fn build_archive(original_header: &str, exchanges: &[Exchange]) -> String {
let mut archive = String::new();
archive.push_str("---\n");
archive.push_str("archived_from: compact\n");
archive.push_str(&format!(
"archived_at: {}\n",
chrono_timestamp()
));
archive.push_str(&format!("exchange_count: {}\n", exchanges.len()));
if let Ok((fm, _)) = frontmatter::parse(original_header)
&& let Some(session) = &fm.session
{
archive.push_str(&format!("session: {}\n", session));
}
archive.push_str("---\n\n");
for (i, exchange) in exchanges.iter().enumerate() {
archive.push_str("## User\n\n");
archive.push_str(&exchange.user);
archive.push('\n');
archive.push_str("\n## Assistant\n\n");
archive.push_str(&exchange.assistant);
archive.push('\n');
if i < exchanges.len() - 1 {
archive.push('\n');
}
}
archive
}
fn save_archive(doc: &Path, content: &str) -> Result<std::path::PathBuf> {
let snap_path = snapshot::path_for(doc)?;
let hash = snap_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let project_root = find_project_root(doc)?;
let archive_dir = project_root.join(".agent-doc/archives");
std::fs::create_dir_all(&archive_dir)
.with_context(|| format!("failed to create {}", archive_dir.display()))?;
let timestamp = chrono_timestamp();
let archive_path = archive_dir.join(format!("{}-{}.md", hash, timestamp));
std::fs::write(&archive_path, content)
.with_context(|| format!("failed to write {}", archive_path.display()))?;
Ok(archive_path)
}
fn build_compacted(
original: &str,
body: &str,
kept_exchanges: &[Exchange],
archive_path: &Path,
archived_count: usize,
) -> String {
let body_start = original.len() - body.len();
let header = &original[..body_start];
let mut result = header.to_string();
result.push_str(&format!(
"*{} earlier exchange(s) archived to `{}`*\n\n",
archived_count,
archive_path.display()
));
for exchange in kept_exchanges {
result.push_str("## User\n\n");
result.push_str(&exchange.user);
result.push_str("\n\n## Assistant\n\n");
result.push_str(&exchange.assistant);
result.push_str("\n\n");
}
result.push_str("## User\n\n");
result
}
fn create_pre_compact_tag(file: &Path, tag_override: Option<&str>) -> Result<()> {
let canonical = file
.canonicalize()
.with_context(|| format!("file not found: {}", file.display()))?;
let parent = canonical.parent().unwrap_or(Path::new("/"));
let toplevel = Command::new("git")
.current_dir(parent)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to run git rev-parse")?;
if !toplevel.status.success() {
anyhow::bail!("file is not in a git repository");
}
let git_root = std::path::PathBuf::from(
String::from_utf8_lossy(&toplevel.stdout).trim(),
);
let tag_name = match tag_override {
Some(name) => name.to_string(),
None => {
let doc_name = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("doc")
.to_string();
let pattern = format!("agent-doc/{}/pre-compact-*", doc_name);
let count = Command::new("git")
.current_dir(&git_root)
.args(["tag", "-l", &pattern])
.output()
.map(|o| {
if o.status.success() {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
} else {
0
}
})
.unwrap_or(0);
format!("agent-doc/{}/pre-compact-{}", doc_name, count + 1)
}
};
let tag_output = Command::new("git")
.current_dir(&git_root)
.args(["tag", &tag_name])
.output()
.with_context(|| format!("failed to create git tag {}", tag_name))?;
if !tag_output.status.success() {
let stderr = String::from_utf8_lossy(&tag_output.stderr);
anyhow::bail!("git tag {} failed: {}", tag_name, stderr.trim());
}
eprintln!("[compact] Tagged pre-compact state as {}", tag_name);
Ok(())
}
fn find_project_root(file: &Path) -> Result<std::path::PathBuf> {
let canonical = file
.canonicalize()
.with_context(|| format!("failed to canonicalize {}", file.display()))?;
let mut dir = canonical.parent();
while let Some(d) = dir {
if d.join(".agent-doc").is_dir() {
return Ok(d.to_path_buf());
}
dir = d.parent();
}
Ok(canonical
.parent()
.unwrap_or(Path::new("."))
.to_path_buf())
}
fn chrono_timestamp() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut y = 1970i64;
let mut remaining_days = days as i64;
loop {
let days_in_year = if is_leap_year(y) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
y += 1;
}
let month_days: &[i64] = if is_leap_year(y) {
&[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
&[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 0;
for &md in month_days {
if remaining_days < md {
break;
}
remaining_days -= md;
m += 1;
}
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
y,
m + 1,
remaining_days + 1,
hours,
minutes,
seconds
)
}
fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_exchanges_basic() {
let body = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nBye\n\n## Assistant\n\nGoodbye\n\n## User\n\n";
let exchanges = parse_exchanges(body);
assert_eq!(exchanges.len(), 2);
assert_eq!(exchanges[0].user, "Hello");
assert_eq!(exchanges[0].assistant, "Hi there");
assert_eq!(exchanges[1].user, "Bye");
assert_eq!(exchanges[1].assistant, "Goodbye");
}
#[test]
fn parse_exchanges_with_code_blocks() {
let body = "## User\n\nHere's code:\n\n```\n## User\n## Assistant\n```\n\n## Assistant\n\nNice code\n\n## User\n\n";
let exchanges = parse_exchanges(body);
assert_eq!(exchanges.len(), 1);
assert!(exchanges[0].user.contains("```"));
assert!(exchanges[0].user.contains("## User"));
}
#[test]
fn parse_exchanges_trailing_user_not_counted() {
let body = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\nPending question\n";
let exchanges = parse_exchanges(body);
assert_eq!(exchanges.len(), 1);
}
#[test]
fn build_archive_format() {
let exchanges = vec![Exchange {
user: "Hello".to_string(),
assistant: "Hi there".to_string(),
}];
let archive = build_archive("---\nsession: test\n---\n", &exchanges);
assert!(archive.contains("archived_from: compact"));
assert!(archive.contains("session: test"));
assert!(archive.contains("## User\n\nHello"));
assert!(archive.contains("## Assistant\n\nHi there"));
}
#[test]
fn build_compacted_format() {
let kept = vec![Exchange {
user: "Recent question".to_string(),
assistant: "Recent answer".to_string(),
}];
let compacted =
build_compacted("---\ntest: true\n---\n\n", "\n", &kept, Path::new("archive.md"), 3);
assert!(compacted.contains("3 earlier exchange(s) archived"));
assert!(compacted.contains("## User\n\nRecent question"));
assert!(compacted.contains("## Assistant\n\nRecent answer"));
assert!(compacted.ends_with("## User\n\n"));
}
#[test]
fn chrono_timestamp_format() {
let ts = chrono_timestamp();
assert_eq!(ts.len(), 15);
assert_eq!(&ts[8..9], "-");
}
#[test]
fn build_component_archive_format() {
let doc = "---\nagent_doc_session: abc-123\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\nOld conversation\n<!-- /agent:exchange -->\n";
let archive = build_component_archive(doc, "exchange", "\nOld conversation\n");
assert!(archive.contains("archived_from: compact"));
assert!(archive.contains("component: exchange"));
assert!(archive.contains("session: abc-123"));
assert!(archive.contains("Old conversation"));
}
#[test]
fn parse_topic_sections_basic() {
let content = "### Session Summary\n\nSome preamble.\n\n### Re: first topic\n\nFirst response.\n\n### Re: second topic\n\nSecond response.\n";
let (preamble, sections) = parse_topic_sections(content);
assert!(preamble.contains("Session Summary"));
assert!(preamble.contains("Some preamble."));
assert_eq!(sections.len(), 2);
assert!(sections[0].starts_with("### Re: first topic"));
assert!(sections[0].contains("First response."));
assert!(sections[1].starts_with("### Re: second topic"));
assert!(sections[1].contains("Second response."));
}
#[test]
fn parse_topic_sections_keep_threshold() {
let content = "### Re: topic 1\nResponse 1.\n### Re: topic 2\nResponse 2.\n";
let (_, sections) = parse_topic_sections(content);
assert_eq!(sections.len(), 2);
}
#[test]
fn parse_topic_sections_strips_boundary_marker() {
let content = "### Re: last topic\n\nContent.\n<!-- agent:boundary:abc123 -->\n";
let (_, sections) = parse_topic_sections(content);
assert_eq!(sections.len(), 1);
assert!(!sections[0].contains("agent:boundary"));
}
#[test]
fn parse_topic_sections_no_re_headings() {
let content = "Just preamble text.\nNo Re: headings here.\n";
let (preamble, sections) = parse_topic_sections(content);
assert!(preamble.contains("Just preamble text."));
assert_eq!(sections.len(), 0);
}
#[test]
fn component_compact_preserves_non_target_components() {
let doc = concat!(
"---\nagent_doc_session: test-123\nagent_doc_format: template\n---\n\n",
"## Status\n\n",
"<!-- agent:status patch=replace -->\n",
"Status: active\n",
"<!-- /agent:status -->\n\n",
"## Exchange\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: topic one\n\nLong response about topic one.\n\n",
"### Re: topic two\n\nLong response about topic two.\n",
"<!-- /agent:exchange -->\n\n",
"## Pending\n\n",
"<!-- agent:pending patch=replace -->\n",
"- Task A: do something important\n",
"- Task B: do something else\n",
"- Task C: critical item\n",
"<!-- /agent:pending -->\n",
);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(&file, doc).unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("archives")).unwrap();
snapshot::save(&file, doc).unwrap();
let components_before = component::parse(doc).unwrap();
let pending_before = components_before
.iter()
.find(|c| c.name == "pending")
.unwrap()
.content(doc)
.to_string();
let status_before = components_before
.iter()
.find(|c| c.name == "status")
.unwrap()
.content(doc)
.to_string();
run_component_compact(&file, doc, "exchange", Some("Compacted summary."), false)
.unwrap();
let result = std::fs::read_to_string(&file).unwrap();
let components_after = component::parse(&result).unwrap();
let pending_after = components_after
.iter()
.find(|c| c.name == "pending")
.unwrap()
.content(&result)
.to_string();
let status_after = components_after
.iter()
.find(|c| c.name == "status")
.unwrap()
.content(&result)
.to_string();
assert_eq!(
pending_before, pending_after,
"pending component must be byte-identical after compact"
);
assert_eq!(
status_before, status_after,
"status component must be byte-identical after compact"
);
let exchange_after = components_after
.iter()
.find(|c| c.name == "exchange")
.unwrap()
.content(&result)
.to_string();
assert!(exchange_after.contains("Compacted summary."));
assert!(!exchange_after.contains("### Re: topic one"));
}
#[test]
fn crdt_compact_preserves_pending_with_state_refresh() {
let doc = concat!(
"---\nagent_doc_session: test-crdt-123\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n",
"## Exchange\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: topic one\n\nResponse one.\n\n",
"### Re: topic two\n\nResponse two.\n\n",
"### Re: topic three\n\nResponse three.\n",
"<!-- /agent:exchange -->\n\n",
"## Pending\n\n",
"<!-- agent:pending patch=replace -->\n",
"- ✅ completed task\n",
"- 🔄 in-progress work\n",
"- 🆕 new task to add\n",
"<!-- /agent:pending -->\n",
);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(&file, doc).unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("archives")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
snapshot::save(&file, doc).unwrap();
let initial_crdt = crate::crdt::CrdtDoc::from_text(doc).encode_state();
snapshot::save_crdt(&file, &initial_crdt).unwrap();
let components_before = component::parse(doc).unwrap();
let pending_before = components_before
.iter()
.find(|c| c.name == "pending")
.unwrap()
.content(doc)
.to_string();
run_component_compact(&file, doc, "exchange", Some("Compacted."), true)
.unwrap();
let result = std::fs::read_to_string(&file).unwrap();
let components_after = component::parse(&result).unwrap();
let pending_after = components_after
.iter()
.find(|c| c.name == "pending")
.unwrap()
.content(&result)
.to_string();
assert_eq!(
pending_before, pending_after,
"pending component must survive CRDT state refresh during compact"
);
assert!(pending_after.contains("completed task"));
assert!(pending_after.contains("in-progress work"));
assert!(pending_after.contains("new task to add"));
}
#[test]
fn compact_preserves_boundary_marker() {
let doc = concat!(
"---\nagent_doc_session: test-boundary\nagent_doc_format: template\n---\n\n",
"## Exchange\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: first topic\n\nResponse one.\n\n",
"### Re: second topic\n\nResponse two.\n",
"<!-- agent:boundary:abc123def456 -->\n",
"<!-- /agent:exchange -->\n\n",
"## Status\n\n",
"<!-- agent:status patch=replace -->\n",
"❯ Critical item: verify preservation\n",
"<!-- /agent:status -->\n",
);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(&file, doc).unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("archives")).unwrap();
snapshot::save(&file, doc).unwrap();
let components_before = component::parse(doc).unwrap();
let status_before = components_before
.iter()
.find(|c| c.name == "status")
.unwrap()
.content(doc)
.to_string();
run_component_compact(&file, doc, "exchange", Some("Archived."), false)
.unwrap();
let result = std::fs::read_to_string(&file).unwrap();
let components_after = component::parse(&result).unwrap();
let status_after = components_after
.iter()
.find(|c| c.name == "status")
.unwrap()
.content(&result)
.to_string();
assert_eq!(status_before, status_after);
assert!(status_after.contains("❯"));
assert!(status_after.contains("Critical item"));
}
#[test]
fn compact_working_tree_consistency() {
let doc = concat!(
"---\nagent_doc_session: test-wt\nagent_doc_format: template\n---\n\n",
"## Exchange\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: topic A\n\nResponse A.\n\n",
"### Re: topic B\n\nResponse B.\n",
"<!-- /agent:exchange -->\n",
);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(&file, doc).unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("archives")).unwrap();
snapshot::save(&file, doc).unwrap();
let file_before = std::fs::read_to_string(&file).unwrap();
run_component_compact(&file, doc, "exchange", Some("Summary."), false)
.unwrap();
let file_after = std::fs::read_to_string(&file).unwrap();
let snap_path = snapshot::path_for(&file).unwrap();
let snapshot_content = std::fs::read_to_string(&snap_path).unwrap();
assert_eq!(
file_after, snapshot_content,
"file and snapshot must match after compact"
);
assert_ne!(file_before, file_after);
assert!(file_after.contains("Summary."));
}
}