use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpcCorruptionKind {
ResponseDeleted,
ResponseDuplicated,
ScaffoldDuplicated,
}
impl IpcCorruptionKind {
pub fn label(&self) -> &'static str {
match self {
IpcCorruptionKind::ResponseDeleted => "response_deleted",
IpcCorruptionKind::ResponseDuplicated => "response_duplicated",
IpcCorruptionKind::ScaffoldDuplicated => "scaffold_duplicated",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpcCorruptionFinding {
pub kind: IpcCorruptionKind,
pub heading: String,
pub prior_count: usize,
pub current_count: usize,
}
fn response_heading_identity(line: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with("### Re:") {
return None;
}
let without_head = trimmed.strip_suffix(" (HEAD)").unwrap_or(trimmed);
Some(without_head.trim_end().to_string())
}
fn response_heading_counts(doc: &str) -> BTreeMap<String, usize> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for line in doc.lines() {
if let Some(id) = response_heading_identity(line) {
*counts.entry(id).or_insert(0) += 1;
}
}
counts
}
pub fn detect_response_block_corruption(prior: &str, current: &str) -> Vec<IpcCorruptionFinding> {
let prior_counts = response_heading_counts(prior);
if prior_counts.is_empty() {
return Vec::new();
}
let current_counts = response_heading_counts(current);
let mut findings = Vec::new();
for (heading, &prior_count) in &prior_counts {
let current_count = current_counts.get(heading).copied().unwrap_or(0);
if current_count == 0 {
findings.push(IpcCorruptionFinding {
kind: IpcCorruptionKind::ResponseDeleted,
heading: heading.clone(),
prior_count,
current_count,
});
} else if current_count > prior_count {
findings.push(IpcCorruptionFinding {
kind: IpcCorruptionKind::ResponseDuplicated,
heading: heading.clone(),
prior_count,
current_count,
});
}
}
findings
}
const STRUCTURAL_MARKERS: &[&str] = &[
"<!-- /agent:exchange -->",
"<!-- agent:queue -->",
"<!-- /agent:queue -->",
"<!-- agent:backlog -->",
"<!-- /agent:backlog -->",
"<!-- agent:icebox -->",
"<!-- /agent:icebox -->",
];
pub fn detect_duplicated_scaffold(current: &str) -> Vec<IpcCorruptionFinding> {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for line in current.lines() {
let trimmed = line.trim();
for marker in STRUCTURAL_MARKERS {
if trimmed == *marker {
*counts.entry(*marker).or_insert(0) += 1;
}
}
}
counts
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(marker, count)| IpcCorruptionFinding {
kind: IpcCorruptionKind::ScaffoldDuplicated,
heading: marker.to_string(),
prior_count: 1,
current_count: count,
})
.collect()
}
pub fn summarize_findings(findings: &[IpcCorruptionFinding]) -> String {
let deleted = findings
.iter()
.filter(|f| f.kind == IpcCorruptionKind::ResponseDeleted)
.count();
let duplicated = findings
.iter()
.filter(|f| f.kind == IpcCorruptionKind::ResponseDuplicated)
.count();
let scaffold = findings
.iter()
.filter(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated)
.count();
let details: Vec<String> = findings
.iter()
.map(|f| {
let mut h = f.heading.clone();
if h.len() > 80 {
let mut end = 80;
while end > 0 && !h.is_char_boundary(end) {
end -= 1;
}
h.truncate(end);
}
format!(
"{}({}:{}->{})",
f.kind.label(),
h,
f.prior_count,
f.current_count
)
})
.collect();
format!(
"deleted={} duplicated={} scaffold_duplicated={} details=[{}]",
deleted,
duplicated,
scaffold,
details.join("; ")
)
}
#[cfg(test)]
mod tests {
use super::*;
const PRIOR: &str = concat!(
"<!-- agent:exchange -->\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"### Re: second topic — opus-4-8\n",
"Second answer.\n",
"<!-- /agent:exchange -->\n",
);
#[test]
fn detect_flags_deleted_prior_response() {
let current = concat!(
"<!-- agent:exchange -->\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"<!-- /agent:exchange -->\n",
);
let findings = detect_response_block_corruption(PRIOR, current);
assert_eq!(findings.len(), 1, "exactly one deletion: {findings:?}");
assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDeleted);
assert_eq!(findings[0].heading, "### Re: second topic — opus-4-8");
assert_eq!(findings[0].prior_count, 1);
assert_eq!(findings[0].current_count, 0);
}
#[test]
fn detect_flags_duplicated_prior_response() {
let current = concat!(
"<!-- agent:exchange -->\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"### Re: second topic — opus-4-8\n",
"Second answer.\n",
"<!-- /agent:exchange -->\n",
);
let findings = detect_response_block_corruption(PRIOR, current);
assert_eq!(findings.len(), 1, "exactly one duplication: {findings:?}");
assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDuplicated);
assert_eq!(findings[0].heading, "### Re: first topic — opus-4-8");
assert_eq!(findings[0].prior_count, 1);
assert_eq!(findings[0].current_count, 2);
}
#[test]
fn detect_ignores_new_response_block() {
let current = concat!(
"<!-- agent:exchange -->\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"### Re: second topic — opus-4-8\n",
"Second answer.\n",
"### Re: third topic — opus-4-8\n",
"Third answer.\n",
"<!-- /agent:exchange -->\n",
);
assert!(
detect_response_block_corruption(PRIOR, current).is_empty(),
"new response heading must not be flagged"
);
}
#[test]
fn detect_ignores_head_annotation_difference() {
let current = concat!(
"<!-- agent:exchange -->\n",
"### Re: first topic — opus-4-8\n",
"First answer.\n",
"### Re: second topic — opus-4-8 (HEAD)\n",
"Second answer.\n",
"<!-- /agent:exchange -->\n",
);
assert!(
detect_response_block_corruption(PRIOR, current).is_empty(),
"(HEAD) annotation must not read as corruption"
);
}
#[test]
fn detect_clean_document_has_no_findings() {
assert!(detect_response_block_corruption(PRIOR, PRIOR).is_empty());
}
#[test]
fn detect_no_prior_headings_is_empty() {
let prior = "<!-- agent:exchange -->\njust a prompt\n<!-- /agent:exchange -->\n";
let current =
"<!-- agent:exchange -->\n### Re: x — opus-4-8\nhi\n<!-- /agent:exchange -->\n";
assert!(detect_response_block_corruption(prior, current).is_empty());
}
#[test]
fn detect_duplicated_scaffold_flags_two_exchange_close_markers() {
let corrupted = concat!(
"<!-- agent:exchange -->\n",
"### Re: prior — opus-4-8\nAnswer.\n",
"<!-- agent:boundary:709a41ae -->\n",
"Is the issue still happening?\nCan it be re\n",
"<!-- /agent:exchange -->\n",
"###\n",
"## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
"## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
"## Icebox\n<!-- agent:icebox -->\n<!-- /agent:icebox -->\n",
"Can it be rep11ro\n",
"<!-- /agent:exchange -->\n",
"###\n",
"## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
);
let findings = detect_duplicated_scaffold(corrupted);
assert!(
findings
.iter()
.any(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated
&& f.heading == "<!-- /agent:exchange -->"
&& f.current_count == 2),
"two exchange close markers must be flagged: {findings:?}"
);
assert!(
findings
.iter()
.any(|f| f.heading == "<!-- agent:queue -->" && f.current_count == 2)
);
}
#[test]
fn detect_duplicated_scaffold_clean_doc_has_no_findings() {
let clean = concat!(
"<!-- agent:exchange -->\n",
"### Re: x — opus-4-8\nAnswer.\n",
"<!-- /agent:exchange -->\n",
"## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
"## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
);
assert!(detect_duplicated_scaffold(clean).is_empty());
}
#[test]
fn detect_duplicated_scaffold_ignores_inline_marker_mentions() {
let doc = concat!(
"<!-- agent:exchange -->\n",
"### Re: x — opus-4-8\n",
"The close marker is written `<!-- /agent:exchange -->` inline here.\n",
"<!-- /agent:exchange -->\n",
);
assert!(
detect_duplicated_scaffold(doc).is_empty(),
"inline mention must not count as a structural duplicate"
);
}
#[test]
fn summarize_is_compact_and_truncates() {
let findings = vec![
IpcCorruptionFinding {
kind: IpcCorruptionKind::ResponseDeleted,
heading: "### Re: a".to_string(),
prior_count: 1,
current_count: 0,
},
IpcCorruptionFinding {
kind: IpcCorruptionKind::ResponseDuplicated,
heading: "### Re: b".to_string(),
prior_count: 1,
current_count: 3,
},
];
let s = summarize_findings(&findings);
assert!(s.contains("deleted=1"), "{s}");
assert!(s.contains("duplicated=1"), "{s}");
assert!(s.contains("response_deleted(### Re: a:1->0)"), "{s}");
assert!(s.contains("response_duplicated(### Re: b:1->3)"), "{s}");
assert!(!s.contains('\n'), "summary must be single-line: {s}");
}
#[test]
fn summarize_findings_truncates_multibyte_heading_without_panic() {
let heading = format!("### Re: {}— opus-4-8", "x".repeat(71));
assert!(heading.len() > 80);
assert!(!heading.is_char_boundary(80));
let findings = vec![IpcCorruptionFinding {
kind: IpcCorruptionKind::ResponseDeleted,
heading,
prior_count: 1,
current_count: 0,
}];
let s = summarize_findings(&findings);
assert!(s.contains("deleted=1"), "{s}");
assert!(!s.contains('\n'), "summary must be single-line: {s}");
}
}