1use anyhow::{Context, Result};
83use serde::Serialize;
84use std::collections::HashSet;
85
86use crate::component::{self, Component, find_comment_end, is_backlog_component};
87
88#[derive(Debug, Clone)]
90pub struct PatchBlock {
91 pub name: String,
92 pub content: String,
93 #[allow(dead_code)]
95 pub attrs: std::collections::HashMap<String, String>,
96}
97
98impl PatchBlock {
99 pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
101 PatchBlock {
102 name: name.into(),
103 content: content.into(),
104 attrs: std::collections::HashMap::new(),
105 }
106 }
107}
108
109#[derive(Debug, Serialize)]
111pub struct TemplateInfo {
112 pub template_mode: bool,
113 pub components: Vec<ComponentInfo>,
114}
115
116#[derive(Debug, Serialize)]
118pub struct ComponentInfo {
119 pub name: String,
120 pub mode: String,
121 pub content: String,
122 pub line: usize,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub max_entries: Option<usize>,
125}
126
127#[cfg(test)]
129pub fn is_template_mode(mode: Option<&str>) -> bool {
130 matches!(mode, Some("template"))
131}
132
133pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
148 let bytes = response.as_bytes();
149 let len = bytes.len();
150 let code_ranges = component::find_code_ranges(response);
151 let mut patches = Vec::new();
152 let mut unmatched = String::new();
153 let mut pos = 0;
154 let mut last_end = 0;
155
156 while pos + 4 <= len {
157 if &bytes[pos..pos + 4] != b"<!--" {
158 pos += 1;
159 continue;
160 }
161
162 if code_ranges
164 .iter()
165 .any(|&(start, end)| pos >= start && pos < end)
166 {
167 pos += 4;
168 continue;
169 }
170
171 let marker_start = pos;
172
173 let close = match find_comment_end(bytes, pos + 4) {
175 Some(c) => c,
176 None => {
177 pos += 4;
178 continue;
179 }
180 };
181
182 let inner = &response[marker_start + 4..close - 3];
183 let trimmed = inner.trim();
184
185 let parsed_prefix: Option<(&str, &str)> = if let Some(rest) = trimmed.strip_prefix("patch:")
190 {
191 Some(("patch", rest))
192 } else if let Some(rest) = trimmed.strip_prefix("replace:") {
193 let rest_trim = rest.trim_start();
197 let name_end = rest_trim
198 .find(|c: char| c.is_whitespace())
199 .unwrap_or(rest_trim.len());
200 let name = &rest_trim[..name_end];
201 if is_backlog_component(name)
202 || name == component::ICEBOX_COMPONENT
203 || name == component::REVIEW_COMPONENT
204 {
205 Some(("replace", rest))
206 } else {
207 None
208 }
209 } else {
210 None
211 };
212
213 if let Some((prefix_kind, rest)) = parsed_prefix {
214 let rest = rest.trim();
215 if rest.is_empty() || rest.starts_with('/') {
216 pos = close;
217 continue;
218 }
219
220 let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
222 let name = &rest[..space_idx];
223 let attr_text = rest[space_idx..].trim();
224 (name, component::parse_attrs(attr_text))
225 } else {
226 (rest, std::collections::HashMap::new())
227 };
228
229 if prefix_kind == "patch" && is_backlog_component(name) {
232 eprintln!(
233 "warning: `<!-- patch:{} -->` is deprecated — use `<!-- replace:{} -->` instead (see #25ag)",
234 name, name
235 );
236 }
237
238 let mut content_start = close;
240 if content_start < len && bytes[content_start] == b'\n' {
241 content_start += 1;
242 }
243
244 let before = &response[last_end..marker_start];
246 let trimmed_before = before.trim();
247 if !trimmed_before.is_empty() {
248 if !unmatched.is_empty() {
249 unmatched.push('\n');
250 }
251 unmatched.push_str(trimmed_before);
252 }
253
254 let close_marker = format!("<!-- /{}:{} -->", prefix_kind, name);
257 if let Some(close_pos) =
258 find_outside_code(&close_marker, response, content_start, &code_ranges)
259 {
260 let content = &response[content_start..close_pos];
261 patches.push(PatchBlock {
262 name: name.to_string(),
263 content: content.to_string(),
264 attrs,
265 });
266
267 let mut end = close_pos + close_marker.len();
268 if end < len && bytes[end] == b'\n' {
269 end += 1;
270 }
271 last_end = end;
272 pos = end;
273 continue;
274 }
275
276 let mut orphan_end = close;
279 if orphan_end < len && bytes[orphan_end] == b'\n' {
280 orphan_end += 1;
281 }
282 last_end = orphan_end;
283 pos = orphan_end;
284 continue;
285 }
286
287 pos = close;
288 }
289
290 if last_end < len {
292 let trailing = response[last_end..].trim();
293 if !trailing.is_empty() {
294 if !unmatched.is_empty() {
295 unmatched.push('\n');
296 }
297 unmatched.push_str(trailing);
298 }
299 }
300
301 Ok((patches, unmatched))
302}
303
304pub fn apply_patches_pure(
313 doc: &str,
314 patches: &[PatchBlock],
315 unmatched: &str,
316 summary: Option<&str>,
317 component_configs: &std::collections::HashMap<String, String>,
318 max_lines_configs: &std::collections::HashMap<String, usize>,
319) -> Result<String> {
320 apply_patches_with_overrides_pure(
321 doc,
322 patches,
323 unmatched,
324 summary,
325 component_configs,
326 max_lines_configs,
327 &std::collections::HashMap::new(),
328 )
329}
330
331fn is_exchange_turn_heading(trimmed: &str) -> bool {
332 trimmed == "## User"
333 || trimmed == "## Assistant"
334 || trimmed.starts_with("### Re:")
335 || trimmed.starts_with("#### Re:")
336 || trimmed.starts_with("## Re:")
337}
338
339fn preamble_belongs_to_exchange(preamble: &str) -> bool {
340 let mut saw_nonempty = false;
341 for line in preamble.lines() {
342 let trimmed = line.trim();
343 if trimmed.is_empty() {
344 continue;
345 }
346 saw_nonempty = true;
347 if trimmed.starts_with("[//]:")
348 || trimmed.starts_with("<!--")
349 || trimmed.starts_with('#')
350 || trimmed.starts_with('-')
351 || trimmed.starts_with('*')
352 || trimmed.starts_with('>')
353 {
354 return false;
355 }
356 }
357 saw_nonempty
358}
359
360fn is_non_exchange_section_heading(trimmed: &str) -> bool {
361 let hashes = trimmed.chars().take_while(|&ch| ch == '#').count();
362 if hashes == 0 || hashes > 3 || is_exchange_turn_heading(trimmed) {
363 return false;
364 }
365 let rest = &trimmed[hashes..];
366 rest.is_empty() || rest.starts_with(' ')
367}
368
369fn conversation_tail_start_in_range(
370 doc: &str,
371 search_start: usize,
372 search_end: usize,
373) -> Option<usize> {
374 let code_ranges = component::find_code_ranges(doc);
375 let comment_ranges = component::find_non_agent_html_comment_ranges(doc);
376 let in_ignored_range = |pos: usize| {
377 code_ranges
378 .iter()
379 .chain(comment_ranges.iter())
380 .any(|&(start, end)| pos >= start && pos < end)
381 };
382
383 let mut first_nonempty_after = None;
384 let mut first_heading_start = None;
385 let mut offset = 0usize;
386 for line in doc.split_inclusive('\n') {
387 let line_start = offset;
388 offset += line.len();
389 if line_start < search_start || in_ignored_range(line_start) {
390 continue;
391 }
392 if line_start >= search_end {
393 break;
394 }
395 let trimmed = line.trim();
396 if trimmed.is_empty() {
397 continue;
398 }
399 first_nonempty_after.get_or_insert(line_start);
400 if is_exchange_turn_heading(trimmed) {
401 first_heading_start = Some(line_start);
402 break;
403 }
404 }
405
406 let heading_start = first_heading_start?;
407 let first_nonempty_after = first_nonempty_after.unwrap_or(heading_start);
408 if first_nonempty_after < heading_start
409 && preamble_belongs_to_exchange(&doc[first_nonempty_after..heading_start])
410 {
411 Some(first_nonempty_after)
412 } else {
413 Some(heading_start)
414 }
415}
416
417fn prompt_tail_range_in_region(
418 doc: &str,
419 search_start: usize,
420 search_end: usize,
421) -> Option<(usize, usize)> {
422 let code_ranges = component::find_code_ranges(doc);
423 let comment_ranges = component::find_non_agent_html_comment_ranges(doc);
424 let in_ignored_range = |pos: usize| {
425 code_ranges
426 .iter()
427 .chain(comment_ranges.iter())
428 .any(|&(start, end)| pos >= start && pos < end)
429 };
430
431 let mut prompt_start = None;
432 let mut prompt_end = None;
433 let mut offset = 0usize;
434
435 for line in doc.split_inclusive('\n') {
436 let line_start = offset;
437 offset += line.len();
438 if line_start < search_start || in_ignored_range(line_start) {
439 continue;
440 }
441 if line_start >= search_end {
442 break;
443 }
444
445 let trimmed = line.trim();
446 if prompt_start.is_none() {
447 if trimmed.is_empty()
448 || trimmed == "###"
449 || trimmed.starts_with("<!--")
450 || trimmed.starts_with("[//]:")
451 || is_non_exchange_section_heading(trimmed)
452 {
453 continue;
454 }
455 if text_line_looks_like_prompt_target(trimmed) {
456 prompt_start = Some(line_start);
457 prompt_end = Some(offset);
458 continue;
459 }
460 return None;
461 }
462
463 if trimmed.starts_with("<!--")
464 || trimmed.starts_with("[//]:")
465 || trimmed == "###"
466 || is_non_exchange_section_heading(trimmed)
467 {
468 break;
469 }
470 prompt_end = Some(offset);
471 }
472
473 match (prompt_start, prompt_end) {
474 (Some(start), Some(end)) if start < end => Some((start, end)),
475 _ => None,
476 }
477}
478
479fn escaped_prompt_range_outside_exchange(
480 doc: &str,
481 components: &[component::Component],
482 exchange: &component::Component,
483) -> Option<(usize, usize)> {
484 let mut trailing_components: Vec<&component::Component> = components
485 .iter()
486 .filter(|c| c.open_start >= exchange.close_end)
487 .collect();
488 trailing_components.sort_by_key(|c| c.open_start);
489
490 let mut search_start = exchange.close_end;
491 for component in trailing_components {
492 if search_start < component.open_start
493 && let Some(range) =
494 prompt_tail_range_in_region(doc, search_start, component.open_start)
495 {
496 return Some(range);
497 }
498 search_start = search_start.max(component.close_end);
499 }
500
501 if search_start < doc.len()
502 && let Some(range) = prompt_tail_range_in_region(doc, search_start, doc.len())
503 {
504 return Some(range);
505 }
506
507 None
508}
509
510fn escaped_conversation_range_outside_exchange(
511 doc: &str,
512 components: &[component::Component],
513 exchange: &component::Component,
514) -> Option<(usize, usize)> {
515 if let Some(range) = escaped_prompt_range_outside_exchange(doc, components, exchange) {
516 return Some(range);
517 }
518
519 let mut trailing_components: Vec<&component::Component> = components
520 .iter()
521 .filter(|c| c.open_start >= exchange.close_end)
522 .collect();
523 trailing_components.sort_by_key(|c| c.open_start);
524
525 let mut search_start = exchange.close_end;
526 for component in trailing_components {
527 if search_start < component.open_start
528 && let Some(start) =
529 conversation_tail_start_in_range(doc, search_start, component.open_start)
530 {
531 return Some((start, component.open_start));
532 }
533 search_start = search_start.max(component.close_end);
534 }
535
536 if search_start < doc.len() {
537 if let Some(range) = prompt_tail_range_in_region(doc, search_start, doc.len()) {
538 return Some(range);
539 }
540 if let Some(start) = conversation_tail_start_in_range(doc, search_start, doc.len()) {
541 return Some((start, doc.len()));
542 }
543 }
544
545 None
546}
547
548fn tail_is_safe_exchange_content(tail: &str) -> bool {
549 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
550 let fc = trimmed.chars().next()?;
551 if fc != '`' && fc != '~' {
552 return None;
553 }
554 let fl = trimmed.chars().take_while(|&c| c == fc).count();
555 if fl >= 3 { Some((fc, fl)) } else { None }
556 }
557
558 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
559 let fc = trimmed.chars().next().unwrap_or('\0');
560 if fc != fence_char {
561 return false;
562 }
563 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
564 fl >= fence_len && trimmed[fl..].trim().is_empty()
565 }
566
567 let mut in_fence = false;
568 let mut fence_char = '`';
569 let mut fence_len = 3usize;
570
571 for line in tail.lines() {
572 let trimmed = line.trim();
573 if trimmed.starts_with("<!-- agent:")
574 || trimmed.starts_with("<!-- /agent:")
575 || trimmed.starts_with("<!-- patch:")
576 || trimmed.starts_with("<!-- /patch:")
577 {
578 return false;
579 }
580 if !in_fence {
581 if let Some((fc, fl)) = fence_open(trimmed) {
582 in_fence = true;
583 fence_char = fc;
584 fence_len = fl;
585 continue;
586 }
587 } else {
588 if fence_close(trimmed, fence_char, fence_len) {
589 in_fence = false;
590 }
591 continue;
592 }
593
594 if trimmed.is_empty() || is_exchange_turn_heading(trimmed) {
595 continue;
596 }
597
598 let hashes = trimmed.chars().take_while(|&c| c == '#').count();
599 if hashes > 0 {
600 if (4..=6).contains(&hashes) && trimmed.as_bytes().get(hashes) == Some(&b' ') {
601 continue;
602 }
603 return false;
604 }
605 }
606
607 !in_fence
608}
609
610pub fn guard_no_conversation_tail_outside_exchange(doc: &str) -> Result<()> {
615 let components = component::parse(doc).context("failed to parse components")?;
616 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
617 return Ok(());
618 };
619
620 let Some((tail_start, _tail_end)) =
621 escaped_conversation_range_outside_exchange(doc, &components, exchange)
622 else {
623 return Ok(());
624 };
625
626 let tail = &doc[tail_start..];
627 let first_line = tail.lines().next().unwrap_or("(empty)");
628 anyhow::bail!(
629 "prompt/response content would land outside `<!-- agent:exchange -->` — \
630 the write path cannot place conversation content after the exchange close tag. \
631 First escaped line: `{}`",
632 first_line.chars().take(120).collect::<String>()
633 );
634}
635
636pub fn repair_conversation_tail_outside_exchange(doc: &str) -> Result<Option<String>> {
640 let components = component::parse(doc).context("failed to parse components")?;
641 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
642 return Ok(None);
643 };
644
645 let Some((tail_start, tail_end)) =
646 escaped_conversation_range_outside_exchange(doc, &components, exchange)
647 else {
648 return Ok(None);
649 };
650
651 let tail = &doc[tail_start..tail_end];
652 if !tail_is_safe_exchange_content(tail) {
653 anyhow::bail!(
654 "conversation content escaped `agent:exchange`, but the trailing document structure is ambiguous"
655 );
656 }
657
658 let prefix = &doc[..tail_start];
659 let suffix = &doc[tail_end..];
660 let prefix_components = component::parse(prefix).context("failed to parse repair prefix")?;
661 let exchange = prefix_components
662 .iter()
663 .find(|c| c.name == "exchange")
664 .context("exchange component disappeared during repair")?;
665 let escaped = tail.trim();
666 if escaped.is_empty() {
667 return Ok(None);
668 }
669
670 let mut repaired = if let Some(boundary_id) = find_boundary_in_component(prefix, exchange) {
671 exchange.append_with_boundary(prefix, escaped, &boundary_id)
672 } else {
673 let new_content = if exchange.content(prefix).trim().is_empty() {
674 format!("{escaped}\n")
675 } else {
676 format!("{}\n{}\n", exchange.content(prefix).trim_end(), escaped)
677 };
678 exchange.replace_content(prefix, &new_content)
679 };
680
681 repaired.push_str(suffix);
682 Ok(Some(repaired))
683}
684
685pub fn repair_prompt_tail_outside_exchange(doc: &str) -> Result<Option<String>> {
688 let components = component::parse(doc).context("failed to parse components")?;
689 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
690 return Ok(None);
691 };
692
693 let Some((tail_start, tail_end)) =
694 escaped_prompt_range_outside_exchange(doc, &components, exchange)
695 else {
696 return Ok(None);
697 };
698
699 let tail = &doc[tail_start..tail_end];
700 if !tail_is_safe_exchange_content(tail) {
701 anyhow::bail!(
702 "prompt content escaped `agent:exchange`, but the trailing document structure is ambiguous"
703 );
704 }
705
706 let prefix = &doc[..tail_start];
707 let suffix = &doc[tail_end..];
708 let prefix_components = component::parse(prefix).context("failed to parse repair prefix")?;
709 let exchange = prefix_components
710 .iter()
711 .find(|c| c.name == "exchange")
712 .context("exchange component disappeared during prompt repair")?;
713 let escaped = tail.trim();
714 if escaped.is_empty() {
715 return Ok(None);
716 }
717
718 let mut repaired = if let Some(boundary_id) = find_boundary_in_component(prefix, exchange) {
719 exchange.append_with_boundary(prefix, escaped, &boundary_id)
720 } else {
721 let new_content = if exchange.content(prefix).trim().is_empty() {
722 format!("{escaped}\n")
723 } else {
724 format!("{}\n{}\n", exchange.content(prefix).trim_end(), escaped)
725 };
726 exchange.replace_content(prefix, &new_content)
727 };
728
729 repaired.push_str(suffix);
730 Ok(Some(repaired))
731}
732
733pub fn repair_duplicate_exchange_close_tail(doc: &str) -> Result<Option<String>> {
741 let open_tag = "<!-- agent:exchange";
742 let close_tag = "<!-- /agent:exchange -->";
743
744 let Some(open_start) = doc.find(open_tag) else {
745 return Ok(None);
746 };
747 let Some(open_end) = doc[open_start..]
748 .find("-->")
749 .map(|idx| open_start + idx + 3)
750 else {
751 return Ok(None);
752 };
753 let Some(first_close_start) = doc[open_end..].find(close_tag).map(|idx| open_end + idx) else {
754 return Ok(None);
755 };
756 let first_close_end = first_close_start + close_tag.len();
757 let Some(second_close_start) = doc[first_close_end..]
758 .find(close_tag)
759 .map(|idx| first_close_end + idx)
760 else {
761 return Ok(None);
762 };
763 let second_close_end = second_close_start + close_tag.len();
764
765 let escaped = &doc[first_close_end..second_close_start];
766 if !escaped.trim().is_empty() && !tail_is_safe_exchange_content(escaped) {
767 anyhow::bail!(
768 "conversation content escaped `agent:exchange`, but the duplicate close repair suffix is ambiguous"
769 );
770 }
771
772 let prefix = &doc[..first_close_end];
773 let prefix_components = component::parse(prefix).context("failed to parse repair prefix")?;
774 let exchange = prefix_components
775 .iter()
776 .find(|c| c.name == "exchange")
777 .context("exchange component disappeared during duplicate-close repair")?;
778
779 let mut repaired = if escaped.trim().is_empty() {
780 prefix.to_string()
781 } else if let Some(boundary_id) = find_boundary_in_component(prefix, exchange) {
782 exchange.append_with_boundary(prefix, escaped.trim(), &boundary_id)
783 } else {
784 let new_content = if exchange.content(prefix).trim().is_empty() {
785 format!("{}\n", escaped.trim())
786 } else {
787 format!(
788 "{}\n{}\n",
789 exchange.content(prefix).trim_end(),
790 escaped.trim()
791 )
792 };
793 exchange.replace_content(prefix, &new_content)
794 };
795
796 repaired.push_str(&doc[second_close_end..]);
797 Ok(Some(repaired))
798}
799
800pub fn repair_duplicate_exchange_close_scaffold(doc: &str) -> Result<Option<String>> {
809 let open_tag = "<!-- agent:exchange";
810 let close_tag = "<!-- /agent:exchange -->";
811
812 let Some(open_start) = doc.find(open_tag) else {
813 return Ok(None);
814 };
815 let Some(open_end) = doc[open_start..]
816 .find("-->")
817 .map(|idx| open_start + idx + 3)
818 else {
819 return Ok(None);
820 };
821 let Some(first_close_start) = doc[open_end..].find(close_tag).map(|idx| open_end + idx) else {
822 return Ok(None);
823 };
824 let first_close_end = first_close_start + close_tag.len();
825 let Some(second_close_start) = doc[first_close_end..]
826 .find(close_tag)
827 .map(|idx| first_close_end + idx)
828 else {
829 return Ok(None);
830 };
831 let second_close_end = second_close_start + close_tag.len();
832
833 let duplicate_scaffold = &doc[first_close_end..second_close_start];
834 if !is_safe_duplicate_template_scaffold(duplicate_scaffold) {
835 return Ok(None);
836 }
837
838 let mut repaired = String::with_capacity(doc.len() - (second_close_end - first_close_end));
839 repaired.push_str(&doc[..first_close_end]);
840 repaired.push_str(&doc[second_close_end..]);
841 Ok(Some(repaired))
842}
843
844pub fn repair_duplicate_exchange_close_mixed_scaffold_tail(doc: &str) -> Result<Option<String>> {
852 let open_tag = "<!-- agent:exchange";
853 let close_tag = "<!-- /agent:exchange -->";
854
855 let Some(open_start) = doc.find(open_tag) else {
856 return Ok(None);
857 };
858 let Some(open_end) = doc[open_start..]
859 .find("-->")
860 .map(|idx| open_start + idx + 3)
861 else {
862 return Ok(None);
863 };
864 let Some(first_close_start) = doc[open_end..].find(close_tag).map(|idx| open_end + idx) else {
865 return Ok(None);
866 };
867 let first_close_end = first_close_start + close_tag.len();
868 let Some(second_close_start) = doc[first_close_end..]
869 .find(close_tag)
870 .map(|idx| first_close_end + idx)
871 else {
872 return Ok(None);
873 };
874 let second_close_end = second_close_start + close_tag.len();
875
876 let duplicate_segment = &doc[first_close_end..second_close_start];
877 let Some(exchange_tail) = safe_exchange_tail_from_duplicate_scaffold(duplicate_segment)? else {
878 return Ok(None);
879 };
880
881 let prefix = &doc[..first_close_end];
882 let mut repaired = append_tail_to_exchange_end(prefix, &exchange_tail)?;
883 repaired.push_str(&doc[second_close_end..]);
884 Ok(Some(repaired))
885}
886
887fn safe_exchange_tail_from_duplicate_scaffold(segment: &str) -> Result<Option<String>> {
888 let has_scaffold_component = segment.contains("<!-- agent:queue -->")
889 || segment.contains("<!-- agent:backlog")
890 || segment.contains("<!-- agent:pending")
891 || segment.contains("<!-- agent:done");
892 if !has_scaffold_component {
893 return Ok(None);
894 }
895
896 let mut residue = segment.to_string();
897 if let Ok(components) = component::parse(segment) {
898 let mut ranges: Vec<(usize, usize)> = components
899 .iter()
900 .filter(|component| {
901 matches!(
902 component.name.as_str(),
903 "queue" | "backlog" | "pending" | "icebox" | "done"
904 )
905 })
906 .map(|component| (component.open_start, component.close_end))
907 .collect();
908 ranges.sort_by_key(|range| std::cmp::Reverse(range.0));
909 for (start, end) in ranges {
910 residue.replace_range(start..end, "");
911 }
912 }
913
914 let residue = strip_html_comments(&residue);
915 let tail = residue
916 .lines()
917 .filter_map(|line| {
918 let trimmed = line.trim();
919 if trimmed.is_empty() || trimmed.starts_with('#') {
920 None
921 } else {
922 Some(line.trim_end())
923 }
924 })
925 .collect::<Vec<_>>()
926 .join("\n");
927 let tail = tail.trim();
928 if tail.is_empty() {
929 return Ok(None);
930 }
931 if !tail_is_safe_exchange_content(tail) {
932 anyhow::bail!(
933 "conversation content escaped `agent:exchange`, but the duplicate scaffold repair suffix is ambiguous"
934 );
935 }
936 Ok(Some(tail.to_string()))
937}
938
939fn append_tail_to_exchange_end(prefix: &str, tail: &str) -> Result<String> {
940 let prefix_without_boundaries = remove_all_boundaries(prefix);
941 let components =
942 component::parse(&prefix_without_boundaries).context("failed to parse repair prefix")?;
943 let exchange = components
944 .iter()
945 .find(|c| c.name == "exchange")
946 .context("exchange component disappeared during mixed duplicate-scaffold repair")?;
947
948 let existing = exchange.content(&prefix_without_boundaries).trim_end();
949 let mut new_content = String::new();
950 if !existing.is_empty() {
951 new_content.push_str(existing);
952 new_content.push('\n');
953 }
954 new_content.push_str(tail.trim_end());
955 new_content.push('\n');
956 new_content.push_str(&crate::id::format_boundary_marker(&crate::id::new_boundary_id()));
957 new_content.push('\n');
958 Ok(exchange.replace_content(&prefix_without_boundaries, &new_content))
959}
960
961pub fn normalize_editor_visible_template_structure(doc: &str) -> Result<String> {
969 let mut normalized = crate::component::strip_backlog_patch_attr(doc);
970 while let Some(merged) = repair_duplicate_exchange_opener(&normalized)? {
971 normalized = merged;
972 }
973 if let Some(cleaned) = remove_duplicate_answered_exchange_prompt_tail(&normalized) {
974 normalized = cleaned;
975 }
976 guard_no_duplicate_prompt_residue_outside_exchange(&normalized)
977 .context("template duplicate prompt residue guard failed")?;
978
979 match guard_no_conversation_tail_outside_exchange(&normalized) {
980 Ok(()) => Ok(normalized),
981 Err(err)
982 if err.chain().any(|cause| {
983 cause
984 .to_string()
985 .contains("closing marker <!-- /agent:exchange --> without matching open")
986 }) =>
987 {
988 if let Some(repaired) = repair_duplicate_exchange_close_scaffold(&normalized)? {
989 guard_no_duplicate_prompt_residue_outside_exchange(&repaired)
990 .context("template duplicate prompt residue guard failed after duplicate-scaffold repair")?;
991 guard_no_conversation_tail_outside_exchange(&repaired).with_context(
992 || "template structure guard failed after duplicate-scaffold repair",
993 )?;
994 return Ok(repaired);
995 }
996 if repair_duplicate_exchange_close_mixed_scaffold_tail(&normalized)?.is_some() {
997 anyhow::bail!(
998 "mixed duplicate scaffold tail: live conversation text is interleaved with duplicated template scaffold; refusing automatic visible-document repair"
999 );
1000 }
1001 if let Some(repaired) = repair_duplicate_exchange_close_tail(&normalized)? {
1002 guard_no_duplicate_prompt_residue_outside_exchange(&repaired).context(
1003 "template duplicate prompt residue guard failed after duplicate-close repair",
1004 )?;
1005 guard_no_conversation_tail_outside_exchange(&repaired).with_context(
1006 || "template structure guard failed after duplicate-close repair",
1007 )?;
1008 return Ok(repaired);
1009 }
1010 Err(err).context("template structure guard failed")
1011 }
1012 Err(err) => Err(err).context("template structure guard failed"),
1013 }
1014}
1015
1016pub fn remove_post_exchange_duplicate_prompt_comments(doc: &str) -> Option<String> {
1022 remove_post_exchange_duplicate_prompt_comments_preserving_docs(doc, &[])
1023}
1024
1025pub fn remove_post_exchange_duplicate_prompt_comments_preserving(
1032 doc: &str,
1033 preserve_doc: Option<&str>,
1034) -> Option<String> {
1035 match preserve_doc {
1036 Some(preserve_doc) => {
1037 remove_post_exchange_duplicate_prompt_comments_preserving_docs(doc, &[preserve_doc])
1038 }
1039 None => remove_post_exchange_duplicate_prompt_comments_preserving_docs(doc, &[]),
1040 }
1041}
1042
1043pub fn remove_post_exchange_duplicate_prompt_comments_preserving_docs(
1047 doc: &str,
1048 preserve_docs: &[&str],
1049) -> Option<String> {
1050 let components = component::parse(doc).ok()?;
1051 let exchange = components
1052 .iter()
1053 .find(|component| component.name == "exchange")?;
1054 let prompts = exchange_prompt_comment_targets(exchange.content(doc));
1055 if prompts.is_empty() {
1056 return None;
1057 }
1058
1059 let protected_ranges = components
1060 .iter()
1061 .filter(|component| component.name != "exchange")
1062 .map(|component| (component.open_start, component.close_end))
1063 .collect::<Vec<_>>();
1064 let mut preserved_comment_lines = HashSet::new();
1065 for preserve_doc in preserve_docs {
1066 preserved_comment_lines.extend(post_exchange_comment_line_preserve_set(preserve_doc));
1067 }
1068
1069 let mut replacements = Vec::<(usize, usize, String)>::new();
1070 for (start, end) in component::find_non_agent_html_comment_ranges(doc) {
1071 if start < exchange.close_end {
1072 continue;
1073 }
1074 if protected_ranges
1075 .iter()
1076 .any(|(protected_start, protected_end)| {
1077 start >= *protected_start && end <= *protected_end
1078 })
1079 {
1080 continue;
1081 }
1082 let original = &doc[start..end];
1083 if !original.ends_with("-->") {
1084 continue;
1085 }
1086 let body = &doc[start + 4..end - 3];
1087 let Some(cleaned_body) =
1088 strip_duplicate_prompt_comment_body(body, &prompts, &preserved_comment_lines)
1089 else {
1090 replacements.push((start, end, empty_html_comment_like(body)));
1091 continue;
1092 };
1093 if cleaned_body != body {
1094 replacements.push((start, end, format!("<!--{}-->", cleaned_body)));
1095 }
1096 }
1097
1098 if replacements.is_empty() {
1099 return None;
1100 }
1101
1102 let mut cleaned = doc.to_string();
1103 for (start, end, replacement) in replacements.into_iter().rev() {
1104 cleaned.replace_range(start..end, &replacement);
1105 }
1106 Some(cleaned)
1107}
1108
1109fn post_exchange_comment_line_preserve_set(doc: &str) -> HashSet<String> {
1110 let Ok(components) = component::parse(doc) else {
1111 return HashSet::new();
1112 };
1113 let Some(exchange) = components
1114 .iter()
1115 .find(|component| component.name == "exchange")
1116 else {
1117 return HashSet::new();
1118 };
1119 let protected_ranges = components
1120 .iter()
1121 .filter(|component| component.name != "exchange")
1122 .map(|component| (component.open_start, component.close_end))
1123 .collect::<Vec<_>>();
1124
1125 let mut preserved = HashSet::new();
1126 for (start, end) in component::find_non_agent_html_comment_ranges(doc) {
1127 if start < exchange.close_end {
1128 continue;
1129 }
1130 if protected_ranges
1131 .iter()
1132 .any(|(protected_start, protected_end)| {
1133 start >= *protected_start && end <= *protected_end
1134 })
1135 {
1136 continue;
1137 }
1138 let original = &doc[start..end];
1139 if !original.ends_with("-->") {
1140 continue;
1141 }
1142 let body = &doc[start + 4..end - 3];
1143 for line in comment_body_lines(body) {
1144 if let Some(normalized) = normalize_prompt_comment_text(line) {
1145 preserved.insert(normalized);
1146 }
1147 }
1148 }
1149 preserved
1150}
1151
1152pub fn remove_duplicate_answered_exchange_prompt_tail(doc: &str) -> Option<String> {
1160 let components = component::parse(doc).ok()?;
1161 let exchange = components
1162 .iter()
1163 .find(|component| component.name == "exchange")?;
1164 let exchange_content = exchange.content(doc);
1165 let duplicate_start = duplicate_answered_exchange_prompt_tail_start(exchange_content)?;
1166
1167 let mut cleaned_exchange = exchange_content[..duplicate_start].to_string();
1168 if !cleaned_exchange.ends_with('\n') {
1169 cleaned_exchange.push('\n');
1170 }
1171 Some(exchange.replace_content(doc, &cleaned_exchange))
1172}
1173
1174fn duplicate_answered_exchange_prompt_tail_start(exchange: &str) -> Option<usize> {
1175 let lines = exchange_line_spans(exchange);
1176 let boundary_idx = lines
1177 .iter()
1178 .rposition(|(_, _, line)| line.trim().starts_with("<!-- agent:boundary:"))?;
1179 let tail_start = lines
1180 .get(boundary_idx)
1181 .map(|(_, end, _)| *end)
1182 .unwrap_or(exchange.len());
1183 let tail = duplicate_exchange_tail_prompt_lines(&lines[boundary_idx + 1..])?;
1184 if answered_exchange_prompt_blocks_before_boundary(&lines, boundary_idx)
1185 .into_iter()
1186 .any(|block| block == tail)
1187 {
1188 Some(tail_start)
1189 } else {
1190 None
1191 }
1192}
1193
1194fn exchange_line_spans(text: &str) -> Vec<(usize, usize, &str)> {
1195 let mut spans = Vec::new();
1196 let mut offset = 0usize;
1197 for segment in text.split_inclusive('\n') {
1198 let start = offset;
1199 offset += segment.len();
1200 spans.push((start, offset, segment));
1201 }
1202 if spans.is_empty() && !text.is_empty() {
1203 spans.push((0, text.len(), text));
1204 }
1205 spans
1206}
1207
1208fn duplicate_exchange_tail_prompt_lines(lines: &[(usize, usize, &str)]) -> Option<Vec<String>> {
1209 let mut prompt_lines = Vec::new();
1210 for (_, _, line) in lines {
1211 let trimmed = line.trim();
1212 if trimmed.is_empty() || trimmed.starts_with("<!--") {
1213 continue;
1214 }
1215 if is_exchange_turn_heading(trimmed) {
1216 return None;
1217 }
1218 prompt_lines.push(normalize_duplicate_exchange_prompt_line(trimmed)?);
1219 }
1220 if prompt_lines.is_empty() {
1221 None
1222 } else {
1223 Some(prompt_lines)
1224 }
1225}
1226
1227fn answered_exchange_prompt_blocks_before_boundary(
1228 lines: &[(usize, usize, &str)],
1229 boundary_idx: usize,
1230) -> Vec<Vec<String>> {
1231 let mut blocks = Vec::new();
1232 for response_idx in 0..boundary_idx {
1233 if !is_exchange_turn_heading(lines[response_idx].2.trim()) {
1234 continue;
1235 }
1236 let mut block = Vec::new();
1237 let mut cursor = response_idx;
1238 while cursor > 0 {
1239 cursor -= 1;
1240 let trimmed = lines[cursor].2.trim();
1241 if trimmed.is_empty() || trimmed.starts_with("<!--") {
1242 if block.is_empty() {
1243 continue;
1244 }
1245 break;
1246 }
1247 if trimmed.starts_with('❯')
1248 && let Some(normalized) = normalize_duplicate_exchange_prompt_line(trimmed)
1249 {
1250 block.push(normalized);
1251 continue;
1252 }
1253 if block.is_empty()
1254 && looks_like_prompt_comment_target(trimmed)
1255 && let Some(normalized) = normalize_duplicate_exchange_prompt_line(trimmed)
1256 {
1257 block.push(normalized);
1258 continue;
1259 }
1260 break;
1261 }
1262 if !block.is_empty() {
1263 block.reverse();
1264 blocks.push(block);
1265 }
1266 }
1267 blocks
1268}
1269
1270fn normalize_duplicate_exchange_prompt_line(line: &str) -> Option<String> {
1271 let unprefixed = line.trim().strip_prefix('❯').unwrap_or(line.trim()).trim();
1272 let normalized = unprefixed.split_whitespace().collect::<Vec<_>>().join(" ");
1273 if normalized.is_empty() {
1274 None
1275 } else {
1276 Some(normalized)
1277 }
1278}
1279
1280pub fn guard_no_duplicate_prompt_residue_outside_exchange(doc: &str) -> Result<()> {
1288 let components = match component::parse(doc) {
1289 Ok(components) => components,
1290 Err(err)
1291 if err
1292 .chain()
1293 .any(|cause| cause.to_string().contains("without matching open")) =>
1294 {
1295 return Ok(());
1296 }
1297 Err(err) => return Err(err).context("failed to parse components"),
1298 };
1299 let Some(exchange) = components
1300 .iter()
1301 .find(|component| component.name == "exchange")
1302 else {
1303 return Ok(());
1304 };
1305 let prompts = exchange_prompt_comment_targets(exchange.content(doc));
1306 if prompts.is_empty() {
1307 return Ok(());
1308 }
1309
1310 let protected_ranges = components
1311 .iter()
1312 .filter(|component| component.name != "exchange")
1313 .map(|component| (component.open_start, component.close_end))
1314 .collect::<Vec<_>>();
1315 let in_protected_component = |pos: usize| {
1316 protected_ranges
1317 .iter()
1318 .any(|(start, end)| pos >= *start && pos < *end)
1319 };
1320 let ordinary_comment_ranges = component::find_non_agent_html_comment_ranges(doc);
1321 let in_ordinary_comment = |pos: usize| {
1322 ordinary_comment_ranges
1323 .iter()
1324 .any(|(start, end)| pos >= *start && pos < *end)
1325 };
1326
1327 let mut offset = 0usize;
1328 for segment in doc.split_inclusive('\n') {
1329 let line_start = offset;
1330 offset += segment.len();
1331 if line_start < exchange.close_end
1332 || in_protected_component(line_start)
1333 || in_ordinary_comment(line_start)
1334 {
1335 continue;
1336 }
1337 let trimmed = segment.trim();
1338 if !is_duplicate_prompt_comment_text(trimmed, &prompts) {
1339 continue;
1340 }
1341 anyhow::bail!(
1342 "duplicate prompt residue outside `<!-- agent:exchange -->`; refusing to commit or dispatch ambiguous Markdown tail. First duplicate line: `{}`",
1343 trimmed.chars().take(120).collect::<String>()
1344 );
1345 }
1346
1347 Ok(())
1348}
1349
1350fn empty_html_comment_like(body: &str) -> String {
1351 if body.contains('\n') {
1352 "<!--\n-->".to_string()
1353 } else {
1354 "<!-- -->".to_string()
1355 }
1356}
1357
1358fn strip_duplicate_prompt_comment_body(
1359 body: &str,
1360 prompts: &[String],
1361 preserved_comment_lines: &HashSet<String>,
1362) -> Option<String> {
1363 if !body.contains('\n') && is_duplicate_prompt_comment_text(body, prompts) {
1364 if normalized_comment_line_is_preserved(body, preserved_comment_lines) {
1365 return Some(body.to_string());
1366 }
1367 return None;
1368 }
1369
1370 let mut changed = false;
1371 let mut cleaned = String::new();
1372 for segment in body.split_inclusive('\n') {
1373 let line = segment.strip_suffix('\n').unwrap_or(segment);
1374 if is_duplicate_prompt_comment_text(line, prompts) {
1375 if normalized_comment_line_is_preserved(line, preserved_comment_lines) {
1376 cleaned.push_str(segment);
1377 continue;
1378 }
1379 changed = true;
1380 continue;
1381 }
1382 cleaned.push_str(segment);
1383 }
1384
1385 if !changed {
1386 return Some(body.to_string());
1387 }
1388 if cleaned.trim().is_empty() {
1389 None
1390 } else {
1391 Some(cleaned)
1392 }
1393}
1394
1395fn comment_body_lines(body: &str) -> Vec<&str> {
1396 if body.contains('\n') {
1397 body.split_inclusive('\n')
1398 .map(|segment| segment.strip_suffix('\n').unwrap_or(segment))
1399 .collect()
1400 } else {
1401 vec![body]
1402 }
1403}
1404
1405fn normalized_comment_line_is_preserved(
1406 line: &str,
1407 preserved_comment_lines: &HashSet<String>,
1408) -> bool {
1409 normalize_prompt_comment_text(line)
1410 .map(|normalized| preserved_comment_lines.contains(&normalized))
1411 .unwrap_or(false)
1412}
1413
1414fn exchange_prompt_comment_targets(exchange: &str) -> Vec<String> {
1415 let mut targets = Vec::new();
1416 let mut seen = HashSet::new();
1417 let mut in_response_block = false;
1418 let mut in_fence = false;
1419
1420 for line in exchange.lines() {
1421 let trimmed = line.trim();
1422 if is_fence_delimiter(trimmed) {
1423 in_fence = !in_fence;
1424 continue;
1425 }
1426 if in_fence {
1427 continue;
1428 }
1429 if trimmed.starts_with("<!-- agent:boundary:") {
1430 in_response_block = false;
1431 continue;
1432 }
1433 if trimmed.starts_with("### Re:") || trimmed.starts_with("## Assistant") {
1434 in_response_block = true;
1435 continue;
1436 }
1437 if in_response_block {
1438 continue;
1439 }
1440 let Some(normalized) = normalize_prompt_comment_text(trimmed) else {
1441 continue;
1442 };
1443 if seen.insert(normalized.clone()) {
1444 targets.push(normalized);
1445 }
1446 }
1447
1448 targets
1449}
1450
1451fn looks_like_prompt_comment_target(text: &str) -> bool {
1452 let trimmed = text.trim();
1453 let lower = trimmed.to_lowercase();
1454 trimmed.starts_with('❯')
1455 || trimmed.contains('#')
1456 || trimmed.ends_with('?')
1457 || lower.starts_with("do ")
1458 || lower.starts_with("fix ")
1459 || lower.starts_with("run ")
1460 || lower.starts_with("please ")
1461 || lower.contains(" spec-test-")
1462 || lower.contains(" reproduce ")
1463}
1464
1465fn normalize_prompt_comment_text(text: &str) -> Option<String> {
1466 let trimmed = text.trim();
1467 if trimmed.is_empty()
1468 || trimmed.starts_with("<!--")
1469 || trimmed.starts_with("### Re:")
1470 || trimmed.starts_with("## Assistant")
1471 || trimmed.starts_with("## User")
1472 || is_markdown_heading(trimmed)
1473 {
1474 return None;
1475 }
1476 let unprefixed = trimmed.strip_prefix('❯').unwrap_or(trimmed).trim();
1477 let collapsed = unprefixed.split_whitespace().collect::<Vec<_>>().join(" ");
1478 if collapsed.len() < 32 {
1479 None
1480 } else {
1481 Some(collapsed)
1482 }
1483}
1484
1485fn is_markdown_heading(trimmed: &str) -> bool {
1486 let hashes = trimmed.bytes().take_while(|byte| *byte == b'#').count();
1487 (1..=6).contains(&hashes) && trimmed.as_bytes().get(hashes) == Some(&b' ')
1488}
1489
1490fn is_fence_delimiter(trimmed: &str) -> bool {
1491 let Some(first) = trimmed.chars().next() else {
1492 return false;
1493 };
1494 if first != '`' && first != '~' {
1495 return false;
1496 }
1497 trimmed.chars().take_while(|ch| *ch == first).count() >= 3
1498}
1499
1500fn is_duplicate_prompt_comment_text(candidate: &str, prompts: &[String]) -> bool {
1501 let Some(candidate) = normalize_prompt_comment_text(candidate) else {
1502 return false;
1503 };
1504 let candidate_lower = candidate.to_lowercase();
1505 let candidate_tokens = prompt_comment_tokens(&candidate);
1506 if candidate_tokens.len() < 8 {
1507 return false;
1508 }
1509
1510 prompts.iter().any(|prompt| {
1511 let prompt_lower = prompt.to_lowercase();
1512 if candidate_lower == prompt_lower
1513 || prompt_lower.contains(&candidate_lower)
1514 || candidate_lower.contains(&prompt_lower)
1515 {
1516 return true;
1517 }
1518
1519 let prompt_tokens = prompt_comment_tokens(prompt);
1520 if prompt_tokens.len() < 8 {
1521 return false;
1522 }
1523 ordered_token_coverage(&candidate_tokens, &prompt_tokens) >= 0.85
1524 || ordered_token_coverage(&prompt_tokens, &candidate_tokens) >= 0.85
1525 })
1526}
1527
1528fn prompt_comment_tokens(text: &str) -> Vec<String> {
1529 text.split(|ch: char| !ch.is_alphanumeric())
1530 .filter(|token| !token.is_empty())
1531 .map(|token| token.to_lowercase())
1532 .collect()
1533}
1534
1535fn ordered_token_coverage(needle: &[String], haystack: &[String]) -> f64 {
1536 if needle.is_empty() {
1537 return 0.0;
1538 }
1539 let mut matched = 0usize;
1540 for token in haystack {
1541 if needle.get(matched).is_some_and(|needle| needle == token) {
1542 matched += 1;
1543 if matched == needle.len() {
1544 break;
1545 }
1546 }
1547 }
1548 matched as f64 / needle.len() as f64
1549}
1550
1551fn is_safe_duplicate_template_scaffold(segment: &str) -> bool {
1552 let trimmed = segment.trim();
1553 if trimmed.is_empty() {
1554 return false;
1555 }
1556 if trimmed.contains("### Re:")
1557 || trimmed.contains("## User")
1558 || trimmed.contains("## Assistant")
1559 || trimmed.contains("❯ ")
1560 {
1561 return false;
1562 }
1563
1564 let has_scaffold_component = trimmed.contains("<!-- agent:queue -->")
1565 || trimmed.contains("<!-- agent:backlog")
1566 || trimmed.contains("<!-- agent:pending")
1567 || trimmed.contains("<!-- agent:done");
1568 if !has_scaffold_component {
1569 return false;
1570 }
1571 if !duplicate_scaffold_has_only_structural_residue(trimmed) {
1572 return false;
1573 }
1574
1575 let wrapped = format!("<!-- agent:scaffold -->\n{trimmed}\n<!-- /agent:scaffold -->\n");
1576 let Ok(components) = component::parse(&wrapped) else {
1577 return false;
1578 };
1579 let allowed = ["scaffold", "queue", "backlog", "pending", "icebox", "done"];
1580 components
1581 .iter()
1582 .all(|component| allowed.contains(&component.name.as_str()))
1583}
1584
1585fn duplicate_scaffold_has_only_structural_residue(segment: &str) -> bool {
1586 let mut residue = segment.to_string();
1587 if let Ok(components) = component::parse(segment) {
1588 let mut ranges: Vec<(usize, usize)> = components
1589 .iter()
1590 .filter(|component| {
1591 matches!(
1592 component.name.as_str(),
1593 "queue" | "backlog" | "pending" | "icebox" | "done"
1594 )
1595 })
1596 .map(|component| (component.open_start, component.close_end))
1597 .collect();
1598 ranges.sort_by_key(|range| std::cmp::Reverse(range.0));
1599 for (start, end) in ranges {
1600 residue.replace_range(start..end, "");
1601 }
1602 }
1603
1604 let residue = strip_html_comments(&residue);
1605 residue.lines().all(|line| {
1606 let trimmed = line.trim();
1607 trimmed.is_empty() || trimmed.starts_with('#')
1608 })
1609}
1610
1611fn strip_html_comments(text: &str) -> String {
1612 let mut result = String::with_capacity(text.len());
1613 let mut rest = text;
1614 loop {
1615 let Some(start) = rest.find("<!--") else {
1616 result.push_str(rest);
1617 break;
1618 };
1619 result.push_str(&rest[..start]);
1620 let after_start = &rest[start + 4..];
1621 let Some(end) = after_start.find("-->") else {
1622 break;
1623 };
1624 rest = &after_start[end + 3..];
1625 }
1626 result
1627}
1628
1629pub fn repair_duplicate_exchange_opener(doc: &str) -> Result<Option<String>> {
1636 let components = match component::parse(doc) {
1637 Ok(c) => c,
1638 Err(_) => return Ok(None),
1639 };
1640 let exchanges: Vec<&Component> = components.iter().filter(|c| c.name == "exchange").collect();
1641 if exchanges.len() < 2 {
1642 return Ok(None);
1643 }
1644 let first = exchanges[0];
1645 let second = exchanges[1];
1646
1647 let first_content = first.content(doc).trim_end().to_string();
1648 let second_content = second.content(doc).trim().to_string();
1649
1650 let merged = if first_content.trim().is_empty() && second_content.is_empty() {
1651 "\n".to_string()
1652 } else if first_content.trim().is_empty() {
1653 format!("{}\n", second_content)
1654 } else if second_content.is_empty() {
1655 format!("{}\n", first_content)
1656 } else {
1657 format!("{}\n{}\n", first_content, second_content)
1658 };
1659
1660 let mut result = String::new();
1661 result.push_str(&doc[..first.open_end]);
1662 result.push_str(&merged);
1663 result.push_str(&doc[first.close_start..second.open_start]);
1664 result.push_str(&doc[second.close_end..]);
1665
1666 if exchanges.len() > 2 {
1667 eprintln!(
1668 "[template] {} exchange components found — merged first two, {} remain",
1669 exchanges.len(),
1670 exchanges.len() - 1
1671 );
1672 }
1673
1674 Ok(Some(result))
1675}
1676
1677pub fn strip_conversation_tail_outside_exchange(doc: &str) -> Result<Option<String>> {
1683 let components = component::parse(doc).context("failed to parse components")?;
1684 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
1685 return Ok(None);
1686 };
1687
1688 let Some((tail_start, tail_end)) =
1689 escaped_conversation_range_outside_exchange(doc, &components, exchange)
1690 else {
1691 return Ok(None);
1692 };
1693 let tail = &doc[tail_start..tail_end];
1694 if !tail_is_safe_exchange_content(tail) {
1695 anyhow::bail!(
1696 "conversation content escaped `agent:exchange`, but the trailing document structure is ambiguous"
1697 );
1698 }
1699
1700 let mut stripped = String::with_capacity(doc.len() - (tail_end - tail_start));
1701 stripped.push_str(&doc[..tail_start]);
1702 stripped.push_str(&doc[tail_end..]);
1703 Ok(Some(stripped))
1704}
1705
1706pub fn deleted_conversation_tail_cleanup(before: &str, after: &str) -> Result<Option<String>> {
1709 if before == after || after.len() >= before.len() {
1710 return Ok(None);
1711 }
1712
1713 if let Some(stripped) = strip_conversation_tail_outside_exchange(before)?
1714 && stripped == after
1715 {
1716 return Ok(Some(stripped));
1717 }
1718
1719 let Some(deleted) = single_deleted_range(before, after) else {
1720 return Ok(None);
1721 };
1722
1723 let components = component::parse(before).context("failed to parse components")?;
1724 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
1725 return Ok(None);
1726 };
1727 if deleted.start < exchange.close_end {
1728 return Ok(None);
1729 }
1730 if components
1731 .iter()
1732 .any(|component| component.open_start < deleted.end && component.close_end > deleted.start)
1733 {
1734 return Ok(None);
1735 }
1736
1737 let tail = &before[deleted.clone()];
1738 if !tail_is_safe_exchange_content(tail) || !tail_contains_exchange_signal(tail) {
1739 return Ok(None);
1740 }
1741
1742 Ok(Some(after.to_string()))
1743}
1744
1745fn single_deleted_range(before: &str, after: &str) -> Option<std::ops::Range<usize>> {
1746 let prefix = common_prefix_boundary_len(before, after);
1747 let mut before_end = before.len();
1748 let mut after_end = after.len();
1749
1750 while before_end > prefix && after_end > prefix {
1751 let (before_prev, before_ch) = previous_char(before, before_end)?;
1752 let (after_prev, after_ch) = previous_char(after, after_end)?;
1753 if before_ch != after_ch {
1754 break;
1755 }
1756 before_end = before_prev;
1757 after_end = after_prev;
1758 }
1759
1760 if prefix >= before_end {
1761 return None;
1762 }
1763
1764 let mut reconstructed = String::with_capacity(after.len());
1765 reconstructed.push_str(&before[..prefix]);
1766 reconstructed.push_str(&before[before_end..]);
1767 if reconstructed == after {
1768 Some(prefix..before_end)
1769 } else {
1770 None
1771 }
1772}
1773
1774fn common_prefix_boundary_len(a: &str, b: &str) -> usize {
1775 let mut len = 0usize;
1776 for ((a_idx, a_ch), (_b_idx, b_ch)) in a.char_indices().zip(b.char_indices()) {
1777 if a_ch != b_ch {
1778 break;
1779 }
1780 len = a_idx + a_ch.len_utf8();
1781 }
1782 len
1783}
1784
1785fn previous_char(s: &str, end: usize) -> Option<(usize, char)> {
1786 s.get(..end)?.char_indices().next_back()
1787}
1788
1789fn tail_contains_exchange_signal(tail: &str) -> bool {
1790 tail.lines().any(|line| {
1791 let trimmed = line.trim();
1792 is_exchange_turn_heading(trimmed)
1793 || text_line_looks_like_prompt_target(trimmed)
1794 || line_looks_like_prompt_preset_reference(trimmed)
1795 })
1796}
1797
1798fn line_looks_like_prompt_preset_reference(line: &str) -> bool {
1799 let trimmed = line
1800 .trim_start_matches('❯')
1801 .trim_start()
1802 .trim_start_matches("- ")
1803 .trim_start_matches("* ")
1804 .trim_start_matches("+ ")
1805 .trim_start();
1806 let Some(rest) = trimmed.strip_prefix('#') else {
1807 return false;
1808 };
1809 let id_len = rest
1810 .char_indices()
1811 .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
1812 .map(|(idx, ch)| idx + ch.len_utf8())
1813 .last()
1814 .unwrap_or(0);
1815 id_len > 0
1816 && rest[id_len..]
1817 .chars()
1818 .next()
1819 .is_none_or(|ch| ch.is_whitespace())
1820}
1821
1822pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
1834 let trailing_nl = content.ends_with('\n');
1835 let mut lines: Vec<&str> = content.split('\n').collect();
1836 if trailing_nl {
1839 lines.pop();
1840 }
1841 while let Some(last) = lines.last() {
1842 let t = last.trim();
1843 if t == "❯" {
1844 lines.pop();
1845 } else {
1846 break;
1847 }
1848 }
1849 let mut out = lines.join("\n");
1850 if trailing_nl {
1851 out.push('\n');
1852 }
1853 out
1854}
1855
1856#[derive(Debug, Clone)]
1857struct PromptTailBlock {
1858 text: String,
1859 end: usize,
1860 pending_ids: HashSet<String>,
1861}
1862
1863fn is_exchange_response_heading(trimmed: &str) -> bool {
1864 trimmed.starts_with("### Re:")
1865 || trimmed.starts_with("#### Re:")
1866 || trimmed.starts_with("##### Re:")
1867}
1868
1869fn extract_pending_ids(text: &str) -> HashSet<String> {
1870 let mut ids = HashSet::new();
1871 let chars: Vec<char> = text.chars().collect();
1872 let mut idx = 0usize;
1873 while idx < chars.len() {
1874 if chars[idx] != '#' {
1875 idx += 1;
1876 continue;
1877 }
1878 let start = idx + 1;
1879 let mut end = start;
1880 while end < chars.len() {
1881 let ch = chars[end];
1882 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1883 end += 1;
1884 } else {
1885 break;
1886 }
1887 }
1888 if end > start {
1889 let id = chars[start..end].iter().collect::<String>();
1890 if is_valid_pending_like_id(&id) {
1891 ids.insert(id);
1892 }
1893 }
1894 idx = end.max(idx + 1);
1895 }
1896 ids
1897}
1898
1899fn is_valid_pending_like_id(id: &str) -> bool {
1900 !id.is_empty()
1901 && id
1902 .chars()
1903 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
1904}
1905
1906fn text_line_looks_like_prompt_target(line: &str) -> bool {
1907 let trimmed = line.trim();
1908 !trimmed.is_empty()
1909 && !trimmed.starts_with("<!--")
1910 && !trimmed.starts_with("```")
1911 && !trimmed.starts_with("~~~")
1912 && !is_exchange_response_heading(trimmed)
1913 && (trimmed.starts_with('❯')
1914 || trimmed.ends_with('?')
1915 || looks_like_prompt_directive(trimmed))
1916}
1917
1918fn looks_like_prompt_directive(line: &str) -> bool {
1919 let lower = line.trim_start_matches('❯').trim().to_ascii_lowercase();
1920 lower == "go"
1921 || lower == "continue"
1922 || lower.starts_with("do #")
1923 || lower.starts_with("do [#")
1924 || lower.starts_with("fix #")
1925 || lower.starts_with("run ")
1926 || lower.starts_with("rerun ")
1927 || lower.starts_with("build ")
1928 || lower.starts_with("test ")
1929 || lower.starts_with("commit ")
1930 || lower.starts_with("push ")
1931 || lower.starts_with("verify ")
1932 || lower.starts_with("investigate ")
1933}
1934
1935fn unresolved_tail_after_boundary(doc: &str, component_name: &str) -> Option<String> {
1936 let components = component::parse(doc).ok()?;
1937 let comp = components.iter().find(|comp| comp.name == component_name)?;
1938 let prefix = "<!-- agent:boundary:";
1939 let content_region = &doc[comp.open_end..comp.close_start];
1940 let code_ranges = component::find_code_ranges(doc);
1941 let mut search_from = 0usize;
1942 while let Some(start) = content_region[search_from..].find(prefix) {
1943 let abs_start = comp.open_end + search_from + start;
1944 if code_ranges
1945 .iter()
1946 .any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
1947 {
1948 search_from += start + prefix.len();
1949 continue;
1950 }
1951 let line_start = search_from + start;
1952 let marker_end = content_region[line_start..]
1953 .find('\n')
1954 .map(|idx| line_start + idx + 1)
1955 .unwrap_or(content_region.len());
1956 return Some(content_region[marker_end..].to_string());
1957 }
1958 None
1959}
1960
1961fn extract_prompt_tail_blocks(text: &str) -> Vec<PromptTailBlock> {
1962 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1963 let fc = trimmed.chars().next()?;
1964 if fc != '`' && fc != '~' {
1965 return None;
1966 }
1967 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1968 if fl >= 3 { Some((fc, fl)) } else { None }
1969 }
1970
1971 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1972 let fc = trimmed.chars().next().unwrap_or('\0');
1973 if fc != fence_char {
1974 return false;
1975 }
1976 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1977 fl >= fence_len && trimmed[fl..].trim().is_empty()
1978 }
1979
1980 fn block_start(line: &str) -> bool {
1981 let trimmed = line.trim();
1982 !trimmed.is_empty()
1983 && !trimmed.starts_with("<!--")
1984 && !trimmed.starts_with("```")
1985 && !trimmed.starts_with("~~~")
1986 && !is_exchange_response_heading(trimmed)
1987 && text_line_looks_like_prompt_target(trimmed)
1988 }
1989
1990 fn push_block(blocks: &mut Vec<PromptTailBlock>, text: &str, start: Option<usize>, end: usize) {
1991 let Some(start) = start else {
1992 return;
1993 };
1994 let raw = &text[start..end];
1995 let trimmed = raw.trim();
1996 if trimmed.is_empty() {
1997 return;
1998 }
1999 blocks.push(PromptTailBlock {
2000 text: trimmed.to_string(),
2001 end,
2002 pending_ids: extract_pending_ids(trimmed),
2003 });
2004 }
2005
2006 let mut blocks = Vec::new();
2007 let mut current_start = None;
2008 let mut current_end = 0usize;
2009 let mut current_has_blank = false;
2010 let mut in_fence = false;
2011 let mut fence_char = '`';
2012 let mut fence_len = 3usize;
2013 let mut offset = 0usize;
2014
2015 for segment in text.split_inclusive('\n') {
2016 let line = segment.trim_end_matches('\n');
2017 let trimmed = line.trim();
2018 let starts_new = current_start.is_some()
2019 && !in_fence
2020 && ((current_has_blank && !trimmed.is_empty()) || trimmed.starts_with('❯'))
2021 && block_start(line);
2022 if starts_new {
2023 push_block(&mut blocks, text, current_start.take(), current_end);
2024 current_end = 0;
2025 current_has_blank = false;
2026 }
2027
2028 if current_start.is_none() {
2029 if !block_start(line) {
2030 offset += segment.len();
2031 continue;
2032 }
2033 current_start = Some(offset);
2034 current_has_blank = false;
2035 }
2036
2037 current_end = offset + segment.len();
2038 if trimmed.is_empty() {
2039 current_has_blank = true;
2040 }
2041
2042 if !in_fence {
2043 if let Some((fc, fl)) = fence_open(trimmed) {
2044 in_fence = true;
2045 fence_char = fc;
2046 fence_len = fl;
2047 }
2048 } else if fence_close(trimmed, fence_char, fence_len) {
2049 in_fence = false;
2050 }
2051
2052 offset += segment.len();
2053 }
2054
2055 push_block(&mut blocks, text, current_start, current_end);
2056 blocks
2057}
2058
2059fn find_tail_anchor_block<'a>(
2060 response_content: &str,
2061 prompt_blocks: &'a [PromptTailBlock],
2062) -> Result<Option<&'a PromptTailBlock>> {
2063 if prompt_blocks.is_empty() {
2064 return Ok(None);
2065 }
2066
2067 let response_ids = extract_pending_ids(response_content);
2068 let candidate_idx = if response_ids.is_empty() {
2069 0
2070 } else if let Some(idx) = prompt_blocks
2071 .iter()
2072 .position(|block| !block.pending_ids.is_disjoint(&response_ids))
2073 {
2074 idx
2075 } else if prompt_blocks.len() == 1 {
2076 0
2077 } else {
2078 anyhow::bail!(
2079 "exchange patchback is ambiguous: response references pending ids {:?}, but none of the unresolved prompt blocks match",
2080 response_ids
2081 );
2082 };
2083
2084 if candidate_idx > 0 {
2085 anyhow::bail!(
2086 "exchange patchback would skip older unresolved prompt(s): oldest pending block is `{}`, but the response matches later block `{}`",
2087 prompt_blocks[0].text.lines().next().unwrap_or("(empty)"),
2088 prompt_blocks[candidate_idx]
2089 .text
2090 .lines()
2091 .next()
2092 .unwrap_or("(empty)")
2093 );
2094 }
2095
2096 Ok(prompt_blocks.get(candidate_idx))
2097}
2098
2099fn append_exchange_patch_after_prompt_anchor(
2100 original_doc: &str,
2101 current_doc: &str,
2102 comp: &Component,
2103 patch_content: &str,
2104) -> Result<Option<String>> {
2105 let Some(original_tail) = unresolved_tail_after_boundary(original_doc, &comp.name) else {
2106 return Ok(None);
2107 };
2108 let prompt_blocks = extract_prompt_tail_blocks(&original_tail);
2109 let Some(anchor) = find_tail_anchor_block(patch_content, &prompt_blocks)? else {
2110 return Ok(None);
2111 };
2112
2113 let content_region = comp.content(current_doc);
2114 let boundary_marker = find_boundary_in_component(current_doc, comp)
2115 .map(|id| format!("<!-- agent:boundary:{id} -->"))
2116 .context("exchange append anchor missing boundary marker")?;
2117 let boundary_pos = content_region
2118 .find(&boundary_marker)
2119 .context("exchange append anchor boundary marker not found in current content")?;
2120 let boundary_line_end = content_region[boundary_pos..]
2121 .find('\n')
2122 .map(|idx| boundary_pos + idx + 1)
2123 .unwrap_or(content_region.len());
2124 let user_region = &content_region[..boundary_pos];
2125 let tail_start = user_region.rfind(&original_tail).with_context(|| {
2126 format!(
2127 "failed to locate unresolved prompt tail in current exchange for anchored patchback: {}",
2128 anchor.text.lines().next().unwrap_or("(empty)")
2129 )
2130 })?;
2131 let insert_at = tail_start + anchor.end;
2132
2133 let new_id = crate::id::new_boundary_id();
2134 let new_marker = crate::id::format_boundary_marker(&new_id);
2135 let mut new_content =
2136 String::with_capacity(content_region.len() + patch_content.len() + new_marker.len() + 2);
2137 new_content.push_str(&user_region[..insert_at]);
2138 if !new_content.is_empty() && !new_content.ends_with('\n') {
2139 new_content.push('\n');
2140 }
2141 new_content.push_str(patch_content.trim_end());
2142 new_content.push('\n');
2143 new_content.push_str(&new_marker);
2144 new_content.push('\n');
2145 new_content.push_str(&user_region[insert_at..]);
2146 new_content.push_str(&content_region[boundary_line_end..]);
2147
2148 Ok(Some(comp.replace_content(current_doc, &new_content)))
2149}
2150
2151pub fn apply_patches_with_overrides_pure(
2158 doc: &str,
2159 patches: &[PatchBlock],
2160 unmatched: &str,
2161 summary: Option<&str>,
2162 component_configs: &std::collections::HashMap<String, String>,
2163 max_lines_configs: &std::collections::HashMap<String, usize>,
2164 mode_overrides: &std::collections::HashMap<String, String>,
2165) -> Result<String> {
2166 let mut result = remove_all_boundaries(doc);
2171 if let Ok(components) = component::parse(&result)
2172 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2173 {
2174 let id = crate::id::new_boundary_id_with_summary(summary);
2175 let marker = crate::id::format_boundary_marker(&id);
2176 let content = exchange.content(&result);
2177 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
2178 result = exchange.replace_content(&result, &new_content);
2179 eprintln!(
2180 "[template] pre-patch boundary {} inserted at end of exchange",
2181 id
2182 );
2183 }
2184
2185 let components = component::parse(&result).context("failed to parse components")?;
2187
2188 let configs = component_configs;
2190
2191 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
2196 let mut overflow = String::new();
2197 for patch in patches {
2198 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
2199 ops.push((idx, patch));
2200 } else {
2201 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
2202 eprintln!(
2203 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
2204 patch.name,
2205 available.join(", ")
2206 );
2207 if !overflow.is_empty() {
2208 overflow.push('\n');
2209 }
2210 overflow.push_str(&patch.content);
2211 }
2212 }
2213
2214 ops.sort_by_key(|op| std::cmp::Reverse(op.0));
2216
2217 for (idx, patch) in &ops {
2218 let comp = &components[*idx];
2219 let mode = mode_overrides
2221 .get(&patch.name)
2222 .map(|s| s.as_str())
2223 .or_else(|| comp.patch_mode())
2224 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
2225 .unwrap_or_else(|| default_mode(&patch.name));
2226 let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
2229 std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
2230 } else {
2231 std::borrow::Cow::Borrowed(patch.content.as_str())
2232 };
2233 if mode == "append"
2235 && let Some(bid) = find_boundary_in_component(&result, comp)
2236 {
2237 if patch.name == "exchange"
2238 && patch_content
2239 .lines()
2240 .any(|line| is_exchange_response_heading(line.trim_start()))
2241 && let Some(anchored) =
2242 append_exchange_patch_after_prompt_anchor(doc, &result, comp, &patch_content)?
2243 {
2244 result = anchored;
2245 continue;
2246 }
2247 result = comp.append_with_boundary(&result, &patch_content, &bid);
2248 continue;
2249 }
2250 let new_content = apply_mode(mode, comp.content(&result), &patch_content);
2251 result = comp.replace_content(&result, &new_content);
2252 }
2253
2254 let mut all_unmatched = String::new();
2256 let exchange_patch_present = patches.iter().any(|patch| patch.name == "exchange");
2257 if !overflow.is_empty() {
2258 all_unmatched.push_str(&overflow);
2259 }
2260 if !unmatched.is_empty() {
2261 if !all_unmatched.is_empty() {
2262 all_unmatched.push('\n');
2263 }
2264 all_unmatched.push_str(unmatched);
2265 }
2266
2267 if !all_unmatched.is_empty() {
2269 let components =
2271 component::parse(&result).context("failed to re-parse components after patching")?;
2272
2273 if let Some(output_comp) = components
2274 .iter()
2275 .find(|c| c.name == "exchange" || c.name == "output")
2276 {
2277 let stripped = if output_comp.name == "exchange" {
2280 strip_trailing_caret_lines(&all_unmatched)
2281 } else {
2282 all_unmatched.clone()
2283 };
2284 let unmatched = &stripped;
2285 let force_replace = output_comp.name == "exchange"
2286 && mode_overrides
2287 .get("exchange")
2288 .map(|mode| mode == "replace")
2289 .unwrap_or(false)
2290 && !exchange_patch_present;
2291 if force_replace {
2292 let new_content = format!("{}\n", unmatched.trim_end());
2293 result = output_comp.replace_content(&result, &new_content);
2294 } else if let Some(bid) = find_boundary_in_component(&result, output_comp) {
2295 eprintln!(
2297 "[template] unmatched content: using boundary {} for insertion",
2298 &bid[..bid.len().min(8)]
2299 );
2300 result = output_comp.append_with_boundary(&result, unmatched, &bid);
2301 } else {
2302 let existing = output_comp.content(&result);
2304 let new_content = if existing.trim().is_empty() {
2305 format!("{}\n", unmatched)
2306 } else {
2307 format!("{}{}\n", existing, unmatched)
2308 };
2309 result = output_comp.replace_content(&result, &new_content);
2310 }
2311 } else {
2312 let stripped = strip_trailing_caret_lines(&all_unmatched);
2314 if !result.ends_with('\n') {
2315 result.push('\n');
2316 }
2317 result.push_str("\n<!-- agent:exchange -->\n");
2318 result.push_str(&stripped);
2319 result.push_str("\n<!-- /agent:exchange -->\n");
2320 }
2321 }
2322
2323 while let Some(merged) = repair_duplicate_exchange_opener(&result)? {
2325 eprintln!("[template] post-patch: merged duplicate exchange opener");
2326 result = merged;
2327 }
2328
2329 result = dedup_exchange_adjacent_lines(&result);
2332
2333 {
2339 'stability: for _ in 0..3 {
2340 let Ok(components) = component::parse(&result) else {
2341 break;
2342 };
2343 for comp in &components {
2344 let max_lines = comp
2345 .attrs
2346 .get("max_lines")
2347 .and_then(|s| s.parse::<usize>().ok())
2348 .or_else(|| max_lines_configs.get(&comp.name).copied())
2349 .unwrap_or(0);
2350 if max_lines > 0 {
2351 let content = comp.content(&result);
2352 let trimmed = limit_lines(content, max_lines);
2353 if trimmed.len() != content.len() {
2354 let trimmed = format!("{}\n", trimmed.trim_end());
2355 result = comp.replace_content(&result, &trimmed);
2356 continue 'stability;
2358 }
2359 }
2360 }
2361 break; }
2363 }
2364
2365 {
2371 if let Ok(components) = component::parse(&result)
2372 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2373 && find_boundary_in_component(&result, exchange).is_none()
2374 {
2375 let id = uuid::Uuid::new_v4().to_string();
2377 let marker = format!("<!-- agent:boundary:{} -->", id);
2378 let content = exchange.content(&result);
2379 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
2380 result = exchange.replace_content(&result, &new_content);
2381 eprintln!(
2382 "[template] re-inserted boundary {} at end of exchange",
2383 &id[..id.len().min(8)]
2384 );
2385 }
2386 }
2387
2388 result = annotate_exchange_headings_against_baseline(&result, doc);
2389
2390 Ok(result)
2391}
2392
2393pub fn reposition_boundary_to_end(doc: &str) -> String {
2401 reposition_boundary_to_end_with_summary(doc, None)
2402}
2403
2404pub fn reposition_boundary_to_end_clean(doc: &str) -> String {
2410 reposition_boundary_to_end_clean_with_id(doc, None)
2411}
2412
2413pub fn reposition_boundary_to_end_clean_with_id(doc: &str, boundary_id: Option<&str>) -> String {
2418 reposition_boundary_to_end_clean_internal(doc, boundary_id, None)
2419}
2420
2421pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
2426 reposition_boundary_to_end_with_baseline(doc, summary, None)
2427}
2428
2429pub fn reposition_boundary_to_end_clean_with_summary(doc: &str, summary: Option<&str>) -> String {
2432 reposition_boundary_to_end_clean_internal(doc, None, summary)
2433}
2434
2435fn reposition_boundary_to_end_clean_internal(
2436 doc: &str,
2437 boundary_id: Option<&str>,
2438 summary: Option<&str>,
2439) -> String {
2440 let mut result = strip_transient_head_markers(&remove_all_boundaries(doc));
2441 if let Ok(components) = component::parse(&result)
2442 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2443 {
2444 let id = boundary_id
2445 .map(ToOwned::to_owned)
2446 .unwrap_or_else(|| crate::id::new_boundary_id_with_summary(summary));
2447 let marker = crate::id::format_boundary_marker(&id);
2448 let content = exchange.content(&result).to_string();
2449 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
2450 result = exchange.replace_content(&result, &new_content);
2451 }
2452 result
2453}
2454
2455pub fn reposition_boundary_to_end_preserve_head(doc: &str) -> String {
2460 reposition_boundary_to_end_preserve_head_with_id(doc, None)
2461}
2462
2463pub fn reposition_boundary_to_end_preserve_head_with_id(
2465 doc: &str,
2466 boundary_id: Option<&str>,
2467) -> String {
2468 let mut result = remove_all_boundaries(doc);
2469 if let Ok(components) = component::parse(&result)
2470 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2471 {
2472 let id = boundary_id
2473 .map(ToOwned::to_owned)
2474 .unwrap_or_else(crate::id::new_boundary_id);
2475 let marker = crate::id::format_boundary_marker(&id);
2476 let content = exchange.content(&result).to_string();
2477 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
2478 result = exchange.replace_content(&result, &new_content);
2479 }
2480 result
2481}
2482
2483fn strip_transient_head_markers(content: &str) -> String {
2486 let code_ranges = component::find_code_ranges(content);
2487 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
2488
2489 let mut result = String::with_capacity(content.len());
2490 let mut offset = 0usize;
2491 for line in content.split_inclusive('\n') {
2492 let line_start = offset;
2493 offset += line.len();
2494
2495 let mut rewritten = line.to_string();
2496 if !in_code(line_start) {
2497 let had_newline = rewritten.ends_with('\n');
2498 let body = rewritten.trim_end_matches('\n').trim_end_matches('\r');
2499 let trimmed = body.trim_start();
2500 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
2501 let is_markdown_heading =
2502 hash_count > 0 && hash_count <= 6 && trimmed[hash_count..].starts_with(' ');
2503 let is_bold_pseudo_header = trimmed.starts_with("**") && trimmed.ends_with("** (HEAD)");
2504 if (is_markdown_heading || is_bold_pseudo_header)
2505 && let Some(stripped) = body.strip_suffix(" (HEAD)")
2506 {
2507 rewritten = if had_newline {
2508 format!("{stripped}\n")
2509 } else {
2510 stripped.to_string()
2511 };
2512 }
2513 }
2514
2515 result.push_str(&rewritten);
2516 }
2517
2518 result
2519}
2520
2521pub fn reposition_boundary_to_end_with_baseline(
2530 doc: &str,
2531 summary: Option<&str>,
2532 baseline_headings: Option<&std::collections::HashSet<String>>,
2533) -> String {
2534 let mut result = remove_all_boundaries(doc);
2535 if let Ok(components) = component::parse(&result)
2536 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2537 {
2538 let id = crate::id::new_boundary_id_with_summary(summary);
2539 let marker = crate::id::format_boundary_marker(&id);
2540 let content = exchange.content(&result).to_string();
2541 let annotated = annotate_re_headings_with_head(&content, baseline_headings);
2542 let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
2543 result = exchange.replace_content(&result, &new_content);
2544 }
2545 result
2546}
2547
2548pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
2556 if let Ok(components) = component::parse(doc)
2557 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
2558 {
2559 return collect_re_headings(exchange.content(doc));
2560 }
2561 std::collections::HashSet::new()
2562}
2563
2564pub fn annotate_exchange_headings_against_baseline(doc: &str, baseline_doc: &str) -> String {
2565 let Ok(components) = component::parse(doc) else {
2566 return doc.to_string();
2567 };
2568 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
2569 return doc.to_string();
2570 };
2571
2572 let baseline_exchange = component::parse(baseline_doc)
2573 .ok()
2574 .and_then(|components| {
2575 components
2576 .iter()
2577 .find(|c| c.name == "exchange")
2578 .map(|c| c.content(baseline_doc).to_string())
2579 })
2580 .unwrap_or_default();
2581 let baseline_headings = exchange_baseline_headings(baseline_doc);
2582 let content = exchange.content(doc);
2583 let normalize_for_compare = |value: &str| {
2584 strip_transient_head_markers(value)
2585 .lines()
2586 .filter(|line| !line.trim().starts_with("<!-- agent:boundary:"))
2587 .collect::<Vec<_>>()
2588 .join("\n")
2589 };
2590 if normalize_for_compare(content) == normalize_for_compare(&baseline_exchange) {
2591 return doc.to_string();
2592 }
2593 let annotated = annotate_re_headings_with_head(content, Some(&baseline_headings));
2594 if annotated == content {
2595 return doc.to_string();
2596 }
2597 exchange.replace_content(doc, &annotated)
2598}
2599
2600fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
2604 let code_ranges = component::find_code_ranges(content);
2605 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
2606 let mut set = std::collections::HashSet::new();
2607 let mut offset = 0usize;
2608 for line in content.split_inclusive('\n') {
2609 let line_start = offset;
2610 offset += line.len();
2611 if in_code(line_start) {
2612 continue;
2613 }
2614 let body = line.trim_end_matches('\n').trim_end_matches('\r');
2615 let trimmed = body.trim_start();
2616 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
2617 if hash_count == 0 || hash_count > 6 {
2618 continue;
2619 }
2620 let after_hash = &trimmed[hash_count..];
2621 if !after_hash.starts_with(' ') {
2622 continue;
2623 }
2624 if !after_hash.trim_start().starts_with("Re:") {
2625 continue;
2626 }
2627 let stripped = body
2628 .trim_start()
2629 .trim_end()
2630 .trim_end_matches(" (HEAD)")
2631 .to_string();
2632 set.insert(stripped);
2633 }
2634 set
2635}
2636
2637pub(crate) fn annotate_re_headings_with_head(
2656 content: &str,
2657 baseline: Option<&std::collections::HashSet<String>>,
2658) -> String {
2659 let code_ranges = component::find_code_ranges(content);
2660 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
2661
2662 let mut lines: Vec<String> = content
2663 .split_inclusive('\n')
2664 .map(|s| s.to_string())
2665 .collect();
2666 let mut re_indices: Vec<usize> = Vec::new();
2667 let mut offset = 0usize;
2668
2669 for (idx, line) in lines.iter_mut().enumerate() {
2670 let line_start = offset;
2671 offset += line.len();
2672 if in_code(line_start) {
2673 continue;
2674 }
2675 let had_newline = line.ends_with('\n');
2676 let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
2677 let trimmed = body_ref.trim_start();
2678 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
2679 if hash_count == 0 || hash_count > 6 {
2680 continue;
2681 }
2682 let after_hash = &trimmed[hash_count..];
2683 if !after_hash.starts_with(' ') {
2684 continue;
2685 }
2686 if !after_hash.trim_start().starts_with("Re:") {
2687 continue;
2688 }
2689 let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
2691 *line = if had_newline {
2692 format!("{stripped}\n")
2693 } else {
2694 stripped.to_string()
2695 };
2696 re_indices.push(idx);
2697 }
2698
2699 let mark_indices: Vec<usize> = match baseline {
2709 Some(baseline_set) => {
2710 let filtered: Vec<usize> = re_indices
2711 .iter()
2712 .copied()
2713 .filter(|&idx| {
2714 let line = &lines[idx];
2715 let key = line
2716 .trim_end_matches('\n')
2717 .trim_end_matches('\r')
2718 .trim_start()
2719 .trim_end();
2720 !baseline_set.contains(key)
2721 })
2722 .collect();
2723 if filtered.is_empty() {
2724 re_indices.last().copied().into_iter().collect()
2725 } else {
2726 filtered
2727 }
2728 }
2729 None => re_indices.last().copied().into_iter().collect(),
2730 };
2731
2732 for idx in mark_indices {
2733 let line = &lines[idx];
2734 let had_newline = line.ends_with('\n');
2735 let body = line.trim_end_matches('\n').trim_end_matches('\r');
2736 lines[idx] = if had_newline {
2737 format!("{body} (HEAD)\n")
2738 } else {
2739 format!("{body} (HEAD)")
2740 };
2741 }
2742
2743 lines.concat()
2744}
2745
2746fn remove_all_boundaries(doc: &str) -> String {
2749 let prefix = "<!-- agent:boundary:";
2750 let suffix = " -->";
2751 let code_ranges = component::find_code_ranges(doc);
2752 let in_code = |pos: usize| {
2753 code_ranges
2754 .iter()
2755 .any(|&(start, end)| pos >= start && pos < end)
2756 };
2757 let mut result = String::with_capacity(doc.len());
2758 let mut offset = 0;
2759 for line in doc.lines() {
2760 let trimmed = line.trim();
2761 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
2762 if is_boundary && !in_code(offset) {
2763 offset += line.len() + 1; continue;
2766 }
2767 result.push_str(line);
2768 result.push('\n');
2769 offset += line.len() + 1;
2770 }
2771 if !doc.ends_with('\n') && result.ends_with('\n') {
2772 result.pop();
2773 }
2774 result
2775}
2776
2777fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
2779 let prefix = "<!-- agent:boundary:";
2780 let suffix = " -->";
2781 let content_region = &doc[comp.open_end..comp.close_start];
2782 let code_ranges = component::find_code_ranges(doc);
2783 let mut search_from = 0;
2784 while let Some(start) = content_region[search_from..].find(prefix) {
2785 let abs_start = comp.open_end + search_from + start;
2786 if code_ranges
2787 .iter()
2788 .any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
2789 {
2790 search_from += start + prefix.len();
2791 continue;
2792 }
2793 let after_prefix = &content_region[search_from + start + prefix.len()..];
2794 if let Some(end) = after_prefix.find(suffix) {
2795 return Some(after_prefix[..end].trim().to_string());
2796 }
2797 break;
2798 }
2799 None
2800}
2801
2802fn default_mode(name: &str) -> &'static str {
2805 match name {
2806 "exchange" | "findings" => "append",
2807 _ => "replace",
2808 }
2809}
2810
2811fn limit_lines(content: &str, max_lines: usize) -> String {
2813 let lines: Vec<&str> = content.lines().collect();
2814 if lines.len() <= max_lines {
2815 return content.to_string();
2816 }
2817 lines[lines.len() - max_lines..].join("\n")
2818}
2819
2820fn dedup_exchange_adjacent_lines(doc: &str) -> String {
2829 let Ok(components) = component::parse(doc) else {
2830 return doc.to_string();
2831 };
2832 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
2833 return doc.to_string();
2834 };
2835 let content = exchange.content(doc);
2836 let mut deduped = String::with_capacity(content.len());
2837 let mut prev_nonempty: Option<&str> = None;
2838 for line in content.lines() {
2839 let trimmed = line.trim();
2840 let is_fence = is_code_fence_delimiter(trimmed);
2841 if !trimmed.is_empty() && !is_fence && prev_nonempty == Some(line) {
2842 continue;
2843 }
2844 deduped.push_str(line);
2845 deduped.push('\n');
2846 if !trimmed.is_empty() {
2847 prev_nonempty = Some(line);
2848 }
2849 }
2850 if !content.ends_with('\n') && deduped.ends_with('\n') {
2851 deduped.pop();
2852 }
2853 if deduped == content {
2854 return doc.to_string();
2855 }
2856 exchange.replace_content(doc, &deduped)
2857}
2858
2859fn is_code_fence_delimiter(trimmed: &str) -> bool {
2860 let fc = match trimmed.chars().next() {
2861 Some(c) if c == '`' || c == '~' => c,
2862 _ => return false,
2863 };
2864 let fence_len = trimmed.chars().take_while(|&c| c == fc).count();
2865 fence_len >= 3
2866}
2867
2868fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
2870 match mode {
2871 "append" => {
2872 let stripped = strip_leading_overlap(existing, new_content);
2873 format!("{}{}", existing, stripped)
2874 }
2875 "prepend" => format!("{}{}", new_content, existing),
2876 _ => new_content.to_string(), }
2878}
2879
2880fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
2885 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
2886 let Some(last) = last_nonempty else {
2887 return new_content;
2888 };
2889 let test = format!("{}\n", last);
2890 if new_content.starts_with(test.as_str()) {
2891 &new_content[test.len()..]
2892 } else {
2893 new_content
2894 }
2895}
2896
2897
2898fn find_outside_code(
2901 needle: &str,
2902 haystack: &str,
2903 from: usize,
2904 code_ranges: &[(usize, usize)],
2905) -> Option<usize> {
2906 let mut search_start = from;
2907 loop {
2908 let rel = haystack[search_start..].find(needle)?;
2909 let abs = search_start + rel;
2910 if code_ranges
2911 .iter()
2912 .any(|&(start, end)| abs >= start && abs < end)
2913 {
2914 search_start = abs + needle.len();
2916 continue;
2917 }
2918 return Some(abs);
2919 }
2920}
2921
2922#[cfg(test)]
2923mod tests {
2924 use super::*;
2925 use std::path::Path;
2926 use tempfile::TempDir;
2927
2928 fn setup_project() -> TempDir {
2929 let dir = TempDir::new().unwrap();
2930 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
2931 dir
2932 }
2933
2934 #[allow(dead_code)]
2937 fn apply_patches_via_path(
2938 doc: &str,
2939 patches: &[PatchBlock],
2940 unmatched: &str,
2941 file: &Path,
2942 ) -> Result<String> {
2943 let summary = file.file_stem().and_then(|s| s.to_str());
2944 let empty_str = std::collections::HashMap::new();
2945 let empty_usize = std::collections::HashMap::new();
2946 apply_patches_pure(doc, patches, unmatched, summary, &empty_str, &empty_usize)
2947 }
2948
2949 #[allow(dead_code)]
2951 fn apply_patches_with_overrides_via_path(
2952 doc: &str,
2953 patches: &[PatchBlock],
2954 unmatched: &str,
2955 file: &Path,
2956 mode_overrides: &std::collections::HashMap<String, String>,
2957 ) -> Result<String> {
2958 let summary = file.file_stem().and_then(|s| s.to_str());
2959 let empty_str = std::collections::HashMap::new();
2960 let empty_usize = std::collections::HashMap::new();
2961 apply_patches_with_overrides_pure(
2962 doc,
2963 patches,
2964 unmatched,
2965 summary,
2966 &empty_str,
2967 &empty_usize,
2968 mode_overrides,
2969 )
2970 }
2971
2972 #[test]
2973 fn parse_single_patch() {
2974 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
2975 let (patches, unmatched) = parse_patches(response).unwrap();
2976 assert_eq!(patches.len(), 1);
2977 assert_eq!(patches[0].name, "status");
2978 assert_eq!(patches[0].content, "Build passing.\n");
2979 assert!(unmatched.is_empty());
2980 }
2981
2982 #[test]
2983 fn parse_multiple_patches() {
2984 let response = "\
2985<!-- patch:status -->
2986All green.
2987<!-- /patch:status -->
2988
2989<!-- patch:log -->
2990- New entry
2991<!-- /patch:log -->
2992";
2993 let (patches, unmatched) = parse_patches(response).unwrap();
2994 assert_eq!(patches.len(), 2);
2995 assert_eq!(patches[0].name, "status");
2996 assert_eq!(patches[0].content, "All green.\n");
2997 assert_eq!(patches[1].name, "log");
2998 assert_eq!(patches[1].content, "- New entry\n");
2999 assert!(unmatched.is_empty());
3000 }
3001
3002 #[test]
3003 fn parse_with_unmatched_content() {
3004 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
3005 let (patches, unmatched) = parse_patches(response).unwrap();
3006 assert_eq!(patches.len(), 1);
3007 assert_eq!(patches[0].name, "status");
3008 assert!(unmatched.contains("Some free text."));
3009 assert!(unmatched.contains("Trailing text."));
3010 }
3011
3012 #[test]
3013 fn parse_empty_response() {
3014 let (patches, unmatched) = parse_patches("").unwrap();
3015 assert!(patches.is_empty());
3016 assert!(unmatched.is_empty());
3017 }
3018
3019 #[test]
3020 fn parse_no_patches() {
3021 let response = "Just a plain response with no patch blocks.";
3022 let (patches, unmatched) = parse_patches(response).unwrap();
3023 assert!(patches.is_empty());
3024 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
3025 }
3026
3027 #[test]
3028 fn apply_patches_replace() {
3029 let dir = setup_project();
3030 let doc_path = dir.path().join("test.md");
3031 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
3032 std::fs::write(&doc_path, doc).unwrap();
3033
3034 let patches = vec![PatchBlock {
3035 name: "status".to_string(),
3036 content: "new\n".to_string(),
3037 attrs: Default::default(),
3038 }];
3039 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3040 assert!(result.contains("new\n"));
3041 assert!(!result.contains("\nold\n"));
3042 assert!(result.contains("<!-- agent:status -->"));
3043 }
3044
3045 #[test]
3046 fn apply_patches_unmatched_creates_exchange() {
3047 let dir = setup_project();
3048 let doc_path = dir.path().join("test.md");
3049 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
3050 std::fs::write(&doc_path, doc).unwrap();
3051
3052 let result = apply_patches_via_path(doc, &[], "Extra info here", &doc_path).unwrap();
3053 assert!(result.contains("<!-- agent:exchange -->"));
3054 assert!(result.contains("Extra info here"));
3055 assert!(result.contains("<!-- /agent:exchange -->"));
3056 }
3057
3058 #[test]
3059 fn apply_patches_unmatched_appends_to_existing_exchange() {
3060 let dir = setup_project();
3061 let doc_path = dir.path().join("test.md");
3062 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
3063 std::fs::write(&doc_path, doc).unwrap();
3064
3065 let result = apply_patches_via_path(doc, &[], "new stuff", &doc_path).unwrap();
3066 assert!(result.contains("previous"));
3067 assert!(result.contains("new stuff"));
3068 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
3070 }
3071
3072 #[test]
3073 fn apply_patches_missing_component_routes_to_exchange() {
3074 let dir = setup_project();
3075 let doc_path = dir.path().join("test.md");
3076 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
3077 std::fs::write(&doc_path, doc).unwrap();
3078
3079 let patches = vec![PatchBlock {
3080 name: "nonexistent".to_string(),
3081 content: "overflow data\n".to_string(),
3082 attrs: Default::default(),
3083 }];
3084 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3085 assert!(
3087 result.contains("overflow data"),
3088 "missing patch content should appear in exchange"
3089 );
3090 assert!(
3091 result.contains("previous"),
3092 "existing exchange content should be preserved"
3093 );
3094 }
3095
3096 #[test]
3097 fn apply_patches_missing_component_creates_exchange() {
3098 let dir = setup_project();
3099 let doc_path = dir.path().join("test.md");
3100 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
3101 std::fs::write(&doc_path, doc).unwrap();
3102
3103 let patches = vec![PatchBlock {
3104 name: "nonexistent".to_string(),
3105 content: "overflow data\n".to_string(),
3106 attrs: Default::default(),
3107 }];
3108 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3109 assert!(
3111 result.contains("<!-- agent:exchange -->"),
3112 "should create exchange component"
3113 );
3114 assert!(
3115 result.contains("overflow data"),
3116 "overflow content should be in exchange"
3117 );
3118 }
3119
3120 #[test]
3121 fn is_template_mode_detection() {
3122 assert!(is_template_mode(Some("template")));
3123 assert!(!is_template_mode(Some("append")));
3124 assert!(!is_template_mode(None));
3125 }
3126
3127
3128 #[test]
3129 fn parse_patches_ignores_markers_in_fenced_code_block() {
3130 let response = "\
3131<!-- patch:exchange -->
3132Here is how you use component markers:
3133
3134```markdown
3135<!-- agent:exchange -->
3136example content
3137<!-- /agent:exchange -->
3138```
3139
3140<!-- /patch:exchange -->
3141";
3142 let (patches, unmatched) = parse_patches(response).unwrap();
3143 assert_eq!(patches.len(), 1);
3144 assert_eq!(patches[0].name, "exchange");
3145 assert!(patches[0].content.contains("```markdown"));
3146 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
3147 assert!(unmatched.is_empty());
3148 }
3149
3150 #[test]
3151 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
3152 let response = "\
3154<!-- patch:exchange -->
3155Real content here.
3156
3157```markdown
3158<!-- patch:fake -->
3159This is just an example.
3160<!-- /patch:fake -->
3161```
3162
3163<!-- /patch:exchange -->
3164";
3165 let (patches, unmatched) = parse_patches(response).unwrap();
3166 assert_eq!(patches.len(), 1, "should only find the outer real patch");
3167 assert_eq!(patches[0].name, "exchange");
3168 assert!(
3169 patches[0].content.contains("<!-- patch:fake -->"),
3170 "code block content should be preserved"
3171 );
3172 assert!(unmatched.is_empty());
3173 }
3174
3175 #[test]
3176 fn parse_patches_ignores_markers_in_tilde_fence() {
3177 let response = "\
3178<!-- patch:status -->
3179OK
3180<!-- /patch:status -->
3181
3182~~~
3183<!-- patch:fake -->
3184example
3185<!-- /patch:fake -->
3186~~~
3187";
3188 let (patches, _unmatched) = parse_patches(response).unwrap();
3189 assert_eq!(patches.len(), 1);
3191 assert_eq!(patches[0].name, "status");
3192 }
3193
3194 #[test]
3195 fn parse_patches_ignores_closing_marker_in_code_block() {
3196 let response = "\
3199<!-- patch:exchange -->
3200Example:
3201
3202```
3203<!-- /patch:exchange -->
3204```
3205
3206Real content continues.
3207<!-- /patch:exchange -->
3208";
3209 let (patches, _unmatched) = parse_patches(response).unwrap();
3210 assert_eq!(patches.len(), 1);
3211 assert_eq!(patches[0].name, "exchange");
3212 assert!(patches[0].content.contains("Real content continues."));
3213 }
3214
3215 #[test]
3216 fn parse_patches_normal_markers_still_work() {
3217 let response = "\
3219<!-- patch:status -->
3220All systems go.
3221<!-- /patch:status -->
3222<!-- patch:log -->
3223- Entry 1
3224<!-- /patch:log -->
3225";
3226 let (patches, unmatched) = parse_patches(response).unwrap();
3227 assert_eq!(patches.len(), 2);
3228 assert_eq!(patches[0].name, "status");
3229 assert_eq!(patches[0].content, "All systems go.\n");
3230 assert_eq!(patches[1].name, "log");
3231 assert_eq!(patches[1].content, "- Entry 1\n");
3232 assert!(unmatched.is_empty());
3233 }
3234
3235 #[test]
3236 fn parse_patches_accepts_replace_icebox() {
3237 let response = "\
3238<!-- replace:icebox -->
3239- [ ] [#park1] Parked follow-up
3240<!-- /replace:icebox -->
3241";
3242 let (patches, unmatched) = parse_patches(response).unwrap();
3243 assert_eq!(patches.len(), 1);
3244 assert_eq!(patches[0].name, "icebox");
3245 assert_eq!(patches[0].content, "- [ ] [#park1] Parked follow-up\n");
3246 assert!(unmatched.is_empty());
3247 }
3248
3249 #[test]
3250 fn parse_patches_orphaned_opener_does_not_leak_into_unmatched() {
3251 let response = "\
3254Some real content here.
3255<!-- patch:exchange -->
3256This opener has no matching close.
3257";
3258 let (patches, unmatched) = parse_patches(response).unwrap();
3259 assert!(
3260 patches.is_empty(),
3261 "orphaned opener should not produce a patch"
3262 );
3263 assert_eq!(
3264 unmatched, "Some real content here.\nThis opener has no matching close.",
3265 "unmatched should contain text before and after the orphaned marker, but not the marker itself"
3266 );
3267 }
3268
3269 #[test]
3270 fn parse_patches_orphaned_opener_between_valid_patches() {
3271 let response = "\
3274<!-- patch:status -->
3275All good.
3276<!-- /patch:status -->
3277Interstitial text.
3278<!-- patch:exchange -->
3279<!-- patch:log -->
3280- Log entry
3281<!-- /patch:log -->
3282";
3283 let (patches, unmatched) = parse_patches(response).unwrap();
3284 assert_eq!(patches.len(), 2);
3285 assert_eq!(patches[0].name, "status");
3286 assert_eq!(patches[1].name, "log");
3287 assert_eq!(unmatched, "Interstitial text.");
3288 }
3289
3290 #[test]
3293 fn inline_attr_mode_overrides_config() {
3294 let dir = setup_project();
3296 let doc_path = dir.path().join("test.md");
3297 std::fs::write(
3299 dir.path().join(".agent-doc/config.toml"),
3300 "[components.status]\npatch = \"append\"\n",
3301 )
3302 .unwrap();
3303 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
3305 std::fs::write(&doc_path, doc).unwrap();
3306
3307 let patches = vec![PatchBlock {
3308 name: "status".to_string(),
3309 content: "new\n".to_string(),
3310 attrs: Default::default(),
3311 }];
3312 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3313 assert!(result.contains("new\n"));
3315 assert!(!result.contains("old\n"));
3316 }
3317
3318 #[test]
3319 fn inline_attr_mode_overrides_default() {
3320 let dir = setup_project();
3322 let doc_path = dir.path().join("test.md");
3323 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
3324 std::fs::write(&doc_path, doc).unwrap();
3325
3326 let patches = vec![PatchBlock {
3327 name: "exchange".to_string(),
3328 content: "new\n".to_string(),
3329 attrs: Default::default(),
3330 }];
3331 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3332 assert!(result.contains("new\n"));
3333 assert!(!result.contains("old\n"));
3334 }
3335
3336
3337 #[test]
3338 fn no_inline_attr_no_config_falls_back_to_default() {
3339 let dir = setup_project();
3341 let doc_path = dir.path().join("test.md");
3342 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
3343 std::fs::write(&doc_path, doc).unwrap();
3344
3345 let patches = vec![PatchBlock {
3346 name: "exchange".to_string(),
3347 content: "new\n".to_string(),
3348 attrs: Default::default(),
3349 }];
3350 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3351 assert!(result.contains("old\n"));
3353 assert!(result.contains("new\n"));
3354 }
3355
3356 #[test]
3357 fn inline_patch_attr_overrides_config() {
3358 let dir = setup_project();
3360 let doc_path = dir.path().join("test.md");
3361 std::fs::write(
3362 dir.path().join(".agent-doc/config.toml"),
3363 "[components.status]\npatch = \"append\"\n",
3364 )
3365 .unwrap();
3366 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
3367 std::fs::write(&doc_path, doc).unwrap();
3368
3369 let patches = vec![PatchBlock {
3370 name: "status".to_string(),
3371 content: "new\n".to_string(),
3372 attrs: Default::default(),
3373 }];
3374 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3375 assert!(result.contains("new\n"));
3376 assert!(!result.contains("old\n"));
3377 }
3378
3379 #[test]
3380 fn inline_patch_attr_overrides_mode_attr() {
3381 let dir = setup_project();
3383 let doc_path = dir.path().join("test.md");
3384 let doc =
3385 "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
3386 std::fs::write(&doc_path, doc).unwrap();
3387
3388 let patches = vec![PatchBlock {
3389 name: "exchange".to_string(),
3390 content: "new\n".to_string(),
3391 attrs: Default::default(),
3392 }];
3393 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3394 assert!(result.contains("new\n"));
3395 assert!(!result.contains("old\n"));
3396 }
3397
3398 #[test]
3399
3400 #[test]
3401 fn stream_override_beats_inline_attr() {
3402 let dir = setup_project();
3404 let doc_path = dir.path().join("test.md");
3405 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
3406 std::fs::write(&doc_path, doc).unwrap();
3407
3408 let patches = vec![PatchBlock {
3409 name: "exchange".to_string(),
3410 content: "new\n".to_string(),
3411 attrs: Default::default(),
3412 }];
3413 let mut overrides = std::collections::HashMap::new();
3414 overrides.insert("exchange".to_string(), "replace".to_string());
3415 let result =
3416 apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &overrides).unwrap();
3417 assert!(result.contains("new\n"));
3419 assert!(!result.contains("old\n"));
3420 }
3421
3422 #[test]
3423 fn exchange_replace_override_replaces_unmatched_content() {
3424 let dir = setup_project();
3425 let doc_path = dir.path().join("test.md");
3426 let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
3427 std::fs::write(&doc_path, doc).unwrap();
3428
3429 let mut overrides = std::collections::HashMap::new();
3430 overrides.insert("exchange".to_string(), "replace".to_string());
3431 let result =
3432 apply_patches_with_overrides_via_path(doc, &[], "Compacted summary.\n", &doc_path, &overrides)
3433 .unwrap();
3434
3435 assert!(result.contains("Compacted summary.\n"));
3436 assert!(!result.contains("old\n"));
3437 }
3438
3439 #[test]
3440 fn exchange_replace_override_keeps_explicit_exchange_patch_authoritative() {
3441 let dir = setup_project();
3442 let doc_path = dir.path().join("test.md");
3443 let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
3444 std::fs::write(&doc_path, doc).unwrap();
3445
3446 let patches = vec![PatchBlock {
3447 name: "exchange".to_string(),
3448 content: "Compacted summary.\n".to_string(),
3449 attrs: Default::default(),
3450 }];
3451 let mut overrides = std::collections::HashMap::new();
3452 overrides.insert("exchange".to_string(), "replace".to_string());
3453 let result =
3454 apply_patches_with_overrides_via_path(doc, &patches, "trailing note", &doc_path, &overrides)
3455 .unwrap();
3456
3457 assert!(result.contains("Compacted summary.\n"));
3458 assert!(result.contains("trailing note"));
3459 assert!(!result.contains("old\n"));
3460 }
3461
3462 #[test]
3463 fn apply_patches_ignores_component_tags_in_code_blocks() {
3464 let dir = setup_project();
3467 let doc_path = dir.path().join("test.md");
3468 let doc = "\
3469# Scaffold Guide
3470
3471Here is an example of a component:
3472
3473```markdown
3474<!-- agent:status -->
3475example scaffold content
3476<!-- /agent:status -->
3477```
3478
3479<!-- agent:status -->
3480real status content
3481<!-- /agent:status -->
3482";
3483 std::fs::write(&doc_path, doc).unwrap();
3484
3485 let patches = vec![PatchBlock {
3486 name: "status".to_string(),
3487 content: "patched status\n".to_string(),
3488 attrs: Default::default(),
3489 }];
3490 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3491
3492 assert!(
3494 result.contains("patched status\n"),
3495 "real component should receive the patch"
3496 );
3497 assert!(
3499 result.contains("example scaffold content"),
3500 "code block content should be preserved"
3501 );
3502 assert!(
3504 result.contains("```markdown\n<!-- agent:status -->"),
3505 "code block markers should be preserved"
3506 );
3507 }
3508
3509 #[test]
3510 fn unmatched_content_uses_boundary_marker() {
3511 let dir = setup_project();
3512 let file = dir.path().join("test.md");
3513 let doc = concat!(
3514 "---\nagent_doc_format: template\n---\n",
3515 "<!-- agent:exchange patch=append -->\n",
3516 "User prompt here.\n",
3517 "<!-- agent:boundary:test-uuid-123 -->\n",
3518 "<!-- /agent:exchange -->\n",
3519 );
3520 std::fs::write(&file, doc).unwrap();
3521
3522 let patches = vec![];
3524 let unmatched = "### Re: Response\n\nResponse content here.\n";
3525
3526 let result = apply_patches_via_path(doc, &patches, unmatched, &file).unwrap();
3527
3528 let prompt_pos = result.find("User prompt here.").unwrap();
3530 let response_pos = result.find("### Re: Response").unwrap();
3531 assert!(
3532 response_pos > prompt_pos,
3533 "response should appear after the user prompt (boundary insertion)"
3534 );
3535
3536 assert!(
3538 !result.contains("test-uuid-123"),
3539 "boundary marker should be consumed after insertion"
3540 );
3541 }
3542
3543 #[test]
3544 fn explicit_patch_uses_boundary_marker() {
3545 let dir = setup_project();
3546 let file = dir.path().join("test.md");
3547 let doc = concat!(
3548 "---\nagent_doc_format: template\n---\n",
3549 "<!-- agent:exchange patch=append -->\n",
3550 "User prompt here.\n",
3551 "<!-- agent:boundary:patch-uuid-456 -->\n",
3552 "<!-- /agent:exchange -->\n",
3553 );
3554 std::fs::write(&file, doc).unwrap();
3555
3556 let patches = vec![PatchBlock {
3558 name: "exchange".to_string(),
3559 content: "### Re: Response\n\nResponse content.\n".to_string(),
3560 attrs: Default::default(),
3561 }];
3562
3563 let result = apply_patches_via_path(doc, &patches, "", &file).unwrap();
3564
3565 let prompt_pos = result.find("User prompt here.").unwrap();
3567 let response_pos = result.find("### Re: Response").unwrap();
3568 assert!(
3569 response_pos > prompt_pos,
3570 "response should appear after user prompt"
3571 );
3572
3573 assert!(
3575 !result.contains("patch-uuid-456"),
3576 "boundary marker should be consumed by explicit patch"
3577 );
3578 }
3579
3580 #[test]
3581 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
3582 let dir = setup_project();
3585 let file = dir.path().join("test.md");
3586 let doc =
3588 "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
3589 std::fs::write(&file, doc).unwrap();
3590
3591 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
3592 let (patches, unmatched) = parse_patches(response).unwrap();
3593 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
3594
3595 assert!(
3597 result.contains("<!-- agent:boundary:"),
3598 "boundary must be re-inserted even when original doc had no boundary: {result}"
3599 );
3600 }
3601
3602 #[test]
3603 fn boundary_survives_multiple_cycles() {
3604 let dir = setup_project();
3606 let file = dir.path().join("test.md");
3607 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
3608 std::fs::write(&file, doc).unwrap();
3609
3610 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
3612 let (patches1, unmatched1) = parse_patches(response1).unwrap();
3613 let result1 = apply_patches_via_path(doc, &patches1, &unmatched1, &file).unwrap();
3614 assert!(
3615 result1.contains("<!-- agent:boundary:"),
3616 "cycle 1 must have boundary"
3617 );
3618
3619 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
3621 let (patches2, unmatched2) = parse_patches(response2).unwrap();
3622 let result2 = apply_patches_via_path(&result1, &patches2, &unmatched2, &file).unwrap();
3623 assert!(
3624 result2.contains("<!-- agent:boundary:"),
3625 "cycle 2 must have boundary"
3626 );
3627 }
3628
3629 #[test]
3630 fn exchange_boundary_insert_adds_blank_line_before_response_heading() {
3631 let dir = setup_project();
3632 let file = dir.path().join("test.md");
3633 let doc = concat!(
3634 "<!-- agent:exchange patch=append -->\n",
3635 "### Re: Prior -- gpt-5\n\n",
3636 "This guards against a future regression.\n",
3637 "<!-- agent:boundary:abc123 -->\n",
3638 "<!-- /agent:exchange -->\n",
3639 );
3640 std::fs::write(&file, doc).unwrap();
3641
3642 let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
3643 let (patches, unmatched) = parse_patches(response).unwrap();
3644 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
3645
3646 assert!(
3647 result.contains("future regression.\n\n### Re: Follow-up"),
3648 "response heading should be separated from the previous paragraph: {result}"
3649 );
3650 assert!(
3651 !result.contains("future regression.\n### Re: Follow-up"),
3652 "response heading must not attach to the previous paragraph: {result}"
3653 );
3654 }
3655
3656 #[test]
3657 fn exchange_fallback_append_adds_blank_line_before_response_heading() {
3658 let dir = setup_project();
3659 let file = dir.path().join("test.md");
3660 let doc = concat!(
3661 "<!-- agent:exchange patch=append -->\n",
3662 "### Re: Prior -- gpt-5\n\n",
3663 "This guards against a future regression.\n",
3664 "<!-- /agent:exchange -->\n",
3665 );
3666 std::fs::write(&file, doc).unwrap();
3667
3668 let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
3669 let (patches, unmatched) = parse_patches(response).unwrap();
3670 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
3671
3672 assert!(
3673 result.contains("future regression.\n\n### Re: Follow-up"),
3674 "response heading should be separated from the previous paragraph: {result}"
3675 );
3676 assert!(
3677 !result.contains("future regression.\n### Re: Follow-up"),
3678 "response heading must not attach to the previous paragraph: {result}"
3679 );
3680 }
3681
3682 #[test]
3683 fn remove_all_boundaries_skips_code_blocks() {
3684 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
3685 let result = remove_all_boundaries(doc);
3686 assert!(
3688 result.contains("<!-- agent:boundary:fake-id -->"),
3689 "boundary inside code block must be preserved: {result}"
3690 );
3691 assert!(
3693 !result.contains("<!-- agent:boundary:real-id -->"),
3694 "boundary outside code block must be removed: {result}"
3695 );
3696 }
3697
3698 #[test]
3699 fn reposition_boundary_moves_to_end() {
3700 let doc = "\
3701<!-- agent:exchange -->
3702Previous response.
3703<!-- agent:boundary:old-id -->
3704User prompt here.
3705<!-- /agent:exchange -->";
3706 let result = reposition_boundary_to_end(doc);
3707 assert!(!result.contains("old-id"), "old boundary should be removed");
3709 assert!(
3711 result.contains("<!-- agent:boundary:"),
3712 "new boundary should be inserted"
3713 );
3714 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
3716 let prompt_pos = result.find("User prompt here.").unwrap();
3717 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
3718 assert!(
3719 boundary_pos > prompt_pos,
3720 "boundary should be after user prompt"
3721 );
3722 assert!(
3723 boundary_pos < close_pos,
3724 "boundary should be before close tag"
3725 );
3726 }
3727
3728 #[test]
3729 fn reposition_boundary_no_exchange_unchanged() {
3730 let doc = "\
3731<!-- agent:output -->
3732Some content.
3733<!-- /agent:output -->";
3734 let result = reposition_boundary_to_end(doc);
3735 assert!(
3736 !result.contains("<!-- agent:boundary:"),
3737 "no boundary should be added to non-exchange"
3738 );
3739 }
3740
3741 #[test]
3742 fn reposition_boundary_clean_reuses_explicit_id() {
3743 let doc = "\
3744<!-- agent:exchange -->
3745Previous response.
3746<!-- agent:boundary:old-id -->
3747User prompt here.
3748<!-- /agent:exchange -->";
3749 let result = reposition_boundary_to_end_clean_with_id(doc, Some("keep-this-id"));
3750 assert!(!result.contains("old-id"), "old boundary should be removed");
3751 assert!(
3752 result.contains("<!-- agent:boundary:keep-this-id -->"),
3753 "explicit boundary id should be reused"
3754 );
3755 assert_eq!(
3756 result.matches("<!-- agent:boundary:").count(),
3757 1,
3758 "exactly one boundary should remain"
3759 );
3760 }
3761
3762 #[test]
3763 fn reposition_appends_head_to_last_re_heading() {
3764 let doc = "\
3768<!-- agent:exchange -->
3769### Re: older (HEAD)
3770old body
3771### Re: newer
3772new body
3773<!-- /agent:exchange -->";
3774 let result = reposition_boundary_to_end(doc);
3775 assert!(
3776 !result.contains("### Re: older (HEAD)"),
3777 "stale (HEAD) on prior heading must be stripped; got:\n{result}"
3778 );
3779 assert!(
3780 result.contains("### Re: older\n"),
3781 "older heading must remain (without HEAD); got:\n{result}"
3782 );
3783 assert!(
3784 result.contains("### Re: newer (HEAD)"),
3785 "latest heading must get (HEAD); got:\n{result}"
3786 );
3787 assert_eq!(
3788 result.matches("(HEAD)").count(),
3789 1,
3790 "exactly one (HEAD) in result; got:\n{result}"
3791 );
3792 }
3793
3794 #[test]
3795 fn reposition_head_annotation_no_re_heading_unchanged() {
3796 let doc = "\
3798<!-- agent:exchange -->
3799User text with no response headings.
3800<!-- /agent:exchange -->";
3801 let result = reposition_boundary_to_end(doc);
3802 assert!(
3803 !result.contains("(HEAD)"),
3804 "no heading → no (HEAD); got:\n{result}"
3805 );
3806 }
3807
3808 #[test]
3809 fn reposition_head_annotation_skips_code_fence() {
3810 let doc = "\
3812<!-- agent:exchange -->
3813### Re: real heading
3814```markdown
3815### Re: fake heading in code fence
3816```
3817<!-- /agent:exchange -->";
3818 let result = reposition_boundary_to_end(doc);
3819 assert!(
3820 result.contains("### Re: real heading (HEAD)"),
3821 "real heading outside fence gets (HEAD); got:\n{result}"
3822 );
3823 assert!(
3824 result.contains("### Re: fake heading in code fence\n"),
3825 "fenced heading must be untouched; got:\n{result}"
3826 );
3827 assert_eq!(
3828 result.matches("(HEAD)").count(),
3829 1,
3830 "exactly one (HEAD) — fenced heading ignored; got:\n{result}"
3831 );
3832 }
3833
3834 #[test]
3835 fn reposition_with_baseline_marks_all_new_re_headings() {
3836 let doc = "\
3840<!-- agent:exchange -->
3841### Re: old-1
3842body a
3843### Re: old-2 (HEAD)
3844body b
3845### Re: new-1
3846body c
3847### Re: new-2
3848body d
3849<!-- /agent:exchange -->";
3850 let mut baseline = std::collections::HashSet::new();
3853 baseline.insert("### Re: old-1".to_string());
3854 baseline.insert("### Re: old-2".to_string());
3855
3856 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
3857
3858 assert!(
3860 result.contains("### Re: old-1\n"),
3861 "old-1 must not have (HEAD); got:\n{result}"
3862 );
3863 assert!(
3864 result.contains("### Re: old-2\n"),
3865 "old-2 must not have (HEAD); got:\n{result}"
3866 );
3867 assert!(
3869 result.contains("### Re: new-1 (HEAD)"),
3870 "new-1 must get (HEAD); got:\n{result}"
3871 );
3872 assert!(
3873 result.contains("### Re: new-2 (HEAD)"),
3874 "new-2 must get (HEAD); got:\n{result}"
3875 );
3876 assert_eq!(
3878 result.matches("(HEAD)").count(),
3879 2,
3880 "exactly two (HEAD) markers; got:\n{result}"
3881 );
3882 }
3883
3884 #[test]
3885 fn reposition_with_empty_baseline_marks_every_re_heading() {
3886 let doc = "\
3889<!-- agent:exchange -->
3890### Re: first
3891a
3892### Re: second
3893b
3894<!-- /agent:exchange -->";
3895 let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
3896 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
3897 assert!(
3898 result.contains("### Re: first (HEAD)"),
3899 "first gets (HEAD); got:\n{result}"
3900 );
3901 assert!(
3902 result.contains("### Re: second (HEAD)"),
3903 "second gets (HEAD); got:\n{result}"
3904 );
3905 assert_eq!(
3906 result.matches("(HEAD)").count(),
3907 2,
3908 "exactly two (HEAD) markers; got:\n{result}"
3909 );
3910 }
3911
3912 #[test]
3913 fn exchange_baseline_headings_extracts_stripped_re_lines() {
3914 let doc = "\
3915<!-- agent:exchange -->
3916### Re: one (HEAD)
3917body
3918### Re: two
3919more body
3920### Not a Re heading
3921body
3922<!-- /agent:exchange -->";
3923 let set = exchange_baseline_headings(doc);
3924 assert!(
3925 set.contains("### Re: one"),
3926 "stripped one present; got: {set:?}"
3927 );
3928 assert!(set.contains("### Re: two"), "two present; got: {set:?}");
3929 assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
3930 }
3931
3932 #[test]
3933 fn exchange_baseline_headings_normalizes_leading_whitespace() {
3934 let doc = "\
3937<!-- agent:exchange -->
3938 ### Re: indented
3939body
3940### Re: flush
3941more
3942<!-- /agent:exchange -->";
3943 let set = exchange_baseline_headings(doc);
3944 assert!(
3945 set.contains("### Re: indented"),
3946 "indented entry normalized; got: {set:?}"
3947 );
3948 assert!(
3949 set.contains("### Re: flush"),
3950 "flush entry present; got: {set:?}"
3951 );
3952 }
3953
3954 #[test]
3955 fn reposition_with_baseline_matches_indented_heading() {
3956 let doc = "\
3964<!-- agent:exchange -->
3965 ### Re: foo
3966body
3967### Re: bar (HEAD)
3968body2
3969<!-- /agent:exchange -->";
3970 let mut baseline = std::collections::HashSet::new();
3971 baseline.insert("### Re: foo".to_string());
3972 baseline.insert("### Re: bar".to_string());
3973 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
3974 assert!(
3979 result.contains(" ### Re: foo\n"),
3980 "indented heading must remain unmarked; got:\n{result}"
3981 );
3982 assert!(
3983 result.contains("### Re: bar (HEAD)"),
3984 "last heading gets fallback (HEAD) marker; got:\n{result}"
3985 );
3986 assert_eq!(
3987 result.matches("(HEAD)").count(),
3988 1,
3989 "exactly one (HEAD) via fallback; got:\n{result}"
3990 );
3991 }
3992
3993 #[test]
3994 fn baseline_filter_empty_falls_back_to_last_heading() {
3995 let doc = "\
4000<!-- agent:exchange -->
4001### Re: older
4002body
4003### Re: newer (HEAD)
4004more
4005<!-- /agent:exchange -->";
4006 let mut baseline = std::collections::HashSet::new();
4007 baseline.insert("### Re: older".to_string());
4008 baseline.insert("### Re: newer".to_string());
4009 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
4010 assert!(
4011 result.contains("### Re: newer (HEAD)"),
4012 "last heading retains (HEAD) via fallback; got:\n{result}"
4013 );
4014 assert!(
4015 result.contains("### Re: older\n"),
4016 "older heading remains unmarked; got:\n{result}"
4017 );
4018 assert_eq!(
4019 result.matches("(HEAD)").count(),
4020 1,
4021 "exactly one (HEAD) marker after fallback; got:\n{result}"
4022 );
4023 }
4024
4025 #[test]
4026 fn reposition_head_annotation_strips_multiple_stale() {
4027 let doc = "\
4029<!-- agent:exchange -->
4030### Re: one (HEAD)
4031a
4032### Re: two (HEAD)
4033b
4034### Re: three
4035c
4036<!-- /agent:exchange -->";
4037 let result = reposition_boundary_to_end(doc);
4038 assert_eq!(
4039 result.matches("(HEAD)").count(),
4040 1,
4041 "exactly one (HEAD) after reposition; got:\n{result}"
4042 );
4043 assert!(result.contains("### Re: three (HEAD)"));
4044 assert!(result.contains("### Re: one\n"));
4045 assert!(result.contains("### Re: two\n"));
4046 }
4047
4048 #[test]
4049 fn preserve_head_keeps_head_markers_on_reposition() {
4050 let doc = "\
4051<!-- agent:exchange -->
4052### Re: older
4053body a
4054### Re: newer (HEAD)
4055body b
4056<!-- agent:boundary:old-id -->
4057<!-- /agent:exchange -->";
4058 let result = reposition_boundary_to_end_preserve_head(doc);
4059 assert!(
4060 result.contains("### Re: newer (HEAD)"),
4061 "preserve_head must keep (HEAD) on newest heading; got:\n{result}"
4062 );
4063 assert!(
4064 !result.contains("old-id"),
4065 "old boundary should be removed; got:\n{result}"
4066 );
4067 assert_eq!(
4068 result.matches("<!-- agent:boundary:").count(),
4069 1,
4070 "exactly one fresh boundary; got:\n{result}"
4071 );
4072 }
4073
4074 #[test]
4075 fn preserve_head_with_id_keeps_head_and_uses_explicit_id() {
4076 let doc = "\
4077<!-- agent:exchange -->
4078### Re: topic (HEAD)
4079body
4080<!-- agent:boundary:old-id -->
4081<!-- /agent:exchange -->";
4082 let result = reposition_boundary_to_end_preserve_head_with_id(doc, Some("explicit-id"));
4083 assert!(
4084 result.contains("### Re: topic (HEAD)"),
4085 "preserve_head must keep (HEAD); got:\n{result}"
4086 );
4087 assert!(
4088 result.contains("<!-- agent:boundary:explicit-id -->"),
4089 "explicit boundary id should be used; got:\n{result}"
4090 );
4091 assert!(
4092 !result.contains("old-id"),
4093 "old boundary gone; got:\n{result}"
4094 );
4095 }
4096
4097 #[test]
4098 fn clean_strips_head_but_preserve_head_keeps_it() {
4099 let doc = "\
4100<!-- agent:exchange -->
4101### Re: first
4102body a
4103### Re: second (HEAD)
4104body b
4105<!-- /agent:exchange -->";
4106 let clean = reposition_boundary_to_end_clean(doc);
4107 let preserved = reposition_boundary_to_end_preserve_head(doc);
4108
4109 assert!(
4110 !clean.contains("(HEAD)"),
4111 "clean variant must strip (HEAD); got:\n{clean}"
4112 );
4113 assert!(
4114 preserved.contains("### Re: second (HEAD)"),
4115 "preserve_head variant must keep (HEAD); got:\n{preserved}"
4116 );
4117 }
4118
4119 #[test]
4120 fn max_lines_inline_attr_trims_content() {
4121 let dir = setup_project();
4122 let doc_path = dir.path().join("test.md");
4123 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
4124 std::fs::write(&doc_path, doc).unwrap();
4125
4126 let patches = vec![PatchBlock {
4127 name: "log".to_string(),
4128 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
4129 attrs: Default::default(),
4130 }];
4131 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4132 assert!(!result.contains("line1"));
4133 assert!(!result.contains("line2"));
4134 assert!(result.contains("line3"));
4135 assert!(result.contains("line4"));
4136 assert!(result.contains("line5"));
4137 }
4138
4139 #[test]
4140 fn max_lines_noop_when_under_limit() {
4141 let dir = setup_project();
4142 let doc_path = dir.path().join("test.md");
4143 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
4144 std::fs::write(&doc_path, doc).unwrap();
4145
4146 let patches = vec![PatchBlock {
4147 name: "log".to_string(),
4148 content: "line1\nline2\n".to_string(),
4149 attrs: Default::default(),
4150 }];
4151 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4152 assert!(result.contains("line1"));
4153 assert!(result.contains("line2"));
4154 }
4155
4156 #[test]
4157
4158 #[test]
4159 fn max_lines_inline_beats_toml() {
4160 let dir = setup_project();
4161 let doc_path = dir.path().join("test.md");
4162 std::fs::write(
4163 dir.path().join(".agent-doc/config.toml"),
4164 "[components.log]\nmax_lines = 1\n",
4165 )
4166 .unwrap();
4167 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
4168 std::fs::write(&doc_path, doc).unwrap();
4169
4170 let patches = vec![PatchBlock {
4171 name: "log".to_string(),
4172 content: "a\nb\nc\nd\n".to_string(),
4173 attrs: Default::default(),
4174 }];
4175 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4176 assert!(result.contains("b"));
4178 assert!(result.contains("c"));
4179 assert!(result.contains("d"));
4180 }
4181
4182 #[test]
4183 fn parse_patch_with_transfer_source_attr() {
4184 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
4185 let (patches, unmatched) = parse_patches(response).unwrap();
4186 assert_eq!(patches.len(), 1);
4187 assert_eq!(patches[0].name, "exchange");
4188 assert_eq!(patches[0].content, "Transferred content.\n");
4189 assert_eq!(
4190 patches[0].attrs.get("transfer-source"),
4191 Some(&"\"tasks/eval-runner.md\"".to_string())
4192 );
4193 assert!(unmatched.is_empty());
4194 }
4195
4196 #[test]
4197 fn parse_patch_without_attrs() {
4198 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
4199 let (patches, _) = parse_patches(response).unwrap();
4200 assert_eq!(patches.len(), 1);
4201 assert!(patches[0].attrs.is_empty());
4202 }
4203
4204 #[test]
4205 fn parse_patch_with_multiple_attrs() {
4206 let response =
4207 "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
4208 let (patches, _) = parse_patches(response).unwrap();
4209 assert_eq!(patches.len(), 1);
4210 assert_eq!(patches[0].name, "output");
4211 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
4212 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
4213 }
4214
4215 #[test]
4216 fn apply_patches_dedup_exchange_adjacent_echo() {
4217 let dir = setup_project();
4221 let doc_path = dir.path().join("test.md");
4222 let doc = "\
4223<!-- agent:exchange patch=append -->
4224❯ How do I configure .mise.toml?
4225<!-- /agent:exchange -->
4226";
4227 std::fs::write(&doc_path, doc).unwrap();
4228
4229 let patches = vec![PatchBlock {
4231 name: "exchange".to_string(),
4232 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
4233 attrs: Default::default(),
4234 }];
4235 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4236
4237 let count = result.matches("❯ How do I configure .mise.toml?").count();
4238 assert_eq!(
4239 count, 1,
4240 "prompt line should appear exactly once, got:\n{result}"
4241 );
4242 assert!(
4243 result.contains("### Re: configure .mise.toml"),
4244 "response heading should be present"
4245 );
4246 assert!(
4247 result.contains("Use `[env]` section."),
4248 "response body should be present"
4249 );
4250 }
4251
4252 #[test]
4253 fn apply_patches_dedup_preserves_blank_lines() {
4254 let dir = setup_project();
4256 let doc_path = dir.path().join("test.md");
4257 let doc = "\
4258<!-- agent:exchange patch=append -->
4259Previous response.
4260<!-- /agent:exchange -->
4261";
4262 std::fs::write(&doc_path, doc).unwrap();
4263
4264 let patches = vec![PatchBlock {
4265 name: "exchange".to_string(),
4266 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
4267 attrs: Default::default(),
4268 }];
4269 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4270 assert!(
4271 result.contains("Previous response."),
4272 "existing content preserved"
4273 );
4274 assert!(
4275 result.contains("### Re: something"),
4276 "response heading present"
4277 );
4278 assert!(result.contains('\n'), "blank lines preserved");
4280 }
4281
4282 #[test]
4283 fn apply_patches_dedup_preserves_adjacent_code_fences() {
4284 let dir = setup_project();
4285 let doc_path = dir.path().join("test.md");
4286 let doc = "\
4287<!-- agent:exchange patch=append -->
4288Some text.
4289```
4290code block 1
4291```
4292```
4293code block 2
4294```
4295<!-- /agent:exchange -->
4296";
4297 std::fs::write(&doc_path, doc).unwrap();
4298
4299 let patches = vec![PatchBlock {
4300 name: "exchange".to_string(),
4301 content: "### Re: test — opus-4-6\n\nResponse.\n".to_string(),
4302 attrs: Default::default(),
4303 }];
4304 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4305 let fence_count = result.matches("```").count();
4306 assert_eq!(
4307 fence_count, 4,
4308 "all four code fences must survive dedup, got:\n{result}"
4309 );
4310 assert!(
4311 result.contains("```\n```"),
4312 "adjacent code fences must be preserved"
4313 );
4314 }
4315
4316 #[test]
4317 fn apply_patches_marks_new_exchange_headings_with_head() {
4318 let dir = setup_project();
4319 let doc_path = dir.path().join("test.md");
4320 let doc = "\
4321<!-- agent:exchange patch=append -->
4322### Re: earlier — gpt-5
4323
4324Existing answer.
4325<!-- /agent:exchange -->
4326";
4327 std::fs::write(&doc_path, doc).unwrap();
4328
4329 let patches = vec![PatchBlock {
4330 name: "exchange".to_string(),
4331 content: "### Re: latest — gpt-5\n\nFresh answer.\n".to_string(),
4332 attrs: Default::default(),
4333 }];
4334 let result =
4335 apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &Default::default())
4336 .unwrap();
4337
4338 assert!(
4339 result.contains("### Re: earlier — gpt-5\n"),
4340 "existing response heading must stay clean; got:\n{result}"
4341 );
4342 assert!(
4343 result.contains("### Re: latest — gpt-5 (HEAD)\n"),
4344 "new response heading must surface as transient HEAD; got:\n{result}"
4345 );
4346 assert_eq!(
4347 result.matches("(HEAD)").count(),
4348 1,
4349 "exactly one transient HEAD marker expected; got:\n{result}"
4350 );
4351 }
4352
4353 #[test]
4354 fn apply_patches_binds_exchange_response_to_oldest_matching_unresolved_prompt() {
4355 let dir = setup_project();
4356 let doc_path = dir.path().join("test.md");
4357 let doc = concat!(
4358 "<!-- agent:exchange patch=append -->\n",
4359 "### Re: prior — gpt-5\n\n",
4360 "Done.\n",
4361 "<!-- agent:boundary:old-anchor -->\n",
4362 "❯ do #wcup1. spec-test-commit-push\n\n",
4363 "❯ do #wcx1. spec-test-commit-push\n",
4364 "<!-- /agent:exchange -->\n",
4365 );
4366 std::fs::write(&doc_path, doc).unwrap();
4367
4368 let patches = vec![PatchBlock {
4369 name: "exchange".to_string(),
4370 content: "### Re: #wcup1 — gpt-5\n\nAlready complete.\n".to_string(),
4371 attrs: Default::default(),
4372 }];
4373 let result =
4374 apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &Default::default())
4375 .unwrap();
4376
4377 let wcup1_prompt = result.find("❯ do #wcup1. spec-test-commit-push").unwrap();
4378 let wcup1_response = result.find("### Re: #wcup1 — gpt-5 (HEAD)").unwrap();
4379 let new_boundary = result.rfind("<!-- agent:boundary:").unwrap();
4380 let wcx1_prompt = result.find("❯ do #wcx1. spec-test-commit-push").unwrap();
4381
4382 assert!(
4383 wcup1_prompt < wcup1_response,
4384 "response must land after the matched older prompt:\n{result}"
4385 );
4386 assert!(
4387 wcup1_response < new_boundary,
4388 "new boundary must move behind the response:\n{result}"
4389 );
4390 assert!(
4391 new_boundary < wcx1_prompt,
4392 "newer unresolved prompts must remain after the new boundary:\n{result}"
4393 );
4394 }
4395
4396 #[test]
4397 fn apply_patches_rejects_exchange_response_that_skips_older_unresolved_prompt() {
4398 let dir = setup_project();
4399 let doc_path = dir.path().join("test.md");
4400 let doc = concat!(
4401 "<!-- agent:exchange patch=append -->\n",
4402 "### Re: prior — gpt-5\n\n",
4403 "Done.\n",
4404 "<!-- agent:boundary:old-anchor -->\n",
4405 "❯ do #wcup1. spec-test-commit-push\n\n",
4406 "❯ do #wcx1. spec-test-commit-push\n",
4407 "<!-- /agent:exchange -->\n",
4408 );
4409 std::fs::write(&doc_path, doc).unwrap();
4410
4411 let patches = vec![PatchBlock {
4412 name: "exchange".to_string(),
4413 content: "### Re: #wcx1 — gpt-5\n\nNot done yet.\n".to_string(),
4414 attrs: Default::default(),
4415 }];
4416 let err = apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &Default::default())
4417 .expect_err("later-matching response should fail closed");
4418 assert!(
4419 err.to_string().contains("skip older unresolved prompt"),
4420 "unexpected error: {err}"
4421 );
4422 }
4423
4424 #[test]
4425 fn apply_mode_append_strips_leading_overlap() {
4426 let existing = "❯ How do I configure .mise.toml?\n";
4429 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
4430 let result = apply_mode("append", existing, new_content);
4431 let count = result.matches("❯ How do I configure .mise.toml?").count();
4432 assert_eq!(count, 1, "overlap line should appear exactly once");
4433 assert!(result.contains("### Re: configure"));
4434 }
4435
4436 #[test]
4437 fn strip_trailing_caret_removes_bare_prompt_line() {
4438 let content = "Answer text.\n❯\n";
4439 assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
4440 }
4441
4442 #[test]
4443 fn strip_trailing_caret_removes_multiple_trailing_lines() {
4444 let content = "Answer.\n❯\n❯\n";
4445 assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
4446 }
4447
4448 #[test]
4449 fn strip_trailing_caret_preserves_mid_content_caret() {
4450 let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
4452 assert_eq!(strip_trailing_caret_lines(content), content);
4453 }
4454
4455 #[test]
4456 fn strip_trailing_caret_preserves_caret_with_text() {
4457 let content = "Answer.\n❯ follow-up\n";
4459 assert_eq!(strip_trailing_caret_lines(content), content);
4460 }
4461
4462 #[test]
4463 fn strip_trailing_caret_handles_no_trailing_newline() {
4464 let content = "Answer.\n❯";
4465 assert_eq!(strip_trailing_caret_lines(content), "Answer.");
4466 }
4467
4468 #[test]
4469 fn strip_trailing_caret_noop_when_no_caret() {
4470 let content = "Answer.\n";
4471 assert_eq!(strip_trailing_caret_lines(content), content);
4472 }
4473
4474 #[test]
4475 fn apply_patches_strips_trailing_caret_from_exchange() {
4476 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
4477 let patches = vec![PatchBlock {
4478 name: "exchange".to_string(),
4479 content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
4480 attrs: Default::default(),
4481 }];
4482 let doc_path = std::path::PathBuf::from("/tmp/test.md");
4483 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4484 let components = component::parse(&result).unwrap();
4486 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
4487 let content = exchange.content(&result);
4488 let has_bare_caret_before_boundary = content
4490 .lines()
4491 .collect::<Vec<_>>()
4492 .windows(2)
4493 .any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
4494 assert!(
4495 !has_bare_caret_before_boundary,
4496 "bare ❯ line must not appear before boundary marker. content:\n{}",
4497 content
4498 );
4499 }
4500
4501 #[test]
4502 fn apply_patches_preserves_caret_in_non_exchange() {
4503 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
4506 let patches = vec![PatchBlock {
4507 name: "notes".to_string(),
4508 content: "note body\n❯\n".to_string(),
4509 attrs: Default::default(),
4510 }];
4511 let doc_path = std::path::PathBuf::from("/tmp/test.md");
4512 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
4513 let components = component::parse(&result).unwrap();
4514 let notes = components.iter().find(|c| c.name == "notes").unwrap();
4515 assert!(
4516 notes.content(&result).contains("❯"),
4517 "non-exchange content retains ❯"
4518 );
4519 }
4520
4521 #[test]
4522 fn apply_mode_append_no_overlap_unchanged() {
4523 let existing = "Previous content.\n";
4526 let new_content = "### Re: something\n\nAnswer.\n";
4527 let result = apply_mode("append", existing, new_content);
4528 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
4529 }
4530
4531 #[test]
4532 fn repair_conversation_tail_outside_exchange_moves_tail_back_inside_exchange() {
4533 let doc = concat!(
4534 "---\nagent_doc_format: template\n---\n\n",
4535 "## Exchange\n\n",
4536 "<!-- agent:exchange patch=append -->\n",
4537 "### Re: earlier — gpt-5\n\n",
4538 "Done.\n",
4539 "<!-- agent:boundary:abc123 -->\n",
4540 "<!-- /agent:exchange -->\n\n",
4541 "## Pending / Not Built\n\n",
4542 "<!-- agent:pending -->\n",
4543 "- [ ] keep me\n",
4544 "<!-- /agent:pending -->\n\n",
4545 "## Assistant\n\n",
4546 "Follow-up response.\n"
4547 );
4548
4549 let repaired = repair_conversation_tail_outside_exchange(doc)
4550 .unwrap()
4551 .expect("repair should apply");
4552 let exchange_close = repaired.find("<!-- /agent:exchange -->").unwrap();
4553 let pending_open = repaired.find("<!-- agent:pending -->").unwrap();
4554 let assistant = repaired.find("## Assistant").unwrap();
4555
4556 assert!(
4557 assistant < exchange_close,
4558 "assistant tail should move back inside exchange:\n{repaired}"
4559 );
4560 assert!(
4561 pending_open > exchange_close,
4562 "pending should remain outside exchange:\n{repaired}"
4563 );
4564 assert_eq!(
4565 repaired.matches("<!-- agent:boundary:").count(),
4566 1,
4567 "repair should leave exactly one boundary marker"
4568 );
4569 }
4570
4571 #[test]
4572 fn repair_conversation_tail_outside_exchange_rejects_ambiguous_suffix() {
4573 let doc = concat!(
4574 "---\nagent_doc_format: template\n---\n\n",
4575 "<!-- agent:exchange patch=append -->\n",
4576 "### Re: earlier — gpt-5\n",
4577 "<!-- agent:boundary:abc123 -->\n",
4578 "<!-- /agent:exchange -->\n\n",
4579 "## Assistant\n\n",
4580 "Escaped answer.\n\n",
4581 "## Todo / Backlog\n\n",
4582 "- not conversation content\n"
4583 );
4584
4585 let err = repair_conversation_tail_outside_exchange(doc).unwrap_err();
4586 assert!(
4587 err.to_string().contains("escaped `agent:exchange`"),
4588 "unexpected error: {err}"
4589 );
4590 }
4591
4592 #[test]
4593 fn repair_conversation_tail_outside_exchange_moves_plain_trailing_suffix_after_todo() {
4594 let doc = concat!(
4595 "---\nagent_doc_format: template\n---\n\n",
4596 "<!-- agent:exchange patch=append -->\n",
4597 "## User\n",
4598 "compact exchange\n",
4599 "<!-- agent:boundary:abc123 -->\n",
4600 "<!-- /agent:exchange -->\n\n",
4601 "<!-- agent:pending -->\n",
4602 "- [ ] keep me\n",
4603 "<!-- /agent:pending -->\n\n",
4604 "<!-- agent:todo patch=replace -->\n",
4605 "- [ ] backlog\n",
4606 "<!-- /agent:todo -->\n\n",
4607 "Exchange compacted. No new work was run in this turn.\n\n",
4608 "## Assistant\n\n",
4609 "Exchange compacted. No new work was run in this turn.\n\n",
4610 "## User\n"
4611 );
4612
4613 let repaired = repair_conversation_tail_outside_exchange(doc)
4614 .unwrap()
4615 .expect("repair should apply");
4616 let exchange_close = repaired.find("<!-- /agent:exchange -->").unwrap();
4617 let pending_open = repaired.find("<!-- agent:pending -->").unwrap();
4618 let todo_open = repaired.find("<!-- agent:todo patch=replace -->").unwrap();
4619 let trailing_summary = repaired
4620 .rfind("Exchange compacted. No new work was run in this turn.")
4621 .unwrap();
4622
4623 assert!(
4624 trailing_summary < exchange_close,
4625 "plain trailing suffix should move back inside exchange:\n{repaired}"
4626 );
4627 assert!(
4628 pending_open > exchange_close && todo_open > exchange_close,
4629 "sibling components should stay outside exchange:\n{repaired}"
4630 );
4631 assert_eq!(
4632 repaired.matches("<!-- agent:boundary:").count(),
4633 1,
4634 "repair should leave exactly one boundary marker"
4635 );
4636 }
4637
4638 #[test]
4639 fn repair_conversation_tail_outside_exchange_ignores_comment_only_suffix() {
4640 let doc = concat!(
4641 "---\nagent_doc_format: template\n---\n\n",
4642 "<!-- agent:exchange patch=append -->\n",
4643 "### Re: earlier — gpt-5\n",
4644 "<!-- agent:boundary:abc123 -->\n",
4645 "<!-- /agent:exchange -->\n\n",
4646 "[//]: # (leave this note outside exchange)\n"
4647 );
4648
4649 let repaired = repair_conversation_tail_outside_exchange(doc).unwrap();
4650 assert!(
4651 repaired.is_none(),
4652 "comment-only suffix should stay outside exchange"
4653 );
4654 }
4655
4656 #[test]
4657 fn repair_conversation_tail_outside_exchange_ignores_html_comment_body() {
4658 let doc = concat!(
4659 "---\nagent_doc_format: template\n---\n\n",
4660 "<!-- agent:exchange patch=append -->\n",
4661 "### Re: earlier — gpt-5\n",
4662 "<!-- agent:boundary:abc123 -->\n",
4663 "<!-- /agent:exchange -->\n\n",
4664 "<!--\n",
4665 "do #hidden. spec-test-build-install-commit-push\n",
4666 "Can this stay hidden?\n",
4667 "-->\n\n",
4668 "<!-- agent:backlog -->\n",
4669 "- [ ] keep me\n",
4670 "<!-- /agent:backlog -->\n"
4671 );
4672
4673 let repaired = repair_conversation_tail_outside_exchange(doc).unwrap();
4674 assert!(
4675 repaired.is_none(),
4676 "prompt-like text inside ordinary HTML comments must not be moved into exchange"
4677 );
4678 }
4679
4680 #[test]
4681 fn repair_conversation_tail_outside_exchange_ignores_unterminated_html_comment_body() {
4682 let doc = concat!(
4683 "---\nagent_doc_format: template\n---\n\n",
4684 "<!-- agent:exchange patch=append -->\n",
4685 "### Re: earlier — gpt-5\n",
4686 "<!-- agent:boundary:abc123 -->\n",
4687 "<!-- /agent:exchange -->\n\n",
4688 "<!--\n",
4689 "do #hidden. spec-test-build-install-commit-push\n",
4690 "Still typing this scratch note.\n"
4691 );
4692
4693 let repaired = repair_conversation_tail_outside_exchange(doc).unwrap();
4694 assert!(
4695 repaired.is_none(),
4696 "prompt-like text inside a transiently unclosed ordinary HTML comment must not be moved into exchange"
4697 );
4698 guard_no_conversation_tail_outside_exchange(doc).unwrap();
4699 }
4700
4701 #[test]
4702 fn repair_conversation_tail_outside_exchange_moves_gap_before_backlog_inside_exchange() {
4703 let doc = concat!(
4704 "---\nagent_doc_format: template\n---\n\n",
4705 "<!-- agent:exchange patch=append -->\n",
4706 "### Re: earlier — gpt-5\n",
4707 "Done.\n",
4708 "<!-- agent:boundary:abc123 -->\n",
4709 "<!-- /agent:exchange -->\n\n",
4710 "###\n\n",
4711 "### Re: gap — gpt-5\n\n",
4712 "Escaped answer.\n\n",
4713 "<!-- agent:backlog -->\n",
4714 "- [ ] keep me\n",
4715 "<!-- /agent:backlog -->\n"
4716 );
4717
4718 let repaired = repair_conversation_tail_outside_exchange(doc)
4719 .unwrap()
4720 .expect("repair should apply");
4721 let exchange_close = repaired.find("<!-- /agent:exchange -->").unwrap();
4722 let response = repaired.find("### Re: gap — gpt-5").unwrap();
4723 let gap_marker = repaired.find("\n###\n\n").unwrap();
4724 let backlog = repaired.find("<!-- agent:backlog -->").unwrap();
4725
4726 assert!(
4727 response < exchange_close,
4728 "gap response should move back inside exchange:\n{repaired}"
4729 );
4730 assert!(
4731 gap_marker > exchange_close,
4732 "plain gap marker should remain outside exchange:\n{repaired}"
4733 );
4734 assert!(
4735 backlog > exchange_close,
4736 "backlog should remain outside exchange:\n{repaired}"
4737 );
4738 }
4739
4740 #[test]
4741 fn repair_conversation_tail_outside_exchange_moves_prompt_before_gap_inside_exchange() {
4742 let doc = concat!(
4743 "---\nagent_doc_format: template\n---\n\n",
4744 "<!-- agent:exchange patch=append -->\n",
4745 "### Re: earlier — gpt-5\n",
4746 "Done.\n",
4747 "<!-- agent:boundary:abc123 -->\n",
4748 "<!-- /agent:exchange -->\n\n",
4749 "do [#oobprompt]. spec-test-build-install-commit-push\n",
4750 "###\n\n",
4751 "<!-- agent:backlog -->\n",
4752 "- [ ] keep me\n",
4753 "<!-- /agent:backlog -->\n"
4754 );
4755
4756 let repaired = repair_conversation_tail_outside_exchange(doc)
4757 .unwrap()
4758 .expect("repair should apply");
4759 let exchange_close = repaired.find("<!-- /agent:exchange -->").unwrap();
4760 let prompt = repaired
4761 .find("do [#oobprompt]. spec-test-build-install-commit-push")
4762 .unwrap();
4763 let gap_marker = repaired.find("\n###\n\n").unwrap();
4764 let backlog = repaired.find("<!-- agent:backlog -->").unwrap();
4765
4766 assert!(
4767 prompt < exchange_close,
4768 "prompt should move back inside exchange:\n{repaired}"
4769 );
4770 assert!(
4771 gap_marker > exchange_close,
4772 "plain gap marker should remain outside exchange:\n{repaired}"
4773 );
4774 assert!(
4775 backlog > exchange_close,
4776 "backlog should remain outside exchange:\n{repaired}"
4777 );
4778 }
4779
4780 #[test]
4781 fn repair_duplicate_exchange_close_tail_moves_escaped_response_back_inside_exchange() {
4782 let doc = concat!(
4783 "---\nagent_doc_format: template\n---\n\n",
4784 "<!-- agent:exchange patch=append -->\n",
4785 "❯ earlier question\n",
4786 "<!-- agent:boundary:abc123 -->\n",
4787 "<!-- /agent:exchange -->\n\n",
4788 "### Re: later — gpt-5\n\n",
4789 "Escaped answer.\n",
4790 "<!-- /agent:exchange -->\n\n",
4791 "<!-- agent:backlog -->\n",
4792 "- [ ] keep me\n",
4793 "<!-- /agent:backlog -->\n"
4794 );
4795
4796 let repaired = repair_duplicate_exchange_close_tail(doc)
4797 .unwrap()
4798 .expect("duplicate close repair should apply");
4799 let exchange_close = repaired.find("<!-- /agent:exchange -->").unwrap();
4800 let response = repaired.find("### Re: later — gpt-5").unwrap();
4801 let backlog = repaired.find("<!-- agent:backlog -->").unwrap();
4802
4803 assert!(
4804 response < exchange_close,
4805 "escaped response should move back inside exchange:\n{repaired}"
4806 );
4807 assert!(
4808 backlog > exchange_close,
4809 "backlog should remain outside exchange:\n{repaired}"
4810 );
4811 assert_eq!(
4812 repaired.matches("<!-- /agent:exchange -->").count(),
4813 1,
4814 "repair should leave exactly one exchange close marker"
4815 );
4816 }
4817
4818 #[test]
4819 fn repair_duplicate_exchange_close_scaffold_drops_inserted_template_shell() {
4820 let doc = concat!(
4821 "---\nagent_doc_format: template\n---\n\n",
4822 "## Exchange\n\n",
4823 "<!-- agent:exchange patch=append -->\n",
4824 "### Session Summary\n\n",
4825 "<!-- agent:boundary:abc123 -->\n",
4826 "JB `Run Agent Doc` failed on this document.\n",
4827 "<!-- /agent:exchange -->\n",
4828 "###\n",
4829 "<!--\n",
4830 "-->\n\n",
4831 "<!-- agent:queue -->\n",
4832 "<!-- /agent:queue -->\n\n",
4833 "## Pending / Not Built\n\n",
4834 "<!-- agent:backlog -->\n",
4835 "- [ ] [#aaaa] keep me\n",
4836 "<!-- /agent:backlog -->\n\n",
4837 "## Completed / Reaped\n\n",
4838 "<!-- agent:done -->\n",
4839 "<!-- /agent:done -->\n",
4840 "<!-- /agent:exchange -->\n",
4841 "###\n",
4842 "<!--\n",
4843 "-->\n\n",
4844 "<!-- agent:queue -->\n",
4845 "<!-- /agent:queue -->\n\n",
4846 "## Pending / Not Built\n\n",
4847 "<!-- agent:backlog -->\n",
4848 "- [ ] [#aaaa] keep me\n",
4849 "<!-- /agent:backlog -->\n"
4850 );
4851
4852 let repaired = repair_duplicate_exchange_close_scaffold(doc)
4853 .unwrap()
4854 .expect("duplicate scaffold repair should apply");
4855
4856 assert_eq!(
4857 repaired.matches("<!-- /agent:exchange -->").count(),
4858 1,
4859 "repair should leave one exchange close:\n{repaired}"
4860 );
4861 assert_eq!(
4862 repaired.matches("<!-- agent:queue -->").count(),
4863 1,
4864 "repair should drop the duplicated queue scaffold:\n{repaired}"
4865 );
4866 assert!(repaired.contains("JB `Run Agent Doc` failed on this document."));
4867 assert!(component::parse(&repaired).is_ok());
4868 }
4869
4870 #[test]
4871 fn repair_duplicate_exchange_close_scaffold_rejects_mixed_user_text() {
4872 let doc = concat!(
4873 "---\nagent_doc_format: template\n---\n\n",
4874 "## Exchange\n\n",
4875 "<!-- agent:exchange patch=append -->\n",
4876 "### Session Summary\n\n",
4877 "<!-- agent:boundary:abc123 -->\n",
4878 "c The arrow\n",
4879 "<!-- /agent:exchange -->\n",
4880 "###\n",
4881 "<!--\n",
4882 "Use TEST_THREADS=8.\n",
4883 "-->\n\n",
4884 "<!-- agent:queue -->\n",
4885 "<!-- /agent:queue -->\n\n",
4886 "## Pending / Not Built\n\n",
4887 "<!-- agent:backlog -->\n",
4888 "- [ ] [#aaaa] keep me\n",
4889 "<!-- /agent:backlog -->\n\n",
4890 "## Completed / Reaped\n\n",
4891 "<!-- agent:done -->\n",
4892 "<!-- /agent:done -->\n",
4893 "corky.md The arrow\n",
4894 "<!-- /agent:exchange -->\n",
4895 "###\n",
4896 "<!--\n",
4897 "Use TEST_THREADS=8.\n",
4898 "-->\n\n",
4899 "<!-- agent:queue -->\n",
4900 "<!-- /agent:queue -->\n\n",
4901 "## Pending / Not Built\n\n",
4902 "<!-- agent:backlog -->\n",
4903 "- [ ] [#aaaa] keep me\n",
4904 "<!-- /agent:backlog -->\n"
4905 );
4906
4907 let repaired = repair_duplicate_exchange_close_scaffold(doc).unwrap();
4908
4909 assert!(
4910 repaired.is_none(),
4911 "mixed user text must not be dropped as duplicated scaffold"
4912 );
4913 }
4914
4915 #[test]
4916 fn normalize_editor_visible_template_structure_repairs_duplicate_scaffold() {
4917 let doc = concat!(
4918 "<!-- agent:exchange patch=append -->\n",
4919 "### Session Summary\n",
4920 "<!-- /agent:exchange -->\n",
4921 "###\n",
4922 "<!--\n",
4923 "Use TEST_THREADS=8.\n",
4924 "-->\n\n",
4925 "<!-- agent:queue -->\n",
4926 "<!-- /agent:queue -->\n\n",
4927 "<!-- agent:backlog -->\n",
4928 "- [ ] [#aaaa] keep me\n",
4929 "<!-- /agent:backlog -->\n\n",
4930 "<!-- /agent:exchange -->\n",
4931 "###\n",
4932 "<!--\n",
4933 "Use TEST_THREADS=8.\n",
4934 "-->\n\n",
4935 "<!-- agent:queue -->\n",
4936 "<!-- /agent:queue -->\n\n",
4937 "<!-- agent:backlog -->\n",
4938 "- [ ] [#aaaa] keep me\n",
4939 "<!-- /agent:backlog -->\n"
4940 );
4941
4942 let repaired = normalize_editor_visible_template_structure(doc)
4943 .expect("safe duplicate scaffold should repair before editor write");
4944
4945 assert_eq!(repaired.matches("<!-- /agent:exchange -->").count(), 1);
4946 assert_eq!(repaired.matches("<!-- agent:queue -->").count(), 1);
4947 assert_eq!(repaired.matches("<!-- agent:backlog -->").count(), 1);
4948 guard_no_conversation_tail_outside_exchange(&repaired).unwrap();
4949 }
4950
4951 #[test]
4952 fn normalize_editor_visible_template_structure_preserves_duplicate_prompt_html_comment_body() {
4953 let prompt = "The duplicate content corrupting document and duplicate prompt issues happened yet again. Very tired of playing whack-a-mole. Reproduce bugs with tests first that fail and fix the implementation. #spec-test-build-install-commit-push";
4954 let doc = format!(
4955 concat!(
4956 "<!-- agent:exchange patch=append -->\n",
4957 "### Re: prior — gpt-5\n",
4958 "Done.\n",
4959 "<!-- agent:boundary:head -->\n",
4960 "{prompt}\n",
4961 "<!-- /agent:exchange -->\n\n",
4962 "###\n\n",
4963 "<!--\n",
4964 "{prompt}\n",
4965 "-->\n\n",
4966 "<!--\n",
4967 "Keep this unrelated scratch note hidden.\n",
4968 "-->\n\n",
4969 "<!-- agent:backlog -->\n",
4970 "- [ ] keep me\n",
4971 "<!-- /agent:backlog -->\n"
4972 ),
4973 prompt = prompt
4974 );
4975
4976 let repaired = normalize_editor_visible_template_structure(&doc)
4977 .expect("editor-visible normalization should preserve scratch comments");
4978
4979 let duplicate_comment = format!("\n<!--\n{prompt}\n-->\n");
4980 assert!(
4981 repaired.contains(&duplicate_comment),
4982 "editor-visible normalization has no baseline ownership proof and must preserve post-exchange scratch text:\n{repaired}"
4983 );
4984 assert!(
4985 repaired.contains("<!-- agent:backlog -->\n- [ ] keep me"),
4986 "tracked-work scaffold should remain intact:\n{repaired}"
4987 );
4988 assert!(
4989 repaired.contains("Keep this unrelated scratch note hidden."),
4990 "unrelated scratch comments must stay outside exchange:\n{repaired}"
4991 );
4992 }
4993
4994 #[test]
4995 fn normalize_editor_visible_template_structure_preserves_mixed_prompt_comment_scratch_lines() {
4996 let exchange_prompt = "The content of the html comment below this agent:exchange element was deleted after the last agent-doc turn. The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
4997 let duplicate_prompt_line = "The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
4998 let doc = format!(
4999 concat!(
5000 "<!-- agent:exchange patch=append -->\n",
5001 "### Re: prior - gpt-5\n",
5002 "Done.\n",
5003 "<!-- agent:boundary:head -->\n",
5004 "{exchange_prompt}\n",
5005 "#spec-test-build-install-commit-push\n",
5006 "<!-- /agent:exchange -->\n\n",
5007 "###\n",
5008 "<!--\n",
5009 "{duplicate_prompt_line}\n",
5010 "#spec-test-build-install-commit-push\n",
5011 "---\n",
5012 "Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.\n",
5013 "-->\n\n",
5014 "<!-- agent:backlog -->\n",
5015 "<!-- /agent:backlog -->\n"
5016 ),
5017 exchange_prompt = exchange_prompt,
5018 duplicate_prompt_line = duplicate_prompt_line,
5019 );
5020
5021 let repaired = normalize_editor_visible_template_structure(&doc)
5022 .expect("editor-visible normalization should preserve mixed scratch comments");
5023
5024 assert!(
5025 repaired.contains(&format!("<!--\n{duplicate_prompt_line}")),
5026 "editor-visible normalization must not scrub mixed scratch comments without baseline proof:\n{repaired}"
5027 );
5028 assert!(
5029 repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
5030 "unrelated scratch lines in the same ordinary comment must survive:\n{repaired}"
5031 );
5032 assert!(
5033 repaired.contains(&format!(
5034 "<!--\n{duplicate_prompt_line}\n#spec-test-build-install-commit-push\n---\nLook through"
5035 )),
5036 "the mixed comment shell and full body should be preserved:\n{repaired}"
5037 );
5038 }
5039
5040 #[test]
5041 fn normalize_editor_visible_template_structure_preserves_scratch_comment_after_compact_summary()
5042 {
5043 let prompt = "The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
5044 let doc = format!(
5045 concat!(
5046 "<!-- agent:exchange patch=append -->\n",
5047 "### Session Summary\n\n",
5048 "Compacted content:\n",
5049 "- Trailing prompt/context: {prompt}\n",
5050 "❯ {prompt}\n",
5051 "❯ #spec-test-build-install-commit-push\n",
5052 "### Re: compact prompt duplication — gpt-5\n\n",
5053 "Line-by-line diff was the right diagnostic.\n",
5054 "<!-- agent:boundary:head -->\n",
5055 "<!-- /agent:exchange -->\n",
5056 "###\n",
5057 "<!--\n",
5058 "Look through the Claude + Codex + agent-doc session logs\n",
5059 "-->\n\n",
5060 "<!-- agent:backlog -->\n",
5061 "<!-- /agent:backlog -->\n"
5062 ),
5063 prompt = prompt
5064 );
5065
5066 let repaired = normalize_editor_visible_template_structure(&doc)
5067 .expect("editor-visible normalization should preserve unrelated scratch comments");
5068
5069 assert!(
5070 repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
5071 "unrelated post-exchange scratch comment text must survive:\n{repaired}"
5072 );
5073 }
5074
5075 #[test]
5076 fn normalize_editor_visible_template_structure_ignores_response_quoted_scratch_comment() {
5077 let scratch =
5078 "Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.";
5079 let doc = format!(
5080 concat!(
5081 "<!-- agent:exchange patch=append -->\n",
5082 "❯ Please inspect the latest route cleanup report. #spec-test-build-install-commit-push\n",
5083 "### Re: route cleanup — gpt-5\n\n",
5084 "{scratch}\n",
5085 "<!-- agent:boundary:head -->\n",
5086 "<!-- /agent:exchange -->\n",
5087 "###\n",
5088 "<!--\n",
5089 "{scratch}\n",
5090 "-->\n\n",
5091 "<!-- agent:backlog -->\n",
5092 "<!-- /agent:backlog -->\n"
5093 ),
5094 scratch = scratch
5095 );
5096
5097 let repaired = normalize_editor_visible_template_structure(&doc)
5098 .expect("response-quoted scratch comments should not be prompt residue");
5099
5100 assert!(
5101 repaired.contains(scratch),
5102 "response text must not authorize deleting matching user-owned scratch comments:\n{repaired}"
5103 );
5104 assert!(
5105 repaired.contains(&format!("<!--\n{scratch}\n-->")),
5106 "ordinary scratch comment body must stay intact:\n{repaired}"
5107 );
5108 }
5109
5110 #[test]
5111 fn normalize_removes_duplicate_answered_prompt_tail_after_boundary() {
5112 let prompt = "The content of the html comment below this agent:exchange element was deleted after the last agent-doc turn. Should we diff line by line?";
5113 let doc = format!(
5114 concat!(
5115 "<!-- agent:exchange patch=append -->\n",
5116 "❯ {prompt}\n",
5117 "❯ #spec-test-build-install-commit-push\n",
5118 "### Re: mixed scratch comment deletion — gpt-5\n\n",
5119 "Answered already.\n",
5120 "<!-- agent:boundary:head -->\n",
5121 "{prompt}\n",
5122 "#spec-test-build-install-commit-push\n",
5123 "<!-- /agent:exchange -->\n"
5124 ),
5125 prompt = prompt
5126 );
5127
5128 let repaired = normalize_editor_visible_template_structure(&doc)
5129 .expect("editor-visible normalization should remove duplicate answered prompt tail");
5130
5131 assert!(
5132 repaired.contains(&format!(
5133 "❯ {prompt}\n❯ #spec-test-build-install-commit-push\n### Re:"
5134 )),
5135 "answered prompt block must remain in exchange history:\n{repaired}"
5136 );
5137 assert!(
5138 !repaired.contains(&format!("<!-- agent:boundary:head -->\n{prompt}")),
5139 "duplicate raw prompt tail after the boundary should be removed:\n{repaired}"
5140 );
5141 assert!(
5142 repaired.contains("<!-- agent:boundary:head -->\n<!-- /agent:exchange -->"),
5143 "boundary should stay at the exchange end after cleanup:\n{repaired}"
5144 );
5145 }
5146
5147 #[test]
5148 fn guard_no_duplicate_prompt_residue_outside_exchange_rejects_plain_markdown_duplicate() {
5149 let prompt =
5150 "Please keep this exact sentence around for duplicate residue coverage in markdown";
5151 let doc = format!(
5152 concat!(
5153 "<!-- agent:exchange patch=append -->\n",
5154 "❯ {prompt}\n",
5155 "### Re: duplicate residue — gpt-5\n\n",
5156 "Answered.\n",
5157 "<!-- agent:boundary:head -->\n",
5158 "<!-- /agent:exchange -->\n\n",
5159 "# Notes\n\n",
5160 "{prompt}\n"
5161 ),
5162 prompt = prompt
5163 );
5164
5165 let err = guard_no_duplicate_prompt_residue_outside_exchange(&doc).unwrap_err();
5166
5167 assert!(
5168 err.to_string().contains("duplicate prompt residue outside"),
5169 "unexpected error: {err}"
5170 );
5171 }
5172
5173 #[test]
5174 fn normalize_editor_visible_template_structure_rejects_plain_markdown_duplicate_residue() {
5175 let prompt =
5176 "Please keep this exact sentence around for duplicate residue coverage in markdown";
5177 let doc = format!(
5178 concat!(
5179 "<!-- agent:exchange patch=append -->\n",
5180 "❯ {prompt}\n",
5181 "### Re: duplicate residue — gpt-5\n\n",
5182 "Answered.\n",
5183 "<!-- agent:boundary:head -->\n",
5184 "<!-- /agent:exchange -->\n\n",
5185 "###\n\n",
5186 "{prompt}\n",
5187 "<!-- agent:backlog -->\n",
5188 "- [ ] keep me\n",
5189 "<!-- /agent:backlog -->\n"
5190 ),
5191 prompt = prompt
5192 );
5193
5194 let err = normalize_editor_visible_template_structure(&doc).unwrap_err();
5195
5196 assert!(
5197 err.to_string().contains("duplicate prompt residue"),
5198 "editor-visible normalization must fail closed on duplicate prompt Markdown residue: {err}"
5199 );
5200 }
5201
5202 #[test]
5203 fn guard_no_duplicate_prompt_residue_outside_exchange_allows_tracked_components() {
5204 let prompt =
5205 "Please keep this exact sentence around for duplicate residue coverage in markdown";
5206 let doc = format!(
5207 concat!(
5208 "<!-- agent:exchange patch=append -->\n",
5209 "❯ {prompt}\n",
5210 "### Re: duplicate residue — gpt-5\n\n",
5211 "Answered.\n",
5212 "<!-- agent:boundary:head -->\n",
5213 "<!-- /agent:exchange -->\n\n",
5214 "<!-- agent:backlog -->\n",
5215 "{prompt}\n",
5216 "<!-- /agent:backlog -->\n"
5217 ),
5218 prompt = prompt
5219 );
5220
5221 guard_no_duplicate_prompt_residue_outside_exchange(&doc).unwrap();
5222 }
5223
5224 #[test]
5225 fn normalize_editor_visible_template_structure_rejects_mixed_duplicate_scaffold() {
5226 let doc = concat!(
5227 "<!-- agent:exchange patch=append -->\n",
5228 "### Session Summary\n",
5229 "<!-- /agent:exchange -->\n",
5230 "###\n",
5231 "<!-- agent:queue -->\n",
5232 "<!-- /agent:queue -->\n",
5233 "user typed into duplicated shell\n",
5234 "<!-- /agent:exchange -->\n",
5235 "###\n",
5236 "<!-- agent:queue -->\n",
5237 "<!-- /agent:queue -->\n"
5238 );
5239
5240 let err = normalize_editor_visible_template_structure(doc).unwrap_err();
5241 assert!(
5242 err.to_string().contains("mixed duplicate scaffold"),
5243 "unexpected error: {err}"
5244 );
5245 }
5246
5247 #[test]
5248 fn repair_duplicate_exchange_close_tail_rejects_ambiguous_suffix() {
5249 let doc = concat!(
5250 "---\nagent_doc_format: template\n---\n\n",
5251 "<!-- agent:exchange patch=append -->\n",
5252 "❯ earlier question\n",
5253 "<!-- /agent:exchange -->\n\n",
5254 "## Todo / Backlog\n\n",
5255 "- keep me outside exchange\n",
5256 "<!-- /agent:exchange -->\n"
5257 );
5258
5259 let err = repair_duplicate_exchange_close_tail(doc).unwrap_err();
5260 assert!(
5261 err.to_string()
5262 .contains("duplicate close repair suffix is ambiguous"),
5263 "unexpected error: {err}"
5264 );
5265 }
5266
5267 #[test]
5268 fn repair_duplicate_exchange_opener_merges_two_blocks() {
5269 let doc = concat!(
5270 "---\nagent_doc_format: template\n---\n\n",
5271 "<!-- agent:exchange patch=append -->\n",
5272 "❯ first question\n\n",
5273 "### Re: first — opus-4-6\n\n",
5274 "First answer.\n",
5275 "<!-- /agent:exchange -->\n",
5276 "<!-- agent:exchange patch=append -->\n",
5277 "❯ second question\n\n",
5278 "### Re: second — opus-4-6\n\n",
5279 "Second answer.\n",
5280 "<!-- /agent:exchange -->\n\n",
5281 "<!-- agent:backlog -->\n",
5282 "- [ ] keep me\n",
5283 "<!-- /agent:backlog -->\n"
5284 );
5285
5286 let repaired = repair_duplicate_exchange_opener(doc)
5287 .unwrap()
5288 .expect("duplicate opener repair should apply");
5289 assert_eq!(
5290 repaired.matches("<!-- agent:exchange").count(),
5291 1,
5292 "repair should leave exactly one exchange opener:\n{repaired}"
5293 );
5294 assert_eq!(
5295 repaired.matches("<!-- /agent:exchange -->").count(),
5296 1,
5297 "repair should leave exactly one exchange closer:\n{repaired}"
5298 );
5299 assert!(
5300 repaired.contains("First answer."),
5301 "first block content should be preserved:\n{repaired}"
5302 );
5303 assert!(
5304 repaired.contains("Second answer."),
5305 "second block content should be preserved:\n{repaired}"
5306 );
5307 assert!(
5308 repaired.contains("<!-- agent:backlog -->"),
5309 "backlog should be preserved:\n{repaired}"
5310 );
5311 let first_pos = repaired.find("First answer.").unwrap();
5312 let second_pos = repaired.find("Second answer.").unwrap();
5313 assert!(
5314 first_pos < second_pos,
5315 "first content should appear before second:\n{repaired}"
5316 );
5317 }
5318
5319 #[test]
5320 fn repair_duplicate_exchange_opener_returns_none_for_single() {
5321 let doc = concat!(
5322 "---\nagent_doc_format: template\n---\n\n",
5323 "<!-- agent:exchange patch=append -->\n",
5324 "❯ question\n",
5325 "### Re: answer — opus-4-6\n\n",
5326 "Answer.\n",
5327 "<!-- /agent:exchange -->\n"
5328 );
5329
5330 let result = repair_duplicate_exchange_opener(doc).unwrap();
5331 assert!(result.is_none(), "single exchange block should return None");
5332 }
5333
5334 #[test]
5335 fn strip_conversation_tail_outside_exchange_removes_escaped_heading_tail_only() {
5336 let doc = concat!(
5337 "---\nagent_doc_format: template\n---\n\n",
5338 "<!-- agent:exchange patch=append -->\n",
5339 "### Re: earlier — gpt-5\n",
5340 "<!-- agent:boundary:abc123 -->\n",
5341 "<!-- /agent:exchange -->\n\n",
5342 "[//]: # (leave this note outside exchange)\n\n",
5343 "## Assistant\n\n",
5344 "Escaped answer.\n"
5345 );
5346
5347 let stripped = strip_conversation_tail_outside_exchange(doc)
5348 .unwrap()
5349 .expect("escaped heading tail should be removable");
5350 assert!(
5351 stripped.contains("[//]: # (leave this note outside exchange)"),
5352 "comment-only note should remain outside exchange:\n{stripped}"
5353 );
5354 assert!(
5355 !stripped.contains("## Assistant"),
5356 "escaped assistant tail should be removed:\n{stripped}"
5357 );
5358 }
5359
5360 #[test]
5361 fn strip_conversation_tail_outside_exchange_removes_gap_before_backlog_only() {
5362 let doc = concat!(
5363 "---\nagent_doc_format: template\n---\n\n",
5364 "<!-- agent:exchange patch=append -->\n",
5365 "### Re: earlier — gpt-5\n",
5366 "<!-- agent:boundary:abc123 -->\n",
5367 "<!-- /agent:exchange -->\n\n",
5368 "###\n\n",
5369 "### Re: gap — gpt-5\n\n",
5370 "Escaped answer.\n\n",
5371 "<!-- agent:backlog -->\n",
5372 "- [ ] keep me\n",
5373 "<!-- /agent:backlog -->\n"
5374 );
5375
5376 let stripped = strip_conversation_tail_outside_exchange(doc)
5377 .unwrap()
5378 .expect("escaped gap should be removable");
5379 assert!(
5380 stripped.contains("\n###\n\n<!-- agent:backlog -->"),
5381 "plain gap marker should stay outside exchange:\n{stripped}"
5382 );
5383 assert!(
5384 !stripped.contains("### Re: gap — gpt-5"),
5385 "escaped response should be removed from the gap:\n{stripped}"
5386 );
5387 }
5388
5389 #[test]
5390 fn deleted_conversation_tail_cleanup_accepts_prompt_prelude_deletion() {
5391 let before = concat!(
5392 "---\nagent_doc_format: template\n---\n\n",
5393 "<!-- agent:exchange patch=append -->\n",
5394 "### Re: earlier — gpt-5\n",
5395 "<!-- agent:boundary:abc123 -->\n",
5396 "<!-- /agent:exchange -->\n\n",
5397 "I see this routed prompt sitting outside exchange.\n",
5398 "It should have been entered by the managed pane.\n\n",
5399 "do #oobtaildel. spec-test-build-install-commit-push\n\n",
5400 "<!-- agent:backlog -->\n",
5401 "- [ ] keep me\n",
5402 "<!-- /agent:backlog -->\n"
5403 );
5404 let after = concat!(
5405 "---\nagent_doc_format: template\n---\n\n",
5406 "<!-- agent:exchange patch=append -->\n",
5407 "### Re: earlier — gpt-5\n",
5408 "<!-- agent:boundary:abc123 -->\n",
5409 "<!-- /agent:exchange -->\n\n",
5410 "<!-- agent:backlog -->\n",
5411 "- [ ] keep me\n",
5412 "<!-- /agent:backlog -->\n"
5413 );
5414
5415 let cleaned = deleted_conversation_tail_cleanup(before, after)
5416 .unwrap()
5417 .expect("prompt-prelude cleanup should be accepted");
5418 assert_eq!(cleaned, after);
5419 }
5420
5421 #[test]
5422 fn deleted_conversation_tail_cleanup_rejects_plain_note_deletion() {
5423 let before = concat!(
5424 "---\nagent_doc_format: template\n---\n\n",
5425 "<!-- agent:exchange patch=append -->\n",
5426 "### Re: earlier — gpt-5\n",
5427 "<!-- /agent:exchange -->\n\n",
5428 "Design note: keep this outside exchange.\n\n",
5429 "<!-- agent:backlog -->\n",
5430 "- [ ] keep me\n",
5431 "<!-- /agent:backlog -->\n"
5432 );
5433 let after = before.replace("Design note: keep this outside exchange.\n\n", "");
5434
5435 let cleaned = deleted_conversation_tail_cleanup(before, &after).unwrap();
5436 assert!(
5437 cleaned.is_none(),
5438 "ordinary note deletion should not be treated as escaped conversation cleanup"
5439 );
5440 }
5441
5442 #[test]
5443 fn guard_no_conversation_tail_outside_exchange_passes_for_normal_content() {
5444 let doc = concat!(
5445 "---\nagent_doc_format: template\n---\n\n",
5446 "<!-- agent:exchange patch=append -->\n",
5447 "❯ user question\n",
5448 "### Re: question — opus-4-6\n\n",
5449 "Answer.\n",
5450 "<!-- agent:boundary:abc123 -->\n",
5451 "<!-- /agent:exchange -->\n\n",
5452 "<!-- agent:backlog -->\n",
5453 "- [ ] some task\n",
5454 "<!-- /agent:backlog -->\n"
5455 );
5456 guard_no_conversation_tail_outside_exchange(doc).unwrap();
5457 }
5458
5459 #[test]
5460 fn guard_no_conversation_tail_outside_exchange_fails_on_tail_after_session_digest() {
5461 let doc = concat!(
5462 "---\nagent_doc_format: template\n---\n\n",
5463 "<!-- agent:exchange patch=append -->\n",
5464 "❯ earlier question\n",
5465 "### Re: earlier — opus-4-6\n\n",
5466 "Done.\n",
5467 "<!-- agent:boundary:abc123 -->\n",
5468 "<!-- /agent:exchange -->\n\n",
5469 "# Session Digest\n\n",
5470 "Summary of work.\n\n",
5471 "❯ Why were the backlog items removed?\n",
5472 "### Re: backlog — opus-4-6\n\n",
5473 "Escaped answer.\n"
5474 );
5475 let err = guard_no_conversation_tail_outside_exchange(doc).unwrap_err();
5476 assert!(
5477 err.to_string()
5478 .contains("outside `<!-- agent:exchange -->`"),
5479 "unexpected error: {err}"
5480 );
5481 }
5482
5483 #[test]
5484 fn guard_no_conversation_tail_outside_exchange_fails_on_tail_after_backlog() {
5485 let doc = concat!(
5486 "---\nagent_doc_format: template\n---\n\n",
5487 "<!-- agent:exchange patch=append -->\n",
5488 "### Re: earlier — gpt-5\n\n",
5489 "Done.\n",
5490 "<!-- agent:boundary:abc123 -->\n",
5491 "<!-- /agent:exchange -->\n\n",
5492 "<!-- agent:backlog -->\n",
5493 "- [ ] keep me\n",
5494 "<!-- /agent:backlog -->\n\n",
5495 "## Assistant\n\n",
5496 "Follow-up response.\n"
5497 );
5498 let err = guard_no_conversation_tail_outside_exchange(doc).unwrap_err();
5499 assert!(
5500 err.to_string()
5501 .contains("outside `<!-- agent:exchange -->`"),
5502 "unexpected error: {err}"
5503 );
5504 }
5505
5506 #[test]
5507 fn guard_no_conversation_tail_outside_exchange_fails_on_gap_before_backlog() {
5508 let doc = concat!(
5509 "---\nagent_doc_format: template\n---\n\n",
5510 "<!-- agent:exchange patch=append -->\n",
5511 "### Re: earlier — gpt-5\n\n",
5512 "Done.\n",
5513 "<!-- agent:boundary:abc123 -->\n",
5514 "<!-- /agent:exchange -->\n\n",
5515 "###\n\n",
5516 "### Re: gap — gpt-5\n\n",
5517 "Escaped answer.\n\n",
5518 "<!-- agent:backlog -->\n",
5519 "- [ ] keep me\n",
5520 "<!-- /agent:backlog -->\n"
5521 );
5522 let err = guard_no_conversation_tail_outside_exchange(doc).unwrap_err();
5523 assert!(
5524 err.to_string()
5525 .contains("outside `<!-- agent:exchange -->`"),
5526 "unexpected error: {err}"
5527 );
5528 }
5529
5530 #[test]
5531 fn guard_no_conversation_tail_outside_exchange_fails_on_prompt_before_gap_marker() {
5532 let doc = concat!(
5533 "---\nagent_doc_format: template\n---\n\n",
5534 "<!-- agent:exchange patch=append -->\n",
5535 "### Re: earlier — gpt-5\n",
5536 "Done.\n",
5537 "<!-- agent:boundary:abc123 -->\n",
5538 "<!-- /agent:exchange -->\n\n",
5539 "do [#oobprompt]. spec-test-build-install-commit-push\n",
5540 "###\n\n",
5541 "<!-- agent:backlog -->\n",
5542 "- [ ] keep me\n",
5543 "<!-- /agent:backlog -->\n"
5544 );
5545
5546 let err = guard_no_conversation_tail_outside_exchange(doc).unwrap_err();
5547 assert!(
5548 err.to_string()
5549 .contains("outside `<!-- agent:exchange -->`"),
5550 "unexpected error: {err}"
5551 );
5552 }
5553
5554 #[test]
5555 fn guard_no_conversation_tail_outside_exchange_passes_without_exchange_block() {
5556 let doc = concat!(
5557 "---\nagent_doc_format: template\n---\n\n",
5558 "## Status\n\n",
5559 "<!-- agent:status -->\n",
5560 "Active\n",
5561 "<!-- /agent:status -->\n"
5562 );
5563 guard_no_conversation_tail_outside_exchange(doc).unwrap();
5564 }
5565
5566 #[test]
5567 fn guard_no_conversation_tail_outside_exchange_passes_for_comment_only_tail() {
5568 let doc = concat!(
5569 "---\nagent_doc_format: template\n---\n\n",
5570 "<!-- agent:exchange patch=append -->\n",
5571 "### Re: earlier — gpt-5\n",
5572 "<!-- agent:boundary:abc123 -->\n",
5573 "<!-- /agent:exchange -->\n\n",
5574 "[//]: # (leave this note outside exchange)\n"
5575 );
5576 guard_no_conversation_tail_outside_exchange(doc).unwrap();
5577 }
5578
5579 #[test]
5580 fn guard_no_conversation_tail_outside_exchange_passes_for_html_comment_body() {
5581 let doc = concat!(
5582 "---\nagent_doc_format: template\n---\n\n",
5583 "<!-- agent:exchange patch=append -->\n",
5584 "### Re: earlier — gpt-5\n",
5585 "<!-- agent:boundary:abc123 -->\n",
5586 "<!-- /agent:exchange -->\n\n",
5587 "<!--\n",
5588 "do #hidden. spec-test-build-install-commit-push\n",
5589 "Can this stay hidden?\n",
5590 "-->\n"
5591 );
5592 guard_no_conversation_tail_outside_exchange(doc).unwrap();
5593 }
5594}