Skip to main content

agent_doc_element/
element.rs

1//! # Module: element
2//!
3//! ## Spec
4//! - Defines `Component`, the parsed representation of a bounded document region delimited by
5//!   `<!-- agent:name [attrs...] -->` (open) and `<!-- /agent:name -->` (close) HTML comments.
6//! - `parse(doc)` scans the raw document bytes for `<!--` / `-->` comment pairs, builds a
7//!   stack-based nesting model, and returns all `Component` values sorted by `open_start`.
8//! - Markers that appear inside fenced code blocks (backtick or tilde) or inline code spans are
9//!   skipped; `find_code_ranges(doc)` uses the `pulldown-cmark` AST (CommonMark-compliant) to
10//!   locate these regions.
11//! - `is_agent_marker(comment_text)` classifies whether the inner text of a comment is an
12//!   agent open/close marker vs. an ordinary HTML comment.
13//! - Component names must match `[a-zA-Z0-9][a-zA-Z0-9-]*`; invalid names, unmatched opens,
14//!   unmatched closes, and mismatched open/close pairs all return `Err`.
15//! - `boundary:*` prefixed markers (`<!-- agent:boundary:ID -->`) are recognised and skipped
16//!   during component parsing; they are not treated as component open tags.
17//! - Opening markers may carry space-separated `key=value` inline attributes
18//!   (e.g., `patch=append`). `patch=` takes precedence over the legacy `mode=` alias;
19//!   `patch_mode()` encapsulates this lookup.
20//! - The non-agent comment fast path only previews comment text on UTF-8 char boundaries, so
21//!   ordinary prose comments near multibyte glyphs never panic the parser.
22//! - Marker `open_end` / `close_end` byte offsets include a trailing newline when present,
23//!   so that content slices are clean.
24//! - `replace_content(doc, new_content)` rebuilds the document preserving both markers,
25//!   replacing only the bytes between them.
26//! - `append_with_caret(doc, content, caret_offset)` appends content after existing text, or
27//!   inserts before the caret line when the caret falls inside the component.
28//! - `append_with_boundary(doc, content, boundary_id)` locates `<!-- agent:boundary:ID -->`
29//!   inside the component (skipping any occurrence that lives inside a code block), replaces it
30//!   with the new content, and re-inserts a fresh boundary marker; falls back to
31//!   `append_with_caret` when no boundary is found.
32//!
33//! ## Agentic Contracts
34//! - `parse` is pure and deterministic: identical input always yields identical output.
35//! - All byte offsets (`open_start`, `open_end`, `close_start`, `close_end`) are valid UTF-8
36//!   char boundaries within the document string they were parsed from.
37//! - `content(doc)` returns exactly `&doc[open_end..close_start]` — no allocation.
38//! - `replace_content` and `append_with_*` never mutate the original `&str`; they return a
39//!   new `String` with all offsets consistent for a fresh `parse` call.
40//! - Markers inside any code region (fenced block or inline span) are never parsed as
41//!   components and never mutated by any append/replace operation.
42//! - `append_with_boundary` always re-inserts a boundary marker with a fresh UUID, preserving
43//!   the invariant that exactly one boundary exists inside the component after each write.
44//! - Unknown or malformed `key=value` tokens in inline attributes are silently discarded;
45//!   they never cause a parse error.
46//!
47//! ## Evals
48//! - single_range: doc with one component → one `Component`, correct name and content slice
49//! - nested_ranges: outer + inner components → two entries sorted outer-first
50//! - siblings: two adjacent components → two entries, each with correct content
51//! - no_ranges: plain markdown → empty vec, no error
52//! - unmatched_open_error: open without close → `Err` containing "unclosed component"
53//! - unmatched_close_error: close without open → `Err` containing "without matching open"
54//! - mismatched_names_error: `<!-- agent:foo -->…<!-- /agent:bar -->` → `Err` "mismatched"
55//! - invalid_name: name starting with `-` → `Err` "invalid component name"
56//! - markers_in_fenced_code_block_ignored: marker inside ``` block → not parsed as component
57//! - markers_in_inline_code_ignored: marker inside `` `…` `` span → not parsed
58//! - markers_in_tilde_fence_ignored: marker inside ~~~ block → not parsed
59//! - markers_in_indented_fenced_code_block_ignored: up-to-3-space-indented fence → not parsed
60//! - double_backtick_comment_before_agent_marker: `` `<!--` `` followed by real marker → one component
61//! - parse_component_with_patch_attr: `patch=append` on opening tag → `patch_mode()` = "append"
62//! - patch_attr_takes_precedence_over_mode: both `patch=` and `mode=` present → `patch=` wins
63//! - mode_attr_backward_compat: only `mode=append` present → `patch_mode()` = "append"
64//! - replace_roundtrip: replace content, re-parse → one component with new content
65//! - append_with_boundary_no_code_block: boundary found → content inserted, old ID consumed, new boundary present
66//! - append_with_boundary_skips_code_block: boundary inside code block skipped, real boundary used
67
68use anyhow::{Result, bail};
69use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
70use std::collections::HashMap;
71
72/// Canonical component name for the backlog/pending component.
73pub const BACKLOG_COMPONENT: &str = "backlog";
74
75/// Legacy alias for the backlog component.
76pub const BACKLOG_ALIAS: &str = "pending";
77
78/// Canonical component name for the completed/reaped backlog archive.
79pub const BACKLOG_DONE_COMPONENT: &str = "done";
80
81/// Canonical component name for tracked work awaiting human review.
82pub const REVIEW_COMPONENT: &str = "review";
83
84/// Canonical component name for the icebox component.
85pub const ICEBOX_COMPONENT: &str = "icebox";
86
87/// Canonical component name for the conversation exchange.
88const EXCHANGE_COMPONENT: &str = "exchange";
89
90/// Check whether a component name refers to the backlog component
91/// (accepts both canonical `"backlog"` and legacy `"pending"`).
92pub fn is_backlog_component(name: &str) -> bool {
93    name == BACKLOG_COMPONENT || name == BACKLOG_ALIAS
94}
95
96/// Check whether a component name refers to the completed/reaped backlog archive.
97pub fn is_backlog_done_component(name: &str) -> bool {
98    name == BACKLOG_DONE_COMPONENT
99}
100
101/// Check whether a component name refers to the review component.
102pub fn is_review_component(name: &str) -> bool {
103    name == REVIEW_COMPONENT
104}
105
106/// Check whether a component name refers to the icebox component.
107pub fn is_icebox_component(name: &str) -> bool {
108    name == ICEBOX_COMPONENT
109}
110
111/// Check whether a component name refers to tracked work that participates in
112/// `do #id` closeout (`agent:backlog` / legacy `agent:pending`,
113/// `agent:review`, and `agent:icebox`).
114pub fn is_tracked_work_component(name: &str) -> bool {
115    is_backlog_component(name) || is_review_component(name) || is_icebox_component(name)
116}
117
118/// Strip deprecated `patch=...` (and legacy `mode=...`) attributes from
119/// backlog/pending component opening tags. The backlog component's patch mode
120/// is always `replace` by built-in default; the inline attribute is redundant
121/// since the binary owns all backlog mutations via `--pending-*` flags.
122///
123/// Emits a deprecation warning to stderr for each stripped attribute.
124pub fn strip_backlog_patch_attr(doc: &str) -> String {
125    let mut result = doc.to_string();
126    let mut warned = false;
127
128    for name in &[BACKLOG_COMPONENT, BACKLOG_ALIAS] {
129        let prefix = format!("<!-- agent:{} ", name);
130        while let Some(start) = result.find(&prefix) {
131            let tag_start = start;
132            let rest = &result[start + prefix.len()..];
133            let Some(end_rel) = rest.find("-->") else {
134                break;
135            };
136            let attrs_text = &rest[..end_rel];
137            let tag_end = start + prefix.len() + end_rel + 3; // past `-->`
138
139            let tokens: Vec<&str> = attrs_text.split_whitespace().collect();
140            let has_patch_or_mode = tokens.iter().any(|t| {
141                t.split_once('=')
142                    .map(|(k, _)| k == "patch" || k == "mode")
143                    .unwrap_or(false)
144            });
145
146            if !has_patch_or_mode {
147                break;
148            }
149
150            if !warned {
151                eprintln!(
152                    "[deprecation] patch/mode attribute on agent:{} is deprecated and will be stripped — \
153                     the binary owns backlog mutations via --pending-* flags",
154                    name
155                );
156                warned = true;
157            }
158
159            let remaining: Vec<&str> = tokens
160                .iter()
161                .filter(|t| {
162                    t.split_once('=')
163                        .map(|(k, _)| k != "patch" && k != "mode")
164                        .unwrap_or(true)
165                })
166                .copied()
167                .collect();
168
169            let new_tag = if remaining.is_empty() {
170                format!("<!-- agent:{} -->", name)
171            } else {
172                format!("<!-- agent:{} {} -->", name, remaining.join(" "))
173            };
174
175            result.replace_range(tag_start..tag_end, &new_tag);
176        }
177    }
178
179    result
180}
181
182/// A parsed component in a document.
183///
184/// Components are bounded regions marked by `<!-- agent:name -->...<!-- /agent:name -->`.
185/// Opening tags may contain inline attributes: `<!-- agent:name key=value -->`.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Component {
188    pub name: String,
189    /// Inline attributes parsed from the opening tag (e.g., `patch=append`).
190    pub attrs: HashMap<String, String>,
191    /// Byte offset of `<` in opening marker.
192    pub open_start: usize,
193    /// Byte offset past `>` in opening marker (includes trailing newline if present).
194    pub open_end: usize,
195    /// Byte offset of `<` in closing marker.
196    pub close_start: usize,
197    /// Byte offset past `>` in closing marker (includes trailing newline if present).
198    pub close_end: usize,
199}
200
201impl Component {
202    /// Extract the content between the opening and closing markers.
203    #[allow(dead_code)] // public API — used by tests and future consumers
204    pub fn content<'a>(&self, doc: &'a str) -> &'a str {
205        &doc[self.open_end..self.close_start]
206    }
207
208    /// Get the patch mode from inline attributes.
209    ///
210    /// Checks `patch=` first, falls back to `mode=` for backward compatibility.
211    pub fn patch_mode(&self) -> Option<&str> {
212        self.attrs
213            .get("patch")
214            .map(|s| s.as_str())
215            .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
216    }
217
218    /// Replace the content between markers, returning the new document.
219    /// The markers themselves are preserved.
220    pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
221        let mut result = String::with_capacity(doc.len() + new_content.len());
222        result.push_str(&doc[..self.open_end]);
223        result.push_str(new_content);
224        result.push_str(&doc[self.close_start..]);
225        result
226    }
227
228    /// Append content into this component, inserting before the caret position
229    /// if the caret is inside the component. Falls back to normal append if the
230    /// caret is outside the component.
231    ///
232    /// `caret_offset`: byte offset of the caret in the document. Pass `None` for
233    /// normal append behavior.
234    pub fn append_with_caret(
235        &self,
236        doc: &str,
237        content: &str,
238        caret_offset: Option<usize>,
239    ) -> String {
240        let existing = &doc[self.open_end..self.close_start];
241        if append_patch_already_present(existing, content) {
242            return doc.to_string();
243        }
244
245        if let Some(caret) = caret_offset {
246            // Check if caret is inside this component
247            if caret > self.open_end && caret <= self.close_start {
248                // Find the line boundary before the caret
249                let insert_at = doc[..caret]
250                    .rfind('\n')
251                    .map(|i| i + 1)
252                    .unwrap_or(self.open_end);
253
254                // Clamp to component bounds
255                let insert_at = insert_at.max(self.open_end);
256                let prior = &doc[self.open_end..insert_at];
257
258                let mut result = String::with_capacity(doc.len() + content.len() + 1);
259                result.push_str(&doc[..insert_at]);
260                if needs_exchange_heading_separator(&self.name, prior, content) {
261                    result.push('\n');
262                }
263                result.push_str(content.trim_end());
264                result.push('\n');
265                result.push_str(&doc[insert_at..]);
266                return result;
267            }
268        }
269
270        // Normal append: add after existing content
271        let mut result = String::with_capacity(doc.len() + content.len() + 1);
272        result.push_str(&doc[..self.open_end]);
273        let trimmed_existing = existing.trim_end();
274        result.push_str(trimmed_existing);
275        if needs_exchange_heading_separator(&self.name, trimmed_existing, content) {
276            result.push('\n');
277        }
278        result.push('\n');
279        result.push_str(content.trim_end());
280        result.push('\n');
281        result.push_str(&doc[self.close_start..]);
282        result
283    }
284
285    /// Append content into this component at the boundary marker position.
286    ///
287    /// Finds `<!-- agent:boundary:ID -->` inside the component. If found,
288    /// inserts content at the line start of the boundary marker (replacing
289    /// the marker). Falls back to normal append if the boundary is not found.
290    pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
291        let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
292        let content_region = &doc[self.open_end..self.close_start];
293        if append_patch_already_present(content_region, content) {
294            return doc.to_string();
295        }
296        let code_ranges = find_code_ranges(doc);
297
298        // Search for boundary marker, skipping matches inside code blocks
299        let mut search_from = 0;
300        let found_pos = loop {
301            match content_region[search_from..].find(&boundary_marker) {
302                Some(rel_pos) => {
303                    let abs_pos = self.open_end + search_from + rel_pos;
304                    if code_ranges
305                        .iter()
306                        .any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce)
307                    {
308                        // Inside a code block — skip and keep searching
309                        search_from += rel_pos + boundary_marker.len();
310                        continue;
311                    }
312                    break Some(abs_pos);
313                }
314                None => break None,
315            }
316        };
317
318        if let Some(abs_pos) = found_pos {
319            // Find start of the line containing the marker
320            let line_start = doc[..abs_pos]
321                .rfind('\n')
322                .map(|i| i + 1)
323                .unwrap_or(self.open_end)
324                .max(self.open_end);
325
326            // Find end of the marker line (including trailing newline)
327            let marker_end = abs_pos + boundary_marker.len();
328            let line_end = if marker_end < self.close_start
329                && doc.as_bytes().get(marker_end) == Some(&b'\n')
330            {
331                marker_end + 1
332            } else {
333                marker_end
334            };
335            let line_end = line_end.min(self.close_start);
336
337            // Replace the boundary marker with response content + new boundary.
338            // The boundary is consumed and re-inserted, matching the binary's
339            // post-patch behavior in apply_patches_with_overrides().
340            let new_id = crate::id::new_boundary_id();
341            let new_marker = crate::id::format_boundary_marker(&new_id);
342            let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
343            let prior = &doc[self.open_end..line_start];
344            result.push_str(&doc[..line_start]);
345            if needs_exchange_heading_separator(&self.name, prior, content) {
346                result.push('\n');
347            }
348            result.push_str(content.trim_end());
349            result.push('\n');
350            result.push_str(&new_marker);
351            result.push('\n');
352            result.push_str(&doc[line_end..]);
353            return result;
354        }
355
356        // Boundary not found — fall back to normal append
357        self.append_with_caret(doc, content, None)
358    }
359}
360
361/// Converge the `agent:queue` opening-tag `auto` attribute to `want_auto`.
362///
363/// A content patch (the only editor-visible IPC seam for template documents)
364/// replaces a component's *body* between its markers — it cannot add or remove
365/// an attribute on the `<!-- agent:queue auto -->` opening tag. After a queue
366/// halt strips `auto` on disk, a live route-owned editor buffer that still shows
367/// `<!-- agent:queue auto -->` re-diverges from the committed inactive snapshot on
368/// its next flush, regenerating the snapshot/HEAD drift loop on every preflight
369/// (`#adoc-queue-ipc-buffer-divergence`). Editor plugins call this (via the
370/// `agent_doc_converge_queue_auto` FFI export) to converge the live buffer's queue
371/// tag to the committed shape.
372///
373/// Returns `Some(new_doc)` when the tag changed, `None` when there is no `queue`
374/// component or the tag already matches `want_auto`. Other opening-tag attributes
375/// (e.g. `patch=append`) are preserved.
376pub fn converge_queue_auto(doc: &str, want_auto: bool) -> Option<String> {
377    let components = parse(doc).ok()?;
378    let queue = components.iter().find(|c| c.name == "queue")?;
379    let raw_tag = &doc[queue.open_start..queue.open_end];
380    let new_tag = set_auto_in_queue_tag(raw_tag, want_auto);
381    if new_tag == *raw_tag {
382        return None;
383    }
384    let mut result = String::with_capacity(doc.len());
385    result.push_str(&doc[..queue.open_start]);
386    result.push_str(&new_tag);
387    result.push_str(&doc[queue.open_end..]);
388    Some(result)
389}
390
391/// Rebuild an `agent:queue` opening tag with the `auto` attribute present or
392/// absent per `want_auto`, preserving every other token (the `agent:queue` name,
393/// `patch=…`, etc.) and any trailing whitespace/newline after `-->`.
394fn set_auto_in_queue_tag(tag: &str, want_auto: bool) -> String {
395    let Some((mut tokens, tail)) = queue_tag_tokens(tag) else {
396        return tag.to_string();
397    };
398    tokens = tokens
399        .into_iter()
400        .map(|token| normalize_queue_tag_token(&token))
401        .collect();
402    let has_auto = tokens.iter().any(|token| token == "auto");
403    if want_auto == has_auto {
404        return format!("<!-- {} -->{}", tokens.join(" "), tail);
405    }
406    if want_auto {
407        // Insert `auto` right after the `agent:queue` name token so it stays the
408        // first attribute (matches how route/queue activation writes the tag).
409        let insert_at = if tokens.is_empty() { 0 } else { 1 };
410        tokens.insert(insert_at, "auto".to_string());
411    } else {
412        tokens.retain(|token| token != "auto");
413    }
414    format!("<!-- {} -->{}", tokens.join(" "), tail)
415}
416
417fn queue_tag_tokens(tag: &str) -> Option<(Vec<String>, &str)> {
418    let close_idx = tag.find("-->")?;
419    let core = &tag[..close_idx + 3];
420    let tail = &tag[close_idx + 3..];
421    let inner = core
422        .trim_start_matches("<!--")
423        .trim_end_matches("-->")
424        .trim();
425    Some((split_marker_tokens(inner), tail))
426}
427
428fn split_marker_tokens(inner: &str) -> Vec<String> {
429    let mut tokens = Vec::new();
430    let mut current = String::new();
431    let mut quote: Option<char> = None;
432    for ch in inner.chars() {
433        if ch.is_whitespace() && quote.is_none() {
434            if !current.is_empty() {
435                tokens.push(std::mem::take(&mut current));
436            }
437            continue;
438        }
439        if matches!(ch, '"' | '\'') {
440            if quote == Some(ch) {
441                quote = None;
442            } else if quote.is_none() {
443                quote = Some(ch);
444            }
445        }
446        current.push(ch);
447    }
448    if !current.is_empty() {
449        tokens.push(current);
450    }
451    tokens
452}
453
454fn normalize_queue_tag_token(token: &str) -> String {
455    let Some((key, value)) = token.split_once('=') else {
456        return token.to_string();
457    };
458    if is_queue_boolean_attr(key) && value.eq_ignore_ascii_case("true") {
459        return key.to_string();
460    }
461    if key == "preset"
462        && let Some(stripped) = strip_malformed_true_suffix(value)
463    {
464        return format!("{key}={stripped}");
465    }
466    token.to_string()
467}
468
469fn is_queue_boolean_attr(key: &str) -> bool {
470    matches!(key, "auto" | "priority" | "go" | "start" | "stop")
471}
472
473fn strip_malformed_true_suffix(value: &str) -> Option<&str> {
474    let stripped = value.strip_suffix("=true")?;
475    if (stripped.starts_with('"') && stripped.ends_with('"'))
476        || (stripped.starts_with('\'') && stripped.ends_with('\''))
477    {
478        Some(stripped)
479    } else {
480        None
481    }
482}
483
484/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
485fn is_valid_name(name: &str) -> bool {
486    if name.is_empty() {
487        return false;
488    }
489    let first = name.as_bytes()[0];
490    if !first.is_ascii_alphanumeric() {
491        return false;
492    }
493    name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
494}
495
496fn append_patch_already_present(existing: &str, content: &str) -> bool {
497    let patch = normalize_append_patch_content(content);
498    if patch.is_empty() {
499        return false;
500    }
501    let existing_norm = normalize_append_patch_content(existing);
502    if existing_norm.contains(&patch) {
503        return true;
504    }
505    // #prompt-duplicated-while-typing (L1 structural buffer-snapshot race): a
506    // synthesized boundary-aware exchange patch carries an *earlier* keystroke
507    // snapshot of the live user region (T1 — e.g. a half-typed line "Add to ")
508    // while the editor buffer kept being typed (T2 — "Add to resume"). The two
509    // texts then diverge mid-region, so the `contains` substring check above
510    // misses the duplicate and the whole tail re-appends from the divergence
511    // point. Recognize when the patch aligns to a contiguous run of the existing
512    // region under per-line prefix matching (each patch line is a prefix of, or
513    // equal to, the aligned existing line) and treat it as already present.
514    // This can only *suppress* re-appending text the live region already
515    // supersedes — the no-op keeps the longer live buffer — so it can never
516    // delete content or collapse a genuinely distinct prompt.
517    patch_is_typing_snapshot_of(&existing_norm, &patch)
518}
519
520/// True when `patch` is an in-progress keystroke-snapshot of a contiguous run
521/// already present in `existing`: every patch line equals, or is a non-empty
522/// prefix of, the aligned existing line, with at least one such prefix
523/// divergence (an exact run is already handled by the `contains` check). Both
524/// inputs must already be normalized via `normalize_append_patch_content`.
525fn patch_is_typing_snapshot_of(existing: &str, patch: &str) -> bool {
526    let e: Vec<&str> = existing.lines().collect();
527    let p: Vec<&str> = patch.lines().collect();
528    if p.is_empty() || p.len() > e.len() {
529        return false;
530    }
531    'outer: for start in 0..=(e.len() - p.len()) {
532        let mut saw_prefix_divergence = false;
533        for (j, pl) in p.iter().enumerate() {
534            let el = e[start + j];
535            if pl == &el {
536                continue;
537            }
538            // An empty patch line must match exactly — `starts_with("")` is
539            // always true and would let a blank line align to anything.
540            if !pl.is_empty() && el.starts_with(pl) {
541                saw_prefix_divergence = true;
542                continue;
543            }
544            continue 'outer;
545        }
546        if saw_prefix_divergence {
547            return true;
548        }
549    }
550    false
551}
552
553fn needs_exchange_heading_separator(component_name: &str, prior: &str, content: &str) -> bool {
554    component_name == EXCHANGE_COMPONENT
555        && !prior.trim().is_empty()
556        && !prior.ends_with("\n\n")
557        && starts_with_response_heading(content)
558}
559
560fn starts_with_response_heading(content: &str) -> bool {
561    content.trim_start().starts_with("### Re:")
562}
563
564fn normalize_append_patch_content(content: &str) -> String {
565    content
566        .lines()
567        .filter_map(|line| {
568            let trimmed = line.trim();
569            if trimmed.starts_with("<!-- agent:boundary:") && trimmed.ends_with(" -->") {
570                None
571            } else if markdown_heading_has_head_marker(line) {
572                Some(strip_user_prompt_prefix(line.trim_end_matches(" (HEAD)")))
573            } else {
574                Some(strip_user_prompt_prefix(line))
575            }
576        })
577        .collect::<Vec<_>>()
578        .join("\n")
579        .trim()
580        .to_string()
581}
582
583/// Strip a leading `❯ ` / `❯` user-prompt prefix from a line for dedup comparison.
584///
585/// The exchange user-prompt normalization adds a `❯ ` prefix to user lines, but a
586/// synthesized boundary-aware exchange patch and the live editor buffer can differ
587/// only by that prefix (one normalized, the other not). Without stripping it, the
588/// `append_patch_already_present` `contains` check misses the duplicate and the
589/// prompt is re-appended — the `#prompt-duplicated-while-typing` duplication. The
590/// `❯` glyph is a presentation prefix, not prompt content, so stripping it for
591/// comparison cannot drop a genuinely distinct prompt. Lines without the prefix are
592/// returned unchanged.
593fn strip_user_prompt_prefix(line: &str) -> String {
594    let trimmed_start = line.trim_start();
595    if let Some(rest) = trimmed_start.strip_prefix("❯ ") {
596        rest.to_string()
597    } else if let Some(rest) = trimmed_start.strip_prefix('❯') {
598        rest.trim_start().to_string()
599    } else {
600        line.to_string()
601    }
602}
603
604fn markdown_heading_has_head_marker(line: &str) -> bool {
605    let trimmed = line.trim_start();
606    let hashes = trimmed.bytes().take_while(|b| *b == b'#').count();
607    (1..=6).contains(&hashes)
608        && trimmed.as_bytes().get(hashes) == Some(&b' ')
609        && line.ends_with(" (HEAD)")
610}
611
612/// True if the text inside `<!-- ... -->` is an agent component marker.
613///
614/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
615pub fn is_agent_marker(comment_text: &str) -> bool {
616    let trimmed = comment_text.trim();
617    if let Some(rest) = trimmed.strip_prefix("/agent:") {
618        is_valid_name(rest)
619    } else if let Some(rest) = trimmed.strip_prefix("agent:") {
620        // Opening marker may have attributes after the name: `agent:NAME key=value`
621        let name_part = rest.split_whitespace().next().unwrap_or("");
622        is_valid_name(name_part)
623    } else {
624        false
625    }
626}
627
628fn is_agent_boundary_marker(comment_text: &str) -> bool {
629    let trimmed = comment_text.trim();
630    if let Some(rest) = trimmed.strip_prefix("/agent:") {
631        rest.starts_with("boundary:")
632    } else if let Some(rest) = trimmed.strip_prefix("agent:") {
633        rest.split_whitespace()
634            .next()
635            .is_some_and(|name| name.starts_with("boundary:"))
636    } else {
637        false
638    }
639}
640
641fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
642    debug_assert!(doc.is_char_boundary(start));
643    let mut end = doc.len().min(start.saturating_add(max_bytes));
644    while end > start && !doc.is_char_boundary(end) {
645        end -= 1;
646    }
647    &doc[start..end]
648}
649
650/// Parse `key=value` pairs from the attribute portion of an opening marker.
651///
652/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
653/// Values are unquoted (no quote support needed for simple mode values).
654pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
655    let mut attrs = HashMap::new();
656    for token in attr_text.split_whitespace() {
657        if let Some((key, value)) = token.split_once('=') {
658            if !key.is_empty() && !value.is_empty() {
659                attrs.insert(key.to_string(), value.to_string());
660            }
661        } else if !token.is_empty() {
662            attrs.insert(token.to_string(), String::new());
663        }
664    }
665    attrs
666}
667
668/// Find byte ranges of code regions (fenced code blocks + inline code spans).
669/// Markers inside these ranges are treated as literal text, not component markers.
670///
671/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
672/// code regions per the CommonMark spec.
673pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
674    let t = std::time::Instant::now();
675    let mut ranges = Vec::new();
676    let parser = Parser::new_ext(doc, Options::empty());
677    let mut iter = parser.into_offset_iter();
678    while let Some((event, range)) = iter.next() {
679        match event {
680            // Inline code span: `code` or ``code``
681            Event::Code(_) => {
682                ranges.push((range.start, range.end));
683            }
684            // Fenced or indented code block: consume until End(CodeBlock)
685            Event::Start(Tag::CodeBlock(_)) => {
686                let block_start = range.start;
687                let mut block_end = range.end;
688                for (inner_event, inner_range) in iter.by_ref() {
689                    block_end = inner_range.end;
690                    if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
691                        break;
692                    }
693                }
694                ranges.push((block_start, block_end));
695            }
696            _ => {}
697        }
698    }
699    let elapsed = t.elapsed().as_millis();
700    if elapsed > 0 {
701        eprintln!("[perf] find_code_ranges: {}ms", elapsed);
702    }
703    ranges
704}
705
706/// Find byte ranges of same-line double-quoted spans (`"..."`) — `#0kjc`/`#mdastquote`.
707///
708/// A `<!-- agent:* -->` marker (or other tag-looking token) embedded inside a
709/// double-quoted prose span — for example a queue/backlog item that quotes a
710/// marker by name like `"<!-- agent:queue -->"` — is content, not a real
711/// document tag, and must not be parsed as one (same family as the code-span /
712/// code-fence masking in [`find_code_ranges`], and as `#lintfence`).
713///
714/// Quotes are scoped to a single line, so an unterminated quote only masks the
715/// rest of its own line (fail-safe: it can never swallow a real marker on a
716/// later line). The opening `"` is included in the returned span. A real
717/// marker's own attribute value (for example `preset="..."`) sits to the right
718/// of the marker's `<!--`, so the marker's start byte is never inside one of
719/// these spans and stays parseable. Mirrors the `\"`-escape handling of
720/// `crate::gate_verify`'s `strip_quoted_spans` (`#gng8`).
721pub fn find_quoted_ranges(doc: &str) -> Vec<(usize, usize)> {
722    let mut ranges = Vec::new();
723    let mut offset = 0usize;
724    for raw_line in doc.split_inclusive('\n') {
725        let line_start = offset;
726        offset += raw_line.len();
727        let bytes = raw_line.as_bytes();
728        let line_content_len = raw_line.trim_end_matches(['\n', '\r']).len();
729        let mut i = 0usize;
730        while i < bytes.len() {
731            if bytes[i] != b'"' {
732                i += 1;
733                continue;
734            }
735            let span_start = line_start + i;
736            let mut j = i + 1;
737            let mut closed = false;
738            while j < bytes.len() {
739                match bytes[j] {
740                    b'\\' => {
741                        j += 2;
742                        continue;
743                    }
744                    b'"' => {
745                        closed = true;
746                        break;
747                    }
748                    b'\n' | b'\r' => break,
749                    _ => j += 1,
750                }
751            }
752            if closed {
753                ranges.push((span_start, line_start + j + 1));
754                i = j + 1;
755            } else {
756                // Unterminated quote: mask to end of line (fail-safe), then stop
757                // scanning this line.
758                ranges.push((span_start, line_start + line_content_len));
759                break;
760            }
761        }
762    }
763    ranges
764}
765
766/// Find byte ranges for ordinary HTML comments, skipping code spans/blocks and
767/// preserving `agent:` markers for the component parser.
768///
769/// If an ordinary comment is currently unterminated, treat the rest of the
770/// document as comment body. Users can be editing a scratch comment while an
771/// agent-doc cycle is running; prompt-like text in that transiently broken
772/// comment must not be interpreted as escaped exchange content and moved or
773/// stripped by repair.
774pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
775    let code_ranges = find_code_ranges(doc);
776    let quoted_ranges = find_quoted_ranges(doc);
777    let in_code = |pos: usize| {
778        code_ranges
779            .iter()
780            .chain(quoted_ranges.iter())
781            .any(|&(start, end)| pos >= start && pos < end)
782    };
783
784    let bytes = doc.as_bytes();
785    let len = bytes.len();
786    let mut ranges = Vec::new();
787    let mut pos = 0usize;
788    while pos + 4 <= len {
789        if &bytes[pos..pos + 4] != b"<!--" {
790            pos += 1;
791            continue;
792        }
793        if in_code(pos) {
794            pos += 4;
795            continue;
796        }
797        let Some(end) = find_comment_end(bytes, pos + 4) else {
798            let inner = &doc[pos + 4..];
799            if !is_agent_marker(inner) && !is_agent_boundary_marker(inner) {
800                ranges.push((pos, len));
801                break;
802            }
803            pos += 4;
804            continue;
805        };
806        let inner = &doc[pos + 4..end - 3];
807        if !is_agent_marker(inner) && !is_agent_boundary_marker(inner) {
808            ranges.push((pos, end));
809        }
810        pos = end;
811    }
812    ranges
813}
814
815/// Parse all components from a document.
816///
817/// Uses a stack for nesting. Returns components sorted by `open_start`.
818/// Errors on unmatched open/close markers or invalid names.
819/// Skips markers inside fenced code blocks and inline code spans.
820pub fn parse(doc: &str) -> Result<Vec<Component>> {
821    let bytes = doc.as_bytes();
822    let len = bytes.len();
823    let code_ranges = find_code_ranges(doc);
824    let quoted_ranges = find_quoted_ranges(doc);
825    let mut templates: Vec<Component> = Vec::new();
826    // Stack of (name, attrs, open_start, open_end)
827    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
828    let mut pos = 0;
829
830    while pos + 4 <= len {
831        // Look for `<!--`
832        if &bytes[pos..pos + 4] != b"<!--" {
833            pos += 1;
834            continue;
835        }
836
837        // Skip markers inside code regions or double-quoted prose spans
838        // (a quoted `"<!-- agent:* -->"` is content, not a real marker — `#0kjc`).
839        if code_ranges
840            .iter()
841            .chain(quoted_ranges.iter())
842            .any(|&(start, end)| pos >= start && pos < end)
843        {
844            pos += 4;
845            continue;
846        }
847
848        // Quick peek: only consume `<!-- ... -->` if the text after `<!--`
849        // starts with ` agent:` or ` /agent:` (with optional whitespace).
850        // Non-agent `<!--` sequences (e.g., `<!-- )` in prose) must NOT be
851        // consumed — their `-->` search would eat real component close markers.
852        let peek_start = pos + 4;
853        let peek = bounded_preview(doc, peek_start, 20);
854        let peek_trimmed = peek.trim_start();
855        if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
856            pos += 1;
857            continue;
858        }
859
860        let marker_start = pos;
861
862        // Find closing `-->`
863        let close = match find_comment_end(bytes, pos + 4) {
864            Some(c) => c,
865            None => {
866                pos += 4;
867                continue;
868            }
869        };
870
871        // close points to the byte after `>`
872        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
873        let trimmed = inner.trim();
874
875        // Determine end offset — consume trailing newline if present
876        let mut marker_end = close;
877        if marker_end < len && bytes[marker_end] == b'\n' {
878            marker_end += 1;
879        }
880
881        if is_agent_boundary_marker(trimmed) {
882            pos = close;
883            continue;
884        }
885
886        if let Some(name) = trimmed.strip_prefix("/agent:") {
887            // Closing marker
888            if !is_valid_name(name) {
889                bail!("invalid component name: '{}'", name);
890            }
891            match stack.pop() {
892                Some((open_name, open_attrs, open_start, open_end)) => {
893                    if open_name != name {
894                        bail!(
895                            "mismatched component: opened '{}' but closed '{}'",
896                            open_name,
897                            name
898                        );
899                    }
900                    templates.push(Component {
901                        name: name.to_string(),
902                        attrs: open_attrs,
903                        open_start,
904                        open_end,
905                        close_start: marker_start,
906                        close_end: marker_end,
907                    });
908                }
909                None => bail!(
910                    "closing marker <!-- /agent:{} --> without matching open",
911                    name
912                ),
913            }
914        } else if let Some(rest) = trimmed.strip_prefix("agent:") {
915            // Skip boundary markers — these are not component markers
916            if is_agent_boundary_marker(trimmed) {
917                pos = close;
918                continue;
919            }
920            // Opening marker — may have attributes: `agent:NAME key=value`
921            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
922            let name = parts.next().unwrap_or("");
923            let attr_text = parts.next().unwrap_or("");
924            if !is_valid_name(name) {
925                bail!("invalid component name: '{}'", name);
926            }
927            let attrs = parse_attrs(attr_text);
928            stack.push((name.to_string(), attrs, marker_start, marker_end));
929        }
930
931        pos = close;
932    }
933
934    if let Some((name, _, _, _)) = stack.last() {
935        bail!(
936            "unclosed component: <!-- agent:{} --> without matching close",
937            name
938        );
939    }
940
941    templates.sort_by_key(|t| t.open_start);
942    Ok(templates)
943}
944
945/// Singleton components that must appear at most once in a structurally valid
946/// document. Mirrors the singleton list compaction's item-count guard uses
947/// (`#compactdropitem`). Components not listed here (e.g. `log`) may repeat.
948const SINGLETON_COMPONENT_NAMES: &[&str] = &[
949    EXCHANGE_COMPONENT,
950    "status",
951    "queue",
952    BACKLOG_COMPONENT,
953    REVIEW_COMPONENT,
954    ICEBOX_COMPONENT,
955    BACKLOG_DONE_COMPONENT,
956];
957
958/// Map a component name to its canonical singleton key, collapsing the legacy
959/// backlog alias (`pending` → `backlog`). Returns `None` for names that may
960/// legitimately repeat.
961fn canonical_singleton_name(name: &str) -> Option<&'static str> {
962    if is_backlog_component(name) {
963        return Some(BACKLOG_COMPONENT);
964    }
965    SINGLETON_COMPONENT_NAMES
966        .iter()
967        .copied()
968        .find(|&s| s == name)
969}
970
971/// True when `marker` contains an odd number of unescaped double-quotes — an
972/// unterminated quoted attribute value (e.g. `<!-- agent:queue tasks=" -->`, a
973/// CRDT merge-boundary split). Backslash escapes are honored to match
974/// [`find_quoted_ranges`] semantics.
975fn marker_has_unterminated_quote(marker: &str) -> bool {
976    let mut open = false;
977    let mut escaped = false;
978    for ch in marker.chars() {
979        if escaped {
980            escaped = false;
981            continue;
982        }
983        match ch {
984            '\\' => escaped = true,
985            '"' => open = !open,
986            _ => {}
987        }
988    }
989    open
990}
991
992fn byte_in_ranges(offset: usize, ranges: &[(usize, usize)]) -> bool {
993    ranges
994        .iter()
995        .any(|&(start, end)| offset >= start && offset < end)
996}
997
998/// Detect agent-looking HTML comment markers that were truncated before their
999/// same-line `-->` terminator. The normal parser only sees complete comments,
1000/// so a CRDT/editor split like `<!-- /agent:exchange --` can otherwise be
1001/// skipped or consume a later unrelated terminator.
1002pub fn malformed_agent_comment_reason(doc: &str) -> Option<String> {
1003    let code_ranges = find_code_ranges(doc);
1004    let quoted_ranges = find_quoted_ranges(doc);
1005    let mut offset = 0usize;
1006    for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1007        let line = raw_line.trim_end_matches(['\n', '\r']);
1008        let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
1009        let marker_start = offset + leading;
1010        let trimmed = &line[leading..];
1011        offset += raw_line.len();
1012
1013        if !trimmed.starts_with("<!--") {
1014            continue;
1015        }
1016        if byte_in_ranges(marker_start, &code_ranges)
1017            || byte_in_ranges(marker_start, &quoted_ranges)
1018        {
1019            continue;
1020        }
1021        let inner = trimmed["<!--".len()..].trim_start();
1022        if (inner.starts_with("agent:") || inner.starts_with("/agent:")) && !trimmed.contains("-->")
1023        {
1024            let preview = trimmed.chars().take(80).collect::<String>();
1025            return Some(format!(
1026                "malformed_agent_comment:line{}:{preview}",
1027                line_idx + 1
1028            ));
1029        }
1030    }
1031    None
1032}
1033
1034/// Detect structural corruption that must never be adopted as a snapshot base
1035/// (`#dupcontent`). Returns `Some(reason)` when the document is corrupt:
1036/// - a parse failure (mismatched / unclosed markers),
1037/// - more than one opening marker for a singleton component (duplicate block),
1038/// - an unterminated double-quoted attribute on a component opening marker.
1039///
1040/// Returns `None` when the document is structurally sound. Used to fail closed
1041/// before adopting a `content_ours` / visible-write IPC buffer whose
1042/// prior CRDT merge produced duplicate singleton blocks or a split attribute,
1043/// so the corrupt buffer never reaches disk.
1044pub fn structural_corruption_reason(doc: &str) -> Option<String> {
1045    if let Some(reason) = malformed_agent_comment_reason(doc) {
1046        return Some(reason);
1047    }
1048
1049    let components = match parse(doc) {
1050        Ok(c) => c,
1051        Err(e) => return Some(format!("parse_error: {e}")),
1052    };
1053
1054    // Duplicate singleton component blocks.
1055    let mut counts: HashMap<&str, usize> = HashMap::new();
1056    for c in &components {
1057        if let Some(canonical) = canonical_singleton_name(&c.name) {
1058            *counts.entry(canonical).or_insert(0) += 1;
1059        }
1060    }
1061    let mut dupes: Vec<(&str, usize)> = counts
1062        .iter()
1063        .filter(|&(_, &n)| n > 1)
1064        .map(|(&k, &n)| (k, n))
1065        .collect();
1066    if !dupes.is_empty() {
1067        dupes.sort();
1068        let detail = dupes
1069            .iter()
1070            .map(|(k, n)| format!("{k}={n}"))
1071            .collect::<Vec<_>>()
1072            .join(",");
1073        return Some(format!("duplicate_singleton_component:{detail}"));
1074    }
1075
1076    // Unterminated double-quoted attribute in a component opening marker.
1077    for c in &components {
1078        let marker = &doc[c.open_start..c.open_end];
1079        if marker_has_unterminated_quote(marker) {
1080            return Some(format!("malformed_attr:{}", c.name));
1081        }
1082    }
1083
1084    None
1085}
1086
1087/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
1088pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1089    let len = bytes.len();
1090    let mut i = start;
1091    while i + 3 <= len {
1092        if &bytes[i..i + 3] == b"-->" {
1093            return Some(i + 3);
1094        }
1095        i += 1;
1096    }
1097    None
1098}
1099
1100/// Strip comments from document content for diff comparison.
1101///
1102/// Removes:
1103/// - HTML comments `<!-- ... -->` (single and multiline) — EXCEPT agent range markers
1104/// - Link reference comments `[//]: # (...)`
1105///
1106/// Skips `<!--` sequences inside fenced code blocks and inline backtick spans
1107/// to prevent code examples containing `<!--` from being misinterpreted as
1108/// comment starts.
1109///
1110/// This function is the shared implementation used by both `diff::compute` (binary)
1111/// and external crates like `eval-runner`.
1112pub fn strip_comments(content: &str) -> String {
1113    let code_ranges = find_code_ranges(content);
1114    let in_code = |pos: usize| {
1115        code_ranges
1116            .iter()
1117            .any(|&(start, end)| pos >= start && pos < end)
1118    };
1119
1120    let mut result = String::with_capacity(content.len());
1121    let bytes = content.as_bytes();
1122    let len = bytes.len();
1123    let mut pos = 0;
1124
1125    while pos < len {
1126        // Check for link reference comment: `[//]: # (...)`
1127        if bytes[pos] == b'['
1128            && !in_code(pos)
1129            && is_line_start_at(bytes, pos)
1130            && let Some(end) = match_link_ref_comment_at(bytes, pos)
1131        {
1132            pos = end;
1133            continue;
1134        }
1135
1136        // Check for HTML comment: `<!-- ... -->`
1137        if pos + 4 <= len
1138            && &bytes[pos..pos + 4] == b"<!--"
1139            && !in_code(pos)
1140            && let Some((end, inner)) = match_html_comment_at(content, pos)
1141        {
1142            if is_agent_marker(inner) {
1143                // Preserve agent markers — copy them through
1144                result.push_str(&content[pos..end]);
1145                pos = end;
1146            } else {
1147                // Strip the comment (and trailing newline if on its own line)
1148                let mut skip_to = end;
1149                if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1150                    skip_to += 1;
1151                }
1152                pos = skip_to;
1153            }
1154            continue;
1155        }
1156
1157        result.push(content[pos..].chars().next().unwrap());
1158        pos += content[pos..].chars().next().unwrap().len_utf8();
1159    }
1160
1161    result
1162}
1163
1164/// True if `pos` is at the start of a line (pos == 0 or bytes[pos-1] == '\n').
1165fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1166    pos == 0 || bytes[pos - 1] == b'\n'
1167}
1168
1169/// Match `[//]: # (...)` starting at `pos`. Returns byte offset past the line end.
1170fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1171    let prefix = b"[//]: # (";
1172    let len = bytes.len();
1173    if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1174        return None;
1175    }
1176    let mut i = pos + prefix.len();
1177    while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1178        i += 1;
1179    }
1180    if i < len && bytes[i] == b')' {
1181        i += 1;
1182        if i < len && bytes[i] == b'\n' {
1183            i += 1;
1184        }
1185        Some(i)
1186    } else {
1187        None
1188    }
1189}
1190
1191/// Match `<!-- ... -->` starting at `pos`. Returns (end_offset, inner_text).
1192fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1193    let bytes = content.as_bytes();
1194    let len = bytes.len();
1195    let mut i = pos + 4;
1196    while i + 3 <= len {
1197        if &bytes[i..i + 3] == b"-->" {
1198            let inner = &content[pos + 4..i];
1199            return Some((i + 3, inner));
1200        }
1201        i += 1;
1202    }
1203    None
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn single_range() {
1212        let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1213        let ranges = parse(doc).unwrap();
1214        assert_eq!(ranges.len(), 1);
1215        assert_eq!(ranges[0].name, "status");
1216        assert_eq!(ranges[0].content(doc), "Hello\n");
1217    }
1218
1219    #[test]
1220    fn nested_ranges() {
1221        let doc = "\
1222<!-- agent:outer -->
1223<!-- agent:inner -->
1224content
1225<!-- /agent:inner -->
1226<!-- /agent:outer -->
1227";
1228        let ranges = parse(doc).unwrap();
1229        assert_eq!(ranges.len(), 2);
1230        // Sorted by open_start — outer first
1231        assert_eq!(ranges[0].name, "outer");
1232        assert_eq!(ranges[1].name, "inner");
1233        assert_eq!(ranges[1].content(doc), "content\n");
1234    }
1235
1236    #[test]
1237    fn siblings() {
1238        let doc = "\
1239<!-- agent:a -->
1240alpha
1241<!-- /agent:a -->
1242<!-- agent:b -->
1243beta
1244<!-- /agent:b -->
1245";
1246        let ranges = parse(doc).unwrap();
1247        assert_eq!(ranges.len(), 2);
1248        assert_eq!(ranges[0].name, "a");
1249        assert_eq!(ranges[0].content(doc), "alpha\n");
1250        assert_eq!(ranges[1].name, "b");
1251        assert_eq!(ranges[1].content(doc), "beta\n");
1252    }
1253
1254    #[test]
1255    fn no_ranges() {
1256        let doc = "# Just a document\n\nWith no range templates.\n";
1257        let ranges = parse(doc).unwrap();
1258        assert!(ranges.is_empty());
1259    }
1260
1261    #[test]
1262    fn unmatched_open_error() {
1263        let doc = "<!-- agent:orphan -->\nContent\n";
1264        let err = parse(doc).unwrap_err();
1265        assert!(err.to_string().contains("unclosed component"));
1266    }
1267
1268    #[test]
1269    fn unmatched_close_error() {
1270        let doc = "Content\n<!-- /agent:orphan -->\n";
1271        let err = parse(doc).unwrap_err();
1272        assert!(err.to_string().contains("without matching open"));
1273    }
1274
1275    #[test]
1276    fn mismatched_names_error() {
1277        let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1278        let err = parse(doc).unwrap_err();
1279        assert!(err.to_string().contains("mismatched"));
1280    }
1281
1282    #[test]
1283    fn invalid_name() {
1284        let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1285        let err = parse(doc).unwrap_err();
1286        assert!(err.to_string().contains("invalid component name"));
1287    }
1288
1289    #[test]
1290    fn name_validation() {
1291        assert!(is_valid_name("status"));
1292        assert!(is_valid_name("my-section"));
1293        assert!(is_valid_name("a1"));
1294        assert!(is_valid_name("A"));
1295        assert!(!is_valid_name(""));
1296        assert!(!is_valid_name("-bad"));
1297        assert!(!is_valid_name("has space"));
1298        assert!(!is_valid_name("has_underscore"));
1299    }
1300
1301    #[test]
1302    fn content_extraction() {
1303        let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1304        let ranges = parse(doc).unwrap();
1305        assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1306    }
1307
1308    #[test]
1309    fn replace_roundtrip() {
1310        let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1311        let ranges = parse(doc).unwrap();
1312        let new_doc = ranges[0].replace_content(doc, "new\n");
1313        assert_eq!(
1314            new_doc,
1315            "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1316        );
1317        // Re-parse should work
1318        let ranges2 = parse(&new_doc).unwrap();
1319        assert_eq!(ranges2.len(), 1);
1320        assert_eq!(ranges2[0].content(&new_doc), "new\n");
1321    }
1322
1323    #[test]
1324    fn is_agent_marker_yes() {
1325        assert!(is_agent_marker(" agent:status "));
1326        assert!(is_agent_marker("/agent:status"));
1327        assert!(is_agent_marker("agent:my-thing"));
1328        assert!(is_agent_marker(" /agent:A1 "));
1329    }
1330
1331    #[test]
1332    fn is_agent_marker_no() {
1333        assert!(!is_agent_marker("just a comment"));
1334        assert!(!is_agent_marker("agent:"));
1335        assert!(!is_agent_marker("/agent:"));
1336        assert!(!is_agent_marker("agent:-bad"));
1337        assert!(!is_agent_marker("some agent:fake stuff"));
1338    }
1339
1340    #[test]
1341    fn regular_comments_ignored() {
1342        let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1343        let ranges = parse(doc).unwrap();
1344        assert_eq!(ranges.len(), 1);
1345        assert_eq!(ranges[0].name, "x");
1346    }
1347
1348    #[test]
1349    fn regular_comments_with_multibyte_preview_boundary_ignored() {
1350        let doc = "\
1351<!-- 123456789012345678❯ ordinary comment -->
1352<!-- agent:x -->
1353data
1354<!-- /agent:x -->
1355";
1356        let ranges = parse(doc).unwrap();
1357        assert_eq!(ranges.len(), 1);
1358        assert_eq!(ranges[0].name, "x");
1359    }
1360
1361    #[test]
1362    fn multiline_comment_ignored() {
1363        let doc = "\
1364<!--
1365multi
1366line
1367comment
1368-->
1369<!-- agent:s -->
1370content
1371<!-- /agent:s -->
1372";
1373        let ranges = parse(doc).unwrap();
1374        assert_eq!(ranges.len(), 1);
1375        assert_eq!(ranges[0].name, "s");
1376    }
1377
1378    #[test]
1379    fn empty_content() {
1380        let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1381        let ranges = parse(doc).unwrap();
1382        assert_eq!(ranges.len(), 1);
1383        assert_eq!(ranges[0].content(doc), "");
1384    }
1385
1386    #[test]
1387    fn markers_in_fenced_code_block_ignored() {
1388        let doc = "\
1389<!-- agent:real -->
1390content
1391<!-- /agent:real -->
1392```markdown
1393<!-- agent:fake -->
1394this is just an example
1395<!-- /agent:fake -->
1396```
1397";
1398        let ranges = parse(doc).unwrap();
1399        assert_eq!(ranges.len(), 1);
1400        assert_eq!(ranges[0].name, "real");
1401    }
1402
1403    #[test]
1404    fn markers_in_inline_code_ignored() {
1405        let doc = "\
1406Use `<!-- agent:example -->` markers for components.
1407<!-- agent:real -->
1408content
1409<!-- /agent:real -->
1410";
1411        let ranges = parse(doc).unwrap();
1412        assert_eq!(ranges.len(), 1);
1413        assert_eq!(ranges[0].name, "real");
1414    }
1415
1416    #[test]
1417    fn markers_in_tilde_fence_ignored() {
1418        let doc = "\
1419<!-- agent:x -->
1420data
1421<!-- /agent:x -->
1422~~~
1423<!-- agent:y -->
1424example
1425<!-- /agent:y -->
1426~~~
1427";
1428        let ranges = parse(doc).unwrap();
1429        assert_eq!(ranges.len(), 1);
1430        assert_eq!(ranges[0].name, "x");
1431    }
1432
1433    #[test]
1434    fn markers_in_indented_fenced_code_block_ignored() {
1435        // CommonMark allows up to 3 spaces before fence opener
1436        let doc = "\
1437<!-- agent:exchange -->
1438Content here.
1439<!-- /agent:exchange -->
1440
1441  ```markdown
1442  <!-- agent:fake -->
1443  demo without closing tag
1444  ```
1445";
1446        let ranges = parse(doc).unwrap();
1447        assert_eq!(ranges.len(), 1);
1448        assert_eq!(ranges[0].name, "exchange");
1449    }
1450
1451    #[test]
1452    fn indented_fence_inside_component_ignored() {
1453        // Indented code block inside a component should not cause mismatched errors
1454        let doc = "\
1455<!-- agent:exchange -->
1456Here's how to set up:
1457
1458   ```markdown
1459   <!-- agent:status -->
1460   Your status here
1461   ```
1462
1463Done explaining.
1464<!-- /agent:exchange -->
1465";
1466        let ranges = parse(doc).unwrap();
1467        assert_eq!(ranges.len(), 1);
1468        assert_eq!(ranges[0].name, "exchange");
1469    }
1470
1471    #[test]
1472    fn deeply_indented_fence_ignored() {
1473        // Tabs and many spaces should still be detected as a fence
1474        let doc = "\
1475<!-- agent:x -->
1476ok
1477<!-- /agent:x -->
1478      ```
1479      <!-- agent:y -->
1480      inside fence
1481      ```
1482";
1483        let ranges = parse(doc).unwrap();
1484        assert_eq!(ranges.len(), 1);
1485        assert_eq!(ranges[0].name, "x");
1486    }
1487
1488    #[test]
1489    fn indented_fence_code_ranges_detected() {
1490        let doc = "before\n  ```\n  code\n  ```\nafter\n";
1491        let ranges = find_code_ranges(doc);
1492        assert_eq!(ranges.len(), 1);
1493        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1494    }
1495
1496    #[test]
1497    fn code_ranges_detected() {
1498        let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1499        let ranges = find_code_ranges(doc);
1500        assert_eq!(ranges.len(), 2);
1501        // Fenced block
1502        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1503        // Inline span
1504        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1505    }
1506
1507    #[test]
1508    fn code_ranges_double_backtick() {
1509        // CommonMark: `` `<!--` `` is a code span containing `<!--`
1510        let doc = "text `` `<!--` `` more\n";
1511        let ranges = find_code_ranges(doc);
1512        assert_eq!(ranges.len(), 1);
1513        let span = &doc[ranges[0].0..ranges[0].1];
1514        assert!(
1515            span.contains("<!--"),
1516            "double-backtick span should contain <!--: {:?}",
1517            span
1518        );
1519    }
1520
1521    #[test]
1522    fn code_ranges_double_backtick_does_not_match_single() {
1523        // `` should not match a single ` close
1524        let doc = "text `` foo ` bar `` end\n";
1525        let ranges = find_code_ranges(doc);
1526        assert_eq!(ranges.len(), 1);
1527        let span = &doc[ranges[0].0..ranges[0].1];
1528        assert_eq!(span, "`` foo ` bar ``");
1529    }
1530
1531    #[test]
1532    fn double_backtick_comment_before_agent_marker() {
1533        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
1534        let doc = "\
1535<!-- agent:exchange -->\n\
1536text `` `<!--` `` description\n\
1537new content here\n\
1538<!-- /agent:exchange -->\n";
1539        let components = parse(doc).unwrap();
1540        assert_eq!(components.len(), 1);
1541        assert_eq!(components[0].name, "exchange");
1542        assert!(components[0].content(doc).contains("new content here"));
1543    }
1544
1545    #[test]
1546    fn quoted_agent_marker_in_prose_is_not_parsed() {
1547        // #0kjc: a marker quoted by name in prose is content, not a real marker.
1548        // Without quote-masking the dangling `<!-- agent:queue -->` inside the
1549        // quotes would open an unmatched component and error the whole parse.
1550        let doc = "\
1551<!-- agent:exchange -->\n\
1552Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1553new content here\n\
1554<!-- /agent:exchange -->\n";
1555        let components = parse(doc).unwrap();
1556        assert_eq!(components.len(), 1);
1557        assert_eq!(components[0].name, "exchange");
1558        assert!(components[0].content(doc).contains("new content here"));
1559        assert!(
1560            components[0]
1561                .content(doc)
1562                .contains("attributes were dropped")
1563        );
1564    }
1565
1566    #[test]
1567    fn quoted_id_token_in_prose_is_content() {
1568        // #0kjc: a `#id`-looking token in a quoted span is prose, never a tag.
1569        let doc = "\
1570<!-- agent:exchange -->\n\
1571The operator asked to clear \"#advance-review\" but no such line exists.\n\
1572<!-- /agent:exchange -->\n";
1573        let components = parse(doc).unwrap();
1574        assert_eq!(components.len(), 1);
1575        assert!(components[0].content(doc).contains("#advance-review"));
1576    }
1577
1578    #[test]
1579    fn fenced_agent_marker_in_prose_is_not_parsed() {
1580        // Regression guard: code-fence masking (pre-#0kjc) must keep working — a
1581        // marker inside a fenced block stays content.
1582        let doc = "\
1583<!-- agent:exchange -->\n\
1584```\n\
1585<!-- agent:queue -->\n\
1586```\n\
1587new content here\n\
1588<!-- /agent:exchange -->\n";
1589        let components = parse(doc).unwrap();
1590        assert_eq!(components.len(), 1);
1591        assert_eq!(components[0].name, "exchange");
1592        assert!(components[0].content(doc).contains("new content here"));
1593    }
1594
1595    #[test]
1596    fn real_marker_with_quoted_preset_attr_still_parses() {
1597        // #0kjc safety: a real marker carrying a double-quoted attribute value
1598        // must NOT be masked — its `<!--` precedes the quotes, so its start byte
1599        // is never inside a quoted span.
1600        let doc = "\
1601<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1602- :pushpin: do [#thing]\n\
1603<!-- /agent:queue -->\n";
1604        let components = parse(doc).unwrap();
1605        assert_eq!(components.len(), 1);
1606        assert_eq!(components[0].name, "queue");
1607        // The marker is NOT masked (proving the safety property); the attr
1608        // parser stores the raw quoted value, same as before this change.
1609        assert!(
1610            components[0]
1611                .attrs
1612                .get("preset")
1613                .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1614            "preset attr should survive quote-masking, got {:?}",
1615            components[0].attrs.get("preset")
1616        );
1617    }
1618
1619    #[test]
1620    fn find_quoted_ranges_is_same_line_and_fail_safe() {
1621        // Balanced same-line span is masked; a span never crosses a newline.
1622        let doc = "a \"bc\" d\n\"ef\n";
1623        let ranges = find_quoted_ranges(doc);
1624        // First line: `"bc"` (bytes 2..6). Second line: unterminated `"ef`
1625        // masks to end-of-line only (not into the next line).
1626        assert_eq!(ranges.len(), 2);
1627        assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1628        assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1629    }
1630
1631    // --- Inline attribute tests ---
1632
1633    #[test]
1634    fn parse_component_with_mode_attr() {
1635        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1636        let components = parse(doc).unwrap();
1637        assert_eq!(components.len(), 1);
1638        assert_eq!(components[0].name, "exchange");
1639        assert_eq!(
1640            components[0].attrs.get("mode").map(|s| s.as_str()),
1641            Some("append")
1642        );
1643        assert_eq!(components[0].content(doc), "Content\n");
1644    }
1645
1646    #[test]
1647    fn parse_component_with_multiple_attrs() {
1648        let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1649        let components = parse(doc).unwrap();
1650        assert_eq!(components.len(), 1);
1651        assert_eq!(components[0].name, "log");
1652        assert_eq!(
1653            components[0].attrs.get("mode").map(|s| s.as_str()),
1654            Some("prepend")
1655        );
1656        assert_eq!(
1657            components[0].attrs.get("timestamp").map(|s| s.as_str()),
1658            Some("true")
1659        );
1660    }
1661
1662    #[test]
1663    fn parse_component_no_attrs_backward_compat() {
1664        let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1665        let components = parse(doc).unwrap();
1666        assert_eq!(components.len(), 1);
1667        assert_eq!(components[0].name, "status");
1668        assert!(components[0].attrs.is_empty());
1669    }
1670
1671    #[test]
1672    fn is_agent_marker_with_attrs() {
1673        assert!(is_agent_marker(" agent:exchange mode=append "));
1674        assert!(is_agent_marker("agent:status mode=replace"));
1675        assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1676    }
1677
1678    #[test]
1679    fn bounded_preview_clamps_to_char_boundary_inside_multibyte() {
1680        // Regression (`#utf8p`): the parser peeks up to 20 bytes past a `<!--`
1681        // to decide whether it is an agent marker. When that window ended inside
1682        // a multibyte glyph such as `❯` (3 bytes), the old raw
1683        // `&doc[peek_start..peek_start + 20]` slice panicked with
1684        // "byte index N is not a char boundary", which aborted the cdylib and
1685        // crashed the host editor (SIGABRT on the `agent-doc-crdt-*` thread).
1686        // `bounded_preview` must clamp the upper bound back to a char boundary.
1687        let doc = format!("<!--{}❯ trailing", "x".repeat(18));
1688        // peek_start = 4; peek_start + 20 = 24 lands on the 3rd byte of `❯`
1689        // (which occupies bytes 22..25), so a raw slice would panic here.
1690        let preview = bounded_preview(&doc, 4, 20);
1691        assert!(doc.is_char_boundary(4 + preview.len()));
1692        assert_eq!(preview, "x".repeat(18)); // stops just before `❯`
1693    }
1694
1695    #[test]
1696    fn parse_does_not_panic_on_prompt_glyph_adjacent_to_agent_marker() {
1697        // Mirrors the live reitrades.md shape: `❯` prompt lines sitting directly
1698        // against agent component / boundary markers. Parsing must stay panic-free
1699        // and still recover the real `exchange` component.
1700        let doc = "<!-- agent:exchange -->\n❯ tighten this up — simpler — lower rates\n<!-- agent:boundary:0d861cfa -->\n❯ His platform is in Rust & Vue.\n<!-- /agent:exchange -->\n";
1701        let components = parse(doc).expect("prompt glyphs near markers must not panic");
1702        assert_eq!(components.len(), 1);
1703        assert_eq!(components[0].name, "exchange");
1704    }
1705
1706    #[test]
1707    fn parse_skips_boundary_close_artifact_as_non_component() {
1708        let doc = "<!-- agent:exchange -->\nanswer\n<!-- /agent:boundary:a37c9696 -->\n<!-- /agent:exchange -->\n";
1709        let components = parse(doc).expect("boundary close artifact must not be a component");
1710        assert_eq!(components.len(), 1);
1711        assert_eq!(components[0].name, "exchange");
1712        assert!(
1713            components[0]
1714                .content(doc)
1715                .contains("<!-- /agent:boundary:a37c9696 -->")
1716        );
1717    }
1718
1719    #[test]
1720    fn boundary_close_artifact_is_not_an_ordinary_comment_range() {
1721        let doc = concat!(
1722            "<!-- agent:exchange -->\n",
1723            "answer\n",
1724            "<!-- /agent:boundary:a37c9696 -->\n",
1725            "<!-- /agent:exchange -->\n"
1726        );
1727        assert!(
1728            find_non_agent_html_comment_ranges(doc).is_empty(),
1729            "boundary close artifacts are transient agent comments, not ordinary comments"
1730        );
1731    }
1732
1733    #[test]
1734    fn closing_tag_unchanged_with_attrs() {
1735        // Closing tags never have attributes
1736        let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1737        let components = parse(doc).unwrap();
1738        assert_eq!(components.len(), 1);
1739        let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1740        assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1741        assert!(new_doc.contains("<!-- /agent:status -->"));
1742        assert!(new_doc.contains("- [ ] Todo"));
1743    }
1744
1745    #[test]
1746    fn parse_component_with_patch_attr() {
1747        let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1748        let components = parse(doc).unwrap();
1749        assert_eq!(components.len(), 1);
1750        assert_eq!(components[0].name, "exchange");
1751        assert_eq!(components[0].patch_mode(), Some("append"));
1752        assert_eq!(components[0].content(doc), "Content\n");
1753    }
1754
1755    #[test]
1756    fn patch_attr_takes_precedence_over_mode() {
1757        let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1758        let components = parse(doc).unwrap();
1759        assert_eq!(components[0].patch_mode(), Some("replace"));
1760    }
1761
1762    #[test]
1763    fn mode_attr_backward_compat() {
1764        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1765        let components = parse(doc).unwrap();
1766        assert_eq!(components[0].patch_mode(), Some("append"));
1767    }
1768
1769    #[test]
1770    fn no_patch_or_mode_attr() {
1771        let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1772        let components = parse(doc).unwrap();
1773        assert_eq!(components[0].patch_mode(), None);
1774    }
1775
1776    // --- Inline backtick code span exclusion tests ---
1777
1778    #[test]
1779    fn single_backtick_component_tag_ignored() {
1780        // A component tag wrapped in single backticks should not be parsed
1781        let doc = "\
1782Use `<!-- agent:pending -->` to mark pending sections.
1783<!-- agent:real -->
1784content
1785<!-- /agent:real -->
1786";
1787        let components = parse(doc).unwrap();
1788        assert_eq!(components.len(), 1);
1789        assert_eq!(components[0].name, "real");
1790    }
1791
1792    #[test]
1793    fn double_backtick_component_tag_ignored() {
1794        // A component tag wrapped in double backticks should not be parsed
1795        let doc = "\
1796Use ``<!-- agent:pending -->`` to mark pending sections.
1797<!-- agent:real -->
1798content
1799<!-- /agent:real -->
1800";
1801        let components = parse(doc).unwrap();
1802        assert_eq!(components.len(), 1);
1803        assert_eq!(components[0].name, "real");
1804    }
1805
1806    #[test]
1807    fn component_tags_not_in_backticks_still_work() {
1808        // Tags outside of any backticks are parsed normally
1809        let doc = "\
1810<!-- agent:a -->
1811alpha
1812<!-- /agent:a -->
1813<!-- agent:b patch=append -->
1814beta
1815<!-- /agent:b -->
1816";
1817        let components = parse(doc).unwrap();
1818        assert_eq!(components.len(), 2);
1819        assert_eq!(components[0].name, "a");
1820        assert_eq!(components[1].name, "b");
1821        assert_eq!(components[1].patch_mode(), Some("append"));
1822    }
1823
1824    #[test]
1825    fn mixed_backtick_and_real_tags() {
1826        // Some tags in backticks (ignored), some not (parsed)
1827        let doc = "\
1828Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1829<!-- agent:real -->
1830real content
1831<!-- /agent:real -->
1832Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1833";
1834        let components = parse(doc).unwrap();
1835        assert_eq!(components.len(), 1);
1836        assert_eq!(components[0].name, "real");
1837        assert_eq!(components[0].content(doc), "real content\n");
1838    }
1839
1840    #[test]
1841    fn inline_code_mid_line_with_surrounding_text_ignored() {
1842        // Edge case: component tag inside inline code span on a line with other content
1843        // before and after — must not be parsed as a real component marker.
1844        let doc = "\
1845Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1846<!-- agent:real -->
1847actual content
1848<!-- /agent:real -->
1849";
1850        let components = parse(doc).unwrap();
1851        assert_eq!(components.len(), 1);
1852        assert_eq!(components[0].name, "real");
1853        assert_eq!(components[0].content(doc), "actual content\n");
1854    }
1855
1856    #[test]
1857    fn parse_attrs_unit() {
1858        let attrs = parse_attrs("mode=append");
1859        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1860
1861        let attrs = parse_attrs("mode=replace timestamp=true");
1862        assert_eq!(attrs.len(), 2);
1863
1864        let attrs = parse_attrs("");
1865        assert!(attrs.is_empty());
1866
1867        // Bare tokens (no =) are parsed as boolean flags with empty string values
1868        let attrs = parse_attrs("mode=append broken novalue=");
1869        assert_eq!(attrs.len(), 2);
1870        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1871        assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1872
1873        // auto flag (used by agent:queue)
1874        let attrs = parse_attrs("auto");
1875        assert_eq!(attrs.len(), 1);
1876        assert!(attrs.contains_key("auto"));
1877    }
1878
1879    #[test]
1880    fn append_with_boundary_skips_code_block() {
1881        // Boundary marker inside a code block should be ignored;
1882        // the real marker outside should be used.
1883        let boundary_id = "real-uuid";
1884        let doc = format!(
1885            "<!-- agent:exchange patch=append -->\n\
1886             user prompt\n\
1887             ```\n\
1888             <!-- agent:boundary:{boundary_id} -->\n\
1889             ```\n\
1890             more user text\n\
1891             <!-- agent:boundary:{boundary_id} -->\n\
1892             <!-- /agent:exchange -->\n"
1893        );
1894        let components = parse(&doc).unwrap();
1895        let comp = &components[0];
1896        let result =
1897            comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1898
1899        // Response should replace the REAL marker (outside code block),
1900        // not the one inside the code block.
1901        assert!(result.contains("### Re: Response"));
1902        assert!(result.contains("more user text"));
1903        // The code block example should be preserved
1904        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1905        // The real marker should be consumed (replaced by response)
1906        assert!(!result.contains(&format!(
1907            "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1908        )));
1909    }
1910
1911    #[test]
1912    fn append_with_boundary_no_code_block() {
1913        // Normal case: boundary marker not in a code block
1914        let boundary_id = "simple-uuid";
1915        let doc = format!(
1916            "<!-- agent:exchange patch=append -->\n\
1917             user prompt\n\
1918             <!-- agent:boundary:{boundary_id} -->\n\
1919             <!-- /agent:exchange -->\n"
1920        );
1921        let components = parse(&doc).unwrap();
1922        let comp = &components[0];
1923        let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1924
1925        assert!(result.contains("### Re: Answer"));
1926        assert!(result.contains("user prompt"));
1927        // Original marker should be consumed, but a NEW boundary re-inserted
1928        assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1929        assert!(result.contains("agent:boundary:"));
1930    }
1931
1932    #[test]
1933    fn append_with_boundary_skips_already_present_content() {
1934        let boundary_id = "simple-uuid";
1935        let doc = format!(
1936            "<!-- agent:exchange patch=append -->\n\
1937             ### Re: Duplicate — gpt-5 (HEAD)\n\
1938             \n\
1939             Already applied.\n\
1940             <!-- agent:boundary:old-id -->\n\
1941             <!-- agent:boundary:{boundary_id} -->\n\
1942             <!-- /agent:exchange -->\n"
1943        );
1944        let components = parse(&doc).unwrap();
1945        let comp = &components[0];
1946        let result = comp.append_with_boundary(
1947            &doc,
1948            "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1949            boundary_id,
1950        );
1951
1952        assert_eq!(result, doc);
1953    }
1954
1955    #[test]
1956    fn append_with_caret_skips_already_present_content() {
1957        let doc = "<!-- agent:exchange patch=append -->\n\
1958                   User prompt.\n\
1959                   ### Re: Duplicate — gpt-5\n\
1960                   \n\
1961                   Already applied.\n\
1962                   <!-- /agent:exchange -->\n";
1963        let components = parse(doc).unwrap();
1964        let comp = &components[0];
1965        let result =
1966            comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1967
1968        assert_eq!(result, doc);
1969    }
1970
1971    // --- strip_comments tests (moved from diff.rs) ---
1972
1973    #[test]
1974    fn strip_comments_removes_html_comment() {
1975        let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1976        assert_eq!(result, "before\nafter\n");
1977    }
1978
1979    #[test]
1980    fn non_agent_html_comment_ranges_cover_multiline_body() {
1981        let doc = concat!(
1982            "before\n",
1983            "<!--\n",
1984            "do #hidden. spec-test-build-install-commit-push\n",
1985            "-->\n",
1986            "<!-- agent:exchange -->\n",
1987            "<!-- /agent:exchange -->\n"
1988        );
1989
1990        let ranges = find_non_agent_html_comment_ranges(doc);
1991        assert_eq!(ranges.len(), 1);
1992        let hidden = doc.find("do #hidden").unwrap();
1993        assert!(
1994            ranges
1995                .iter()
1996                .any(|&(start, end)| hidden >= start && hidden < end),
1997            "ordinary comment body should be inside a non-agent comment range"
1998        );
1999        assert!(
2000            ranges
2001                .iter()
2002                .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
2003            "agent component markers must not be treated as ordinary comments"
2004        );
2005    }
2006
2007    #[test]
2008    fn non_agent_html_comment_ranges_cover_unterminated_tail() {
2009        let doc = concat!(
2010            "before\n",
2011            "<!--\n",
2012            "do #hidden. spec-test-build-install-commit-push\n",
2013            "still typing\n"
2014        );
2015
2016        let ranges = find_non_agent_html_comment_ranges(doc);
2017        assert_eq!(ranges.len(), 1);
2018        let (start, end) = ranges[0];
2019        assert_eq!(
2020            &doc[start..end],
2021            "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
2022        );
2023        assert_eq!(end, doc.len());
2024    }
2025
2026    #[test]
2027    fn strip_comments_preserves_agent_markers() {
2028        let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
2029        let result = strip_comments(input);
2030        assert!(result.contains("<!-- agent:status -->"));
2031        assert!(result.contains("<!-- /agent:status -->"));
2032    }
2033
2034    #[test]
2035    fn strip_comments_removes_link_ref() {
2036        let result = strip_comments("[//]: # (hidden note)\nvisible\n");
2037        assert_eq!(result, "visible\n");
2038    }
2039
2040    #[test]
2041    fn html_comment_in_content_does_not_eat_close_marker() {
2042        let doc = "\
2043<!-- agent:pending -->
2044- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
2045<!-- /agent:pending -->
2046";
2047        let comps = parse(doc).unwrap();
2048        assert_eq!(comps.len(), 1);
2049        assert_eq!(comps[0].name, "pending");
2050        assert!(comps[0].content(doc).contains("<!-- )"));
2051    }
2052
2053    #[test]
2054    fn nested_html_comment_like_text_in_exchange() {
2055        let doc = "\
2056<!-- agent:exchange -->
2057User typed <!-- some note --> in the text.
2058<!-- /agent:exchange -->
2059";
2060        let comps = parse(doc).unwrap();
2061        assert_eq!(comps.len(), 1);
2062        assert_eq!(comps[0].name, "exchange");
2063    }
2064
2065    #[test]
2066    fn pending_item_with_literal_html_comment_opener() {
2067        // Regression: a pending item containing `<!-- ` (literal HTML comment start)
2068        // must not consume the `<!-- /agent:pending -->` close marker.
2069        let doc = "\
2070<!-- agent:pending -->
2071- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
2072- [ ] [#xyz] Another item
2073<!-- /agent:pending -->
2074";
2075        let comps = parse(doc).unwrap();
2076        assert_eq!(comps.len(), 1);
2077        assert_eq!(comps[0].name, "pending");
2078        let content = comps[0].content(doc);
2079        assert!(content.contains("#r7hw"));
2080        assert!(content.contains("#xyz"));
2081    }
2082
2083    #[test]
2084    fn multiple_non_agent_html_comments_in_pending() {
2085        // Multiple `<!-- ... -->` fragments that are NOT agent markers.
2086        let doc = "\
2087<!-- agent:pending -->
2088- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
2089- [ ] [#b] Handle <!-- partial comment
2090- [ ] [#c] Normal item
2091<!-- /agent:pending -->
2092";
2093        let comps = parse(doc).unwrap();
2094        assert_eq!(comps.len(), 1);
2095        assert_eq!(comps[0].name, "pending");
2096        let content = comps[0].content(doc);
2097        assert!(content.contains("#a"));
2098        assert!(content.contains("#b"));
2099        assert!(content.contains("#c"));
2100    }
2101
2102    #[test]
2103    fn exchange_and_pending_both_with_html_comments_in_content() {
2104        // Both components contain non-agent HTML comments — parser must handle
2105        // siblings correctly without eating across component boundaries.
2106        let doc = "\
2107<!-- agent:exchange -->
2108The rule checks for <!-- prefix before deciding.
2109### Re: topic — opus
2110Fix applied to skip non-agent <!-- sequences.
2111<!-- /agent:exchange -->
2112
2113<!-- agent:pending -->
2114- [ ] [#cfdy] Verify <!-- in pending items
2115<!-- /agent:pending -->
2116";
2117        let comps = parse(doc).unwrap();
2118        assert_eq!(comps.len(), 2);
2119        assert_eq!(comps[0].name, "exchange");
2120        assert_eq!(comps[1].name, "pending");
2121        assert!(comps[0].content(doc).contains("<!-- prefix"));
2122        assert!(comps[1].content(doc).contains("#cfdy"));
2123    }
2124
2125    #[test]
2126    fn is_backlog_component_accepts_both_names() {
2127        assert!(is_backlog_component("backlog"));
2128        assert!(is_backlog_component("pending"));
2129        assert!(!is_backlog_component("exchange"));
2130        assert!(!is_backlog_component("status"));
2131        assert!(!is_backlog_component("pending-done"));
2132        assert!(!is_backlog_component("backlog-done"));
2133        assert!(!is_backlog_component("done"));
2134    }
2135
2136    #[test]
2137    fn is_backlog_done_component_accepts_only_canonical_done() {
2138        assert!(is_backlog_done_component("done"));
2139        assert!(!is_backlog_done_component("backlog-done"));
2140        assert!(!is_backlog_done_component("pending-done"));
2141        assert!(!is_backlog_done_component("backlog"));
2142        assert!(!is_backlog_done_component("pending"));
2143        assert!(!is_backlog_done_component("icebox"));
2144    }
2145
2146    #[test]
2147    fn is_review_component_accepts_name() {
2148        assert!(is_review_component("review"));
2149        assert!(!is_review_component("backlog"));
2150        assert!(!is_review_component("icebox"));
2151    }
2152
2153    #[test]
2154    fn is_icebox_component_accepts_name() {
2155        assert!(is_icebox_component("icebox"));
2156        assert!(!is_icebox_component("backlog"));
2157        assert!(!is_icebox_component("exchange"));
2158        assert!(!is_icebox_component("icebox-archive"));
2159    }
2160
2161    #[test]
2162    fn tracked_work_component_includes_review() {
2163        assert!(is_tracked_work_component("backlog"));
2164        assert!(is_tracked_work_component("pending"));
2165        assert!(is_tracked_work_component("review"));
2166        assert!(is_tracked_work_component("icebox"));
2167        assert!(!is_tracked_work_component("done"));
2168    }
2169
2170    #[test]
2171    fn icebox_component_parsed() {
2172        let doc = "\
2173<!-- agent:icebox -->
2174- Parked idea
2175<!-- /agent:icebox -->
2176";
2177        let comps = parse(doc).unwrap();
2178        assert_eq!(comps.len(), 1);
2179        assert_eq!(comps[0].name, "icebox");
2180        assert!(is_icebox_component(&comps[0].name));
2181        assert!(comps[0].content(doc).contains("Parked idea"));
2182    }
2183
2184    #[test]
2185    fn backlog_component_parsed_from_new_marker() {
2186        let doc = "\
2187<!-- agent:backlog -->
2188- [ ] [#abc] First item
2189<!-- /agent:backlog -->
2190";
2191        let comps = parse(doc).unwrap();
2192        assert_eq!(comps.len(), 1);
2193        assert_eq!(comps[0].name, "backlog");
2194        assert!(is_backlog_component(&comps[0].name));
2195        assert!(comps[0].content(doc).contains("#abc"));
2196    }
2197
2198    #[test]
2199    fn legacy_pending_marker_still_parsed() {
2200        let doc = "\
2201<!-- agent:pending -->
2202- [ ] [#xyz] Legacy item
2203<!-- /agent:pending -->
2204";
2205        let comps = parse(doc).unwrap();
2206        assert_eq!(comps.len(), 1);
2207        assert_eq!(comps[0].name, "pending");
2208        assert!(is_backlog_component(&comps[0].name));
2209    }
2210
2211    #[test]
2212    fn strip_backlog_patch_attr_removes_patch_replace() {
2213        let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2214        let result = strip_backlog_patch_attr(doc);
2215        assert_eq!(
2216            result,
2217            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2218        );
2219    }
2220
2221    #[test]
2222    fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2223        let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2224        let result = strip_backlog_patch_attr(doc);
2225        assert_eq!(
2226            result,
2227            "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2228        );
2229    }
2230
2231    #[test]
2232    fn strip_backlog_patch_attr_removes_mode_replace() {
2233        let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2234        let result = strip_backlog_patch_attr(doc);
2235        assert_eq!(
2236            result,
2237            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2238        );
2239    }
2240
2241    #[test]
2242    fn strip_backlog_patch_attr_preserves_other_attrs() {
2243        let doc =
2244            "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2245        let result = strip_backlog_patch_attr(doc);
2246        assert_eq!(
2247            result,
2248            "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2249        );
2250    }
2251
2252    #[test]
2253    fn strip_backlog_patch_attr_noop_for_exchange() {
2254        let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2255        let result = strip_backlog_patch_attr(doc);
2256        assert_eq!(result, doc);
2257    }
2258
2259    #[test]
2260    fn strip_backlog_patch_attr_noop_when_no_attr() {
2261        let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2262        let result = strip_backlog_patch_attr(doc);
2263        assert_eq!(result, doc);
2264    }
2265
2266    #[test]
2267    fn converge_queue_auto_strips_auto() {
2268        let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2269        let result = converge_queue_auto(doc, false).expect("tag changed");
2270        assert_eq!(
2271            result,
2272            "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2273        );
2274    }
2275
2276    #[test]
2277    fn converge_queue_auto_adds_auto() {
2278        let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2279        let result = converge_queue_auto(doc, true).expect("tag changed");
2280        assert_eq!(
2281            result,
2282            "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2283        );
2284    }
2285
2286    #[test]
2287    fn converge_queue_auto_preserves_other_attrs() {
2288        let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2289        let result = converge_queue_auto(doc, false).expect("tag changed");
2290        assert_eq!(
2291            result,
2292            "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2293        );
2294    }
2295
2296    #[test]
2297    fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2298        let doc = concat!(
2299            "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2300            "- do [#x]\n",
2301            "<!-- /agent:queue -->\n",
2302        );
2303        let result = converge_queue_auto(doc, false).expect("tag normalized");
2304        assert_eq!(
2305            result,
2306            concat!(
2307                "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2308                "- do [#x]\n",
2309                "<!-- /agent:queue -->\n",
2310            )
2311        );
2312    }
2313
2314    #[test]
2315    fn converge_queue_auto_noop_when_already_matching() {
2316        let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2317        assert_eq!(converge_queue_auto(active, true), None);
2318        let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2319        assert_eq!(converge_queue_auto(inactive, false), None);
2320    }
2321
2322    #[test]
2323    fn converge_queue_auto_none_without_queue_component() {
2324        let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2325        assert_eq!(converge_queue_auto(doc, false), None);
2326    }
2327
2328    // #prompt-duplicated-while-typing: dedup must be agnostic to the `❯ ` user-prompt
2329    // prefix, since a synthesized exchange patch and the live buffer can differ only
2330    // by it. Otherwise the prompt re-appends and duplicates while typing.
2331    #[test]
2332    fn append_patch_already_present_ignores_user_prompt_prefix() {
2333        assert!(
2334            append_patch_already_present("expand the section", "❯ expand the section"),
2335            "bare buffer vs prefixed patch must dedup"
2336        );
2337        assert!(
2338            append_patch_already_present("❯ expand the section", "expand the section"),
2339            "prefixed buffer vs bare patch must dedup"
2340        );
2341        assert!(
2342            append_patch_already_present("❯ expand the section", "❯ expand the section"),
2343            "both prefixed must dedup"
2344        );
2345    }
2346
2347    #[test]
2348    fn append_patch_distinct_prompts_not_deduped() {
2349        assert!(
2350            !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2351            "genuinely distinct prompts must not be treated as duplicates"
2352        );
2353    }
2354
2355    // #prompt-duplicated-while-typing (L1 structural buffer-snapshot race),
2356    // captured live in tasks/resume.md 2026-05-29: the synthesized patch held a
2357    // half-typed snapshot ("- [#s93y]: Add to") while the live buffer had been
2358    // typed further ("- [#s93y]: Add to resume"). The two regions are identical
2359    // except that one line; the old `contains` check broke at the divergence and
2360    // re-appended the entire tail. Dedup must recognize the snapshot relationship.
2361    #[test]
2362    fn append_patch_dedupes_midblock_typing_snapshot() {
2363        let live = concat!(
2364            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2365            "- [#bndq]: confirmed\n",
2366            "- do [#s75b]\n",
2367            "- do [#vapk]\n",
2368            "- [#s93y]: Add to resume\n",
2369            "- [#ca8w]\n",
2370            "Add SampleOrders and sample portal to the resume if not already added.",
2371        );
2372        let snapshot = concat!(
2373            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2374            "- [#bndq]: confirmed\n",
2375            "- do [#s75b]\n",
2376            "- do [#vapk]\n",
2377            "- [#s93y]: Add to\n",
2378            "- [#ca8w]\n",
2379            "Add SampleOrders and sample portal to the resume if not already added.",
2380        );
2381        assert!(
2382            append_patch_already_present(live, snapshot),
2383            "earlier keystroke snapshot must dedup against the live, further-typed region"
2384        );
2385    }
2386
2387    #[test]
2388    fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2389        // A genuinely distinct prompt block (not a prefix snapshot) must still
2390        // append — the divergent line is not a prefix of the live line.
2391        let live = "- do task one\n- and the second thing\n- finally";
2392        let distinct = "- do task one\n- and a different thing\n- finally";
2393        assert!(
2394            !append_patch_already_present(live, distinct),
2395            "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2396        );
2397    }
2398
2399    #[test]
2400    fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2401        // The boundary stays high in the exchange; the live region below it has
2402        // been typed past the synthesized patch's snapshot. Appending the stale
2403        // snapshot must be a full no-op (document unchanged), not a duplication.
2404        let doc = concat!(
2405            "<!-- agent:exchange patch=append -->\n",
2406            "### Re: prior — opus-4-8\n\n",
2407            "Answer.\n",
2408            "<!-- agent:boundary:70bccf9b -->\n",
2409            "- [#yfg1]: ok\n",
2410            "- [#s93y]: Add to resume\n",
2411            "- [#ca8w]\n",
2412            "Add SampleOrders and sample portal to the resume if not already added.\n",
2413            "<!-- /agent:exchange -->\n",
2414        );
2415        let components = parse(doc).unwrap();
2416        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2417        let snapshot = concat!(
2418            "- [#yfg1]: ok\n",
2419            "- [#s93y]: Add to\n",
2420            "- [#ca8w]\n",
2421            "Add SampleOrders and sample portal to the resume if not already added.",
2422        );
2423        let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2424        assert_eq!(
2425            result, doc,
2426            "stale typing-snapshot append must be a no-op:\n{result}"
2427        );
2428        assert_eq!(
2429            result.matches("<!-- /agent:exchange -->").count(),
2430            1,
2431            "exchange close marker must not duplicate:\n{result}"
2432        );
2433    }
2434
2435    #[test]
2436    fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2437        // The live buffer already holds the bare typed prompt after the boundary; a
2438        // synthesized exchange patch carries the same prompt with the `❯ ` prefix.
2439        // Appending must be a no-op (single copy), not a duplicate.
2440        let doc = concat!(
2441            "<!-- agent:exchange patch=append -->\n",
2442            "### Re: prior — opus-4-8\n\n",
2443            "Answer.\n",
2444            "<!-- agent:boundary:abc12345 -->\n",
2445            "expand the Confidential AI Startup section\n",
2446            "<!-- /agent:exchange -->\n",
2447        );
2448        let components = parse(doc).unwrap();
2449        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2450        let result =
2451            exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2452        assert_eq!(
2453            result
2454                .matches("expand the Confidential AI Startup section")
2455                .count(),
2456            1,
2457            "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2458        );
2459    }
2460
2461    // --- #dupcontent: structural_corruption_reason ---
2462
2463    const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2464<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2465<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2466<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2467
2468    #[test]
2469    fn structural_corruption_clean_doc_is_sound() {
2470        assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2471    }
2472
2473    #[test]
2474    fn structural_corruption_flags_duplicate_singleton_queue() {
2475        // The live #dupcontent corruption: two agent:queue blocks ingested from
2476        // a bad content_ours CRDT merge.
2477        let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2478<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2479<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2480        let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2481        assert!(
2482            reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2483            "reason was: {reason}"
2484        );
2485    }
2486
2487    #[test]
2488    fn structural_corruption_flags_backlog_alias_duplicate() {
2489        // Legacy `pending` alias + canonical `backlog` collapse to one singleton.
2490        let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2491<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2492        let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2493        assert!(reason.contains("backlog=2"), "reason was: {reason}");
2494    }
2495
2496    #[test]
2497    fn structural_corruption_flags_unterminated_quote_attr() {
2498        // The truncated merge-boundary marker `<!-- agent:queue tasks=" -->`.
2499        let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2500<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2501        let reason =
2502            structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2503        assert!(
2504            reason.starts_with("malformed_attr:queue"),
2505            "reason was: {reason}"
2506        );
2507    }
2508
2509    #[test]
2510    fn structural_corruption_flags_truncated_agent_comment() {
2511        let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue ->\n\
2512<!-- /agent:exchange --\n";
2513        let reason =
2514            structural_corruption_reason(doc).expect("truncated agent marker must be flagged");
2515        assert!(
2516            reason.starts_with("malformed_agent_comment:"),
2517            "reason was: {reason}"
2518        );
2519    }
2520
2521    #[test]
2522    fn structural_corruption_ignores_truncated_agent_comment_inside_code_fence() {
2523        let doc = "```md\n<!-- /agent:queue ->\n<!-- /agent:exchange --\n```\n";
2524        assert_eq!(structural_corruption_reason(doc), None);
2525    }
2526
2527    #[test]
2528    fn structural_corruption_ignores_truncated_agent_comment_inside_quotes() {
2529        let doc = "\"<!-- /agent:queue ->\"\n";
2530        assert_eq!(structural_corruption_reason(doc), None);
2531    }
2532
2533    #[test]
2534    fn structural_corruption_allows_balanced_quoted_attr() {
2535        // A real queue marker carrying a balanced quoted attribute is sound.
2536        let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2537        assert_eq!(structural_corruption_reason(doc), None);
2538    }
2539
2540    #[test]
2541    fn structural_corruption_allows_repeated_non_singleton() {
2542        // `log` is not a singleton; repeats must NOT be flagged.
2543        let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2544<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2545        assert_eq!(structural_corruption_reason(doc), None);
2546    }
2547
2548    #[test]
2549    fn structural_corruption_flags_parse_failure() {
2550        // Unclosed marker → parse error → corrupt.
2551        let doc = "<!-- agent:queue -->\n- a\n";
2552        let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2553        assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2554    }
2555}