1use std::collections::BTreeMap;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum IpcCorruptionKind {
41 ResponseDeleted,
43 ResponseDuplicated,
45 ScaffoldDuplicated,
49}
50
51impl IpcCorruptionKind {
52 pub fn label(&self) -> &'static str {
54 match self {
55 IpcCorruptionKind::ResponseDeleted => "response_deleted",
56 IpcCorruptionKind::ResponseDuplicated => "response_duplicated",
57 IpcCorruptionKind::ScaffoldDuplicated => "scaffold_duplicated",
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct IpcCorruptionFinding {
65 pub kind: IpcCorruptionKind,
66 pub heading: String,
68 pub prior_count: usize,
70 pub current_count: usize,
72}
73
74fn response_heading_identity(line: &str) -> Option<String> {
78 let trimmed = line.trim();
79 if !trimmed.starts_with("### Re:") {
80 return None;
81 }
82 let without_head = trimmed.strip_suffix(" (HEAD)").unwrap_or(trimmed);
83 Some(without_head.trim_end().to_string())
84}
85
86fn response_heading_counts(doc: &str) -> BTreeMap<String, usize> {
88 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
89 for line in doc.lines() {
90 if let Some(id) = response_heading_identity(line) {
91 *counts.entry(id).or_insert(0) += 1;
92 }
93 }
94 counts
95}
96
97pub fn detect_response_block_corruption(prior: &str, current: &str) -> Vec<IpcCorruptionFinding> {
105 let prior_counts = response_heading_counts(prior);
106 if prior_counts.is_empty() {
107 return Vec::new();
108 }
109 let current_counts = response_heading_counts(current);
110
111 let mut findings = Vec::new();
112 for (heading, &prior_count) in &prior_counts {
113 let current_count = current_counts.get(heading).copied().unwrap_or(0);
114 if current_count == 0 {
115 findings.push(IpcCorruptionFinding {
116 kind: IpcCorruptionKind::ResponseDeleted,
117 heading: heading.clone(),
118 prior_count,
119 current_count,
120 });
121 } else if current_count > prior_count {
122 findings.push(IpcCorruptionFinding {
123 kind: IpcCorruptionKind::ResponseDuplicated,
124 heading: heading.clone(),
125 prior_count,
126 current_count,
127 });
128 }
129 }
130 findings
131}
132
133const STRUCTURAL_MARKERS: &[&str] = &[
139 "<!-- /agent:exchange -->",
140 "<!-- agent:queue -->",
141 "<!-- /agent:queue -->",
142 "<!-- agent:backlog -->",
143 "<!-- /agent:backlog -->",
144 "<!-- agent:icebox -->",
145 "<!-- /agent:icebox -->",
146];
147
148pub fn detect_duplicated_scaffold(current: &str) -> Vec<IpcCorruptionFinding> {
155 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
156 for line in current.lines() {
157 let trimmed = line.trim();
158 for marker in STRUCTURAL_MARKERS {
159 if trimmed == *marker {
160 *counts.entry(*marker).or_insert(0) += 1;
161 }
162 }
163 }
164 counts
165 .into_iter()
166 .filter(|(_, count)| *count > 1)
167 .map(|(marker, count)| IpcCorruptionFinding {
168 kind: IpcCorruptionKind::ScaffoldDuplicated,
169 heading: marker.to_string(),
170 prior_count: 1,
171 current_count: count,
172 })
173 .collect()
174}
175
176pub fn summarize_findings(findings: &[IpcCorruptionFinding]) -> String {
179 let deleted = findings
180 .iter()
181 .filter(|f| f.kind == IpcCorruptionKind::ResponseDeleted)
182 .count();
183 let duplicated = findings
184 .iter()
185 .filter(|f| f.kind == IpcCorruptionKind::ResponseDuplicated)
186 .count();
187 let scaffold = findings
188 .iter()
189 .filter(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated)
190 .count();
191 let details: Vec<String> = findings
192 .iter()
193 .map(|f| {
194 let mut h = f.heading.clone();
195 if h.len() > 80 {
196 let mut end = 80;
201 while end > 0 && !h.is_char_boundary(end) {
202 end -= 1;
203 }
204 h.truncate(end);
205 }
206 format!(
207 "{}({}:{}->{})",
208 f.kind.label(),
209 h,
210 f.prior_count,
211 f.current_count
212 )
213 })
214 .collect();
215 format!(
216 "deleted={} duplicated={} scaffold_duplicated={} details=[{}]",
217 deleted,
218 duplicated,
219 scaffold,
220 details.join("; ")
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 const PRIOR: &str = concat!(
229 "<!-- agent:exchange -->\n",
230 "### Re: first topic — opus-4-8\n",
231 "First answer.\n",
232 "### Re: second topic — opus-4-8\n",
233 "Second answer.\n",
234 "<!-- /agent:exchange -->\n",
235 );
236
237 #[test]
238 fn detect_flags_deleted_prior_response() {
239 let current = concat!(
241 "<!-- agent:exchange -->\n",
242 "### Re: first topic — opus-4-8\n",
243 "First answer.\n",
244 "<!-- /agent:exchange -->\n",
245 );
246 let findings = detect_response_block_corruption(PRIOR, current);
247 assert_eq!(findings.len(), 1, "exactly one deletion: {findings:?}");
248 assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDeleted);
249 assert_eq!(findings[0].heading, "### Re: second topic — opus-4-8");
250 assert_eq!(findings[0].prior_count, 1);
251 assert_eq!(findings[0].current_count, 0);
252 }
253
254 #[test]
255 fn detect_flags_duplicated_prior_response() {
256 let current = concat!(
258 "<!-- agent:exchange -->\n",
259 "### Re: first topic — opus-4-8\n",
260 "First answer.\n",
261 "### Re: first topic — opus-4-8\n",
262 "First answer.\n",
263 "### Re: second topic — opus-4-8\n",
264 "Second answer.\n",
265 "<!-- /agent:exchange -->\n",
266 );
267 let findings = detect_response_block_corruption(PRIOR, current);
268 assert_eq!(findings.len(), 1, "exactly one duplication: {findings:?}");
269 assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDuplicated);
270 assert_eq!(findings[0].heading, "### Re: first topic — opus-4-8");
271 assert_eq!(findings[0].prior_count, 1);
272 assert_eq!(findings[0].current_count, 2);
273 }
274
275 #[test]
276 fn detect_ignores_new_response_block() {
277 let current = concat!(
279 "<!-- agent:exchange -->\n",
280 "### Re: first topic — opus-4-8\n",
281 "First answer.\n",
282 "### Re: second topic — opus-4-8\n",
283 "Second answer.\n",
284 "### Re: third topic — opus-4-8\n",
285 "Third answer.\n",
286 "<!-- /agent:exchange -->\n",
287 );
288 assert!(
289 detect_response_block_corruption(PRIOR, current).is_empty(),
290 "new response heading must not be flagged"
291 );
292 }
293
294 #[test]
295 fn detect_ignores_head_annotation_difference() {
296 let current = concat!(
299 "<!-- agent:exchange -->\n",
300 "### Re: first topic — opus-4-8\n",
301 "First answer.\n",
302 "### Re: second topic — opus-4-8 (HEAD)\n",
303 "Second answer.\n",
304 "<!-- /agent:exchange -->\n",
305 );
306 assert!(
307 detect_response_block_corruption(PRIOR, current).is_empty(),
308 "(HEAD) annotation must not read as corruption"
309 );
310 }
311
312 #[test]
313 fn detect_clean_document_has_no_findings() {
314 assert!(detect_response_block_corruption(PRIOR, PRIOR).is_empty());
315 }
316
317 #[test]
318 fn detect_no_prior_headings_is_empty() {
319 let prior = "<!-- agent:exchange -->\njust a prompt\n<!-- /agent:exchange -->\n";
321 let current =
322 "<!-- agent:exchange -->\n### Re: x — opus-4-8\nhi\n<!-- /agent:exchange -->\n";
323 assert!(detect_response_block_corruption(prior, current).is_empty());
324 }
325
326 #[test]
332 fn detect_duplicated_scaffold_flags_two_exchange_close_markers() {
333 let corrupted = concat!(
334 "<!-- agent:exchange -->\n",
335 "### Re: prior — opus-4-8\nAnswer.\n",
336 "<!-- agent:boundary:709a41ae -->\n",
337 "Is the issue still happening?\nCan it be re\n",
338 "<!-- /agent:exchange -->\n",
339 "###\n",
340 "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
341 "## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
342 "## Icebox\n<!-- agent:icebox -->\n<!-- /agent:icebox -->\n",
343 "Can it be rep11ro\n",
344 "<!-- /agent:exchange -->\n",
345 "###\n",
346 "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
347 );
348 let findings = detect_duplicated_scaffold(corrupted);
349 assert!(
350 findings
351 .iter()
352 .any(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated
353 && f.heading == "<!-- /agent:exchange -->"
354 && f.current_count == 2),
355 "two exchange close markers must be flagged: {findings:?}"
356 );
357 assert!(
359 findings
360 .iter()
361 .any(|f| f.heading == "<!-- agent:queue -->" && f.current_count == 2)
362 );
363 }
364
365 #[test]
366 fn detect_duplicated_scaffold_clean_doc_has_no_findings() {
367 let clean = concat!(
368 "<!-- agent:exchange -->\n",
369 "### Re: x — opus-4-8\nAnswer.\n",
370 "<!-- /agent:exchange -->\n",
371 "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
372 "## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
373 );
374 assert!(detect_duplicated_scaffold(clean).is_empty());
375 }
376
377 #[test]
378 fn detect_duplicated_scaffold_ignores_inline_marker_mentions() {
379 let doc = concat!(
382 "<!-- agent:exchange -->\n",
383 "### Re: x — opus-4-8\n",
384 "The close marker is written `<!-- /agent:exchange -->` inline here.\n",
385 "<!-- /agent:exchange -->\n",
386 );
387 assert!(
388 detect_duplicated_scaffold(doc).is_empty(),
389 "inline mention must not count as a structural duplicate"
390 );
391 }
392
393 #[test]
394 fn summarize_is_compact_and_truncates() {
395 let findings = vec![
396 IpcCorruptionFinding {
397 kind: IpcCorruptionKind::ResponseDeleted,
398 heading: "### Re: a".to_string(),
399 prior_count: 1,
400 current_count: 0,
401 },
402 IpcCorruptionFinding {
403 kind: IpcCorruptionKind::ResponseDuplicated,
404 heading: "### Re: b".to_string(),
405 prior_count: 1,
406 current_count: 3,
407 },
408 ];
409 let s = summarize_findings(&findings);
410 assert!(s.contains("deleted=1"), "{s}");
411 assert!(s.contains("duplicated=1"), "{s}");
412 assert!(s.contains("response_deleted(### Re: a:1->0)"), "{s}");
413 assert!(s.contains("response_duplicated(### Re: b:1->3)"), "{s}");
414 assert!(!s.contains('\n'), "summary must be single-line: {s}");
415 }
416
417 #[test]
418 fn summarize_findings_truncates_multibyte_heading_without_panic() {
419 let heading = format!("### Re: {}— opus-4-8", "x".repeat(71));
426 assert!(heading.len() > 80);
427 assert!(!heading.is_char_boundary(80));
428 let findings = vec![IpcCorruptionFinding {
429 kind: IpcCorruptionKind::ResponseDeleted,
430 heading,
431 prior_count: 1,
432 current_count: 0,
433 }];
434 let s = summarize_findings(&findings);
435 assert!(s.contains("deleted=1"), "{s}");
436 assert!(!s.contains('\n'), "summary must be single-line: {s}");
437 }
438}