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