Skip to main content

agent_doc_core/
template.rs

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