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
331mod tail_repair;
332pub use tail_repair::*;
333
334pub fn normalize_editor_visible_template_structure(doc: &str) -> Result<String> {
342 if let Some(reason) = crate::component::malformed_agent_comment_reason(doc) {
343 anyhow::bail!("template structural corruption guard failed: {reason}");
344 }
345
346 let mut normalized = crate::component::strip_backlog_patch_attr(doc);
347 if let Some(repaired) = repair_queue_struck_items_escaped_below_marker(&normalized) {
351 normalized = repaired;
352 }
353 while let Some(merged) = repair_duplicate_exchange_opener(&normalized)? {
354 normalized = merged;
355 }
356 if let Some(cleaned) = remove_duplicate_answered_exchange_prompt_tail(&normalized) {
357 normalized = cleaned;
358 }
359 guard_no_duplicate_prompt_residue_outside_exchange(&normalized)
360 .context("template duplicate prompt residue guard failed")?;
361
362 match guard_no_conversation_tail_outside_exchange(&normalized) {
363 Ok(()) => {
364 guard_editor_visible_structural_corruption(&normalized)?;
365 Ok(normalized)
366 }
367 Err(err)
368 if err.chain().any(|cause| {
369 cause
370 .to_string()
371 .contains("closing marker <!-- /agent:exchange --> without matching open")
372 }) =>
373 {
374 if let Some(repaired) = repair_duplicate_exchange_close_scaffold(&normalized)? {
375 guard_no_duplicate_prompt_residue_outside_exchange(&repaired)
376 .context("template duplicate prompt residue guard failed after duplicate-scaffold repair")?;
377 guard_no_conversation_tail_outside_exchange(&repaired).with_context(
378 || "template structure guard failed after duplicate-scaffold repair",
379 )?;
380 guard_editor_visible_structural_corruption(&repaired)?;
381 return Ok(repaired);
382 }
383 if repair_duplicate_exchange_close_mixed_scaffold_tail(&normalized)?.is_some() {
384 anyhow::bail!(
385 "mixed duplicate scaffold tail: live conversation text is interleaved with duplicated template scaffold; refusing automatic visible-document repair"
386 );
387 }
388 if let Some(repaired) = repair_duplicate_exchange_close_tail(&normalized)? {
389 guard_no_duplicate_prompt_residue_outside_exchange(&repaired).context(
390 "template duplicate prompt residue guard failed after duplicate-close repair",
391 )?;
392 guard_no_conversation_tail_outside_exchange(&repaired).with_context(
393 || "template structure guard failed after duplicate-close repair",
394 )?;
395 guard_editor_visible_structural_corruption(&repaired)?;
396 return Ok(repaired);
397 }
398 Err(err).context("template structure guard failed")
399 }
400 Err(err) => Err(err).context("template structure guard failed"),
401 }
402}
403
404fn guard_editor_visible_structural_corruption(doc: &str) -> Result<()> {
405 if let Some(reason) = crate::component::structural_corruption_reason(doc) {
406 anyhow::bail!("template structural corruption guard failed: {reason}");
407 }
408 Ok(())
409}
410
411fn is_displaced_struck_queue_item(line: &str) -> bool {
417 let trimmed = line.trim();
418 let Some(rest) = trimmed.strip_prefix("- ") else {
419 return false;
420 };
421 let rest = rest.trim();
422 let Some(inner) = rest.strip_prefix("~~").and_then(|s| s.strip_suffix("~~")) else {
423 return false;
424 };
425 let inner = inner.trim();
426 inner.contains(":round_pushpin:")
427 || inner.contains(":pushpin:")
428 || inner.starts_with("do [#")
429 || inner.starts_with("do #")
430 || inner.starts_with("re [#")
431 || inner.starts_with("re #")
432}
433
434pub fn repair_queue_struck_items_escaped_below_marker(doc: &str) -> Option<String> {
448 let components = component::parse(doc).ok()?;
449 let queue = components.iter().find(|c| c.name == "queue")?;
450 let scan_start = queue.close_end;
451 let protected: Vec<(usize, usize)> = components
453 .iter()
454 .map(|c| (c.open_start, c.close_end))
455 .collect();
456
457 let mut remove_ranges: Vec<(usize, usize)> = Vec::new();
458 let mut offset = scan_start;
459 for line in doc[scan_start..].split_inclusive('\n') {
460 let line_start = offset;
461 let line_end = offset + line.len();
462 offset = line_end;
463 let inside_component = protected
464 .iter()
465 .any(|(start, end)| line_start >= *start && line_end <= *end);
466 if inside_component {
467 continue;
468 }
469 if is_displaced_struck_queue_item(line) {
470 remove_ranges.push((line_start, line_end));
471 }
472 }
473 if remove_ranges.is_empty() {
474 return None;
475 }
476 let mut out = String::with_capacity(doc.len());
477 let mut cursor = 0usize;
478 for (start, end) in &remove_ranges {
479 out.push_str(&doc[cursor..*start]);
480 cursor = *end;
481 }
482 out.push_str(&doc[cursor..]);
483 Some(out)
484}
485
486mod prompt_comment_repair;
492pub use prompt_comment_repair::*;
493
494pub fn repair_duplicate_exchange_opener(doc: &str) -> Result<Option<String>> {
501 let components = match component::parse(doc) {
502 Ok(c) => c,
503 Err(_) => return Ok(None),
504 };
505 let exchanges: Vec<&Component> = components.iter().filter(|c| c.name == "exchange").collect();
506 if exchanges.len() < 2 {
507 return Ok(None);
508 }
509 let first = exchanges[0];
510 let second = exchanges[1];
511
512 let first_content = first.content(doc).trim_end().to_string();
513 let second_content = second.content(doc).trim().to_string();
514
515 let merged = if first_content.trim().is_empty() && second_content.is_empty() {
516 "\n".to_string()
517 } else if first_content.trim().is_empty() {
518 format!("{}\n", second_content)
519 } else if second_content.is_empty() {
520 format!("{}\n", first_content)
521 } else {
522 format!("{}\n{}\n", first_content, second_content)
523 };
524
525 let mut result = String::new();
526 result.push_str(&doc[..first.open_end]);
527 result.push_str(&merged);
528 result.push_str(&doc[first.close_start..second.open_start]);
529 result.push_str(&doc[second.close_end..]);
530
531 if exchanges.len() > 2 {
532 eprintln!(
533 "[template] {} exchange components found — merged first two, {} remain",
534 exchanges.len(),
535 exchanges.len() - 1
536 );
537 }
538
539 Ok(Some(result))
540}
541
542pub fn strip_conversation_tail_outside_exchange(doc: &str) -> Result<Option<String>> {
548 let components = component::parse(doc).context("failed to parse components")?;
549 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
550 return Ok(None);
551 };
552
553 let Some((tail_start, tail_end)) =
554 escaped_conversation_range_outside_exchange(doc, &components, exchange)
555 else {
556 return Ok(None);
557 };
558 let tail = &doc[tail_start..tail_end];
559 if !tail_is_safe_exchange_content(tail) {
560 anyhow::bail!(
561 "conversation content escaped `agent:exchange`, but the trailing document structure is ambiguous"
562 );
563 }
564
565 let mut stripped = String::with_capacity(doc.len() - (tail_end - tail_start));
566 stripped.push_str(&doc[..tail_start]);
567 stripped.push_str(&doc[tail_end..]);
568 Ok(Some(stripped))
569}
570
571pub fn deleted_conversation_tail_cleanup(before: &str, after: &str) -> Result<Option<String>> {
574 if before == after || after.len() >= before.len() {
575 return Ok(None);
576 }
577
578 if let Some(stripped) = strip_conversation_tail_outside_exchange(before)?
579 && stripped == after
580 {
581 return Ok(Some(stripped));
582 }
583
584 let Some(deleted) = single_deleted_range(before, after) else {
585 return Ok(None);
586 };
587
588 let components = component::parse(before).context("failed to parse components")?;
589 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
590 return Ok(None);
591 };
592 if deleted.start < exchange.close_end {
593 return Ok(None);
594 }
595 if components
596 .iter()
597 .any(|component| component.open_start < deleted.end && component.close_end > deleted.start)
598 {
599 return Ok(None);
600 }
601
602 let tail = &before[deleted.clone()];
603 if !tail_is_safe_exchange_content(tail) || !tail_contains_exchange_signal(tail) {
604 return Ok(None);
605 }
606
607 Ok(Some(after.to_string()))
608}
609
610fn single_deleted_range(before: &str, after: &str) -> Option<std::ops::Range<usize>> {
611 let prefix = common_prefix_boundary_len(before, after);
612 let mut before_end = before.len();
613 let mut after_end = after.len();
614
615 while before_end > prefix && after_end > prefix {
616 let (before_prev, before_ch) = previous_char(before, before_end)?;
617 let (after_prev, after_ch) = previous_char(after, after_end)?;
618 if before_ch != after_ch {
619 break;
620 }
621 before_end = before_prev;
622 after_end = after_prev;
623 }
624
625 if prefix >= before_end {
626 return None;
627 }
628
629 let mut reconstructed = String::with_capacity(after.len());
630 reconstructed.push_str(&before[..prefix]);
631 reconstructed.push_str(&before[before_end..]);
632 if reconstructed == after {
633 Some(prefix..before_end)
634 } else {
635 None
636 }
637}
638
639fn common_prefix_boundary_len(a: &str, b: &str) -> usize {
640 let mut len = 0usize;
641 for ((a_idx, a_ch), (_b_idx, b_ch)) in a.char_indices().zip(b.char_indices()) {
642 if a_ch != b_ch {
643 break;
644 }
645 len = a_idx + a_ch.len_utf8();
646 }
647 len
648}
649
650fn previous_char(s: &str, end: usize) -> Option<(usize, char)> {
651 s.get(..end)?.char_indices().next_back()
652}
653
654fn tail_contains_exchange_signal(tail: &str) -> bool {
655 tail.lines().any(|line| {
656 let trimmed = line.trim();
657 is_exchange_turn_heading(trimmed)
658 || text_line_looks_like_prompt_target(trimmed)
659 || line_looks_like_prompt_preset_reference(trimmed)
660 })
661}
662
663fn line_looks_like_prompt_preset_reference(line: &str) -> bool {
664 let trimmed = line
665 .trim_start_matches('❯')
666 .trim_start()
667 .trim_start_matches("- ")
668 .trim_start_matches("* ")
669 .trim_start_matches("+ ")
670 .trim_start();
671 let Some(rest) = trimmed.strip_prefix('#') else {
672 return false;
673 };
674 let id_len = rest
675 .char_indices()
676 .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
677 .map(|(idx, ch)| idx + ch.len_utf8())
678 .last()
679 .unwrap_or(0);
680 id_len > 0
681 && rest[id_len..]
682 .chars()
683 .next()
684 .is_none_or(|ch| ch.is_whitespace())
685}
686
687pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
699 let trailing_nl = content.ends_with('\n');
700 let mut lines: Vec<&str> = content.split('\n').collect();
701 if trailing_nl {
704 lines.pop();
705 }
706 while let Some(last) = lines.last() {
707 let t = last.trim();
708 if t == "❯" {
709 lines.pop();
710 } else {
711 break;
712 }
713 }
714 let mut out = lines.join("\n");
715 if trailing_nl {
716 out.push('\n');
717 }
718 out
719}
720
721#[derive(Debug, Clone)]
722struct PromptTailBlock {
723 text: String,
724 end: usize,
725 pending_ids: HashSet<String>,
726}
727
728fn is_exchange_response_heading(trimmed: &str) -> bool {
729 trimmed.starts_with("### Re:")
730 || trimmed.starts_with("#### Re:")
731 || trimmed.starts_with("##### Re:")
732}
733
734fn extract_pending_ids(text: &str) -> HashSet<String> {
735 let mut ids = HashSet::new();
736 let chars: Vec<char> = text.chars().collect();
737 let mut idx = 0usize;
738 while idx < chars.len() {
739 if chars[idx] != '#' {
740 idx += 1;
741 continue;
742 }
743 let start = idx + 1;
744 let mut end = start;
745 while end < chars.len() {
746 let ch = chars[end];
747 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
748 end += 1;
749 } else {
750 break;
751 }
752 }
753 if end > start {
754 let id = chars[start..end].iter().collect::<String>();
755 if is_valid_pending_like_id(&id) {
756 ids.insert(id);
757 }
758 }
759 idx = end.max(idx + 1);
760 }
761 ids
762}
763
764fn is_valid_pending_like_id(id: &str) -> bool {
765 !id.is_empty()
766 && id
767 .chars()
768 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
769}
770
771fn text_line_looks_like_prompt_target(line: &str) -> bool {
772 let trimmed = line.trim();
773 !trimmed.is_empty()
774 && !trimmed.starts_with("<!--")
775 && !trimmed.starts_with("```")
776 && !trimmed.starts_with("~~~")
777 && !is_exchange_response_heading(trimmed)
778 && (trimmed.starts_with('❯')
779 || trimmed.ends_with('?')
780 || looks_like_prompt_directive(trimmed))
781}
782
783fn looks_like_prompt_directive(line: &str) -> bool {
784 let lower = line.trim_start_matches('❯').trim().to_ascii_lowercase();
785 lower == "go"
786 || lower == "continue"
787 || lower.starts_with("do #")
788 || lower.starts_with("do [#")
789 || lower.starts_with("fix #")
790 || lower.starts_with("run ")
791 || lower.starts_with("rerun ")
792 || lower.starts_with("build ")
793 || lower.starts_with("test ")
794 || lower.starts_with("commit ")
795 || lower.starts_with("push ")
796 || lower.starts_with("verify ")
797 || lower.starts_with("investigate ")
798}
799
800fn unresolved_tail_after_boundary(doc: &str, component_name: &str) -> Option<String> {
801 let components = component::parse(doc).ok()?;
802 let comp = components.iter().find(|comp| comp.name == component_name)?;
803 let prefix = "<!-- agent:boundary:";
804 let content_region = &doc[comp.open_end..comp.close_start];
805 let code_ranges = component::find_code_ranges(doc);
806 let mut search_from = 0usize;
807 while let Some(start) = content_region[search_from..].find(prefix) {
808 let abs_start = comp.open_end + search_from + start;
809 if code_ranges
810 .iter()
811 .any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
812 {
813 search_from += start + prefix.len();
814 continue;
815 }
816 let line_start = search_from + start;
817 let marker_end = content_region[line_start..]
818 .find('\n')
819 .map(|idx| line_start + idx + 1)
820 .unwrap_or(content_region.len());
821 return Some(content_region[marker_end..].to_string());
822 }
823 None
824}
825
826fn extract_prompt_tail_blocks(text: &str) -> Vec<PromptTailBlock> {
827 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
828 let fc = trimmed.chars().next()?;
829 if fc != '`' && fc != '~' {
830 return None;
831 }
832 let fl = trimmed.chars().take_while(|&c| c == fc).count();
833 if fl >= 3 { Some((fc, fl)) } else { None }
834 }
835
836 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
837 let fc = trimmed.chars().next().unwrap_or('\0');
838 if fc != fence_char {
839 return false;
840 }
841 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
842 fl >= fence_len && trimmed[fl..].trim().is_empty()
843 }
844
845 fn block_start(line: &str) -> bool {
846 let trimmed = line.trim();
847 !trimmed.is_empty()
848 && !trimmed.starts_with("<!--")
849 && !trimmed.starts_with("```")
850 && !trimmed.starts_with("~~~")
851 && !is_exchange_response_heading(trimmed)
852 && text_line_looks_like_prompt_target(trimmed)
853 }
854
855 fn push_block(blocks: &mut Vec<PromptTailBlock>, text: &str, start: Option<usize>, end: usize) {
856 let Some(start) = start else {
857 return;
858 };
859 let raw = &text[start..end];
860 let trimmed = raw.trim();
861 if trimmed.is_empty() {
862 return;
863 }
864 blocks.push(PromptTailBlock {
865 text: trimmed.to_string(),
866 end,
867 pending_ids: extract_pending_ids(trimmed),
868 });
869 }
870
871 let mut blocks = Vec::new();
872 let mut current_start = None;
873 let mut current_end = 0usize;
874 let mut current_has_blank = false;
875 let mut in_fence = false;
876 let mut fence_char = '`';
877 let mut fence_len = 3usize;
878 let mut offset = 0usize;
879
880 for segment in text.split_inclusive('\n') {
881 let line = segment.trim_end_matches('\n');
882 let trimmed = line.trim();
883 let starts_new = current_start.is_some()
884 && !in_fence
885 && ((current_has_blank && !trimmed.is_empty()) || trimmed.starts_with('❯'))
886 && block_start(line);
887 if starts_new {
888 push_block(&mut blocks, text, current_start.take(), current_end);
889 current_end = 0;
890 current_has_blank = false;
891 }
892
893 if current_start.is_none() {
894 if !block_start(line) {
895 offset += segment.len();
896 continue;
897 }
898 current_start = Some(offset);
899 current_has_blank = false;
900 }
901
902 current_end = offset + segment.len();
903 if trimmed.is_empty() {
904 current_has_blank = true;
905 }
906
907 if !in_fence {
908 if let Some((fc, fl)) = fence_open(trimmed) {
909 in_fence = true;
910 fence_char = fc;
911 fence_len = fl;
912 }
913 } else if fence_close(trimmed, fence_char, fence_len) {
914 in_fence = false;
915 }
916
917 offset += segment.len();
918 }
919
920 push_block(&mut blocks, text, current_start, current_end);
921 blocks
922}
923
924fn find_tail_anchor_block<'a>(
925 response_content: &str,
926 prompt_blocks: &'a [PromptTailBlock],
927) -> Result<Option<&'a PromptTailBlock>> {
928 if prompt_blocks.is_empty() {
929 return Ok(None);
930 }
931
932 let response_ids = extract_pending_ids(response_content);
933 let candidate_idx = if response_ids.is_empty() {
934 0
935 } else if let Some(idx) = prompt_blocks
936 .iter()
937 .position(|block| !block.pending_ids.is_disjoint(&response_ids))
938 {
939 idx
940 } else if prompt_blocks.len() == 1 {
941 0
942 } else {
943 anyhow::bail!(
944 "exchange patchback is ambiguous: response references pending ids {:?}, but none of the unresolved prompt blocks match",
945 response_ids
946 );
947 };
948
949 if candidate_idx > 0 {
950 anyhow::bail!(
951 "exchange patchback would skip older unresolved prompt(s): oldest pending block is `{}`, but the response matches later block `{}`",
952 prompt_blocks[0].text.lines().next().unwrap_or("(empty)"),
953 prompt_blocks[candidate_idx]
954 .text
955 .lines()
956 .next()
957 .unwrap_or("(empty)")
958 );
959 }
960
961 Ok(prompt_blocks.get(candidate_idx))
962}
963
964fn append_exchange_patch_after_prompt_anchor(
965 original_doc: &str,
966 current_doc: &str,
967 comp: &Component,
968 patch_content: &str,
969) -> Result<Option<String>> {
970 let Some(original_tail) = unresolved_tail_after_boundary(original_doc, &comp.name) else {
971 return Ok(None);
972 };
973 let prompt_blocks = extract_prompt_tail_blocks(&original_tail);
974 let Some(anchor) = find_tail_anchor_block(patch_content, &prompt_blocks)? else {
975 return Ok(None);
976 };
977
978 let content_region = comp.content(current_doc);
979 let boundary_marker = find_boundary_in_component(current_doc, comp)
980 .map(|id| format!("<!-- agent:boundary:{id} -->"))
981 .context("exchange append anchor missing boundary marker")?;
982 let boundary_pos = content_region
983 .find(&boundary_marker)
984 .context("exchange append anchor boundary marker not found in current content")?;
985 let boundary_line_end = content_region[boundary_pos..]
986 .find('\n')
987 .map(|idx| boundary_pos + idx + 1)
988 .unwrap_or(content_region.len());
989 let user_region = &content_region[..boundary_pos];
990 let insert_at = match user_region.rfind(&original_tail) {
1002 Some(tail_start) => tail_start + anchor.end,
1003 None if prompt_blocks.len() == 1 => user_region.trim_end().len(),
1004 None => anyhow::bail!(
1005 "exchange patchback prompt drifted from the baseline ({} unresolved prompt block(s) in the current tail); re-run `agent-doc preflight` to refresh the baseline before closeout. anchor: {}",
1006 prompt_blocks.len(),
1007 anchor.text.lines().next().unwrap_or("(empty)")
1008 ),
1009 };
1010
1011 let new_id = crate::id::new_boundary_id();
1012 let new_marker = crate::id::format_boundary_marker(&new_id);
1013 let mut new_content =
1014 String::with_capacity(content_region.len() + patch_content.len() + new_marker.len() + 2);
1015 new_content.push_str(&user_region[..insert_at]);
1016 if !new_content.is_empty() && !new_content.ends_with('\n') {
1017 new_content.push('\n');
1018 }
1019 new_content.push_str(patch_content.trim_end());
1020 new_content.push('\n');
1021 new_content.push_str(&new_marker);
1022 new_content.push('\n');
1023 new_content.push_str(&user_region[insert_at..]);
1024 new_content.push_str(&content_region[boundary_line_end..]);
1025
1026 Ok(Some(comp.replace_content(current_doc, &new_content)))
1027}
1028
1029pub fn apply_patches_with_overrides_pure(
1036 doc: &str,
1037 patches: &[PatchBlock],
1038 unmatched: &str,
1039 summary: Option<&str>,
1040 component_configs: &std::collections::HashMap<String, String>,
1041 max_lines_configs: &std::collections::HashMap<String, usize>,
1042 mode_overrides: &std::collections::HashMap<String, String>,
1043) -> Result<String> {
1044 let mut result = remove_all_boundaries(doc);
1049 if let Ok(components) = component::parse(&result)
1050 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1051 {
1052 let id = crate::id::new_boundary_id_with_summary(summary);
1053 let marker = crate::id::format_boundary_marker(&id);
1054 let content = exchange.content(&result);
1055 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
1056 result = exchange.replace_content(&result, &new_content);
1057 eprintln!(
1058 "[template] pre-patch boundary {} inserted at end of exchange",
1059 id
1060 );
1061 }
1062
1063 let components = component::parse(&result).context("failed to parse components")?;
1065
1066 let configs = component_configs;
1068
1069 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
1074 let mut overflow = String::new();
1075 for patch in patches {
1076 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
1077 ops.push((idx, patch));
1078 } else {
1079 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
1080 eprintln!(
1081 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
1082 patch.name,
1083 available.join(", ")
1084 );
1085 if !overflow.is_empty() {
1086 overflow.push('\n');
1087 }
1088 overflow.push_str(&patch.content);
1089 }
1090 }
1091
1092 ops.sort_by_key(|op| std::cmp::Reverse(op.0));
1094
1095 for (idx, patch) in &ops {
1096 let comp = &components[*idx];
1097 let mode = mode_overrides
1099 .get(&patch.name)
1100 .map(|s| s.as_str())
1101 .or_else(|| comp.patch_mode())
1102 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
1103 .unwrap_or_else(|| default_mode(&patch.name));
1104 let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
1107 std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
1108 } else {
1109 std::borrow::Cow::Borrowed(patch.content.as_str())
1110 };
1111 if mode == "append"
1113 && let Some(bid) = find_boundary_in_component(&result, comp)
1114 {
1115 if patch.name == "exchange"
1116 && patch_content
1117 .lines()
1118 .any(|line| is_exchange_response_heading(line.trim_start()))
1119 && let Some(anchored) =
1120 append_exchange_patch_after_prompt_anchor(doc, &result, comp, &patch_content)?
1121 {
1122 result = anchored;
1123 continue;
1124 }
1125 result = comp.append_with_boundary(&result, &patch_content, &bid);
1126 continue;
1127 }
1128 let new_content = apply_mode(mode, comp.content(&result), &patch_content);
1129 result = comp.replace_content(&result, &new_content);
1130 }
1131
1132 let mut all_unmatched = String::new();
1134 let exchange_patch_present = patches.iter().any(|patch| patch.name == "exchange");
1135 if !overflow.is_empty() {
1136 all_unmatched.push_str(&overflow);
1137 }
1138 if !unmatched.is_empty() {
1139 if !all_unmatched.is_empty() {
1140 all_unmatched.push('\n');
1141 }
1142 all_unmatched.push_str(unmatched);
1143 }
1144
1145 if !all_unmatched.is_empty() {
1147 let components =
1149 component::parse(&result).context("failed to re-parse components after patching")?;
1150
1151 if let Some(output_comp) = components
1152 .iter()
1153 .find(|c| c.name == "exchange" || c.name == "output")
1154 {
1155 let stripped = if output_comp.name == "exchange" {
1158 strip_trailing_caret_lines(&all_unmatched)
1159 } else {
1160 all_unmatched.clone()
1161 };
1162 let unmatched = &stripped;
1163 let force_replace = output_comp.name == "exchange"
1164 && mode_overrides
1165 .get("exchange")
1166 .map(|mode| mode == "replace")
1167 .unwrap_or(false)
1168 && !exchange_patch_present;
1169 if force_replace {
1170 let new_content = format!("{}\n", unmatched.trim_end());
1171 result = output_comp.replace_content(&result, &new_content);
1172 } else if let Some(bid) = find_boundary_in_component(&result, output_comp) {
1173 eprintln!(
1175 "[template] unmatched content: using boundary {} for insertion",
1176 &bid[..bid.len().min(8)]
1177 );
1178 result = output_comp.append_with_boundary(&result, unmatched, &bid);
1179 } else {
1180 let existing = output_comp.content(&result);
1182 let new_content = if existing.trim().is_empty() {
1183 format!("{}\n", unmatched)
1184 } else {
1185 format!("{}{}\n", existing, unmatched)
1186 };
1187 result = output_comp.replace_content(&result, &new_content);
1188 }
1189 } else {
1190 let stripped = strip_trailing_caret_lines(&all_unmatched);
1192 if !result.ends_with('\n') {
1193 result.push('\n');
1194 }
1195 result.push_str("\n<!-- agent:exchange -->\n");
1196 result.push_str(&stripped);
1197 result.push_str("\n<!-- /agent:exchange -->\n");
1198 }
1199 }
1200
1201 while let Some(merged) = repair_duplicate_exchange_opener(&result)? {
1203 eprintln!("[template] post-patch: merged duplicate exchange opener");
1204 result = merged;
1205 }
1206
1207 result = dedup_exchange_adjacent_lines(&result);
1210
1211 {
1217 'stability: for _ in 0..3 {
1218 let Ok(components) = component::parse(&result) else {
1219 break;
1220 };
1221 for comp in &components {
1222 let max_lines = comp
1223 .attrs
1224 .get("max_lines")
1225 .and_then(|s| s.parse::<usize>().ok())
1226 .or_else(|| max_lines_configs.get(&comp.name).copied())
1227 .unwrap_or(0);
1228 if max_lines > 0 {
1229 let content = comp.content(&result);
1230 let trimmed = limit_lines(content, max_lines);
1231 if trimmed.len() != content.len() {
1232 let trimmed = format!("{}\n", trimmed.trim_end());
1233 result = comp.replace_content(&result, &trimmed);
1234 continue 'stability;
1236 }
1237 }
1238 }
1239 break; }
1241 }
1242
1243 {
1249 if let Ok(components) = component::parse(&result)
1250 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1251 && find_boundary_in_component(&result, exchange).is_none()
1252 {
1253 let id = uuid::Uuid::new_v4().to_string();
1255 let marker = format!("<!-- agent:boundary:{} -->", id);
1256 let content = exchange.content(&result);
1257 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
1258 result = exchange.replace_content(&result, &new_content);
1259 eprintln!(
1260 "[template] re-inserted boundary {} at end of exchange",
1261 &id[..id.len().min(8)]
1262 );
1263 }
1264 }
1265
1266 result = annotate_exchange_headings_against_baseline(&result, doc);
1267
1268 Ok(result)
1269}
1270
1271pub fn reposition_boundary_to_end(doc: &str) -> String {
1279 reposition_boundary_to_end_with_summary(doc, None)
1280}
1281
1282pub fn reposition_boundary_to_end_clean(doc: &str) -> String {
1288 reposition_boundary_to_end_clean_with_id(doc, None)
1289}
1290
1291pub fn reposition_boundary_to_end_clean_with_id(doc: &str, boundary_id: Option<&str>) -> String {
1296 reposition_boundary_to_end_clean_internal(doc, boundary_id, None)
1297}
1298
1299pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
1304 reposition_boundary_to_end_with_baseline(doc, summary, None)
1305}
1306
1307pub fn reposition_boundary_to_end_clean_with_summary(doc: &str, summary: Option<&str>) -> String {
1310 reposition_boundary_to_end_clean_internal(doc, None, summary)
1311}
1312
1313pub fn reposition_boundary_to_end_clean_with_summary_and_id(
1319 doc: &str,
1320 boundary_id: Option<&str>,
1321 summary: Option<&str>,
1322) -> String {
1323 reposition_boundary_to_end_clean_internal(doc, boundary_id, summary)
1324}
1325
1326fn reposition_boundary_to_end_clean_internal(
1327 doc: &str,
1328 boundary_id: Option<&str>,
1329 summary: Option<&str>,
1330) -> String {
1331 let mut result = strip_transient_head_markers(&remove_all_boundaries(doc));
1332 if let Ok(components) = component::parse(&result)
1333 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1334 {
1335 let id = boundary_id
1336 .map(ToOwned::to_owned)
1337 .unwrap_or_else(|| crate::id::new_boundary_id_with_summary(summary));
1338 let marker = crate::id::format_boundary_marker(&id);
1339 let content = exchange.content(&result).to_string();
1340 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
1341 result = exchange.replace_content(&result, &new_content);
1342 }
1343 result
1344}
1345
1346pub fn reposition_boundary_to_end_preserve_head(doc: &str) -> String {
1351 reposition_boundary_to_end_preserve_head_with_id(doc, None)
1352}
1353
1354pub fn reposition_boundary_to_end_preserve_head_with_id(
1356 doc: &str,
1357 boundary_id: Option<&str>,
1358) -> String {
1359 let mut result = remove_all_boundaries(doc);
1360 if let Ok(components) = component::parse(&result)
1361 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1362 {
1363 let id = boundary_id
1364 .map(ToOwned::to_owned)
1365 .unwrap_or_else(crate::id::new_boundary_id);
1366 let marker = crate::id::format_boundary_marker(&id);
1367 let content = exchange.content(&result).to_string();
1368 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
1369 result = exchange.replace_content(&result, &new_content);
1370 }
1371 result
1372}
1373
1374fn strip_transient_head_markers(content: &str) -> String {
1377 let code_ranges = component::find_code_ranges(content);
1378 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
1379
1380 let mut result = String::with_capacity(content.len());
1381 let mut offset = 0usize;
1382 for line in content.split_inclusive('\n') {
1383 let line_start = offset;
1384 offset += line.len();
1385
1386 let mut rewritten = line.to_string();
1387 if !in_code(line_start) {
1388 let had_newline = rewritten.ends_with('\n');
1389 let body = rewritten.trim_end_matches('\n').trim_end_matches('\r');
1390 let trimmed = body.trim_start();
1391 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
1392 let is_markdown_heading =
1393 hash_count > 0 && hash_count <= 6 && trimmed[hash_count..].starts_with(' ');
1394 let is_bold_pseudo_header = trimmed.starts_with("**") && trimmed.ends_with("** (HEAD)");
1395 if (is_markdown_heading || is_bold_pseudo_header)
1396 && let Some(stripped) = body.strip_suffix(" (HEAD)")
1397 {
1398 rewritten = if had_newline {
1399 format!("{stripped}\n")
1400 } else {
1401 stripped.to_string()
1402 };
1403 }
1404 }
1405
1406 result.push_str(&rewritten);
1407 }
1408
1409 result
1410}
1411
1412pub fn reposition_boundary_to_end_with_baseline(
1421 doc: &str,
1422 summary: Option<&str>,
1423 baseline_headings: Option<&std::collections::HashSet<String>>,
1424) -> String {
1425 let mut result = remove_all_boundaries(doc);
1426 if let Ok(components) = component::parse(&result)
1427 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1428 {
1429 let id = crate::id::new_boundary_id_with_summary(summary);
1430 let marker = crate::id::format_boundary_marker(&id);
1431 let content = exchange.content(&result).to_string();
1432 let annotated = annotate_re_headings_with_head(&content, baseline_headings);
1433 let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
1434 result = exchange.replace_content(&result, &new_content);
1435 }
1436 result
1437}
1438
1439pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
1447 if let Ok(components) = component::parse(doc)
1448 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
1449 {
1450 return collect_re_headings(exchange.content(doc));
1451 }
1452 std::collections::HashSet::new()
1453}
1454
1455pub fn annotate_exchange_headings_against_baseline(doc: &str, baseline_doc: &str) -> String {
1456 let Ok(components) = component::parse(doc) else {
1457 return doc.to_string();
1458 };
1459 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
1460 return doc.to_string();
1461 };
1462
1463 let baseline_exchange = component::parse(baseline_doc)
1464 .ok()
1465 .and_then(|components| {
1466 components
1467 .iter()
1468 .find(|c| c.name == "exchange")
1469 .map(|c| c.content(baseline_doc).to_string())
1470 })
1471 .unwrap_or_default();
1472 let baseline_headings = exchange_baseline_headings(baseline_doc);
1473 let content = exchange.content(doc);
1474 let normalize_for_compare = |value: &str| {
1475 strip_transient_head_markers(value)
1476 .lines()
1477 .filter(|line| !line.trim().starts_with("<!-- agent:boundary:"))
1478 .collect::<Vec<_>>()
1479 .join("\n")
1480 };
1481 if normalize_for_compare(content) == normalize_for_compare(&baseline_exchange) {
1482 return doc.to_string();
1483 }
1484 let annotated = annotate_re_headings_with_head(content, Some(&baseline_headings));
1485 if annotated == content {
1486 return doc.to_string();
1487 }
1488 exchange.replace_content(doc, &annotated)
1489}
1490
1491fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
1495 let code_ranges = component::find_code_ranges(content);
1496 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
1497 let mut set = std::collections::HashSet::new();
1498 let mut offset = 0usize;
1499 for line in content.split_inclusive('\n') {
1500 let line_start = offset;
1501 offset += line.len();
1502 if in_code(line_start) {
1503 continue;
1504 }
1505 let body = line.trim_end_matches('\n').trim_end_matches('\r');
1506 let trimmed = body.trim_start();
1507 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
1508 if hash_count == 0 || hash_count > 6 {
1509 continue;
1510 }
1511 let after_hash = &trimmed[hash_count..];
1512 if !after_hash.starts_with(' ') {
1513 continue;
1514 }
1515 if !after_hash.trim_start().starts_with("Re:") {
1516 continue;
1517 }
1518 let stripped = body
1519 .trim_start()
1520 .trim_end()
1521 .trim_end_matches(" (HEAD)")
1522 .to_string();
1523 set.insert(stripped);
1524 }
1525 set
1526}
1527
1528pub(crate) fn annotate_re_headings_with_head(
1547 content: &str,
1548 baseline: Option<&std::collections::HashSet<String>>,
1549) -> String {
1550 let code_ranges = component::find_code_ranges(content);
1551 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
1552
1553 let mut lines: Vec<String> = content
1554 .split_inclusive('\n')
1555 .map(|s| s.to_string())
1556 .collect();
1557 let mut re_indices: Vec<usize> = Vec::new();
1558 let mut offset = 0usize;
1559
1560 for (idx, line) in lines.iter_mut().enumerate() {
1561 let line_start = offset;
1562 offset += line.len();
1563 if in_code(line_start) {
1564 continue;
1565 }
1566 let had_newline = line.ends_with('\n');
1567 let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
1568 let trimmed = body_ref.trim_start();
1569 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
1570 if hash_count == 0 || hash_count > 6 {
1571 continue;
1572 }
1573 let after_hash = &trimmed[hash_count..];
1574 if !after_hash.starts_with(' ') {
1575 continue;
1576 }
1577 if !after_hash.trim_start().starts_with("Re:") {
1578 continue;
1579 }
1580 let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
1582 *line = if had_newline {
1583 format!("{stripped}\n")
1584 } else {
1585 stripped.to_string()
1586 };
1587 re_indices.push(idx);
1588 }
1589
1590 let mark_indices: Vec<usize> = match baseline {
1600 Some(baseline_set) => {
1601 let filtered: Vec<usize> = re_indices
1602 .iter()
1603 .copied()
1604 .filter(|&idx| {
1605 let line = &lines[idx];
1606 let key = line
1607 .trim_end_matches('\n')
1608 .trim_end_matches('\r')
1609 .trim_start()
1610 .trim_end();
1611 !baseline_set.contains(key)
1612 })
1613 .collect();
1614 if filtered.is_empty() {
1615 re_indices.last().copied().into_iter().collect()
1616 } else {
1617 filtered
1618 }
1619 }
1620 None => re_indices.last().copied().into_iter().collect(),
1621 };
1622
1623 for idx in mark_indices {
1624 let line = &lines[idx];
1625 let had_newline = line.ends_with('\n');
1626 let body = line.trim_end_matches('\n').trim_end_matches('\r');
1627 lines[idx] = if had_newline {
1628 format!("{body} (HEAD)\n")
1629 } else {
1630 format!("{body} (HEAD)")
1631 };
1632 }
1633
1634 lines.concat()
1635}
1636
1637fn remove_all_boundaries(doc: &str) -> String {
1640 let prefix = "<!-- agent:boundary:";
1641 let suffix = " -->";
1642 let code_ranges = component::find_code_ranges(doc);
1643 let in_code = |pos: usize| {
1644 code_ranges
1645 .iter()
1646 .any(|&(start, end)| pos >= start && pos < end)
1647 };
1648 let mut result = String::with_capacity(doc.len());
1649 let mut offset = 0;
1650 for line in doc.lines() {
1651 let trimmed = line.trim();
1652 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
1653 if is_boundary && !in_code(offset) {
1654 offset += line.len() + 1; continue;
1657 }
1658 result.push_str(line);
1659 result.push('\n');
1660 offset += line.len() + 1;
1661 }
1662 if !doc.ends_with('\n') && result.ends_with('\n') {
1663 result.pop();
1664 }
1665 result
1666}
1667
1668fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
1670 let prefix = "<!-- agent:boundary:";
1671 let suffix = " -->";
1672 let content_region = &doc[comp.open_end..comp.close_start];
1673 let code_ranges = component::find_code_ranges(doc);
1674 let mut search_from = 0;
1675 while let Some(start) = content_region[search_from..].find(prefix) {
1676 let abs_start = comp.open_end + search_from + start;
1677 if code_ranges
1678 .iter()
1679 .any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
1680 {
1681 search_from += start + prefix.len();
1682 continue;
1683 }
1684 let after_prefix = &content_region[search_from + start + prefix.len()..];
1685 if let Some(end) = after_prefix.find(suffix) {
1686 return Some(after_prefix[..end].trim().to_string());
1687 }
1688 break;
1689 }
1690 None
1691}
1692
1693fn default_mode(name: &str) -> &'static str {
1696 match name {
1697 "exchange" | "findings" => "append",
1698 _ => "replace",
1699 }
1700}
1701
1702fn limit_lines(content: &str, max_lines: usize) -> String {
1704 let lines: Vec<&str> = content.lines().collect();
1705 if lines.len() <= max_lines {
1706 return content.to_string();
1707 }
1708 lines[lines.len() - max_lines..].join("\n")
1709}
1710
1711fn dedup_exchange_adjacent_lines(doc: &str) -> String {
1720 let Ok(components) = component::parse(doc) else {
1721 return doc.to_string();
1722 };
1723 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
1724 return doc.to_string();
1725 };
1726 let content = exchange.content(doc);
1727 let mut deduped = String::with_capacity(content.len());
1728 let mut prev_nonempty: Option<&str> = None;
1729 for line in content.lines() {
1730 let trimmed = line.trim();
1731 let is_fence = is_code_fence_delimiter(trimmed);
1732 if !trimmed.is_empty() && !is_fence && prev_nonempty == Some(line) {
1733 continue;
1734 }
1735 deduped.push_str(line);
1736 deduped.push('\n');
1737 if !trimmed.is_empty() {
1738 prev_nonempty = Some(line);
1739 }
1740 }
1741 if !content.ends_with('\n') && deduped.ends_with('\n') {
1742 deduped.pop();
1743 }
1744 if deduped == content {
1745 return doc.to_string();
1746 }
1747 exchange.replace_content(doc, &deduped)
1748}
1749
1750fn is_code_fence_delimiter(trimmed: &str) -> bool {
1751 let fc = match trimmed.chars().next() {
1752 Some(c) if c == '`' || c == '~' => c,
1753 _ => return false,
1754 };
1755 let fence_len = trimmed.chars().take_while(|&c| c == fc).count();
1756 fence_len >= 3
1757}
1758
1759fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
1761 match mode {
1762 "append" => {
1763 let stripped = strip_leading_overlap(existing, new_content);
1764 format!("{}{}", existing, stripped)
1765 }
1766 "prepend" => format!("{}{}", new_content, existing),
1767 _ => new_content.to_string(), }
1769}
1770
1771fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
1778 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
1779 let Some(last) = last_nonempty else {
1780 return new_content;
1781 };
1782 if is_code_fence_delimiter(last.trim()) {
1783 return new_content;
1784 }
1785 let test = format!("{}\n", last);
1786 if new_content.starts_with(test.as_str()) {
1787 &new_content[test.len()..]
1788 } else {
1789 new_content
1790 }
1791}
1792
1793fn find_outside_code(
1796 needle: &str,
1797 haystack: &str,
1798 from: usize,
1799 code_ranges: &[(usize, usize)],
1800) -> Option<usize> {
1801 let mut search_start = from;
1802 loop {
1803 let rel = haystack[search_start..].find(needle)?;
1804 let abs = search_start + rel;
1805 if code_ranges
1806 .iter()
1807 .any(|&(start, end)| abs >= start && abs < end)
1808 {
1809 search_start = abs + needle.len();
1811 continue;
1812 }
1813 return Some(abs);
1814 }
1815}
1816
1817#[cfg(test)]
1818mod th {
1819 use super::*;
1820 use std::path::Path;
1821 use tempfile::TempDir;
1822 pub(crate) fn setup_project() -> TempDir {
1823 let dir = TempDir::new().unwrap();
1824 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
1825 dir
1826 }
1827 #[allow(dead_code)]
1830 pub(crate) fn apply_patches_via_path(
1831 doc: &str,
1832 patches: &[PatchBlock],
1833 unmatched: &str,
1834 file: &Path,
1835 ) -> Result<String> {
1836 let summary = file.file_stem().and_then(|s| s.to_str());
1837 let empty_str = std::collections::HashMap::new();
1838 let empty_usize = std::collections::HashMap::new();
1839 apply_patches_pure(doc, patches, unmatched, summary, &empty_str, &empty_usize)
1840 }
1841 #[allow(dead_code)]
1843 pub(crate) fn apply_patches_with_overrides_via_path(
1844 doc: &str,
1845 patches: &[PatchBlock],
1846 unmatched: &str,
1847 file: &Path,
1848 mode_overrides: &std::collections::HashMap<String, String>,
1849 ) -> Result<String> {
1850 let summary = file.file_stem().and_then(|s| s.to_str());
1851 let empty_str = std::collections::HashMap::new();
1852 let empty_usize = std::collections::HashMap::new();
1853 apply_patches_with_overrides_pure(
1854 doc,
1855 patches,
1856 unmatched,
1857 summary,
1858 &empty_str,
1859 &empty_usize,
1860 mode_overrides,
1861 )
1862 }
1863 pub(crate) fn exchange_component(doc: &str) -> Component {
1865 component::parse(doc)
1866 .unwrap()
1867 .into_iter()
1868 .find(|c| c.name == "exchange")
1869 .unwrap()
1870 }
1871}
1872#[cfg(test)]
1873pub(crate) use th::{
1874 apply_patches_via_path, apply_patches_with_overrides_via_path, exchange_component,
1875 setup_project,
1876};
1877
1878#[cfg(test)]
1879mod tests {
1880 #![allow(unused_imports)]
1881 use super::*;
1882 use std::path::Path;
1883 use tempfile::TempDir;
1884 #[test]
1885 fn parse_single_patch() {
1886 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
1887 let (patches, unmatched) = parse_patches(response).unwrap();
1888 assert_eq!(patches.len(), 1);
1889 assert_eq!(patches[0].name, "status");
1890 assert_eq!(patches[0].content, "Build passing.\n");
1891 assert!(unmatched.is_empty());
1892 }
1893 #[test]
1894 fn parse_multiple_patches() {
1895 let response = "\
1896<!-- patch:status -->
1897All green.
1898<!-- /patch:status -->
1899
1900<!-- patch:log -->
1901- New entry
1902<!-- /patch:log -->
1903";
1904 let (patches, unmatched) = parse_patches(response).unwrap();
1905 assert_eq!(patches.len(), 2);
1906 assert_eq!(patches[0].name, "status");
1907 assert_eq!(patches[0].content, "All green.\n");
1908 assert_eq!(patches[1].name, "log");
1909 assert_eq!(patches[1].content, "- New entry\n");
1910 assert!(unmatched.is_empty());
1911 }
1912 #[test]
1913 fn parse_with_unmatched_content() {
1914 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
1915 let (patches, unmatched) = parse_patches(response).unwrap();
1916 assert_eq!(patches.len(), 1);
1917 assert_eq!(patches[0].name, "status");
1918 assert!(unmatched.contains("Some free text."));
1919 assert!(unmatched.contains("Trailing text."));
1920 }
1921 #[test]
1922 fn parse_empty_response() {
1923 let (patches, unmatched) = parse_patches("").unwrap();
1924 assert!(patches.is_empty());
1925 assert!(unmatched.is_empty());
1926 }
1927 #[test]
1928 fn parse_no_patches() {
1929 let response = "Just a plain response with no patch blocks.";
1930 let (patches, unmatched) = parse_patches(response).unwrap();
1931 assert!(patches.is_empty());
1932 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
1933 }
1934 fn npe1_doc(prompt: &str) -> String {
1936 format!("# Session\n\n<!-- agent:exchange -->\n{prompt}\n<!-- /agent:exchange -->\n")
1937 }
1938
1939 fn npe1_round_trip(response_body: &str, prompt: &str) -> String {
1940 let dir = setup_project();
1941 let doc_path = dir.path().join("plan.md");
1942 let doc = npe1_doc(prompt);
1943 std::fs::write(&doc_path, &doc).unwrap();
1944 let response =
1945 format!("<!-- patch:exchange -->\n{response_body}\n<!-- /patch:exchange -->\n");
1946 let (patches, unmatched) = parse_patches(&response).unwrap();
1947 let applied = apply_patches_via_path(&doc, &patches, &unmatched, &doc_path).unwrap();
1948 reposition_boundary_to_end_clean_with_summary(&applied, Some("plan"))
1950 }
1951
1952 #[test]
1953 fn npe1_trailing_code_fence_survives_round_trip() {
1954 let prompt = "❯ show me the snippet";
1955 let response =
1956 "### Re: show me the snippet\n\nHere is the code:\n\n```rust\nfn main() {}\n```";
1957 let result = npe1_round_trip(response, prompt);
1958 let fences = result.matches("```").count();
1959 assert_eq!(
1960 fences, 2,
1961 "expected opening + closing fence to survive; got {fences} fences in:\n{result}"
1962 );
1963 assert!(result.contains("fn main() {}"), "code body lost:\n{result}");
1964 assert!(
1965 result.contains("```rust\nfn main() {}\n```"),
1966 "fenced block mangled:\n{result}"
1967 );
1968 }
1969
1970 #[test]
1971 fn npe1_second_cycle_preserves_prior_fence() {
1972 let doc1 = npe1_round_trip("### Re: q\n\n```rust\nfn main() {}\n```", "❯ q");
1974 assert_eq!(doc1.matches("```").count(), 2, "cycle1:\n{doc1}");
1975 let dir = setup_project();
1978 let doc_path = dir.path().join("plan.md");
1979 let doc2_input = doc1.replace(
1980 "<!-- /agent:exchange -->",
1981 "❯ second q\n<!-- /agent:exchange -->",
1982 );
1983 std::fs::write(&doc_path, &doc2_input).unwrap();
1984 let response =
1985 "<!-- patch:exchange -->\n### Re: second q\n\nplain text\n<!-- /patch:exchange -->\n";
1986 let (patches, unmatched) = parse_patches(response).unwrap();
1987 let applied = apply_patches_via_path(&doc2_input, &patches, &unmatched, &doc_path).unwrap();
1988 let result = reposition_boundary_to_end_clean_with_summary(&applied, Some("plan"));
1989 assert_eq!(
1990 result.matches("```").count(),
1991 2,
1992 "prior fence lost in cycle2:\n{result}"
1993 );
1994 assert!(
1995 result.contains("```rust\nfn main() {}\n```"),
1996 "cycle2:\n{result}"
1997 );
1998 }
1999
2000 #[test]
2001 fn npe1_adjacent_fence_cases_survive_round_trip() {
2002 let cases = [
2003 ("only-fence", "### Re: q\n```\nplain\n```"),
2004 (
2005 "fence+trailing-nl",
2006 "### Re: q\n\n```rust\nfn main() {}\n```\n",
2007 ),
2008 (
2009 "indented-fence",
2010 "### Re: q\n\n- item:\n ```rust\n fn main() {}\n ```",
2011 ),
2012 (
2013 "multi-fence",
2014 "### Re: q\n\n```a\none\n```\n\nthen\n\n```b\ntwo\n```",
2015 ),
2016 ("no-heading-fence", "Here:\n\n```rust\nfn main() {}\n```"),
2017 ];
2018 for (name, response) in cases {
2019 let result = npe1_round_trip(response, "❯ q");
2020 let expected = response.matches("```").count();
2021 let got = result.matches("```").count();
2022 assert_eq!(got, expected, "case {name}: fence count changed:\n{result}");
2023 }
2024 }
2025
2026 #[test]
2027 fn apply_patches_replace() {
2028 let dir = setup_project();
2029 let doc_path = dir.path().join("test.md");
2030 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
2031 std::fs::write(&doc_path, doc).unwrap();
2032
2033 let patches = vec![PatchBlock {
2034 name: "status".to_string(),
2035 content: "new\n".to_string(),
2036 attrs: Default::default(),
2037 }];
2038 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2039 assert!(result.contains("new\n"));
2040 assert!(!result.contains("\nold\n"));
2041 assert!(result.contains("<!-- agent:status -->"));
2042 }
2043 #[test]
2044 fn apply_patches_unmatched_creates_exchange() {
2045 let dir = setup_project();
2046 let doc_path = dir.path().join("test.md");
2047 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
2048 std::fs::write(&doc_path, doc).unwrap();
2049
2050 let result = apply_patches_via_path(doc, &[], "Extra info here", &doc_path).unwrap();
2051 assert!(result.contains("<!-- agent:exchange -->"));
2052 assert!(result.contains("Extra info here"));
2053 assert!(result.contains("<!-- /agent:exchange -->"));
2054 }
2055 #[test]
2056 fn apply_patches_unmatched_appends_to_existing_exchange() {
2057 let dir = setup_project();
2058 let doc_path = dir.path().join("test.md");
2059 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
2060 std::fs::write(&doc_path, doc).unwrap();
2061
2062 let result = apply_patches_via_path(doc, &[], "new stuff", &doc_path).unwrap();
2063 assert!(result.contains("previous"));
2064 assert!(result.contains("new stuff"));
2065 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
2067 }
2068 #[test]
2069 fn apply_patches_missing_component_routes_to_exchange() {
2070 let dir = setup_project();
2071 let doc_path = dir.path().join("test.md");
2072 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
2073 std::fs::write(&doc_path, doc).unwrap();
2074
2075 let patches = vec![PatchBlock {
2076 name: "nonexistent".to_string(),
2077 content: "overflow data\n".to_string(),
2078 attrs: Default::default(),
2079 }];
2080 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2081 assert!(
2083 result.contains("overflow data"),
2084 "missing patch content should appear in exchange"
2085 );
2086 assert!(
2087 result.contains("previous"),
2088 "existing exchange content should be preserved"
2089 );
2090 }
2091 #[test]
2092 fn apply_patches_missing_component_creates_exchange() {
2093 let dir = setup_project();
2094 let doc_path = dir.path().join("test.md");
2095 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
2096 std::fs::write(&doc_path, doc).unwrap();
2097
2098 let patches = vec![PatchBlock {
2099 name: "nonexistent".to_string(),
2100 content: "overflow data\n".to_string(),
2101 attrs: Default::default(),
2102 }];
2103 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2104 assert!(
2106 result.contains("<!-- agent:exchange -->"),
2107 "should create exchange component"
2108 );
2109 assert!(
2110 result.contains("overflow data"),
2111 "overflow content should be in exchange"
2112 );
2113 }
2114 #[test]
2115 fn is_template_mode_detection() {
2116 assert!(is_template_mode(Some("template")));
2117 assert!(!is_template_mode(Some("append")));
2118 assert!(!is_template_mode(None));
2119 }
2120 #[test]
2121 fn parse_patches_ignores_markers_in_fenced_code_block() {
2122 let response = "\
2123<!-- patch:exchange -->
2124Here is how you use component markers:
2125
2126```markdown
2127<!-- agent:exchange -->
2128example content
2129<!-- /agent:exchange -->
2130```
2131
2132<!-- /patch:exchange -->
2133";
2134 let (patches, unmatched) = parse_patches(response).unwrap();
2135 assert_eq!(patches.len(), 1);
2136 assert_eq!(patches[0].name, "exchange");
2137 assert!(patches[0].content.contains("```markdown"));
2138 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
2139 assert!(unmatched.is_empty());
2140 }
2141 #[test]
2142 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
2143 let response = "\
2145<!-- patch:exchange -->
2146Real content here.
2147
2148```markdown
2149<!-- patch:fake -->
2150This is just an example.
2151<!-- /patch:fake -->
2152```
2153
2154<!-- /patch:exchange -->
2155";
2156 let (patches, unmatched) = parse_patches(response).unwrap();
2157 assert_eq!(patches.len(), 1, "should only find the outer real patch");
2158 assert_eq!(patches[0].name, "exchange");
2159 assert!(
2160 patches[0].content.contains("<!-- patch:fake -->"),
2161 "code block content should be preserved"
2162 );
2163 assert!(unmatched.is_empty());
2164 }
2165 #[test]
2166 fn parse_patches_ignores_markers_in_tilde_fence() {
2167 let response = "\
2168<!-- patch:status -->
2169OK
2170<!-- /patch:status -->
2171
2172~~~
2173<!-- patch:fake -->
2174example
2175<!-- /patch:fake -->
2176~~~
2177";
2178 let (patches, _unmatched) = parse_patches(response).unwrap();
2179 assert_eq!(patches.len(), 1);
2181 assert_eq!(patches[0].name, "status");
2182 }
2183 #[test]
2184 fn parse_patches_ignores_closing_marker_in_code_block() {
2185 let response = "\
2188<!-- patch:exchange -->
2189Example:
2190
2191```
2192<!-- /patch:exchange -->
2193```
2194
2195Real content continues.
2196<!-- /patch:exchange -->
2197";
2198 let (patches, _unmatched) = parse_patches(response).unwrap();
2199 assert_eq!(patches.len(), 1);
2200 assert_eq!(patches[0].name, "exchange");
2201 assert!(patches[0].content.contains("Real content continues."));
2202 }
2203 #[test]
2204 fn parse_patches_normal_markers_still_work() {
2205 let response = "\
2207<!-- patch:status -->
2208All systems go.
2209<!-- /patch:status -->
2210<!-- patch:log -->
2211- Entry 1
2212<!-- /patch:log -->
2213";
2214 let (patches, unmatched) = parse_patches(response).unwrap();
2215 assert_eq!(patches.len(), 2);
2216 assert_eq!(patches[0].name, "status");
2217 assert_eq!(patches[0].content, "All systems go.\n");
2218 assert_eq!(patches[1].name, "log");
2219 assert_eq!(patches[1].content, "- Entry 1\n");
2220 assert!(unmatched.is_empty());
2221 }
2222 #[test]
2223 fn parse_patches_accepts_replace_icebox() {
2224 let response = "\
2225<!-- replace:icebox -->
2226- [ ] [#park1] Parked follow-up
2227<!-- /replace:icebox -->
2228";
2229 let (patches, unmatched) = parse_patches(response).unwrap();
2230 assert_eq!(patches.len(), 1);
2231 assert_eq!(patches[0].name, "icebox");
2232 assert_eq!(patches[0].content, "- [ ] [#park1] Parked follow-up\n");
2233 assert!(unmatched.is_empty());
2234 }
2235 #[test]
2236 fn parse_patches_orphaned_opener_does_not_leak_into_unmatched() {
2237 let response = "\
2240Some real content here.
2241<!-- patch:exchange -->
2242This opener has no matching close.
2243";
2244 let (patches, unmatched) = parse_patches(response).unwrap();
2245 assert!(
2246 patches.is_empty(),
2247 "orphaned opener should not produce a patch"
2248 );
2249 assert_eq!(
2250 unmatched, "Some real content here.\nThis opener has no matching close.",
2251 "unmatched should contain text before and after the orphaned marker, but not the marker itself"
2252 );
2253 }
2254 #[test]
2255 fn parse_patches_orphaned_opener_between_valid_patches() {
2256 let response = "\
2259<!-- patch:status -->
2260All good.
2261<!-- /patch:status -->
2262Interstitial text.
2263<!-- patch:exchange -->
2264<!-- patch:log -->
2265- Log entry
2266<!-- /patch:log -->
2267";
2268 let (patches, unmatched) = parse_patches(response).unwrap();
2269 assert_eq!(patches.len(), 2);
2270 assert_eq!(patches[0].name, "status");
2271 assert_eq!(patches[1].name, "log");
2272 assert_eq!(unmatched, "Interstitial text.");
2273 }
2274 #[test]
2275 fn inline_attr_mode_overrides_config() {
2276 let dir = setup_project();
2278 let doc_path = dir.path().join("test.md");
2279 std::fs::write(
2281 dir.path().join(".agent-doc/config.toml"),
2282 "[components.status]\npatch = \"append\"\n",
2283 )
2284 .unwrap();
2285 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
2287 std::fs::write(&doc_path, doc).unwrap();
2288
2289 let patches = vec![PatchBlock {
2290 name: "status".to_string(),
2291 content: "new\n".to_string(),
2292 attrs: Default::default(),
2293 }];
2294 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2295 assert!(result.contains("new\n"));
2297 assert!(!result.contains("old\n"));
2298 }
2299 #[test]
2300 fn inline_attr_mode_overrides_default() {
2301 let dir = setup_project();
2303 let doc_path = dir.path().join("test.md");
2304 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
2305 std::fs::write(&doc_path, doc).unwrap();
2306
2307 let patches = vec![PatchBlock {
2308 name: "exchange".to_string(),
2309 content: "new\n".to_string(),
2310 attrs: Default::default(),
2311 }];
2312 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2313 assert!(result.contains("new\n"));
2314 assert!(!result.contains("old\n"));
2315 }
2316 #[test]
2317 fn no_inline_attr_no_config_falls_back_to_default() {
2318 let dir = setup_project();
2320 let doc_path = dir.path().join("test.md");
2321 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
2322 std::fs::write(&doc_path, doc).unwrap();
2323
2324 let patches = vec![PatchBlock {
2325 name: "exchange".to_string(),
2326 content: "new\n".to_string(),
2327 attrs: Default::default(),
2328 }];
2329 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2330 assert!(result.contains("old\n"));
2332 assert!(result.contains("new\n"));
2333 }
2334 #[test]
2335 fn inline_patch_attr_overrides_config() {
2336 let dir = setup_project();
2338 let doc_path = dir.path().join("test.md");
2339 std::fs::write(
2340 dir.path().join(".agent-doc/config.toml"),
2341 "[components.status]\npatch = \"append\"\n",
2342 )
2343 .unwrap();
2344 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
2345 std::fs::write(&doc_path, doc).unwrap();
2346
2347 let patches = vec![PatchBlock {
2348 name: "status".to_string(),
2349 content: "new\n".to_string(),
2350 attrs: Default::default(),
2351 }];
2352 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2353 assert!(result.contains("new\n"));
2354 assert!(!result.contains("old\n"));
2355 }
2356 #[test]
2357 fn inline_patch_attr_overrides_mode_attr() {
2358 let dir = setup_project();
2360 let doc_path = dir.path().join("test.md");
2361 let doc =
2362 "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
2363 std::fs::write(&doc_path, doc).unwrap();
2364
2365 let patches = vec![PatchBlock {
2366 name: "exchange".to_string(),
2367 content: "new\n".to_string(),
2368 attrs: Default::default(),
2369 }];
2370 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2371 assert!(result.contains("new\n"));
2372 assert!(!result.contains("old\n"));
2373 }
2374 #[test]
2375 fn stream_override_beats_inline_attr() {
2376 let dir = setup_project();
2378 let doc_path = dir.path().join("test.md");
2379 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
2380 std::fs::write(&doc_path, doc).unwrap();
2381
2382 let patches = vec![PatchBlock {
2383 name: "exchange".to_string(),
2384 content: "new\n".to_string(),
2385 attrs: Default::default(),
2386 }];
2387 let mut overrides = std::collections::HashMap::new();
2388 overrides.insert("exchange".to_string(), "replace".to_string());
2389 let result =
2390 apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &overrides)
2391 .unwrap();
2392 assert!(result.contains("new\n"));
2394 assert!(!result.contains("old\n"));
2395 }
2396 #[test]
2397 fn exchange_replace_override_replaces_unmatched_content() {
2398 let dir = setup_project();
2399 let doc_path = dir.path().join("test.md");
2400 let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
2401 std::fs::write(&doc_path, doc).unwrap();
2402
2403 let mut overrides = std::collections::HashMap::new();
2404 overrides.insert("exchange".to_string(), "replace".to_string());
2405 let result = apply_patches_with_overrides_via_path(
2406 doc,
2407 &[],
2408 "Compacted summary.\n",
2409 &doc_path,
2410 &overrides,
2411 )
2412 .unwrap();
2413
2414 assert!(result.contains("Compacted summary.\n"));
2415 assert!(!result.contains("old\n"));
2416 }
2417 #[test]
2418 fn exchange_replace_override_keeps_explicit_exchange_patch_authoritative() {
2419 let dir = setup_project();
2420 let doc_path = dir.path().join("test.md");
2421 let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
2422 std::fs::write(&doc_path, doc).unwrap();
2423
2424 let patches = vec![PatchBlock {
2425 name: "exchange".to_string(),
2426 content: "Compacted summary.\n".to_string(),
2427 attrs: Default::default(),
2428 }];
2429 let mut overrides = std::collections::HashMap::new();
2430 overrides.insert("exchange".to_string(), "replace".to_string());
2431 let result = apply_patches_with_overrides_via_path(
2432 doc,
2433 &patches,
2434 "trailing note",
2435 &doc_path,
2436 &overrides,
2437 )
2438 .unwrap();
2439
2440 assert!(result.contains("Compacted summary.\n"));
2441 assert!(result.contains("trailing note"));
2442 assert!(!result.contains("old\n"));
2443 }
2444 #[test]
2445 fn apply_patches_ignores_component_tags_in_code_blocks() {
2446 let dir = setup_project();
2449 let doc_path = dir.path().join("test.md");
2450 let doc = "\
2451# Scaffold Guide
2452
2453Here is an example of a component:
2454
2455```markdown
2456<!-- agent:status -->
2457example scaffold content
2458<!-- /agent:status -->
2459```
2460
2461<!-- agent:status -->
2462real status content
2463<!-- /agent:status -->
2464";
2465 std::fs::write(&doc_path, doc).unwrap();
2466
2467 let patches = vec![PatchBlock {
2468 name: "status".to_string(),
2469 content: "patched status\n".to_string(),
2470 attrs: Default::default(),
2471 }];
2472 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
2473
2474 assert!(
2476 result.contains("patched status\n"),
2477 "real component should receive the patch"
2478 );
2479 assert!(
2481 result.contains("example scaffold content"),
2482 "code block content should be preserved"
2483 );
2484 assert!(
2486 result.contains("```markdown\n<!-- agent:status -->"),
2487 "code block markers should be preserved"
2488 );
2489 }
2490 #[test]
2491 fn unmatched_content_uses_boundary_marker() {
2492 let dir = setup_project();
2493 let file = dir.path().join("test.md");
2494 let doc = concat!(
2495 "---\nagent_doc_format: template\n---\n",
2496 "<!-- agent:exchange patch=append -->\n",
2497 "User prompt here.\n",
2498 "<!-- agent:boundary:test-uuid-123 -->\n",
2499 "<!-- /agent:exchange -->\n",
2500 );
2501 std::fs::write(&file, doc).unwrap();
2502
2503 let patches = vec![];
2505 let unmatched = "### Re: Response\n\nResponse content here.\n";
2506
2507 let result = apply_patches_via_path(doc, &patches, unmatched, &file).unwrap();
2508
2509 let prompt_pos = result.find("User prompt here.").unwrap();
2511 let response_pos = result.find("### Re: Response").unwrap();
2512 assert!(
2513 response_pos > prompt_pos,
2514 "response should appear after the user prompt (boundary insertion)"
2515 );
2516
2517 assert!(
2519 !result.contains("test-uuid-123"),
2520 "boundary marker should be consumed after insertion"
2521 );
2522 }
2523 #[test]
2524 fn explicit_patch_uses_boundary_marker() {
2525 let dir = setup_project();
2526 let file = dir.path().join("test.md");
2527 let doc = concat!(
2528 "---\nagent_doc_format: template\n---\n",
2529 "<!-- agent:exchange patch=append -->\n",
2530 "User prompt here.\n",
2531 "<!-- agent:boundary:patch-uuid-456 -->\n",
2532 "<!-- /agent:exchange -->\n",
2533 );
2534 std::fs::write(&file, doc).unwrap();
2535
2536 let patches = vec![PatchBlock {
2538 name: "exchange".to_string(),
2539 content: "### Re: Response\n\nResponse content.\n".to_string(),
2540 attrs: Default::default(),
2541 }];
2542
2543 let result = apply_patches_via_path(doc, &patches, "", &file).unwrap();
2544
2545 let prompt_pos = result.find("User prompt here.").unwrap();
2547 let response_pos = result.find("### Re: Response").unwrap();
2548 assert!(
2549 response_pos > prompt_pos,
2550 "response should appear after user prompt"
2551 );
2552
2553 assert!(
2555 !result.contains("patch-uuid-456"),
2556 "boundary marker should be consumed by explicit patch"
2557 );
2558 }
2559 #[test]
2560 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
2561 let dir = setup_project();
2564 let file = dir.path().join("test.md");
2565 let doc =
2567 "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
2568 std::fs::write(&file, doc).unwrap();
2569
2570 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
2571 let (patches, unmatched) = parse_patches(response).unwrap();
2572 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
2573
2574 assert!(
2576 result.contains("<!-- agent:boundary:"),
2577 "boundary must be re-inserted even when original doc had no boundary: {result}"
2578 );
2579 }
2580 #[test]
2581 fn boundary_survives_multiple_cycles() {
2582 let dir = setup_project();
2584 let file = dir.path().join("test.md");
2585 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
2586 std::fs::write(&file, doc).unwrap();
2587
2588 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
2590 let (patches1, unmatched1) = parse_patches(response1).unwrap();
2591 let result1 = apply_patches_via_path(doc, &patches1, &unmatched1, &file).unwrap();
2592 assert!(
2593 result1.contains("<!-- agent:boundary:"),
2594 "cycle 1 must have boundary"
2595 );
2596
2597 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
2599 let (patches2, unmatched2) = parse_patches(response2).unwrap();
2600 let result2 = apply_patches_via_path(&result1, &patches2, &unmatched2, &file).unwrap();
2601 assert!(
2602 result2.contains("<!-- agent:boundary:"),
2603 "cycle 2 must have boundary"
2604 );
2605 }
2606 #[test]
2607 fn exchange_boundary_insert_adds_blank_line_before_response_heading() {
2608 let dir = setup_project();
2609 let file = dir.path().join("test.md");
2610 let doc = concat!(
2611 "<!-- agent:exchange patch=append -->\n",
2612 "### Re: Prior -- gpt-5\n\n",
2613 "This guards against a future regression.\n",
2614 "<!-- agent:boundary:abc123 -->\n",
2615 "<!-- /agent:exchange -->\n",
2616 );
2617 std::fs::write(&file, doc).unwrap();
2618
2619 let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
2620 let (patches, unmatched) = parse_patches(response).unwrap();
2621 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
2622
2623 assert!(
2624 result.contains("future regression.\n\n### Re: Follow-up"),
2625 "response heading should be separated from the previous paragraph: {result}"
2626 );
2627 assert!(
2628 !result.contains("future regression.\n### Re: Follow-up"),
2629 "response heading must not attach to the previous paragraph: {result}"
2630 );
2631 }
2632 #[test]
2633 fn exchange_fallback_append_adds_blank_line_before_response_heading() {
2634 let dir = setup_project();
2635 let file = dir.path().join("test.md");
2636 let doc = concat!(
2637 "<!-- agent:exchange patch=append -->\n",
2638 "### Re: Prior -- gpt-5\n\n",
2639 "This guards against a future regression.\n",
2640 "<!-- /agent:exchange -->\n",
2641 );
2642 std::fs::write(&file, doc).unwrap();
2643
2644 let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
2645 let (patches, unmatched) = parse_patches(response).unwrap();
2646 let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
2647
2648 assert!(
2649 result.contains("future regression.\n\n### Re: Follow-up"),
2650 "response heading should be separated from the previous paragraph: {result}"
2651 );
2652 assert!(
2653 !result.contains("future regression.\n### Re: Follow-up"),
2654 "response heading must not attach to the previous paragraph: {result}"
2655 );
2656 }
2657 #[test]
2658 fn remove_all_boundaries_skips_code_blocks() {
2659 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
2660 let result = remove_all_boundaries(doc);
2661 assert!(
2663 result.contains("<!-- agent:boundary:fake-id -->"),
2664 "boundary inside code block must be preserved: {result}"
2665 );
2666 assert!(
2668 !result.contains("<!-- agent:boundary:real-id -->"),
2669 "boundary outside code block must be removed: {result}"
2670 );
2671 }
2672 #[test]
2673 fn reposition_boundary_moves_to_end() {
2674 let doc = "\
2675<!-- agent:exchange -->
2676Previous response.
2677<!-- agent:boundary:old-id -->
2678User prompt here.
2679<!-- /agent:exchange -->";
2680 let result = reposition_boundary_to_end(doc);
2681 assert!(!result.contains("old-id"), "old boundary should be removed");
2683 assert!(
2685 result.contains("<!-- agent:boundary:"),
2686 "new boundary should be inserted"
2687 );
2688 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
2690 let prompt_pos = result.find("User prompt here.").unwrap();
2691 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
2692 assert!(
2693 boundary_pos > prompt_pos,
2694 "boundary should be after user prompt"
2695 );
2696 assert!(
2697 boundary_pos < close_pos,
2698 "boundary should be before close tag"
2699 );
2700 }
2701 #[test]
2702 fn reposition_boundary_no_exchange_unchanged() {
2703 let doc = "\
2704<!-- agent:output -->
2705Some content.
2706<!-- /agent:output -->";
2707 let result = reposition_boundary_to_end(doc);
2708 assert!(
2709 !result.contains("<!-- agent:boundary:"),
2710 "no boundary should be added to non-exchange"
2711 );
2712 }
2713 #[test]
2714 fn reposition_boundary_clean_reuses_explicit_id() {
2715 let doc = "\
2716<!-- agent:exchange -->
2717Previous response.
2718<!-- agent:boundary:old-id -->
2719User prompt here.
2720<!-- /agent:exchange -->";
2721 let result = reposition_boundary_to_end_clean_with_id(doc, Some("keep-this-id"));
2722 assert!(!result.contains("old-id"), "old boundary should be removed");
2723 assert!(
2724 result.contains("<!-- agent:boundary:keep-this-id -->"),
2725 "explicit boundary id should be reused"
2726 );
2727 assert_eq!(
2728 result.matches("<!-- agent:boundary:").count(),
2729 1,
2730 "exactly one boundary should remain"
2731 );
2732 }
2733 #[test]
2734 fn reposition_appends_head_to_last_re_heading() {
2735 let doc = "\
2739<!-- agent:exchange -->
2740### Re: older (HEAD)
2741old body
2742### Re: newer
2743new body
2744<!-- /agent:exchange -->";
2745 let result = reposition_boundary_to_end(doc);
2746 assert!(
2747 !result.contains("### Re: older (HEAD)"),
2748 "stale (HEAD) on prior heading must be stripped; got:\n{result}"
2749 );
2750 assert!(
2751 result.contains("### Re: older\n"),
2752 "older heading must remain (without HEAD); got:\n{result}"
2753 );
2754 assert!(
2755 result.contains("### Re: newer (HEAD)"),
2756 "latest heading must get (HEAD); got:\n{result}"
2757 );
2758 assert_eq!(
2759 result.matches("(HEAD)").count(),
2760 1,
2761 "exactly one (HEAD) in result; got:\n{result}"
2762 );
2763 }
2764 #[test]
2765 fn reposition_head_annotation_no_re_heading_unchanged() {
2766 let doc = "\
2768<!-- agent:exchange -->
2769User text with no response headings.
2770<!-- /agent:exchange -->";
2771 let result = reposition_boundary_to_end(doc);
2772 assert!(
2773 !result.contains("(HEAD)"),
2774 "no heading → no (HEAD); got:\n{result}"
2775 );
2776 }
2777 #[test]
2778 fn reposition_head_annotation_skips_code_fence() {
2779 let doc = "\
2781<!-- agent:exchange -->
2782### Re: real heading
2783```markdown
2784### Re: fake heading in code fence
2785```
2786<!-- /agent:exchange -->";
2787 let result = reposition_boundary_to_end(doc);
2788 assert!(
2789 result.contains("### Re: real heading (HEAD)"),
2790 "real heading outside fence gets (HEAD); got:\n{result}"
2791 );
2792 assert!(
2793 result.contains("### Re: fake heading in code fence\n"),
2794 "fenced heading must be untouched; got:\n{result}"
2795 );
2796 assert_eq!(
2797 result.matches("(HEAD)").count(),
2798 1,
2799 "exactly one (HEAD) — fenced heading ignored; got:\n{result}"
2800 );
2801 }
2802 #[test]
2803 fn reposition_with_baseline_marks_all_new_re_headings() {
2804 let doc = "\
2808<!-- agent:exchange -->
2809### Re: old-1
2810body a
2811### Re: old-2 (HEAD)
2812body b
2813### Re: new-1
2814body c
2815### Re: new-2
2816body d
2817<!-- /agent:exchange -->";
2818 let mut baseline = std::collections::HashSet::new();
2821 baseline.insert("### Re: old-1".to_string());
2822 baseline.insert("### Re: old-2".to_string());
2823
2824 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
2825
2826 assert!(
2828 result.contains("### Re: old-1\n"),
2829 "old-1 must not have (HEAD); got:\n{result}"
2830 );
2831 assert!(
2832 result.contains("### Re: old-2\n"),
2833 "old-2 must not have (HEAD); got:\n{result}"
2834 );
2835 assert!(
2837 result.contains("### Re: new-1 (HEAD)"),
2838 "new-1 must get (HEAD); got:\n{result}"
2839 );
2840 assert!(
2841 result.contains("### Re: new-2 (HEAD)"),
2842 "new-2 must get (HEAD); got:\n{result}"
2843 );
2844 assert_eq!(
2846 result.matches("(HEAD)").count(),
2847 2,
2848 "exactly two (HEAD) markers; got:\n{result}"
2849 );
2850 }
2851 #[test]
2852 fn reposition_with_empty_baseline_marks_every_re_heading() {
2853 let doc = "\
2856<!-- agent:exchange -->
2857### Re: first
2858a
2859### Re: second
2860b
2861<!-- /agent:exchange -->";
2862 let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
2863 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
2864 assert!(
2865 result.contains("### Re: first (HEAD)"),
2866 "first gets (HEAD); got:\n{result}"
2867 );
2868 assert!(
2869 result.contains("### Re: second (HEAD)"),
2870 "second gets (HEAD); got:\n{result}"
2871 );
2872 assert_eq!(
2873 result.matches("(HEAD)").count(),
2874 2,
2875 "exactly two (HEAD) markers; got:\n{result}"
2876 );
2877 }
2878 #[test]
2879 fn exchange_baseline_headings_extracts_stripped_re_lines() {
2880 let doc = "\
2881<!-- agent:exchange -->
2882### Re: one (HEAD)
2883body
2884### Re: two
2885more body
2886### Not a Re heading
2887body
2888<!-- /agent:exchange -->";
2889 let set = exchange_baseline_headings(doc);
2890 assert!(
2891 set.contains("### Re: one"),
2892 "stripped one present; got: {set:?}"
2893 );
2894 assert!(set.contains("### Re: two"), "two present; got: {set:?}");
2895 assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
2896 }
2897 #[test]
2898 fn exchange_baseline_headings_normalizes_leading_whitespace() {
2899 let doc = "\
2902<!-- agent:exchange -->
2903 ### Re: indented
2904body
2905### Re: flush
2906more
2907<!-- /agent:exchange -->";
2908 let set = exchange_baseline_headings(doc);
2909 assert!(
2910 set.contains("### Re: indented"),
2911 "indented entry normalized; got: {set:?}"
2912 );
2913 assert!(
2914 set.contains("### Re: flush"),
2915 "flush entry present; got: {set:?}"
2916 );
2917 }
2918 #[test]
2919 fn reposition_with_baseline_matches_indented_heading() {
2920 let doc = "\
2928<!-- agent:exchange -->
2929 ### Re: foo
2930body
2931### Re: bar (HEAD)
2932body2
2933<!-- /agent:exchange -->";
2934 let mut baseline = std::collections::HashSet::new();
2935 baseline.insert("### Re: foo".to_string());
2936 baseline.insert("### Re: bar".to_string());
2937 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
2938 assert!(
2943 result.contains(" ### Re: foo\n"),
2944 "indented heading must remain unmarked; got:\n{result}"
2945 );
2946 assert!(
2947 result.contains("### Re: bar (HEAD)"),
2948 "last heading gets fallback (HEAD) marker; got:\n{result}"
2949 );
2950 assert_eq!(
2951 result.matches("(HEAD)").count(),
2952 1,
2953 "exactly one (HEAD) via fallback; got:\n{result}"
2954 );
2955 }
2956 #[test]
2957 fn baseline_filter_empty_falls_back_to_last_heading() {
2958 let doc = "\
2963<!-- agent:exchange -->
2964### Re: older
2965body
2966### Re: newer (HEAD)
2967more
2968<!-- /agent:exchange -->";
2969 let mut baseline = std::collections::HashSet::new();
2970 baseline.insert("### Re: older".to_string());
2971 baseline.insert("### Re: newer".to_string());
2972 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
2973 assert!(
2974 result.contains("### Re: newer (HEAD)"),
2975 "last heading retains (HEAD) via fallback; got:\n{result}"
2976 );
2977 assert!(
2978 result.contains("### Re: older\n"),
2979 "older heading remains unmarked; got:\n{result}"
2980 );
2981 assert_eq!(
2982 result.matches("(HEAD)").count(),
2983 1,
2984 "exactly one (HEAD) marker after fallback; got:\n{result}"
2985 );
2986 }
2987 #[test]
2988 fn reposition_head_annotation_strips_multiple_stale() {
2989 let doc = "\
2991<!-- agent:exchange -->
2992### Re: one (HEAD)
2993a
2994### Re: two (HEAD)
2995b
2996### Re: three
2997c
2998<!-- /agent:exchange -->";
2999 let result = reposition_boundary_to_end(doc);
3000 assert_eq!(
3001 result.matches("(HEAD)").count(),
3002 1,
3003 "exactly one (HEAD) after reposition; got:\n{result}"
3004 );
3005 assert!(result.contains("### Re: three (HEAD)"));
3006 assert!(result.contains("### Re: one\n"));
3007 assert!(result.contains("### Re: two\n"));
3008 }
3009 #[test]
3010 fn preserve_head_keeps_head_markers_on_reposition() {
3011 let doc = "\
3012<!-- agent:exchange -->
3013### Re: older
3014body a
3015### Re: newer (HEAD)
3016body b
3017<!-- agent:boundary:old-id -->
3018<!-- /agent:exchange -->";
3019 let result = reposition_boundary_to_end_preserve_head(doc);
3020 assert!(
3021 result.contains("### Re: newer (HEAD)"),
3022 "preserve_head must keep (HEAD) on newest heading; got:\n{result}"
3023 );
3024 assert!(
3025 !result.contains("old-id"),
3026 "old boundary should be removed; got:\n{result}"
3027 );
3028 assert_eq!(
3029 result.matches("<!-- agent:boundary:").count(),
3030 1,
3031 "exactly one fresh boundary; got:\n{result}"
3032 );
3033 }
3034 #[test]
3035 fn preserve_head_with_id_keeps_head_and_uses_explicit_id() {
3036 let doc = "\
3037<!-- agent:exchange -->
3038### Re: topic (HEAD)
3039body
3040<!-- agent:boundary:old-id -->
3041<!-- /agent:exchange -->";
3042 let result = reposition_boundary_to_end_preserve_head_with_id(doc, Some("explicit-id"));
3043 assert!(
3044 result.contains("### Re: topic (HEAD)"),
3045 "preserve_head must keep (HEAD); got:\n{result}"
3046 );
3047 assert!(
3048 result.contains("<!-- agent:boundary:explicit-id -->"),
3049 "explicit boundary id should be used; got:\n{result}"
3050 );
3051 assert!(
3052 !result.contains("old-id"),
3053 "old boundary gone; got:\n{result}"
3054 );
3055 }
3056 #[test]
3057 fn clean_strips_head_but_preserve_head_keeps_it() {
3058 let doc = "\
3059<!-- agent:exchange -->
3060### Re: first
3061body a
3062### Re: second (HEAD)
3063body b
3064<!-- /agent:exchange -->";
3065 let clean = reposition_boundary_to_end_clean(doc);
3066 let preserved = reposition_boundary_to_end_preserve_head(doc);
3067
3068 assert!(
3069 !clean.contains("(HEAD)"),
3070 "clean variant must strip (HEAD); got:\n{clean}"
3071 );
3072 assert!(
3073 preserved.contains("### Re: second (HEAD)"),
3074 "preserve_head variant must keep (HEAD); got:\n{preserved}"
3075 );
3076 }
3077 #[test]
3078 fn max_lines_inline_attr_trims_content() {
3079 let dir = setup_project();
3080 let doc_path = dir.path().join("test.md");
3081 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
3082 std::fs::write(&doc_path, doc).unwrap();
3083
3084 let patches = vec![PatchBlock {
3085 name: "log".to_string(),
3086 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
3087 attrs: Default::default(),
3088 }];
3089 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3090 assert!(!result.contains("line1"));
3091 assert!(!result.contains("line2"));
3092 assert!(result.contains("line3"));
3093 assert!(result.contains("line4"));
3094 assert!(result.contains("line5"));
3095 }
3096 #[test]
3097 fn max_lines_noop_when_under_limit() {
3098 let dir = setup_project();
3099 let doc_path = dir.path().join("test.md");
3100 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
3101 std::fs::write(&doc_path, doc).unwrap();
3102
3103 let patches = vec![PatchBlock {
3104 name: "log".to_string(),
3105 content: "line1\nline2\n".to_string(),
3106 attrs: Default::default(),
3107 }];
3108 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3109 assert!(result.contains("line1"));
3110 assert!(result.contains("line2"));
3111 }
3112 #[test]
3113 fn max_lines_inline_beats_toml() {
3114 let dir = setup_project();
3115 let doc_path = dir.path().join("test.md");
3116 std::fs::write(
3117 dir.path().join(".agent-doc/config.toml"),
3118 "[components.log]\nmax_lines = 1\n",
3119 )
3120 .unwrap();
3121 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
3122 std::fs::write(&doc_path, doc).unwrap();
3123
3124 let patches = vec![PatchBlock {
3125 name: "log".to_string(),
3126 content: "a\nb\nc\nd\n".to_string(),
3127 attrs: Default::default(),
3128 }];
3129 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3130 assert!(result.contains("b"));
3132 assert!(result.contains("c"));
3133 assert!(result.contains("d"));
3134 }
3135 #[test]
3136 fn parse_patch_with_transfer_source_attr() {
3137 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
3138 let (patches, unmatched) = parse_patches(response).unwrap();
3139 assert_eq!(patches.len(), 1);
3140 assert_eq!(patches[0].name, "exchange");
3141 assert_eq!(patches[0].content, "Transferred content.\n");
3142 assert_eq!(
3143 patches[0].attrs.get("transfer-source"),
3144 Some(&"\"tasks/eval-runner.md\"".to_string())
3145 );
3146 assert!(unmatched.is_empty());
3147 }
3148 #[test]
3149 fn parse_patch_without_attrs() {
3150 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
3151 let (patches, _) = parse_patches(response).unwrap();
3152 assert_eq!(patches.len(), 1);
3153 assert!(patches[0].attrs.is_empty());
3154 }
3155 #[test]
3156 fn parse_patch_with_multiple_attrs() {
3157 let response =
3158 "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
3159 let (patches, _) = parse_patches(response).unwrap();
3160 assert_eq!(patches.len(), 1);
3161 assert_eq!(patches[0].name, "output");
3162 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
3163 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
3164 }
3165 #[test]
3166 fn apply_patches_dedup_exchange_adjacent_echo() {
3167 let dir = setup_project();
3171 let doc_path = dir.path().join("test.md");
3172 let doc = "\
3173<!-- agent:exchange patch=append -->
3174❯ How do I configure .mise.toml?
3175<!-- /agent:exchange -->
3176";
3177 std::fs::write(&doc_path, doc).unwrap();
3178
3179 let patches = vec![PatchBlock {
3181 name: "exchange".to_string(),
3182 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
3183 attrs: Default::default(),
3184 }];
3185 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3186
3187 let count = result.matches("❯ How do I configure .mise.toml?").count();
3188 assert_eq!(
3189 count, 1,
3190 "prompt line should appear exactly once, got:\n{result}"
3191 );
3192 assert!(
3193 result.contains("### Re: configure .mise.toml"),
3194 "response heading should be present"
3195 );
3196 assert!(
3197 result.contains("Use `[env]` section."),
3198 "response body should be present"
3199 );
3200 }
3201 #[test]
3202 fn apply_patches_dedup_preserves_blank_lines() {
3203 let dir = setup_project();
3205 let doc_path = dir.path().join("test.md");
3206 let doc = "\
3207<!-- agent:exchange patch=append -->
3208Previous response.
3209<!-- /agent:exchange -->
3210";
3211 std::fs::write(&doc_path, doc).unwrap();
3212
3213 let patches = vec![PatchBlock {
3214 name: "exchange".to_string(),
3215 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
3216 attrs: Default::default(),
3217 }];
3218 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3219 assert!(
3220 result.contains("Previous response."),
3221 "existing content preserved"
3222 );
3223 assert!(
3224 result.contains("### Re: something"),
3225 "response heading present"
3226 );
3227 assert!(result.contains('\n'), "blank lines preserved");
3229 }
3230 #[test]
3231 fn apply_patches_dedup_preserves_adjacent_code_fences() {
3232 let dir = setup_project();
3233 let doc_path = dir.path().join("test.md");
3234 let doc = "\
3235<!-- agent:exchange patch=append -->
3236Some text.
3237```
3238code block 1
3239```
3240```
3241code block 2
3242```
3243<!-- /agent:exchange -->
3244";
3245 std::fs::write(&doc_path, doc).unwrap();
3246
3247 let patches = vec![PatchBlock {
3248 name: "exchange".to_string(),
3249 content: "### Re: test — opus-4-6\n\nResponse.\n".to_string(),
3250 attrs: Default::default(),
3251 }];
3252 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3253 let fence_count = result.matches("```").count();
3254 assert_eq!(
3255 fence_count, 4,
3256 "all four code fences must survive dedup, got:\n{result}"
3257 );
3258 assert!(
3259 result.contains("```\n```"),
3260 "adjacent code fences must be preserved"
3261 );
3262 }
3263 #[test]
3264 fn apply_patches_append_preserves_response_leading_code_fence_after_prompt_fence() {
3265 let dir = setup_project();
3266 let doc_path = dir.path().join("test.md");
3267 let doc = "\
3268<!-- agent:exchange patch=append -->
3269❯ show fenced prompt
3270```
3271prompt body
3272```
3273<!-- /agent:exchange -->
3274";
3275 std::fs::write(&doc_path, doc).unwrap();
3276
3277 let patches = vec![PatchBlock {
3278 name: "exchange".to_string(),
3279 content: "```\nresponse body\n```\n".to_string(),
3280 attrs: Default::default(),
3281 }];
3282 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3283
3284 assert_eq!(
3285 result.matches("```").count(),
3286 4,
3287 "prompt and response fences must all survive append:\n{result}"
3288 );
3289 assert!(
3290 result.contains("```\n```\nresponse body\n```"),
3291 "response opening fence must remain after the prompt closing fence:\n{result}"
3292 );
3293 }
3294 #[test]
3295 fn apply_patches_marks_new_exchange_headings_with_head() {
3296 let dir = setup_project();
3297 let doc_path = dir.path().join("test.md");
3298 let doc = "\
3299<!-- agent:exchange patch=append -->
3300### Re: earlier — gpt-5
3301
3302Existing answer.
3303<!-- /agent:exchange -->
3304";
3305 std::fs::write(&doc_path, doc).unwrap();
3306
3307 let patches = vec![PatchBlock {
3308 name: "exchange".to_string(),
3309 content: "### Re: latest — gpt-5\n\nFresh answer.\n".to_string(),
3310 attrs: Default::default(),
3311 }];
3312 let result = apply_patches_with_overrides_via_path(
3313 doc,
3314 &patches,
3315 "",
3316 &doc_path,
3317 &Default::default(),
3318 )
3319 .unwrap();
3320
3321 assert!(
3322 result.contains("### Re: earlier — gpt-5\n"),
3323 "existing response heading must stay clean; got:\n{result}"
3324 );
3325 assert!(
3326 result.contains("### Re: latest — gpt-5 (HEAD)\n"),
3327 "new response heading must surface as transient HEAD; got:\n{result}"
3328 );
3329 assert_eq!(
3330 result.matches("(HEAD)").count(),
3331 1,
3332 "exactly one transient HEAD marker expected; got:\n{result}"
3333 );
3334 }
3335 #[test]
3336 fn apply_patches_binds_exchange_response_to_oldest_matching_unresolved_prompt() {
3337 let dir = setup_project();
3338 let doc_path = dir.path().join("test.md");
3339 let doc = concat!(
3340 "<!-- agent:exchange patch=append -->\n",
3341 "### Re: prior — gpt-5\n\n",
3342 "Done.\n",
3343 "<!-- agent:boundary:old-anchor -->\n",
3344 "❯ do #wcup1. spec-test-commit-push\n\n",
3345 "❯ do #wcx1. spec-test-commit-push\n",
3346 "<!-- /agent:exchange -->\n",
3347 );
3348 std::fs::write(&doc_path, doc).unwrap();
3349
3350 let patches = vec![PatchBlock {
3351 name: "exchange".to_string(),
3352 content: "### Re: #wcup1 — gpt-5\n\nAlready complete.\n".to_string(),
3353 attrs: Default::default(),
3354 }];
3355 let result = apply_patches_with_overrides_via_path(
3356 doc,
3357 &patches,
3358 "",
3359 &doc_path,
3360 &Default::default(),
3361 )
3362 .unwrap();
3363
3364 let wcup1_prompt = result.find("❯ do #wcup1. spec-test-commit-push").unwrap();
3365 let wcup1_response = result.find("### Re: #wcup1 — gpt-5 (HEAD)").unwrap();
3366 let new_boundary = result.rfind("<!-- agent:boundary:").unwrap();
3367 let wcx1_prompt = result.find("❯ do #wcx1. spec-test-commit-push").unwrap();
3368
3369 assert!(
3370 wcup1_prompt < wcup1_response,
3371 "response must land after the matched older prompt:\n{result}"
3372 );
3373 assert!(
3374 wcup1_response < new_boundary,
3375 "new boundary must move behind the response:\n{result}"
3376 );
3377 assert!(
3378 new_boundary < wcx1_prompt,
3379 "newer unresolved prompts must remain after the new boundary:\n{result}"
3380 );
3381 }
3382 #[test]
3383 fn apply_patches_rejects_exchange_response_that_skips_older_unresolved_prompt() {
3384 let dir = setup_project();
3385 let doc_path = dir.path().join("test.md");
3386 let doc = concat!(
3387 "<!-- agent:exchange patch=append -->\n",
3388 "### Re: prior — gpt-5\n\n",
3389 "Done.\n",
3390 "<!-- agent:boundary:old-anchor -->\n",
3391 "❯ do #wcup1. spec-test-commit-push\n\n",
3392 "❯ do #wcx1. spec-test-commit-push\n",
3393 "<!-- /agent:exchange -->\n",
3394 );
3395 std::fs::write(&doc_path, doc).unwrap();
3396
3397 let patches = vec![PatchBlock {
3398 name: "exchange".to_string(),
3399 content: "### Re: #wcx1 — gpt-5\n\nNot done yet.\n".to_string(),
3400 attrs: Default::default(),
3401 }];
3402 let err = apply_patches_with_overrides_via_path(
3403 doc,
3404 &patches,
3405 "",
3406 &doc_path,
3407 &Default::default(),
3408 )
3409 .expect_err("later-matching response should fail closed");
3410 assert!(
3411 err.to_string().contains("skip older unresolved prompt"),
3412 "unexpected error: {err}"
3413 );
3414 }
3415 #[test]
3416 fn append_anchor_falls_back_when_single_prompt_edited_after_baseline() {
3417 let original = concat!(
3423 "<!-- agent:exchange patch=append -->\n",
3424 "### Re: prior — gpt-5\n\nDone.\n",
3425 "<!-- agent:boundary:b1 -->\n",
3426 "❯ Why is it still running?\n",
3427 "<!-- /agent:exchange -->\n",
3428 );
3429 let current = concat!(
3432 "<!-- agent:exchange patch=append -->\n",
3433 "### Re: prior — gpt-5\n\nDone.\n",
3434 "Why its still running?\n",
3435 "<!-- agent:boundary:b2 -->\n",
3436 "<!-- /agent:exchange -->\n",
3437 );
3438 let comp = exchange_component(current);
3439 let patch = "### Re: still running — gpt-5\n\nBy design.\n";
3440 let result = append_exchange_patch_after_prompt_anchor(original, current, &comp, patch)
3441 .unwrap()
3442 .expect("single edited prompt must anchor via the resilient fallback");
3443 let prompt_pos = result.find("Why its still running?").unwrap();
3444 let resp_pos = result.find("### Re: still running").unwrap();
3445 let boundary_pos = result.rfind("<!-- agent:boundary:").unwrap();
3446 assert!(
3447 prompt_pos < resp_pos,
3448 "response must land after the edited prompt:\n{result}"
3449 );
3450 assert!(
3451 resp_pos < boundary_pos,
3452 "new boundary must move behind the response:\n{result}"
3453 );
3454 }
3455 #[test]
3456 fn append_anchor_fails_closed_when_multiple_prompts_drift_from_baseline() {
3457 let original = concat!(
3462 "<!-- agent:exchange patch=append -->\n",
3463 "<!-- agent:boundary:b1 -->\n",
3464 "❯ First original question?\n\n",
3465 "❯ Second original question?\n",
3466 "<!-- /agent:exchange -->\n",
3467 );
3468 let current = concat!(
3469 "<!-- agent:exchange patch=append -->\n",
3470 "❯ First EDITED question?\n\n",
3471 "❯ Second EDITED question?\n",
3472 "<!-- agent:boundary:b2 -->\n",
3473 "<!-- /agent:exchange -->\n",
3474 );
3475 let comp = exchange_component(current);
3476 let patch = "### Re: answer — gpt-5\n\nBody.\n";
3477 let err = append_exchange_patch_after_prompt_anchor(original, current, &comp, patch)
3478 .expect_err("ambiguous multi-prompt baseline drift must fail closed");
3479 assert!(
3480 err.to_string().contains("drifted from the baseline")
3481 && err.to_string().contains("refresh the baseline"),
3482 "unexpected error: {err}"
3483 );
3484 }
3485 #[test]
3486 fn apply_mode_append_strips_leading_overlap() {
3487 let existing = "❯ How do I configure .mise.toml?\n";
3490 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
3491 let result = apply_mode("append", existing, new_content);
3492 let count = result.matches("❯ How do I configure .mise.toml?").count();
3493 assert_eq!(count, 1, "overlap line should appear exactly once");
3494 assert!(result.contains("### Re: configure"));
3495 }
3496 #[test]
3497 fn apply_mode_append_preserves_leading_code_fence_overlap() {
3498 let existing = "❯ show fenced prompt\n```\nprompt body\n```\n";
3499 let new_content = "```\nresponse body\n```\n";
3500
3501 let result = apply_mode("append", existing, new_content);
3502
3503 assert_eq!(
3504 result.matches("```").count(),
3505 4,
3506 "code fence delimiters are structural, not duplicate prompt overlap:\n{result}"
3507 );
3508 assert!(
3509 result.contains("```\n```\nresponse body\n```"),
3510 "adjacent prompt/response fences must remain distinct:\n{result}"
3511 );
3512 }
3513 #[test]
3514 fn strip_trailing_caret_removes_bare_prompt_line() {
3515 let content = "Answer text.\n❯\n";
3516 assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
3517 }
3518 #[test]
3519 fn strip_trailing_caret_removes_multiple_trailing_lines() {
3520 let content = "Answer.\n❯\n❯\n";
3521 assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
3522 }
3523 #[test]
3524 fn strip_trailing_caret_preserves_mid_content_caret() {
3525 let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
3527 assert_eq!(strip_trailing_caret_lines(content), content);
3528 }
3529 #[test]
3530 fn strip_trailing_caret_preserves_caret_with_text() {
3531 let content = "Answer.\n❯ follow-up\n";
3533 assert_eq!(strip_trailing_caret_lines(content), content);
3534 }
3535 #[test]
3536 fn strip_trailing_caret_handles_no_trailing_newline() {
3537 let content = "Answer.\n❯";
3538 assert_eq!(strip_trailing_caret_lines(content), "Answer.");
3539 }
3540 #[test]
3541 fn strip_trailing_caret_noop_when_no_caret() {
3542 let content = "Answer.\n";
3543 assert_eq!(strip_trailing_caret_lines(content), content);
3544 }
3545 #[test]
3546 fn apply_patches_strips_trailing_caret_from_exchange() {
3547 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
3548 let patches = vec![PatchBlock {
3549 name: "exchange".to_string(),
3550 content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
3551 attrs: Default::default(),
3552 }];
3553 let doc_path = std::path::PathBuf::from("/tmp/test.md");
3554 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3555 let components = component::parse(&result).unwrap();
3557 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
3558 let content = exchange.content(&result);
3559 let has_bare_caret_before_boundary = content
3561 .lines()
3562 .collect::<Vec<_>>()
3563 .windows(2)
3564 .any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
3565 assert!(
3566 !has_bare_caret_before_boundary,
3567 "bare ❯ line must not appear before boundary marker. content:\n{}",
3568 content
3569 );
3570 }
3571 #[test]
3572 fn apply_patches_preserves_caret_in_non_exchange() {
3573 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
3576 let patches = vec![PatchBlock {
3577 name: "notes".to_string(),
3578 content: "note body\n❯\n".to_string(),
3579 attrs: Default::default(),
3580 }];
3581 let doc_path = std::path::PathBuf::from("/tmp/test.md");
3582 let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
3583 let components = component::parse(&result).unwrap();
3584 let notes = components.iter().find(|c| c.name == "notes").unwrap();
3585 assert!(
3586 notes.content(&result).contains("❯"),
3587 "non-exchange content retains ❯"
3588 );
3589 }
3590 #[test]
3591 fn apply_mode_append_no_overlap_unchanged() {
3592 let existing = "Previous content.\n";
3595 let new_content = "### Re: something\n\nAnswer.\n";
3596 let result = apply_mode("append", existing, new_content);
3597 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
3598 }
3599 #[test]
3600 fn repair_queue_escape_removes_struck_items_below_marker_in_parking_lot() {
3601 let doc = concat!(
3606 "<!-- agent:queue go -->\n",
3607 "- ~~:round_pushpin: do [#alpha]~~\n",
3608 "- :round_pushpin: do [#beta]\n",
3609 "<!-- /agent:queue -->\n",
3610 "###\n",
3611 "<!--\n",
3612 "a real scratch note line\n",
3613 "- ~~:round_pushpin: do [#gamma]~~\n",
3614 "- ~~:pushpin: do [#delta]~~\n",
3615 "-->\n",
3616 "<!-- agent:backlog -->\n",
3617 "- [ ] [#keep] real backlog item\n",
3618 "<!-- /agent:backlog -->\n",
3619 );
3620 let repaired =
3621 repair_queue_struck_items_escaped_below_marker(doc).expect("displaced items removed");
3622 assert!(
3624 !repaired.contains("[#gamma]"),
3625 "gamma displaced struck removed"
3626 );
3627 assert!(
3628 !repaired.contains("[#delta]"),
3629 "delta displaced struck removed"
3630 );
3631 assert!(
3633 repaired.contains("- ~~:round_pushpin: do [#alpha]~~"),
3634 "in-queue struck kept"
3635 );
3636 assert!(
3637 repaired.contains("- :round_pushpin: do [#beta]"),
3638 "in-queue active kept"
3639 );
3640 assert!(
3642 repaired.contains("a real scratch note line"),
3643 "scratch text kept"
3644 );
3645 assert!(
3646 repaired.contains("[#keep] real backlog item"),
3647 "backlog content kept"
3648 );
3649 }
3650 #[test]
3651 fn repair_queue_escape_noop_when_no_displaced_items() {
3652 let doc = concat!(
3654 "<!-- agent:queue go -->\n",
3655 "- ~~:round_pushpin: do [#alpha]~~\n",
3656 "- :round_pushpin: do [#beta]\n",
3657 "<!-- /agent:queue -->\n",
3658 "###\n",
3659 "<!--\n",
3660 "- ~~an ordinary user strikethrough note~~\n",
3661 "-->\n",
3662 );
3663 assert!(repair_queue_struck_items_escaped_below_marker(doc).is_none());
3666 }
3667 #[test]
3668 fn repair_queue_escape_handles_bare_lines_after_marker() {
3669 let doc = concat!(
3672 "<!-- agent:queue go -->\n",
3673 "- :round_pushpin: do [#beta]\n",
3674 "<!-- /agent:queue -->\n",
3675 "- ~~:round_pushpin: do [#gamma]~~\n",
3676 "- ~~do [#delta]~~\n",
3677 "<!-- agent:backlog -->\n",
3678 "- [ ] [#keep] item\n",
3679 "<!-- /agent:backlog -->\n",
3680 );
3681 let repaired =
3682 repair_queue_struck_items_escaped_below_marker(doc).expect("bare displaced removed");
3683 assert!(!repaired.contains("[#gamma]"));
3684 assert!(!repaired.contains("[#delta]"));
3685 assert!(repaired.contains("- :round_pushpin: do [#beta]"));
3686 assert!(repaired.contains("[#keep] item"));
3687 }
3688 #[test]
3689 fn normalize_editor_visible_template_structure_preserves_duplicate_prompt_html_comment_body() {
3690 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";
3691 let doc = format!(
3692 concat!(
3693 "<!-- agent:exchange patch=append -->\n",
3694 "### Re: prior — gpt-5\n",
3695 "Done.\n",
3696 "<!-- agent:boundary:head -->\n",
3697 "{prompt}\n",
3698 "<!-- /agent:exchange -->\n\n",
3699 "###\n\n",
3700 "<!--\n",
3701 "{prompt}\n",
3702 "-->\n\n",
3703 "<!--\n",
3704 "Keep this unrelated scratch note hidden.\n",
3705 "-->\n\n",
3706 "<!-- agent:backlog -->\n",
3707 "- [ ] keep me\n",
3708 "<!-- /agent:backlog -->\n"
3709 ),
3710 prompt = prompt
3711 );
3712
3713 let repaired = normalize_editor_visible_template_structure(&doc)
3714 .expect("editor-visible normalization should preserve scratch comments");
3715
3716 let duplicate_comment = format!("\n<!--\n{prompt}\n-->\n");
3717 assert!(
3718 repaired.contains(&duplicate_comment),
3719 "editor-visible normalization has no baseline ownership proof and must preserve post-exchange scratch text:\n{repaired}"
3720 );
3721 assert!(
3722 repaired.contains("<!-- agent:backlog -->\n- [ ] keep me"),
3723 "tracked-work scaffold should remain intact:\n{repaired}"
3724 );
3725 assert!(
3726 repaired.contains("Keep this unrelated scratch note hidden."),
3727 "unrelated scratch comments must stay outside exchange:\n{repaired}"
3728 );
3729 }
3730 #[test]
3731 fn normalize_editor_visible_template_structure_preserves_mixed_prompt_comment_scratch_lines() {
3732 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?";
3733 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?";
3734 let doc = format!(
3735 concat!(
3736 "<!-- agent:exchange patch=append -->\n",
3737 "### Re: prior - gpt-5\n",
3738 "Done.\n",
3739 "<!-- agent:boundary:head -->\n",
3740 "{exchange_prompt}\n",
3741 "#spec-test-build-install-commit-push\n",
3742 "<!-- /agent:exchange -->\n\n",
3743 "###\n",
3744 "<!--\n",
3745 "{duplicate_prompt_line}\n",
3746 "#spec-test-build-install-commit-push\n",
3747 "---\n",
3748 "Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.\n",
3749 "-->\n\n",
3750 "<!-- agent:backlog -->\n",
3751 "<!-- /agent:backlog -->\n"
3752 ),
3753 exchange_prompt = exchange_prompt,
3754 duplicate_prompt_line = duplicate_prompt_line,
3755 );
3756
3757 let repaired = normalize_editor_visible_template_structure(&doc)
3758 .expect("editor-visible normalization should preserve mixed scratch comments");
3759
3760 assert!(
3761 repaired.contains(&format!("<!--\n{duplicate_prompt_line}")),
3762 "editor-visible normalization must not scrub mixed scratch comments without baseline proof:\n{repaired}"
3763 );
3764 assert!(
3765 repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
3766 "unrelated scratch lines in the same ordinary comment must survive:\n{repaired}"
3767 );
3768 assert!(
3769 repaired.contains(&format!(
3770 "<!--\n{duplicate_prompt_line}\n#spec-test-build-install-commit-push\n---\nLook through"
3771 )),
3772 "the mixed comment shell and full body should be preserved:\n{repaired}"
3773 );
3774 }
3775 #[test]
3776 fn normalize_editor_visible_template_structure_preserves_scratch_comment_after_compact_summary()
3777 {
3778 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?";
3779 let doc = format!(
3780 concat!(
3781 "<!-- agent:exchange patch=append -->\n",
3782 "### Session Summary\n\n",
3783 "Compacted content:\n",
3784 "- Trailing prompt/context: {prompt}\n",
3785 "❯ {prompt}\n",
3786 "❯ #spec-test-build-install-commit-push\n",
3787 "### Re: compact prompt duplication — gpt-5\n\n",
3788 "Line-by-line diff was the right diagnostic.\n",
3789 "<!-- agent:boundary:head -->\n",
3790 "<!-- /agent:exchange -->\n",
3791 "###\n",
3792 "<!--\n",
3793 "Look through the Claude + Codex + agent-doc session logs\n",
3794 "-->\n\n",
3795 "<!-- agent:backlog -->\n",
3796 "<!-- /agent:backlog -->\n"
3797 ),
3798 prompt = prompt
3799 );
3800
3801 let repaired = normalize_editor_visible_template_structure(&doc)
3802 .expect("editor-visible normalization should preserve unrelated scratch comments");
3803
3804 assert!(
3805 repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
3806 "unrelated post-exchange scratch comment text must survive:\n{repaired}"
3807 );
3808 }
3809 #[test]
3810 fn normalize_editor_visible_template_structure_ignores_response_quoted_scratch_comment() {
3811 let scratch =
3812 "Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.";
3813 let doc = format!(
3814 concat!(
3815 "<!-- agent:exchange patch=append -->\n",
3816 "❯ Please inspect the latest route cleanup report. #spec-test-build-install-commit-push\n",
3817 "### Re: route cleanup — gpt-5\n\n",
3818 "{scratch}\n",
3819 "<!-- agent:boundary:head -->\n",
3820 "<!-- /agent:exchange -->\n",
3821 "###\n",
3822 "<!--\n",
3823 "{scratch}\n",
3824 "-->\n\n",
3825 "<!-- agent:backlog -->\n",
3826 "<!-- /agent:backlog -->\n"
3827 ),
3828 scratch = scratch
3829 );
3830
3831 let repaired = normalize_editor_visible_template_structure(&doc)
3832 .expect("response-quoted scratch comments should not be prompt residue");
3833
3834 assert!(
3835 repaired.contains(scratch),
3836 "response text must not authorize deleting matching user-owned scratch comments:\n{repaired}"
3837 );
3838 assert!(
3839 repaired.contains(&format!("<!--\n{scratch}\n-->")),
3840 "ordinary scratch comment body must stay intact:\n{repaired}"
3841 );
3842 }
3843 #[test]
3844 fn normalize_removes_duplicate_answered_prompt_tail_after_boundary() {
3845 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?";
3846 let doc = format!(
3847 concat!(
3848 "<!-- agent:exchange patch=append -->\n",
3849 "❯ {prompt}\n",
3850 "❯ #spec-test-build-install-commit-push\n",
3851 "### Re: mixed scratch comment deletion — gpt-5\n\n",
3852 "Answered already.\n",
3853 "<!-- agent:boundary:head -->\n",
3854 "❯ {prompt}\n",
3855 "❯ #spec-test-build-install-commit-push\n",
3856 "<!-- /agent:exchange -->\n"
3857 ),
3858 prompt = prompt
3859 );
3860
3861 let repaired = normalize_editor_visible_template_structure(&doc)
3862 .expect("editor-visible normalization should remove duplicate answered prompt tail");
3863
3864 assert!(
3865 repaired.contains(&format!(
3866 "❯ {prompt}\n❯ #spec-test-build-install-commit-push\n### Re:"
3867 )),
3868 "answered prompt block must remain in exchange history:\n{repaired}"
3869 );
3870 assert!(
3871 !repaired.contains(&format!("<!-- agent:boundary:head -->\n❯ {prompt}")),
3872 "duplicate answered-form prompt tail after the boundary should be removed:\n{repaired}"
3873 );
3874 assert!(
3875 repaired.contains("<!-- agent:boundary:head -->\n<!-- /agent:exchange -->"),
3876 "boundary should stay at the exchange end after cleanup:\n{repaired}"
3877 );
3878 }
3879 #[test]
3880 fn normalize_editor_visible_template_structure_rejects_plain_markdown_duplicate_residue() {
3881 let prompt =
3882 "Please keep this exact sentence around for duplicate residue coverage in markdown";
3883 let doc = format!(
3884 concat!(
3885 "<!-- agent:exchange patch=append -->\n",
3886 "❯ {prompt}\n",
3887 "### Re: duplicate residue — gpt-5\n\n",
3888 "Answered.\n",
3889 "<!-- agent:boundary:head -->\n",
3890 "<!-- /agent:exchange -->\n\n",
3891 "###\n\n",
3892 "{prompt}\n",
3893 "<!-- agent:backlog -->\n",
3894 "- [ ] keep me\n",
3895 "<!-- /agent:backlog -->\n"
3896 ),
3897 prompt = prompt
3898 );
3899
3900 let err = normalize_editor_visible_template_structure(&doc).unwrap_err();
3901
3902 assert!(
3903 err.to_string().contains("duplicate prompt residue"),
3904 "editor-visible normalization must fail closed on duplicate prompt Markdown residue: {err}"
3905 );
3906 }
3907 #[test]
3908 fn normalize_editor_visible_template_structure_rejects_mixed_duplicate_scaffold() {
3909 let doc = concat!(
3910 "<!-- agent:exchange patch=append -->\n",
3911 "### Session Summary\n",
3912 "<!-- /agent:exchange -->\n",
3913 "###\n",
3914 "<!-- agent:queue -->\n",
3915 "<!-- /agent:queue -->\n",
3916 "user typed into duplicated shell\n",
3917 "<!-- /agent:exchange -->\n",
3918 "###\n",
3919 "<!-- agent:queue -->\n",
3920 "<!-- /agent:queue -->\n"
3921 );
3922
3923 let err = normalize_editor_visible_template_structure(doc).unwrap_err();
3924 assert!(
3925 err.to_string().contains("mixed duplicate scaffold"),
3926 "unexpected error: {err}"
3927 );
3928 }
3929 #[test]
3930 fn repair_duplicate_exchange_opener_merges_two_blocks() {
3931 let doc = concat!(
3932 "---\nagent_doc_format: template\n---\n\n",
3933 "<!-- agent:exchange patch=append -->\n",
3934 "❯ first question\n\n",
3935 "### Re: first — opus-4-6\n\n",
3936 "First answer.\n",
3937 "<!-- /agent:exchange -->\n",
3938 "<!-- agent:exchange patch=append -->\n",
3939 "❯ second question\n\n",
3940 "### Re: second — opus-4-6\n\n",
3941 "Second answer.\n",
3942 "<!-- /agent:exchange -->\n\n",
3943 "<!-- agent:backlog -->\n",
3944 "- [ ] keep me\n",
3945 "<!-- /agent:backlog -->\n"
3946 );
3947
3948 let repaired = repair_duplicate_exchange_opener(doc)
3949 .unwrap()
3950 .expect("duplicate opener repair should apply");
3951 assert_eq!(
3952 repaired.matches("<!-- agent:exchange").count(),
3953 1,
3954 "repair should leave exactly one exchange opener:\n{repaired}"
3955 );
3956 assert_eq!(
3957 repaired.matches("<!-- /agent:exchange -->").count(),
3958 1,
3959 "repair should leave exactly one exchange closer:\n{repaired}"
3960 );
3961 assert!(
3962 repaired.contains("First answer."),
3963 "first block content should be preserved:\n{repaired}"
3964 );
3965 assert!(
3966 repaired.contains("Second answer."),
3967 "second block content should be preserved:\n{repaired}"
3968 );
3969 assert!(
3970 repaired.contains("<!-- agent:backlog -->"),
3971 "backlog should be preserved:\n{repaired}"
3972 );
3973 let first_pos = repaired.find("First answer.").unwrap();
3974 let second_pos = repaired.find("Second answer.").unwrap();
3975 assert!(
3976 first_pos < second_pos,
3977 "first content should appear before second:\n{repaired}"
3978 );
3979 }
3980 #[test]
3981 fn repair_duplicate_exchange_opener_returns_none_for_single() {
3982 let doc = concat!(
3983 "---\nagent_doc_format: template\n---\n\n",
3984 "<!-- agent:exchange patch=append -->\n",
3985 "❯ question\n",
3986 "### Re: answer — opus-4-6\n\n",
3987 "Answer.\n",
3988 "<!-- /agent:exchange -->\n"
3989 );
3990
3991 let result = repair_duplicate_exchange_opener(doc).unwrap();
3992 assert!(result.is_none(), "single exchange block should return None");
3993 }
3994 #[test]
3995 fn strip_conversation_tail_outside_exchange_removes_escaped_heading_tail_only() {
3996 let doc = concat!(
3997 "---\nagent_doc_format: template\n---\n\n",
3998 "<!-- agent:exchange patch=append -->\n",
3999 "### Re: earlier — gpt-5\n",
4000 "<!-- agent:boundary:abc123 -->\n",
4001 "<!-- /agent:exchange -->\n\n",
4002 "[//]: # (leave this note outside exchange)\n\n",
4003 "## Assistant\n\n",
4004 "Escaped answer.\n"
4005 );
4006
4007 let stripped = strip_conversation_tail_outside_exchange(doc)
4008 .unwrap()
4009 .expect("escaped heading tail should be removable");
4010 assert!(
4011 stripped.contains("[//]: # (leave this note outside exchange)"),
4012 "comment-only note should remain outside exchange:\n{stripped}"
4013 );
4014 assert!(
4015 !stripped.contains("## Assistant"),
4016 "escaped assistant tail should be removed:\n{stripped}"
4017 );
4018 }
4019 #[test]
4020 fn strip_conversation_tail_outside_exchange_removes_gap_before_backlog_only() {
4021 let doc = concat!(
4022 "---\nagent_doc_format: template\n---\n\n",
4023 "<!-- agent:exchange patch=append -->\n",
4024 "### Re: earlier — gpt-5\n",
4025 "<!-- agent:boundary:abc123 -->\n",
4026 "<!-- /agent:exchange -->\n\n",
4027 "###\n\n",
4028 "### Re: gap — gpt-5\n\n",
4029 "Escaped answer.\n\n",
4030 "<!-- agent:backlog -->\n",
4031 "- [ ] keep me\n",
4032 "<!-- /agent:backlog -->\n"
4033 );
4034
4035 let stripped = strip_conversation_tail_outside_exchange(doc)
4036 .unwrap()
4037 .expect("escaped gap should be removable");
4038 assert!(
4039 stripped.contains("\n###\n\n<!-- agent:backlog -->"),
4040 "plain gap marker should stay outside exchange:\n{stripped}"
4041 );
4042 assert!(
4043 !stripped.contains("### Re: gap — gpt-5"),
4044 "escaped response should be removed from the gap:\n{stripped}"
4045 );
4046 }
4047 #[test]
4048 fn deleted_conversation_tail_cleanup_accepts_prompt_prelude_deletion() {
4049 let before = concat!(
4050 "---\nagent_doc_format: template\n---\n\n",
4051 "<!-- agent:exchange patch=append -->\n",
4052 "### Re: earlier — gpt-5\n",
4053 "<!-- agent:boundary:abc123 -->\n",
4054 "<!-- /agent:exchange -->\n\n",
4055 "I see this routed prompt sitting outside exchange.\n",
4056 "It should have been entered by the managed pane.\n\n",
4057 "do #oobtaildel. spec-test-build-install-commit-push\n\n",
4058 "<!-- agent:backlog -->\n",
4059 "- [ ] keep me\n",
4060 "<!-- /agent:backlog -->\n"
4061 );
4062 let after = concat!(
4063 "---\nagent_doc_format: template\n---\n\n",
4064 "<!-- agent:exchange patch=append -->\n",
4065 "### Re: earlier — gpt-5\n",
4066 "<!-- agent:boundary:abc123 -->\n",
4067 "<!-- /agent:exchange -->\n\n",
4068 "<!-- agent:backlog -->\n",
4069 "- [ ] keep me\n",
4070 "<!-- /agent:backlog -->\n"
4071 );
4072
4073 let cleaned = deleted_conversation_tail_cleanup(before, after)
4074 .unwrap()
4075 .expect("prompt-prelude cleanup should be accepted");
4076 assert_eq!(cleaned, after);
4077 }
4078 #[test]
4079 fn deleted_conversation_tail_cleanup_rejects_plain_note_deletion() {
4080 let before = concat!(
4081 "---\nagent_doc_format: template\n---\n\n",
4082 "<!-- agent:exchange patch=append -->\n",
4083 "### Re: earlier — gpt-5\n",
4084 "<!-- /agent:exchange -->\n\n",
4085 "Design note: keep this outside exchange.\n\n",
4086 "<!-- agent:backlog -->\n",
4087 "- [ ] keep me\n",
4088 "<!-- /agent:backlog -->\n"
4089 );
4090 let after = before.replace("Design note: keep this outside exchange.\n\n", "");
4091
4092 let cleaned = deleted_conversation_tail_cleanup(before, &after).unwrap();
4093 assert!(
4094 cleaned.is_none(),
4095 "ordinary note deletion should not be treated as escaped conversation cleanup"
4096 );
4097 }
4098}