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