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