Skip to main content

agent_doc_core/
crdt.rs

1//! # Module: crdt
2//!
3//! ## Spec
4//! - `CrdtDoc::from_text(content)`: construct a new Yrs document pre-populated with text.
5//! - `CrdtDoc::to_text()`: extract current UTF-8 text from the document.
6//! - `CrdtDoc::apply_edit(offset, delete_len, insert)`: apply a local delta edit (delete then
7//!   insert at byte offset) within a single transaction.
8//! - `CrdtDoc::encode_state()`: serialize full document state as a v1 update byte vector for
9//!   persistence or cross-doc merge.
10//! - `CrdtDoc::decode_state(bytes)`: deserialize a previously encoded state into a new doc.
11//! - `merge(base_state, ours_text, theirs_text)`: three-way CRDT merge.
12//!   1. Decodes base state (or starts from empty if `None`).
13//!   2. Detects stale base: if base shares <50% common prefix with both sides, replaces base
14//!      with `ours_text` to prevent duplicate insertions.
15//!   3. Advances base to the line-snapped common prefix of ours and theirs when that prefix
16//!      extends beyond the current base — prevents duplication of shared new content.
17//!   4. Computes `similar`-based line diffs from base → ours and base → theirs.
18//!   5. Replays diffs onto two independent Yrs docs (agent=client_id 1, user=client_id 2).
19//!   6. Merges by applying theirs' incremental update into the agent doc.
20//!   7. Runs `dedup_adjacent_blocks` to remove identical adjacent paragraph-level blocks.
21//!   8. Returns the merged string (always conflict-free).
22//! - `dedup_adjacent_blocks(text)`: removes duplicate adjacent blocks (separated by `\n\n`)
23//!   where a block has ≥1 non-empty line, to clean up CRDT double-insertion artifacts.
24//!   Structural blocks (thematic breaks like `---`) are exempt from dedup.
25//! - `compact(state)`: decode then re-encode a CRDT state to GC tombstones.
26//!
27//! ## Agentic Contracts
28//! - `merge` is always conflict-free — it never produces conflict markers.
29//! - Agent content (client_id 1) appears before user content (client_id 2) at identical
30//!   insertion points due to Yrs' deterministic client-ID ordering.
31//! - Short-circuit: if `ours_text == theirs_text`, `merge` returns immediately without any
32//!   CRDT operations.
33//! - Stale base detection prevents duplicate insertions across multiple merge cycles.
34//! - Shared common prefix (line-boundary snapped) is never inserted twice.
35//! - `dedup_adjacent_blocks` removes blocks with ≥1 non-empty line; structural blocks
36//!   (thematic breaks like `---`, `***`, `___`) are exempt to avoid false positives.
37//! - `encode_state` / `decode_state` are inverse operations: round-trip preserves all text.
38//! - `compact` is idempotent: compacting already-compact state is a no-op in terms of text.
39//!
40//! ## Evals
41//! - `roundtrip_text`: `from_text(s).to_text() == s` for arbitrary content.
42//! - `encode_decode_roundtrip`: encode then decode preserves text exactly.
43//! - `apply_edit_insert`: insert at offset 0 prepends correctly.
44//! - `apply_edit_delete`: delete range removes exact byte count.
45//! - `apply_edit_replace`: delete + insert at same offset replaces content.
46//! - `merge_both_append`: both sides add different text → both present, no conflict.
47//! - `merge_agent_ordering`: agent and user insert at same position → agent content first.
48//! - `merge_identical_sides`: ours == theirs → short-circuit, result equals either side.
49//! - `merge_no_base_state`: `None` base → valid merged result, both sides present.
50//! - `merge_stale_base_no_duplicates`: base is stale (< 50% common prefix) → no duplication.
51//! - `merge_shared_prefix_no_duplication`: ours and theirs share new content beyond base →
52//!   shared content appears exactly once.
53//! - `dedup_adjacent_blocks_removes_duplicate`: two identical adjacent multi-line blocks →
54//!   deduplicated to one.
55//! - `dedup_adjacent_blocks_preserves_structural`: structural (thematic break) repeated blocks left intact.
56//! - `dedup_adjacent_blocks_removes_single_line_content`: single-line content duplicates are removed.
57//! - `compact_preserves_text`: compact state → decoded text unchanged.
58
59use anyhow::{Context, Result};
60use yrs::updates::decoder::Decode;
61use yrs::{Doc, GetString, ReadTxn, Text, TextRef, Transact, Update};
62
63const TEXT_KEY: &str = "content";
64
65/// CRDT document wrapping a Yjs `Doc` for conflict-free merging.
66pub struct CrdtDoc {
67    doc: Doc,
68}
69
70impl CrdtDoc {
71    /// Create a new CRDT document initialized with the given text content.
72    pub fn from_text(content: &str) -> Self {
73        let doc = Doc::new();
74        let text = doc.get_or_insert_text(TEXT_KEY);
75        let mut txn = doc.transact_mut();
76        text.insert(&mut txn, 0, content);
77        drop(txn);
78        CrdtDoc { doc }
79    }
80
81    /// Extract the current text content from the CRDT document.
82    pub fn to_text(&self) -> String {
83        let text = self.doc.get_or_insert_text(TEXT_KEY);
84        let txn = self.doc.transact();
85        text.get_string(&txn)
86    }
87
88    /// Apply a local edit: delete `delete_len` chars at `offset`, then insert `insert` there.
89    #[allow(dead_code)] // Used in tests and Phase 4 stream write-back
90    pub fn apply_edit(&self, offset: u32, delete_len: u32, insert: &str) {
91        let text = self.doc.get_or_insert_text(TEXT_KEY);
92        let mut txn = self.doc.transact_mut();
93        if delete_len > 0 {
94            text.remove_range(&mut txn, offset, delete_len);
95        }
96        if !insert.is_empty() {
97            text.insert(&mut txn, offset, insert);
98        }
99    }
100
101    /// Encode the full document state (for persistence).
102    pub fn encode_state(&self) -> Vec<u8> {
103        let txn = self.doc.transact();
104        txn.encode_state_as_update_v1(&yrs::StateVector::default())
105    }
106
107    /// Decode a previously encoded state into a new CrdtDoc.
108    pub fn decode_state(bytes: &[u8]) -> Result<Self> {
109        let doc = Doc::new();
110        let update = Update::decode_v1(bytes)
111            .map_err(|e| anyhow::anyhow!("failed to decode CRDT state: {}", e))?;
112        let mut txn = doc.transact_mut();
113        txn.apply_update(update)
114            .map_err(|e| anyhow::anyhow!("failed to apply CRDT update: {}", e))?;
115        drop(txn);
116        Ok(CrdtDoc { doc })
117    }
118}
119
120/// Merge two concurrent text versions against a common base using CRDT.
121///
122/// Creates three CRDT actors: base, ours, theirs.
123/// Applies each side's edits as diffs from the base, then merges updates.
124/// Returns the merged text (conflict-free).
125///
126/// **Stale base detection:** If the CRDT base text doesn't match either ours
127/// or theirs as a prefix/substring, the base is stale. In that case, we use
128/// `ours_text` as the base to prevent duplicate insertions.
129pub fn merge(base_state: Option<&[u8]>, ours_text: &str, theirs_text: &str) -> Result<String> {
130    merge_inner(base_state, ours_text, theirs_text, None)
131}
132
133/// Op-capture / evented-reflection merge (`#qnodemerge4`).
134///
135/// Same contract as [`merge`], but when `theirs_editor_ops` carries the
136/// editor's *real* operations (captured from `DocumentListener.documentChanged`
137/// / `onDidChangeTextDocument` as insert@offset / delete-range), the `theirs`
138/// side of the merge is built by replaying those exact ops onto the CRDT
139/// instead of reconstructing them from a Myers text diff.
140///
141/// A Myers diff picks *a* minimal edit script, not necessarily the one the user
142/// actually performed — so two same-region edits can be mis-attributed and
143/// duplicate. The captured ops are exact and intention-preserving.
144///
145/// **Safety gate (the acceptance invariant "ops replay equals editor-observed
146/// state"):** the captured ops are used *only* if replaying them onto the
147/// resolved merge base reproduces `theirs_text` byte-for-byte. If the ops were
148/// captured against a different base (stale / advanced base, missed event),
149/// replay will not match and the merge transparently falls back to the
150/// diff-guess — never worse than today.
151pub fn merge_with_editor_ops(
152    base_state: Option<&[u8]>,
153    ours_text: &str,
154    theirs_text: &str,
155    theirs_editor_ops: Option<&[EditorOp]>,
156) -> Result<String> {
157    merge_inner(base_state, ours_text, theirs_text, theirs_editor_ops)
158}
159
160fn merge_inner(
161    base_state: Option<&[u8]>,
162    ours_text: &str,
163    theirs_text: &str,
164    theirs_editor_ops: Option<&[EditorOp]>,
165) -> Result<String> {
166    // Short-circuit: if both sides are identical, no merge needed
167    if ours_text == theirs_text {
168        eprintln!("[crdt] ours == theirs, skipping merge");
169        return Ok(ours_text.to_string());
170    }
171
172    // Bootstrap base doc from state or empty
173    let base_doc = if let Some(bytes) = base_state {
174        CrdtDoc::decode_state(bytes).context("failed to decode base CRDT state")?
175    } else {
176        CrdtDoc::from_text("")
177    };
178    let mut base_text = base_doc.to_text();
179
180    // `#qcellmerge1` op-capture rung (default ON; kill-switch via
181    // `AGENT_DOC_CELL_MERGE=0`). When the cell-merge master gate is enabled (the
182    // production default) AND the live (theirs) side carries real captured editor
183    // ops, route each op to the cell whose span contains it and replay it there,
184    // then run the per-cell 3-way join. Ops are absolute offsets against the
185    // merge base, so this happens BEFORE any stale-base / prefix-advancement
186    // mutation of `base_text`. The op-routed join delegates to `merge_3way`, so
187    // it inherits this branch's disjoint-edit `op_level_merge` and
188    // conflict-surfacing. On any boundary-crossing / framing op (or stale ops)
189    // the cell path signals `fell_back` and we drop through to the existing merge
190    // unchanged — where the text-diff `op_level_merge` serves as the degraded
191    // fallback. Master-gate explicitly OFF (`=0`) or ops-absent is byte-identical
192    // to the legacy path.
193    if let Some(ops) = theirs_editor_ops
194        && !ops.is_empty()
195        && crate::cell_doc::cell_merge_enabled()
196    {
197        let outcome = crate::cell_doc::merge_3way_with_ops(&base_text, ours_text, theirs_text, ops);
198        if !outcome.fell_back {
199            if !outcome.conflicts.is_empty() {
200                eprintln!(
201                    "[crdt] cell_merge(op-routed): {} conflict(s) surfaced (policy=ours-wins)",
202                    outcome.conflicts.len()
203                );
204            }
205            eprintln!(
206                "[crdt] cell_merge(op-routed): {} captured op(s) routed to cells and replayed",
207                ops.len()
208            );
209            return Ok(outcome.merged_text);
210        }
211        eprintln!(
212            "[crdt] cell_merge(op-routed): fell back to legacy merge_inner path (boundary-crossing/framing/stale ops)"
213        );
214    }
215
216    eprintln!(
217        "[crdt] merge: base_len={} ours_len={} theirs_len={}",
218        base_text.len(),
219        ours_text.len(),
220        theirs_text.len()
221    );
222
223    // Capture committed assistant response headings from the *original* base,
224    // before stale-base detection / prefix advancement can rewrite `base_text`.
225    // Committed `### Re:` blocks are append-only history; a stale or divergent
226    // `theirs` must not be able to delete them out of the merged result.
227    // (#ipc-crdt-response-drift)
228    let committed_response_headings = response_headings(&base_text);
229
230    // Stale base detection: if the base text doesn't share enough content
231    // with both sides, it's stale. Use ours as the base instead.
232    // This prevents duplicate insertions when both sides contain text
233    // that the stale base doesn't have.
234    //
235    // We check both common prefix AND common suffix. Template documents
236    // have structural content (frontmatter, component markers, pending
237    // sections) that bookend the exchange. When the exchange is short or
238    // empty, only checking the prefix classifies a correct base as stale
239    // (the suffix — closing markers + pending — goes uncounted).
240    let ours_prefix = common_prefix_len(&base_text, ours_text);
241    let ours_suffix = common_suffix_len(&base_text, ours_text);
242    let theirs_prefix = common_prefix_len(&base_text, theirs_text);
243    let theirs_suffix = common_suffix_len(&base_text, theirs_text);
244    let base_len = base_text.len();
245
246    // Clamp shared bytes to base_len (prefix + suffix can overlap for short strings)
247    let ours_shared = (ours_prefix + ours_suffix).min(base_len);
248    let theirs_shared = (theirs_prefix + theirs_suffix).min(base_len);
249
250    if base_len > 0
251        && (ours_shared as f64 / base_len as f64) < 0.5
252        && (theirs_shared as f64 / base_len as f64) < 0.5
253    {
254        eprintln!(
255            "[crdt] Stale CRDT base detected (shared: ours={}%, theirs={}%). Using ours as base.",
256            (ours_shared * 100) / base_len,
257            (theirs_shared * 100) / base_len
258        );
259        base_text = ours_text.to_string();
260    }
261
262    // Advance base to the common prefix of ours and theirs when it extends
263    // beyond the point where the base diverges from either side.
264    //
265    // When both ours and theirs independently added the same text beyond the
266    // base (e.g., both contain a user prompt that the base doesn't have),
267    // the CRDT treats each insertion as independent and includes both, causing
268    // duplication. Fix: use the common prefix of ours and theirs as the effective
269    // base, so shared additions are not treated as independent insertions.
270    //
271    // The divergence point (`base_diverge`) is where the base stops matching
272    // either side. Content shared by ours and theirs beyond this point was
273    // added by both sides independently and must be promoted into the base.
274    //
275    // This handles two patterns:
276    //   Pattern 1 (mutual prefix > base length):
277    //     base   = "old content"
278    //     ours   = "old content + user prompt + agent response"
279    //     theirs = "old content + user prompt + small edit"
280    //   Pattern 2 (base has trailing structure beyond divergence):
281    //     base   = "header + empty exchange + footer"
282    //     ours   = "header + prompt + response + footer"
283    //     theirs = "header + prompt + boundary + footer"
284    //   Both diverge from base at "header", but share "prompt" beyond it.
285    let mutual_prefix = common_prefix_len(ours_text, theirs_text);
286    let base_diverge =
287        common_prefix_len(&base_text, ours_text).max(common_prefix_len(&base_text, theirs_text));
288    if mutual_prefix > base_diverge {
289        // Snap to a line boundary to avoid splitting mid-line/mid-word.
290        // Without this, the shared prefix can include partial formatting
291        // sequences (e.g., a leading `*` from `**bold**`), causing the
292        // CRDT merge to separate that character from the rest of the
293        // formatting, producing garbled text like `*Soft-bristle brush only**`
294        // instead of `**Soft-bristle brush only**`.
295        let snap = &ours_text[..mutual_prefix];
296        let snapped = match snap.rfind('\n') {
297            Some(pos) if pos >= base_diverge => pos + 1,
298            _ => base_diverge, // no suitable line boundary — don't advance
299        };
300        if snapped > base_diverge {
301            eprintln!(
302                "[crdt] Advancing base to shared prefix (diverge={} → {})",
303                base_diverge, snapped
304            );
305            base_text = ours_text[..snapped].to_string();
306        }
307    }
308
309    // Op-capture gate (#qnodemerge4): prefer the editor's real ops for `theirs`
310    // when replaying them onto the resolved base reproduces `theirs_text`
311    // exactly. Otherwise (no ops, or ops captured against a divergent base) fall
312    // back to the diff-guess. Validated against the *resolved* `base_text` so a
313    // stale/advanced base correctly disqualifies the captured ops.
314    let theirs_replay_ops = match theirs_editor_ops {
315        // Conservative offset-safety guard: feed real ops to Yrs only when the
316        // base and theirs are ASCII. The op byte-offsets match `apply_ops`'s
317        // byte-cursor convention, but Yrs index/offset semantics for non-ASCII
318        // text are not asserted here, so unicode regions fall back to the
319        // diff-guess (no regression). Per-component merge (#qnodemerge3) means
320        // an emoji in `queue` never disables op-replay for ASCII `exchange`
321        // prose. (#qnodemerge4)
322        Some(ops) if !ops.is_empty() && base_text.is_ascii() && theirs_text.is_ascii() => {
323            match replay_editor_ops(&base_text, ops) {
324                Some(replayed) if replayed == theirs_text => {
325                    eprintln!(
326                        "[crdt] editor_ops_replayed: {} captured op(s) reproduce theirs exactly (#qnodemerge4)",
327                        ops.len()
328                    );
329                    Some(ops)
330                }
331                _ => {
332                    eprintln!(
333                        "[crdt] editor_ops_fallback: {} captured op(s) do not replay to theirs from the resolved base (stale/misaligned) — using diff-guess (#qnodemerge4)",
334                        ops.len()
335                    );
336                    None
337                }
338            }
339        }
340        _ => None,
341    };
342
343    // Compute diffs from base to each side
344    let ours_ops = compute_edit_ops(&base_text, ours_text);
345    let theirs_ops = compute_edit_ops(&base_text, theirs_text);
346
347    // Create two independent docs from the base state.
348    // If base was overridden (stale detection), rebuild from the new base_text.
349    let base_encoded = if base_text == base_doc.to_text() {
350        base_doc.encode_state()
351    } else {
352        CrdtDoc::from_text(&base_text).encode_state()
353    };
354
355    // Agent gets lower client ID (1) so Yrs natively places agent content
356    // BEFORE human content when both insert at the same position.
357    // Yrs orders concurrent inserts by client ID: lower client ID goes first.
358    let ours_doc = Doc::with_client_id(1);
359    {
360        let update =
361            Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
362        let mut txn = ours_doc.transact_mut();
363        txn.apply_update(update)
364            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
365    }
366
367    let theirs_doc = Doc::with_client_id(2);
368    {
369        let update =
370            Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
371        let mut txn = theirs_doc.transact_mut();
372        txn.apply_update(update)
373            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
374    }
375
376    // Apply ours edits
377    {
378        let text = ours_doc.get_or_insert_text(TEXT_KEY);
379        let mut txn = ours_doc.transact_mut();
380        apply_ops(&text, &mut txn, &ours_ops);
381    }
382
383    // Apply theirs edits — from the editor's real ops when the gate passed,
384    // otherwise from the diff-guess (#qnodemerge4).
385    {
386        let text = theirs_doc.get_or_insert_text(TEXT_KEY);
387        let mut txn = theirs_doc.transact_mut();
388        match theirs_replay_ops {
389            Some(ops) => apply_editor_ops(&text, &mut txn, ops),
390            None => apply_ops(&text, &mut txn, &theirs_ops),
391        }
392    }
393
394    // Merge: apply theirs' changes into ours
395    let ours_sv = {
396        let txn = ours_doc.transact();
397        txn.state_vector()
398    };
399    let theirs_update = {
400        let txn = theirs_doc.transact();
401        txn.encode_state_as_update_v1(&ours_sv)
402    };
403    {
404        let update = Update::decode_v1(&theirs_update)
405            .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
406        let mut txn = ours_doc.transact_mut();
407        txn.apply_update(update)
408            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
409    }
410
411    // Read merged result. With agent/ours=client_id(1) and theirs=client_id(2)
412    // (see the client-id assignment above), Yrs natively orders concurrent
413    // inserts at the same position by ascending client id, so agent content
414    // lands before foreign content at the append boundary. No post-merge
415    // reorder needed.
416    let merged = {
417        let text = ours_doc.get_or_insert_text(TEXT_KEY);
418        let txn = ours_doc.transact();
419        text.get_string(&txn)
420    };
421
422    // Committed-response protection (#ipc-crdt-response-drift): a stale or
423    // divergent `theirs` (e.g. a concurrent foreign supervisor, or a stale
424    // editor live-buffer pushed by convergence) can present a document that has
425    // *lost* a `### Re:` response block which was already committed (present in
426    // `base`) and is still present in `ours`. The line-diff merge would honor
427    // that foreign deletion and strip the committed response from the result
428    // ("### Re: header missing" / prior response hoisted away). Foreign writers
429    // must not delete committed exchange history.
430    let merged =
431        guard_committed_responses(merged, &committed_response_headings, ours_text, theirs_text);
432
433    // Post-merge dedup: remove identical adjacent blocks (#15)
434    Ok(dedup_adjacent_blocks(&merged))
435}
436
437/// One node of the per-component document segmentation (`#qcellmerge1`).
438///
439/// A document is decomposed into an alternating sequence of interstitial text
440/// (preamble, gaps between components, trailing residue) and `<!-- agent:* -->`
441/// component regions. Each node merges against its own base independently so
442/// unrelated nodes (e.g. `exchange` vs `queue`) never contend and content can
443/// never splice across components.
444#[derive(Debug, Clone)]
445enum Node {
446    /// Free text outside any component marker (always present between/around
447    /// components, possibly empty, to keep ours/theirs node sequences aligned).
448    Interstitial(String),
449    /// A full `<!-- agent:name -->...<!-- /agent:name -->` region, markers included.
450    Component { name: String, text: String },
451}
452
453impl Node {
454    fn component_name(&self) -> Option<&str> {
455        match self {
456            Node::Component { name, .. } => Some(name.as_str()),
457            Node::Interstitial(_) => None,
458        }
459    }
460
461    fn text(&self) -> &str {
462        match self {
463            Node::Interstitial(t) => t,
464            Node::Component { text, .. } => text,
465        }
466    }
467}
468
469/// Segment `text` into top-level nodes: `I0 C0 I1 C1 … C(n-1) In`.
470///
471/// Interstitial slots are always emitted (even when empty) so two documents
472/// with the same component-name sequence produce structurally-aligned node
473/// vectors that can be paired by index.
474fn segment_into_nodes(text: &str) -> Result<Vec<Node>> {
475    let comps = crate::component::parse(text)?;
476    // Top-level components only: a component is top-level if it is not nested
477    // inside any other component's span.
478    let mut top: Vec<&crate::component::Component> = comps
479        .iter()
480        .filter(|c| {
481            !comps.iter().any(|o| {
482                !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
483            })
484        })
485        .collect();
486    top.sort_by_key(|c| c.open_start);
487
488    let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
489    let mut cursor = 0usize;
490    for c in top {
491        // Interstitial slot before this component (possibly empty).
492        nodes.push(Node::Interstitial(text[cursor..c.open_start].to_string()));
493        nodes.push(Node::Component {
494            name: c.name.clone(),
495            text: text[c.open_start..c.close_end].to_string(),
496        });
497        cursor = c.close_end;
498    }
499    // Trailing interstitial slot (possibly empty).
500    nodes.push(Node::Interstitial(text[cursor..].to_string()));
501    Ok(nodes)
502}
503
504/// Shared `#qcellmerge1` cell-merge seam used by both the text path
505/// ([`merge_by_component`]) and the persistence path
506/// ([`MultiNodeState::merge`]) so the two entry points compute byte-identical
507/// merged text for the same inputs under the flag.
508///
509/// Returns `Some(merged_text)` when the per-cell 3-way merge succeeds (the
510/// caller should return that text and, on the persistence path, rebuild its
511/// advanced state from the SAME text). Returns `None` on `fell_back`, so the
512/// caller runs its existing legacy merge path unchanged. The caller is
513/// responsible for gating this behind [`cell_doc::cell_merge_enabled`].
514fn try_cell_merge_text(
515    base_text: &str,
516    ours_text: &str,
517    theirs_text: &str,
518    site: &str,
519) -> Option<String> {
520    let outcome = crate::cell_doc::merge_3way(base_text, ours_text, theirs_text);
521    if outcome.fell_back {
522        eprintln!("[crdt] cell_merge: fell back to legacy {site} path");
523        return None;
524    }
525    if !outcome.conflicts.is_empty() {
526        eprintln!(
527            "[crdt] cell_merge: {} conflict(s) surfaced (policy=ours-wins)",
528            outcome.conflicts.len()
529        );
530    }
531    Some(outcome.merged_text)
532}
533
534/// Component-scoped three-way CRDT merge (`#qcellmerge1`) — the anti-corruption rung.
535///
536/// Instead of reconciling the whole document as one text blob (which lets
537/// unrelated edits contend and content splice across components — e.g. agent
538/// console output merged INTO `agent:queue`), this splits ours/theirs/base into
539/// per-component nodes and runs [`merge`] on each node against its own base.
540///
541/// - Nodes are paired by their component-name sequence. Component base text is
542///   resolved by name (components are unique per document in practice), so the
543///   `exchange` committed-response guard inside [`merge`] still sees its real base.
544/// - **Structural divergence** — when ours and theirs disagree on which
545///   components exist (a component added/removed on one side) — falls back to the
546///   whole-document [`merge`] for the entire document, logged, never silently.
547/// - A document with no `agent:*` components (inline mode) delegates directly to
548///   [`merge`] with the original base state, preserving legacy behavior exactly.
549pub fn merge_by_component(
550    base_state: Option<&[u8]>,
551    ours_text: &str,
552    theirs_text: &str,
553) -> Result<String> {
554    if ours_text == theirs_text {
555        return Ok(ours_text.to_string());
556    }
557
558    // `#qcellmerge1` cutover seam (default ON, env kill-switch). Unless
559    // `AGENT_DOC_CELL_MERGE` is explicitly falsy, attempt the lazily-`reconcile`-
560    // based per-cell 3-way merge first. It signals `fell_back` for any structural
561    // divergence; on fallback (or the explicit kill-switch) the existing
562    // whole-doc / per-node path below runs unchanged.
563    if crate::cell_doc::cell_merge_enabled() {
564        let base_text = match base_state {
565            Some(bytes) => CrdtDoc::decode_state(bytes)
566                .map(|d| d.to_text())
567                .unwrap_or_default(),
568            None => String::new(),
569        };
570        if let Some(merged) =
571            try_cell_merge_text(&base_text, ours_text, theirs_text, "merge_by_component")
572        {
573            return Ok(merged);
574        }
575    }
576
577    let ours_nodes = match segment_into_nodes(ours_text) {
578        Ok(c) => c,
579        Err(e) => {
580            eprintln!(
581                "[crdt] merge_by_component: failed to segment ours ({e}); falling back to whole-doc merge"
582            );
583            return merge(base_state, ours_text, theirs_text);
584        }
585    };
586    let theirs_nodes = match segment_into_nodes(theirs_text) {
587        Ok(c) => c,
588        Err(e) => {
589            eprintln!(
590                "[crdt] merge_by_component: failed to segment theirs ({e}); falling back to whole-doc merge"
591            );
592            return merge(base_state, ours_text, theirs_text);
593        }
594    };
595
596    let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
597    let theirs_names: Vec<&str> = theirs_nodes
598        .iter()
599        .filter_map(Node::component_name)
600        .collect();
601
602    // No components on either side (inline-mode doc): preserve exact legacy
603    // behavior by delegating to the whole-doc merge with the original state.
604    if ours_names.is_empty() && theirs_names.is_empty() {
605        return merge(base_state, ours_text, theirs_text);
606    }
607
608    // Structural divergence: the set/order of components differs. A per-node
609    // pairing is unsound, so fall back to the whole-doc merge (logged).
610    if ours_names != theirs_names {
611        eprintln!(
612            "[crdt] merge_by_component: structural divergence (ours components {ours_names:?} != theirs {theirs_names:?}); falling back to whole-doc merge"
613        );
614        return merge(base_state, ours_text, theirs_text);
615    }
616
617    // Resolve base text and a name→content map for per-node base alignment.
618    let base_text = match base_state {
619        Some(bytes) => match CrdtDoc::decode_state(bytes) {
620            Ok(doc) => doc.to_text(),
621            Err(e) => {
622                eprintln!(
623                    "[crdt] merge_by_component: failed to decode base state ({e}); merging nodes without a base"
624                );
625                String::new()
626            }
627        },
628        None => String::new(),
629    };
630    let base_nodes = segment_into_nodes(&base_text).unwrap_or_default();
631    let mut base_by_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
632    for node in &base_nodes {
633        if let Node::Component { name, text } = node {
634            base_by_name.entry(name.as_str()).or_insert(text.as_str());
635        }
636    }
637    // Interstitial base slots, paired positionally with ours/theirs interstitials.
638    let base_interstitials: Vec<&str> = base_nodes
639        .iter()
640        .filter_map(|c| match c {
641            Node::Interstitial(t) => Some(t.as_str()),
642            Node::Component { .. } => None,
643        })
644        .collect();
645
646    let merged_nodes = merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
647        Some(n) => base_by_name.get(n).map(|s| s.to_string()),
648        None => base_interstitials.get(idx).map(|s| s.to_string()),
649    })?;
650    let merged: String = merged_nodes.into_iter().map(|(_, text)| text).collect();
651
652    // Structural safety net (#hap7): a per-node leaf merge can, for some shapes
653    // (e.g. a compacted-exchange body whose prose mentions the component markers,
654    // or an interstitial carrying a stray heading), reassemble into a document
655    // whose top-level component framing no longer round-trips — a duplicated or
656    // dropped `<!-- /agent:NAME -->` close, or a changed component-name sequence.
657    // That malformed shape would then trip the downstream exchange-tail / boundary
658    // repair. Rather than emit it, fall back to the proven whole-doc `merge`
659    // (logged, never silent) — the same "never worse than today" contract the
660    // op-capture gate and the structural-divergence branch above already honor.
661    let merged_names: Vec<String> = match segment_into_nodes(&merged) {
662        Ok(nodes) => nodes
663            .iter()
664            .filter_map(|n| n.component_name().map(str::to_string))
665            .collect(),
666        Err(e) => {
667            eprintln!(
668                "[crdt] merge_by_component: merged result failed to re-segment ({e}); falling back to whole-doc merge"
669            );
670            return merge(base_state, ours_text, theirs_text);
671        }
672    };
673    let names_match = merged_names.len() == ours_names.len()
674        && merged_names
675            .iter()
676            .zip(ours_names.iter())
677            .all(|(m, o)| m.as_str() == *o);
678    if !names_match {
679        eprintln!(
680            "[crdt] merge_by_component: merged component framing diverged (ours {ours_names:?} != merged {merged_names:?}); falling back to whole-doc merge"
681        );
682        return merge(base_state, ours_text, theirs_text);
683    }
684
685    Ok(merged)
686}
687
688/// Merge aligned `ours`/`theirs` node vectors (same component-name sequence),
689/// resolving each node's base text via `resolve_base`. `resolve_base` is called
690/// with `(component_name, interstitial_index)` — `name` is `Some` for a
691/// component node (and `interstitial_index` is meaningless), `None` for an
692/// interstitial node (paired positionally by `interstitial_index`). Returns the
693/// merged `(component_name, text)` per node in document order.
694///
695/// This is the shared per-node reconciliation loop behind both
696/// [`merge_by_component`] (base resolved from a decoded whole-doc state) and
697/// [`MultiNodeState::merge`] (base resolved from per-node persisted states).
698fn merge_aligned_nodes<F>(
699    ours_nodes: &[Node],
700    theirs_nodes: &[Node],
701    mut resolve_base: F,
702) -> Result<Vec<(Option<String>, String)>>
703where
704    F: FnMut(Option<&str>, usize) -> Option<String>,
705{
706    let mut merged = Vec::with_capacity(ours_nodes.len());
707    let mut interstitial_idx = 0usize;
708    for (ours_node, theirs_node) in ours_nodes.iter().zip(theirs_nodes.iter()) {
709        let name = ours_node.component_name();
710        let node_base = resolve_base(name, interstitial_idx);
711        if name.is_none() {
712            interstitial_idx += 1;
713        }
714
715        let ours_slice = ours_node.text();
716        let theirs_slice = theirs_node.text();
717
718        let merged_text = if ours_slice == theirs_slice {
719            ours_slice.to_string()
720        } else if let Some(component_name) = name
721            && let Some(merged) = reconcile_component(
722                component_name,
723                node_base.as_deref(),
724                ours_slice,
725                theirs_slice,
726            )
727        {
728            // Phase 3 (#qnodemerge3): recursive keyed reconciliation drilled
729            // *inside* the component, so two edits to different child nodes (queue
730            // items, `### Re:` blocks) reconcile in separate sub-trees and never
731            // contend. Falls back to the flat whole-component `merge` below when
732            // the component has no keyed child structure or keys are ambiguous.
733            merged
734        } else {
735            let node_base_state = node_base
736                .as_deref()
737                .map(|t| CrdtDoc::from_text(t).encode_state());
738            merge(node_base_state.as_deref(), ours_slice, theirs_slice)?
739        };
740        merged.push((name.map(str::to_string), merged_text));
741    }
742    Ok(merged)
743}
744
745// ---------------------------------------------------------------------------
746// Phase 3 (#qnodemerge3): recursive AST-node keyed reconciliation.
747//
748// The Phase 1/2 layers reconcile a document into top-level nodes (components +
749// interstitials) and run the leaf text `merge` on each whole component. Phase 3
750// drills the *same* keyed reconciliation one level deeper, *inside* a component
751// whose body is a sequence of keyed children:
752//   - `queue`/`backlog`/`review`/`done`: each `- …` list item is a child, keyed
753//     by its durable `#id` (the strike paths' identity) or normalized text.
754//   - `exchange`: each `### Re:` response block is a child, keyed by its heading.
755// Children are matched by key (React-VDOM style): a key present on both sides
756// reconciles against its own base child via the leaf `merge`; a key on only one
757// side is a pure insert (kept) or delete (honored only on a clean
758// delete-vs-unchanged, never for committed `exchange` blocks). Two edits that
759// touch different keyed children therefore can never contend, and an in-progress
760// edit to one queue item cannot stall or drift another.
761// ---------------------------------------------------------------------------
762
763/// Reserved key for the leading text inside a component body before its first
764/// keyed child (e.g. the blank line / preamble before the first list item or
765/// `### Re:` block). Matched positionally-by-key like any other child.
766pub(crate) const PREAMBLE_KEY: &str = "\u{0}::preamble";
767
768/// True for component kinds whose body is a markdown list of keyed items
769/// (`queue`/`backlog`/`review`/`done`), reconciled per item in Phase 3.
770pub(crate) fn is_list_component(name: &str) -> bool {
771    matches!(name, "queue" | "backlog" | "review" | "done")
772}
773
774/// One keyed child within a component body. `text` is the exact source slice so
775/// that `children.iter().map(|c| &c.text).collect::<String>() == body` (lossless
776/// segmentation); `key` is the child's durable identity within the component.
777pub(crate) struct KeyedChild {
778    pub(crate) key: String,
779    pub(crate) text: String,
780}
781
782/// First `[#id]` token in `s` (e.g. `do [#qnodemerge3]` → `qnodemerge3`), the
783/// durable identity the queue/backlog strike paths key off. `None` for a
784/// free-text child with no id.
785fn first_hash_id(s: &str) -> Option<String> {
786    let bytes = s.as_bytes();
787    let mut i = 0;
788    while i + 1 < bytes.len() {
789        // `[` and `#` are ASCII, so byte indexing stays on char boundaries.
790        if bytes[i] == b'[' && bytes[i + 1] == b'#' {
791            let rest = &s[i + 2..];
792            if let Some(close) = rest.find(']') {
793                let id = &rest[..close];
794                if !id.is_empty()
795                    && id
796                        .chars()
797                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
798                {
799                    return Some(id.to_string());
800                }
801            }
802        }
803        i += 1;
804    }
805    None
806}
807
808/// Normalize a list item's identity line for free-text keying: drop the list
809/// marker, checkbox, strike markers, and pin glyphs, and collapse whitespace, so
810/// a struck/edited item still keys to the same child.
811fn normalize_item_text(item: &str) -> String {
812    let first = item.lines().next().unwrap_or("").trim();
813    let mut s = first.strip_prefix("- ").unwrap_or(first).trim_start();
814    for cb in ["[ ]", "[x]", "[X]", "[/]"] {
815        if let Some(r) = s.strip_prefix(cb) {
816            s = r.trim_start();
817            break;
818        }
819    }
820    s.replace("~~", "")
821        .replace(":pushpin:", "")
822        .replace('📌', "")
823        .split_whitespace()
824        .collect::<Vec<_>>()
825        .join(" ")
826}
827
828/// Durable key for a list item: its `#id` when present (stable across strike /
829/// text edits), else its normalized text.
830fn list_item_key(item: &str) -> String {
831    match first_hash_id(item) {
832        Some(id) => format!("id:{id}"),
833        None => format!("txt:{}", normalize_item_text(item)),
834    }
835}
836
837/// Durable key for an `### Re:` exchange block: its heading line minus the
838/// working-tree-only ` (HEAD)` annotation.
839fn exchange_heading_key(block: &str) -> String {
840    let heading = block.lines().next().unwrap_or("").trim();
841    heading
842        .strip_suffix(" (HEAD)")
843        .unwrap_or(heading)
844        .trim_end()
845        .to_string()
846}
847
848/// Split a markdown-list component body into keyed children, one per `- …` item
849/// (continuation lines attach to the preceding item; leading text becomes a
850/// [`PREAMBLE_KEY`] child). Returns `None` when the body has no list item, so
851/// the caller falls back to the flat whole-component merge.
852pub(crate) fn split_list_children(body: &str) -> Option<Vec<KeyedChild>> {
853    let mut starts = Vec::new();
854    let mut pos = 0usize;
855    for line in body.split_inclusive('\n') {
856        let trimmed = line.trim_start();
857        if trimmed.starts_with("- ") || trimmed.trim_end() == "-" {
858            starts.push(pos);
859        }
860        pos += line.len();
861    }
862    if starts.is_empty() {
863        return None;
864    }
865    Some(segment_by_starts(body, &starts, list_item_key))
866}
867
868/// Split an `exchange` component body into keyed children, one per `### Re:`
869/// block (leading text becomes a [`PREAMBLE_KEY`] child). `None` when there is
870/// no `### Re:` heading.
871pub(crate) fn split_exchange_children(body: &str) -> Option<Vec<KeyedChild>> {
872    let mut starts = Vec::new();
873    let mut pos = 0usize;
874    for line in body.split_inclusive('\n') {
875        if line.trim_start().starts_with("### Re:") {
876            starts.push(pos);
877        }
878        pos += line.len();
879    }
880    if starts.is_empty() {
881        return None;
882    }
883    Some(segment_by_starts(body, &starts, exchange_heading_key))
884}
885
886/// Build lossless keyed children from child-start byte offsets: a preamble child
887/// for any leading text, then one child per `[start, next_start)` slice keyed by
888/// `key_of`.
889fn segment_by_starts(body: &str, starts: &[usize], key_of: fn(&str) -> String) -> Vec<KeyedChild> {
890    let mut children = Vec::with_capacity(starts.len() + 1);
891    if starts[0] > 0 {
892        children.push(KeyedChild {
893            key: PREAMBLE_KEY.to_string(),
894            text: body[..starts[0]].to_string(),
895        });
896    }
897    for (i, &s) in starts.iter().enumerate() {
898        let e = starts.get(i + 1).copied().unwrap_or(body.len());
899        let slice = &body[s..e];
900        children.push(KeyedChild {
901            key: key_of(slice),
902            text: slice.to_string(),
903        });
904    }
905    children
906}
907
908/// True when every child key is unique. Keyed reconciliation is only sound with
909/// unique keys; a duplicate (e.g. two identical free-text queue items) makes the
910/// caller fall back to the flat whole-component merge.
911fn keys_unique(children: &[KeyedChild]) -> bool {
912    let mut seen = std::collections::HashSet::new();
913    children.iter().all(|c| seen.insert(c.key.as_str()))
914}
915
916/// Frame a single-component node `text` into `(open_marker, body, close_marker)`
917/// where `body` is the content between the markers. `None` when the text does
918/// not parse as a component (caller falls back to flat merge).
919fn component_framing(text: &str) -> Option<(&str, &str, &str)> {
920    let comps = crate::component::parse(text).ok()?;
921    let comp = comps.iter().min_by_key(|c| c.open_start)?;
922    Some((
923        &text[..comp.open_end],
924        &text[comp.open_end..comp.close_start],
925        &text[comp.close_start..],
926    ))
927}
928
929/// Recursive keyed reconciliation of one component (`#qnodemerge3`). Splits the
930/// component body into keyed children, three-way reconciles them by key, and
931/// reassembles the body inside ours' marker framing. Returns `None` to signal
932/// "fall back to the flat whole-component `merge`" — for an unsplittable
933/// component, malformed framing, or ambiguous (duplicate) keys.
934fn reconcile_component(
935    name: &str,
936    base_text: Option<&str>,
937    ours_text: &str,
938    theirs_text: &str,
939) -> Option<String> {
940    if name != "exchange" && !is_list_component(name) {
941        return None;
942    }
943    let (ours_open, ours_body, ours_close) = component_framing(ours_text)?;
944    let (theirs_open, theirs_body, theirs_close) = component_framing(theirs_text)?;
945    let base_framing = base_text.and_then(component_framing);
946    let base_body = base_framing.map(|(_, b, _)| b);
947
948    let merged_body = reconcile_component_body(name, base_body, ours_body, theirs_body)?;
949    // `#qmarkerauth`: the component marker (`<!-- agent:name attrs -->` and its
950    // closing counterpart) carries OPERATOR-authored configuration — priority /
951    // preset / `go` / queue activation and any custom attributes. It is
952    // operator-owned, so reconcile it operator-authoritatively: an operator
953    // attribute edit on `theirs` (the live editor / disk side) must never be
954    // reverted to `ours`' marker. Previously this unconditionally re-framed with
955    // `ours_open`/`ours_close`, so adding an attribute to `agent:queue` was silently
956    // deleted on the next merge. Mirrors frontmatter field-wise authority
957    // (`#fmreset`) and the list-order theirs-spine (`#qauthorder`).
958    let merged_open = reconcile_marker_operator_authoritative(
959        base_framing.map(|(o, _, _)| o),
960        ours_open,
961        theirs_open,
962    );
963    let merged_close = reconcile_marker_operator_authoritative(
964        base_framing.map(|(_, _, c)| c),
965        ours_close,
966        theirs_close,
967    );
968    Some(format!("{merged_open}{merged_body}{merged_close}"))
969}
970
971/// Reconcile one component marker line operator-authoritatively (`#qmarkerauth`).
972///
973/// The marker (`<!-- agent:name attrs -->` / `<!-- /agent:name -->`) is
974/// operator-authored configuration, so `theirs` (the live editor / disk side) wins
975/// over `ours` (the agent / snapshot side) whenever the operator changed it; an
976/// agent-side change is honored only when the operator left the marker untouched
977/// relative to base. With no base, `theirs` (operator) is authoritative. This is
978/// the marker analogue of the frontmatter field-wise merge (`#fmreset`): an
979/// operator edit is never reverted by the CRDT/snapshot path.
980fn reconcile_marker_operator_authoritative<'a>(
981    base: Option<&str>,
982    ours: &'a str,
983    theirs: &'a str,
984) -> &'a str {
985    if ours == theirs {
986        return ours;
987    }
988    match base {
989        // Operator changed the marker relative to base → operator edit wins.
990        Some(b) if theirs != b => theirs,
991        // Operator left it as base; only the agent changed it → agent change wins.
992        Some(_) => ours,
993        // No base epoch: the operator/editor side is authoritative for markers.
994        None => theirs,
995    }
996}
997
998/// Three-way reconcile a component body's keyed children. `None` falls back to
999/// the flat whole-component merge.
1000fn reconcile_component_body(
1001    name: &str,
1002    base_body: Option<&str>,
1003    ours_body: &str,
1004    theirs_body: &str,
1005) -> Option<String> {
1006    let split = |b: &str| -> Option<Vec<KeyedChild>> {
1007        if name == "exchange" {
1008            split_exchange_children(b)
1009        } else {
1010            split_list_children(b)
1011        }
1012    };
1013    let ours_children = split(ours_body)?;
1014    let theirs_children = split(theirs_body)?;
1015    let base_children = base_body.and_then(split).unwrap_or_default();
1016
1017    if !keys_unique(&ours_children)
1018        || !keys_unique(&theirs_children)
1019        || !keys_unique(&base_children)
1020    {
1021        return None;
1022    }
1023
1024    let ours_map: std::collections::HashMap<&str, &KeyedChild> =
1025        ours_children.iter().map(|c| (c.key.as_str(), c)).collect();
1026    let theirs_map: std::collections::HashMap<&str, &KeyedChild> = theirs_children
1027        .iter()
1028        .map(|c| (c.key.as_str(), c))
1029        .collect();
1030    let base_map: std::collections::HashMap<&str, &KeyedChild> =
1031        base_children.iter().map(|c| (c.key.as_str(), c)).collect();
1032
1033    let ours_keys: Vec<&str> = ours_children.iter().map(|c| c.key.as_str()).collect();
1034    let theirs_keys: Vec<&str> = theirs_children.iter().map(|c| c.key.as_str()).collect();
1035    // Spine selection (#hap7/#qauthorder): for `exchange`, `ours` (the agent side)
1036    // carries the append-only committed-response history, so it stays the order
1037    // spine. For the operator-ordered list components (queue/backlog/review/done),
1038    // `theirs` — the live editor / disk side — carries the operator-authored order,
1039    // which is authoritative; using it as the spine lets an operator reorder (e.g.
1040    // moving an interspersed priority prompt to the queue tail) survive the merge
1041    // instead of being reverted to the snapshot order. Agent-only keys weave in
1042    // after their nearest preceding placed key either way.
1043    let order = if name == "exchange" {
1044        order_union(&ours_keys, &theirs_keys)
1045    } else {
1046        order_union(&theirs_keys, &ours_keys)
1047    };
1048
1049    // `exchange` is append-only committed history: a `### Re:` block present in
1050    // the base must never be dropped by a stale/divergent side (the
1051    // #ipc-crdt-response-drift guard, applied here per block).
1052    let protect_deletes = name == "exchange";
1053
1054    // `#queuestatemachine2`/`#qheadresidue`: the operator-ordered list components
1055    // carry per-item strike lifecycle. Route each matched item through the per-item
1056    // lifecycle JOIN so a stale persisted-CRDT side cannot un-strike (resurrect) a
1057    // head that another side already retired. `exchange` blocks are not list items
1058    // and keep the leaf merge.
1059    let lifecycle_governed = is_list_component(name);
1060
1061    let mut out = String::new();
1062    for key in &order {
1063        let in_o = ours_map.get(key.as_str());
1064        let in_t = theirs_map.get(key.as_str());
1065        let in_b = base_map.get(key.as_str());
1066        let resolved: Option<String> = match (in_o, in_t) {
1067            (Some(o), Some(t)) => {
1068                if o.text == t.text {
1069                    Some(o.text.clone())
1070                } else if lifecycle_governed {
1071                    // Drive the matched item to its lawful state via the per-item
1072                    // lifecycle lattice (`Live < Struck`), exactly as the
1073                    // orchestration `transition_queue_item` join specifies. The
1074                    // highest-ranked side's rendered text governs, so a stale
1075                    // un-strike from the persisted `.yrs` base/agent side can never
1076                    // resurrect a head another side already struck.
1077                    Some(reconcile_list_item_lifecycle(o, t, in_b.copied()))
1078                } else {
1079                    // Matched-but-different child: leaf text merge against its
1080                    // own base child. A leaf merge error falls the whole
1081                    // component back to the flat merge (returns None).
1082                    let base_state = in_b.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1083                    Some(merge(base_state.as_deref(), &o.text, &t.text).ok()?)
1084                }
1085            }
1086            // Present only in ours: insert by ours, or delete by theirs. For list
1087            // components, theirs is the live/operator side, so its delete is
1088            // authoritative even when ours concurrently updated or struck the item.
1089            (Some(o), None) => match in_b {
1090                None => Some(o.text.clone()),
1091                Some(_) if lifecycle_governed => None,
1092                Some(b) if protect_deletes || o.text != b.text => Some(o.text.clone()),
1093                Some(_) => None,
1094            },
1095            // Present only in theirs: insert by theirs, or delete by ours.
1096            (None, Some(t)) => match in_b {
1097                None => Some(t.text.clone()),
1098                Some(b) if protect_deletes || t.text != b.text => Some(t.text.clone()),
1099                Some(_) => None,
1100            },
1101            (None, None) => None,
1102        };
1103        if let Some(text) = resolved {
1104            out.push_str(&text);
1105        }
1106    }
1107    Some(out)
1108}
1109
1110/// Reconcile a matched-but-different list item through the per-item lifecycle
1111/// lattice (`#queuestatemachine2`/`#qheadresidue`).
1112///
1113/// `ours`/`theirs` are the two live sides; `base` is the merge base child (the
1114/// persisted-CRDT view), if any. Their *visible* lifecycle states
1115/// ([`QueueItemLifecycle`]) are joined (`Live < Struck`): the merged item takes the
1116/// rendered text of the side whose lifecycle equals the join, so a stale un-strike
1117/// can never resurrect a head another side already struck.
1118///
1119/// Tie-break within the same lifecycle rank (e.g. both `Live` but differing text —
1120/// an operator finishing a free-text line while the agent appends) preserves the
1121/// historical behavior by falling back to the Yrs leaf merge against the base, so
1122/// genuine concurrent edits at the same lifecycle level still reconcile losslessly.
1123fn reconcile_list_item_lifecycle(
1124    ours: &KeyedChild,
1125    theirs: &KeyedChild,
1126    base: Option<&KeyedChild>,
1127) -> String {
1128    use crate::queue_item_lifecycle::QueueItemLifecycle;
1129
1130    // The preamble child (leading text before the first list item) carries no
1131    // item lifecycle — reconcile it as a plain leaf merge.
1132    if ours.key == PREAMBLE_KEY {
1133        let base_state = base.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1134        return merge(base_state.as_deref(), &ours.text, &theirs.text)
1135            .unwrap_or_else(|_| ours.text.clone());
1136    }
1137
1138    let ours_state = QueueItemLifecycle::classify(&ours.text);
1139    let theirs_state = QueueItemLifecycle::classify(&theirs.text);
1140    let joined = ours_state.join(theirs_state);
1141
1142    match (ours_state == joined, theirs_state == joined) {
1143        // Only one side is at the lawful (joined) lifecycle — that side's rendered
1144        // text governs. This is the anti-resurrection rung: a struck side beats a
1145        // stale live side regardless of which input carried it.
1146        (true, false) => ours.text.clone(),
1147        (false, true) => theirs.text.clone(),
1148        // Both sides share the lawful lifecycle level (both Live or both Struck)
1149        // but differ in text — a genuine concurrent edit at the same level. Fall
1150        // back to the leaf merge against the base so the edit reconciles losslessly.
1151        _ => {
1152            let base_state = base.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1153            merge(base_state.as_deref(), &ours.text, &theirs.text)
1154                .unwrap_or_else(|_| ours.text.clone())
1155        }
1156    }
1157}
1158
1159/// Merge two ordered key sequences into a supersequence: ours' order is the
1160/// spine, and each theirs-only key is woven in immediately after its nearest
1161/// preceding theirs key that is already placed (or at the front). Deterministic
1162/// and order-preserving for the common append/insert-on-one-side cases.
1163fn order_union(ours_keys: &[&str], theirs_keys: &[&str]) -> Vec<String> {
1164    let ours_set: std::collections::HashSet<&str> = ours_keys.iter().copied().collect();
1165    let mut result: Vec<String> = ours_keys.iter().map(|s| s.to_string()).collect();
1166    for (i, &k) in theirs_keys.iter().enumerate() {
1167        if ours_set.contains(k) || result.iter().any(|r| r == k) {
1168            continue;
1169        }
1170        let mut insert_pos = 0usize;
1171        for j in (0..i).rev() {
1172            if let Some(p) = result.iter().position(|r| r == theirs_keys[j]) {
1173                insert_pos = p + 1;
1174                break;
1175            }
1176        }
1177        result.insert(insert_pos, k.to_string());
1178    }
1179    result
1180}
1181
1182/// Encode `text` as a Yrs state with a **fixed** client id, so identical text
1183/// always produces byte-identical state. [`CrdtDoc::from_text`] uses
1184/// `Doc::new()`, which assigns a random client id, making every re-encode of the
1185/// same text differ — unsuitable for stable per-node persistence and no-change
1186/// detection. The base client id is irrelevant to [`merge`] (which reads only the
1187/// base text), so a fixed id is safe here.
1188fn encode_text_deterministic(text: &str) -> Vec<u8> {
1189    let doc = Doc::with_client_id(0);
1190    let yrs_text = doc.get_or_insert_text(TEXT_KEY);
1191    {
1192        let mut txn = doc.transact_mut();
1193        yrs_text.insert(&mut txn, 0, text);
1194    }
1195    let txn = doc.transact();
1196    txn.encode_state_as_update_v1(&yrs::StateVector::default())
1197}
1198
1199/// Magic prefix identifying a [`MultiNodeState`] container on disk, so a per-node
1200/// state file (`<hash>.nodes.yrs`) is unambiguously distinguishable from a legacy
1201/// whole-doc Yrs update blob (`<hash>.yrs`), whose first bytes are a Yrs v1
1202/// update header rather than this ASCII tag.
1203const MULTINODE_MAGIC: &[u8; 4] = b"ADN1";
1204
1205/// A document's CRDT merge state split into one independent Yrs state per
1206/// top-level node — the durable per-node base of `#qnodemerge2`.
1207///
1208/// The legacy whole-doc `<hash>.yrs` blob reconciles every node against one
1209/// shared base, so a node's base can never advance independently and unrelated
1210/// edits share a single Yrs clock. `MultiNodeState` instead persists one Yrs
1211/// state per node (each `agent:*` component plus the interstitial text around
1212/// them) in document order, serialized into a single structured container file
1213/// (`<hash>.nodes.yrs`). Each node therefore carries its own stable base across
1214/// cycles: a node untouched this cycle re-encodes to byte-identical state while a
1215/// changed node's state advances on its own, with no cross-node contention.
1216#[derive(Debug, Clone, Default, PartialEq, Eq)]
1217pub struct MultiNodeState {
1218    nodes: Vec<PersistedNode>,
1219}
1220
1221/// One persisted node within a [`MultiNodeState`], in document order.
1222#[derive(Debug, Clone, PartialEq, Eq)]
1223struct PersistedNode {
1224    /// `Some(name)` for an `agent:*` component node; `None` for interstitial text.
1225    name: Option<String>,
1226    /// This node's own independent Yrs state (a [`CrdtDoc`] encoded state).
1227    state: Vec<u8>,
1228}
1229
1230impl MultiNodeState {
1231    /// Build a per-node state by segmenting `text` into top-level nodes and
1232    /// encoding each node's text as its own independent Yrs state.
1233    pub fn from_text(text: &str) -> Result<Self> {
1234        let nodes = segment_into_nodes(text)?
1235            .iter()
1236            .map(|node| PersistedNode {
1237                name: node.component_name().map(str::to_string),
1238                state: encode_text_deterministic(node.text()),
1239            })
1240            .collect();
1241        Ok(MultiNodeState { nodes })
1242    }
1243
1244    /// Build a per-node state directly from already-merged `(name, text)` nodes.
1245    fn from_merged_nodes(merged: &[(Option<String>, String)]) -> Self {
1246        MultiNodeState {
1247            nodes: merged
1248                .iter()
1249                .map(|(name, text)| PersistedNode {
1250                    name: name.clone(),
1251                    state: encode_text_deterministic(text),
1252                })
1253                .collect(),
1254        }
1255    }
1256
1257    /// Decode a single node's persisted Yrs state back to its text.
1258    fn node_text(node: &PersistedNode) -> Result<String> {
1259        Ok(CrdtDoc::decode_state(&node.state)?.to_text())
1260    }
1261
1262    /// Reconstruct the full document text by concatenating node texts in order.
1263    pub fn to_text(&self) -> Result<String> {
1264        let mut text = String::new();
1265        for node in &self.nodes {
1266            text.push_str(&Self::node_text(node)?);
1267        }
1268        Ok(text)
1269    }
1270
1271    /// Resolve this state's per-node base into a `name → text` map (components)
1272    /// and an ordered list of interstitial texts, mirroring the alignment
1273    /// [`merge_aligned_nodes`] expects.
1274    fn base_lookup(&self) -> Result<(std::collections::HashMap<String, String>, Vec<String>)> {
1275        let mut by_name = std::collections::HashMap::new();
1276        let mut interstitials = Vec::new();
1277        for node in &self.nodes {
1278            let text = Self::node_text(node)?;
1279            match &node.name {
1280                Some(name) => {
1281                    by_name.entry(name.clone()).or_insert(text);
1282                }
1283                None => interstitials.push(text),
1284            }
1285        }
1286        Ok((by_name, interstitials))
1287    }
1288
1289    /// Serialize into the `<hash>.nodes.yrs` container format:
1290    /// `MAGIC(4) | version(1) | node_count(u32 LE)` then per node
1291    /// `name_len(u32 LE) | name bytes | state_len(u32 LE) | state bytes`,
1292    /// where `name_len == u32::MAX` marks an interstitial node (no name).
1293    pub fn encode(&self) -> Vec<u8> {
1294        let mut out = Vec::new();
1295        out.extend_from_slice(MULTINODE_MAGIC);
1296        out.push(1u8); // container version
1297        out.extend_from_slice(&(self.nodes.len() as u32).to_le_bytes());
1298        for node in &self.nodes {
1299            match &node.name {
1300                Some(name) => {
1301                    let bytes = name.as_bytes();
1302                    out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1303                    out.extend_from_slice(bytes);
1304                }
1305                None => out.extend_from_slice(&u32::MAX.to_le_bytes()),
1306            }
1307            out.extend_from_slice(&(node.state.len() as u32).to_le_bytes());
1308            out.extend_from_slice(&node.state);
1309        }
1310        out
1311    }
1312
1313    /// Decode a `<hash>.nodes.yrs` container. Returns an error (never a panic) on
1314    /// a missing magic tag or any truncation, so callers can fall back to
1315    /// migration via [`MultiNodeState::decode_or_migrate`].
1316    pub fn decode(bytes: &[u8]) -> Result<Self> {
1317        let mut cursor = 0usize;
1318        let take = |cursor: &mut usize, n: usize| -> Result<&[u8]> {
1319            let end = cursor
1320                .checked_add(n)
1321                .filter(|end| *end <= bytes.len())
1322                .ok_or_else(|| anyhow::anyhow!("multinode state truncated"))?;
1323            let slice = &bytes[*cursor..end];
1324            *cursor = end;
1325            Ok(slice)
1326        };
1327        let read_u32 = |cursor: &mut usize| -> Result<u32> {
1328            let b = take(cursor, 4)?;
1329            Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1330        };
1331
1332        if take(&mut cursor, 4)? != MULTINODE_MAGIC {
1333            anyhow::bail!("not a multinode state container (magic mismatch)");
1334        }
1335        let version = take(&mut cursor, 1)?[0];
1336        if version != 1 {
1337            anyhow::bail!("unsupported multinode state version {version}");
1338        }
1339        let count = read_u32(&mut cursor)? as usize;
1340        let mut nodes = Vec::with_capacity(count);
1341        for _ in 0..count {
1342            let name_len = read_u32(&mut cursor)?;
1343            let name = if name_len == u32::MAX {
1344                None
1345            } else {
1346                let bytes = take(&mut cursor, name_len as usize)?;
1347                Some(
1348                    std::str::from_utf8(bytes)
1349                        .map_err(|e| anyhow::anyhow!("invalid node name UTF-8: {e}"))?
1350                        .to_string(),
1351                )
1352            };
1353            let state_len = read_u32(&mut cursor)? as usize;
1354            let state = take(&mut cursor, state_len)?.to_vec();
1355            nodes.push(PersistedNode { name, state });
1356        }
1357        Ok(MultiNodeState { nodes })
1358    }
1359
1360    /// Decode `bytes` as a per-node container, migrating older formats:
1361    /// 1. A `<hash>.nodes.yrs` container decodes directly.
1362    /// 2. Otherwise treat `bytes` as a legacy whole-doc `<hash>.yrs` Yrs state,
1363    ///    decode it to text, and split into per-node states.
1364    /// 3. If neither works, fall back to `fallback_text`.
1365    ///
1366    /// Never panics; logs the migration path it took.
1367    pub fn decode_or_migrate(bytes: &[u8], fallback_text: &str) -> Self {
1368        match Self::decode(bytes) {
1369            Ok(state) => return state,
1370            Err(_) => {
1371                // Not a per-node container — try the legacy whole-doc blob.
1372                if let Ok(doc) = CrdtDoc::decode_state(bytes)
1373                    && let Ok(state) = Self::from_text(&doc.to_text())
1374                {
1375                    eprintln!(
1376                        "[crdt] MultiNodeState: migrated legacy whole-doc state into per-node state"
1377                    );
1378                    return state;
1379                }
1380            }
1381        }
1382        eprintln!(
1383            "[crdt] MultiNodeState: state unreadable; rebuilding per-node state from fallback text"
1384        );
1385        Self::from_text(fallback_text).unwrap_or_default()
1386    }
1387
1388    /// Per-node component-scoped three-way merge (`#qnodemerge2`).
1389    ///
1390    /// Like [`merge_by_component`] but each node reconciles against **its own
1391    /// persisted base** (carried in `base`) rather than a slice of one decoded
1392    /// whole-doc base, and the merged result is returned as a fresh
1393    /// `MultiNodeState` where each node's base has advanced independently. An
1394    /// unchanged node re-encodes to byte-identical state.
1395    ///
1396    /// Falls back to the whole-document [`merge`] (against `base`'s projected
1397    /// text) on structural divergence or a component-less document, preserving
1398    /// the same safety net as [`merge_by_component`].
1399    pub fn merge(
1400        base: Option<&MultiNodeState>,
1401        ours_text: &str,
1402        theirs_text: &str,
1403    ) -> Result<(String, MultiNodeState)> {
1404        let whole_doc_fallback = |reason: &str| -> Result<(String, MultiNodeState)> {
1405            let base_text = match base {
1406                Some(b) => b.to_text().unwrap_or_default(),
1407                None => String::new(),
1408            };
1409            let base_state = base.map(|_| CrdtDoc::from_text(&base_text).encode_state());
1410            if !reason.is_empty() {
1411                eprintln!(
1412                    "[crdt] MultiNodeState::merge: {reason}; falling back to whole-doc merge"
1413                );
1414            }
1415            let merged = merge(base_state.as_deref(), ours_text, theirs_text)?;
1416            let state = MultiNodeState::from_text(&merged)?;
1417            Ok((merged, state))
1418        };
1419
1420        if ours_text == theirs_text {
1421            return Ok((ours_text.to_string(), MultiNodeState::from_text(ours_text)?));
1422        }
1423
1424        // `#qcellmerge1` persistence-path seam (default ON, env kill-switch).
1425        // Unless the flag is explicitly falsy, route through the SAME per-cell
1426        // merge as the text path ([`merge_by_component`]) and rebuild the
1427        // persisted per-node state from the cell-merged text via
1428        // [`MultiNodeState::from_text`]. This keeps the returned text and the
1429        // persisted base round-trip consistent (the persisted base reflects the
1430        // cell-merge winner, not the legacy per-node winner). On `fell_back` (or
1431        // the explicit kill-switch) the existing per-node path below runs
1432        // byte-identically to legacy behavior.
1433        if crate::cell_doc::cell_merge_enabled() {
1434            let base_text = match base {
1435                Some(b) => b.to_text().unwrap_or_default(),
1436                None => String::new(),
1437            };
1438            if let Some(merged) =
1439                try_cell_merge_text(&base_text, ours_text, theirs_text, "MultiNodeState::merge")
1440            {
1441                let state = MultiNodeState::from_text(&merged)?;
1442                return Ok((merged, state));
1443            }
1444        }
1445
1446        let ours_nodes = match segment_into_nodes(ours_text) {
1447            Ok(c) => c,
1448            Err(e) => return whole_doc_fallback(&format!("failed to segment ours ({e})")),
1449        };
1450        let theirs_nodes = match segment_into_nodes(theirs_text) {
1451            Ok(c) => c,
1452            Err(e) => return whole_doc_fallback(&format!("failed to segment theirs ({e})")),
1453        };
1454
1455        let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
1456        let theirs_names: Vec<&str> = theirs_nodes
1457            .iter()
1458            .filter_map(Node::component_name)
1459            .collect();
1460
1461        if ours_names.is_empty() && theirs_names.is_empty() {
1462            return whole_doc_fallback("");
1463        }
1464        if ours_names != theirs_names {
1465            return whole_doc_fallback(&format!(
1466                "structural divergence (ours {ours_names:?} != theirs {theirs_names:?})"
1467            ));
1468        }
1469
1470        let (base_by_name, base_interstitials) = match base {
1471            Some(b) => b.base_lookup()?,
1472            None => (std::collections::HashMap::new(), Vec::new()),
1473        };
1474
1475        let merged_nodes =
1476            merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
1477                Some(n) => base_by_name.get(n).cloned(),
1478                None => base_interstitials.get(idx).cloned(),
1479            })?;
1480
1481        let merged_text: String = merged_nodes.iter().map(|(_, text)| text.as_str()).collect();
1482        let new_state = MultiNodeState::from_merged_nodes(&merged_nodes);
1483        Ok((merged_text, new_state))
1484    }
1485}
1486
1487/// True for transient/structural lines that must not count as "new content"
1488/// introduced by `theirs` (boundary markers carry per-cycle ids and `(HEAD)`
1489/// annotations are working-tree-only artifacts).
1490fn is_transient_merge_line(line: &str) -> bool {
1491    let t = line.trim();
1492    t.starts_with("<!-- agent:boundary:")
1493}
1494
1495/// Collect normalized `### Re:` heading lines from a document, ignoring the
1496/// working-tree-only ` (HEAD)` annotation and surrounding whitespace. Matches
1497/// how the rest of the binary treats committed response headings.
1498fn response_headings(text: &str) -> Vec<String> {
1499    text.lines()
1500        .map(str::trim)
1501        .filter(|l| l.starts_with("### Re:"))
1502        .map(|l| {
1503            l.strip_suffix(" (HEAD)")
1504                .unwrap_or(l)
1505                .trim_end()
1506                .to_string()
1507        })
1508        .collect()
1509}
1510
1511/// Restore committed response history when a stale/divergent `theirs` deleted
1512/// it. If the merge dropped a `### Re:` heading that exists in both `base` and
1513/// `ours`, and `theirs` contributed no new non-transient content beyond what
1514/// `ours` already has (i.e. `theirs` is a stale subset that only *lost*
1515/// committed content), the foreign view is stale — return `ours` so the
1516/// committed response is preserved. If `theirs` did introduce genuinely new
1517/// content, keep the merged result (never drop real user input) but log a
1518/// forensic warning. (#ipc-crdt-response-drift)
1519fn guard_committed_responses(
1520    merged: String,
1521    committed_response_headings: &[String],
1522    ours_text: &str,
1523    theirs_text: &str,
1524) -> String {
1525    if committed_response_headings.is_empty() {
1526        return merged;
1527    }
1528    let ours_headings = response_headings(ours_text);
1529    let merged_headings = response_headings(&merged);
1530    let lost: Vec<&String> = committed_response_headings
1531        .iter()
1532        .filter(|h| ours_headings.contains(h) && !merged_headings.contains(h))
1533        .collect();
1534    if lost.is_empty() {
1535        return merged;
1536    }
1537
1538    // Does `theirs` introduce any new non-empty, non-transient line that `ours`
1539    // does not already contain? If so, it carries real user input we must keep.
1540    let ours_lines: std::collections::HashSet<&str> = ours_text
1541        .lines()
1542        .map(str::trim)
1543        .filter(|l| !l.is_empty())
1544        .collect();
1545    let theirs_adds_new = theirs_text
1546        .lines()
1547        .map(str::trim)
1548        .any(|l| !l.is_empty() && !is_transient_merge_line(l) && !ours_lines.contains(l));
1549
1550    if theirs_adds_new {
1551        eprintln!(
1552            "[crdt] WARNING: merge dropped committed response heading(s) {:?} while theirs added new content; keeping merge (#ipc-crdt-response-drift)",
1553            lost
1554        );
1555        merged
1556    } else {
1557        eprintln!(
1558            "[crdt] stale theirs dropped committed response heading(s) {:?}; preferring ours (#ipc-crdt-response-drift)",
1559            lost
1560        );
1561        ours_text.to_string()
1562    }
1563}
1564
1565/// Remove identical adjacent text blocks separated by blank lines.
1566///
1567/// After a CRDT merge, both sides may independently append the same content
1568/// (e.g., a `### Re:` section), resulting in duplicate adjacent blocks.
1569/// This pass deduplicates blocks with ≥1 non-empty line, exempting structural
1570/// blocks (thematic breaks like `---`) that may legitimately repeat.
1571pub fn dedup_adjacent_blocks(text: &str) -> String {
1572    let blocks: Vec<&str> = text.split("\n\n").collect();
1573    if blocks.len() < 2 {
1574        return text.to_string();
1575    }
1576
1577    let mut result: Vec<&str> = Vec::with_capacity(blocks.len());
1578    for block in &blocks {
1579        let trimmed = block.trim();
1580        let non_empty_lines = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
1581        if non_empty_lines >= 1
1582            && !is_structural_block(trimmed)
1583            && let Some(prev) = result.last()
1584            && prev.trim() == trimmed
1585        {
1586            eprintln!(
1587                "[crdt] dedup: removed duplicate block ({} lines)",
1588                non_empty_lines
1589            );
1590            continue;
1591        }
1592        result.push(*block);
1593    }
1594
1595    result.join("\n\n")
1596}
1597
1598/// Returns true for blocks that may legitimately repeat adjacent to themselves.
1599/// Currently covers thematic break markers (`---`, `***`, `___`).
1600fn is_structural_block(trimmed: &str) -> bool {
1601    let mut lines = trimmed.lines();
1602    let first = match lines.next() {
1603        Some(l) => l.trim(),
1604        None => return false,
1605    };
1606    if lines.next().is_some() {
1607        return false;
1608    }
1609    first.len() >= 3 && first.chars().all(|c| c == '-' || c == '*' || c == '_')
1610}
1611
1612/// Compact a CRDT state by re-encoding (GC tombstones where possible).
1613pub fn compact(state: &[u8]) -> Result<Vec<u8>> {
1614    let doc = CrdtDoc::decode_state(state)?;
1615    Ok(doc.encode_state())
1616}
1617
1618/// Count the number of bytes in the common prefix of two strings.
1619fn common_prefix_len(a: &str, b: &str) -> usize {
1620    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
1621}
1622
1623/// Count the number of bytes in the common suffix of two strings.
1624fn common_suffix_len(a: &str, b: &str) -> usize {
1625    a.bytes()
1626        .rev()
1627        .zip(b.bytes().rev())
1628        .take_while(|(x, y)| x == y)
1629        .count()
1630}
1631
1632/// Edit operation for replaying diffs onto a CRDT text.
1633#[derive(Debug)]
1634enum EditOp {
1635    Retain(u32),
1636    Delete(u32),
1637    Insert(String),
1638}
1639
1640/// Compute edit operations to transform `from` into `to` using `similar` diff.
1641fn compute_edit_ops(from: &str, to: &str) -> Vec<EditOp> {
1642    use similar::{ChangeTag, TextDiff};
1643
1644    let diff = TextDiff::from_lines(from, to);
1645    let mut ops = Vec::new();
1646
1647    for change in diff.iter_all_changes() {
1648        match change.tag() {
1649            ChangeTag::Equal => {
1650                let len = change.value().len() as u32;
1651                if let Some(EditOp::Retain(n)) = ops.last_mut() {
1652                    *n += len;
1653                } else {
1654                    ops.push(EditOp::Retain(len));
1655                }
1656            }
1657            ChangeTag::Delete => {
1658                let len = change.value().len() as u32;
1659                if let Some(EditOp::Delete(n)) = ops.last_mut() {
1660                    *n += len;
1661                } else {
1662                    ops.push(EditOp::Delete(len));
1663                }
1664            }
1665            ChangeTag::Insert => {
1666                let s = change.value().to_string();
1667                if let Some(EditOp::Insert(existing)) = ops.last_mut() {
1668                    existing.push_str(&s);
1669                } else {
1670                    ops.push(EditOp::Insert(s));
1671                }
1672            }
1673        }
1674    }
1675
1676    ops
1677}
1678
1679/// A real editor operation captured from the editor's change event
1680/// (`DocumentListener.documentChanged` / `onDidChangeTextDocument`),
1681/// expressed as an absolute-offset mutation in **byte** units — consistent
1682/// with the byte-offset cursor convention used throughout this module's Yrs
1683/// apply path ([`apply_ops`]).
1684///
1685/// A replacement (the editor reports an old fragment + a new fragment at the
1686/// same offset) is captured as a [`EditorOp::Delete`] of the old length
1687/// followed by an [`EditorOp::Insert`] of the new text at the same offset; the
1688/// reporter is responsible for that split so replay stays a flat, ordered
1689/// sequence. Ops are recorded and replayed in the order the editor performed
1690/// them, so each op's offset is absolute against the document state *after* all
1691/// prior ops in the sequence (exactly how the editor's own events behave).
1692#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1693#[serde(tag = "kind", rename_all = "lowercase")]
1694pub enum EditorOp {
1695    /// Insert `text` at byte `offset`.
1696    Insert { offset: usize, text: String },
1697    /// Delete `len` bytes starting at byte `offset`.
1698    Delete { offset: usize, len: usize },
1699}
1700
1701/// Replay captured editor ops onto `base`, reconstructing the editor's final
1702/// text. Ops apply in sequence with each offset absolute against the running
1703/// buffer (matching the editor's own event semantics).
1704///
1705/// Returns `None` if any op is out of bounds or would land on a non-UTF-8
1706/// char boundary — the caller treats `None` as "these ops are stale or
1707/// misaligned, fall back to the text diff" (the `#qnodemerge4` safety gate).
1708/// Returning the reconstructed text lets the merge assert it equals the
1709/// editor-observed `theirs` before trusting the ops.
1710pub fn replay_editor_ops(base: &str, ops: &[EditorOp]) -> Option<String> {
1711    let mut buf = base.to_string();
1712    for op in ops {
1713        match op {
1714            EditorOp::Insert { offset, text } => {
1715                if *offset > buf.len() || !buf.is_char_boundary(*offset) {
1716                    return None;
1717                }
1718                buf.insert_str(*offset, text);
1719            }
1720            EditorOp::Delete { offset, len } => {
1721                let end = offset.checked_add(*len)?;
1722                if end > buf.len() || !buf.is_char_boundary(*offset) || !buf.is_char_boundary(end) {
1723                    return None;
1724                }
1725                buf.replace_range(*offset..end, "");
1726            }
1727        }
1728    }
1729    Some(buf)
1730}
1731
1732/// Apply captured editor ops directly to a Yrs text type, in order, as
1733/// absolute-offset mutations (insert@offset / remove len@offset). This feeds
1734/// the editor's *real* operation sequence into the CRDT so a concurrent agent
1735/// edit merges against the user's actual edit boundaries rather than against a
1736/// Myers-diff reconstruction (`#qnodemerge4`). Offsets are byte offsets,
1737/// consistent with [`apply_ops`]. Callers must validate the ops with
1738/// [`replay_editor_ops`] first; this assumes in-bounds, char-aligned offsets.
1739fn apply_editor_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditorOp]) {
1740    for op in ops {
1741        match op {
1742            EditorOp::Insert { offset, text: s } => {
1743                text.insert(txn, *offset as u32, s);
1744            }
1745            EditorOp::Delete { offset, len } => {
1746                text.remove_range(txn, *offset as u32, *len as u32);
1747            }
1748        }
1749    }
1750}
1751
1752/// Apply edit operations to a Yrs text type within a transaction.
1753fn apply_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditOp]) {
1754    let mut cursor: u32 = 0;
1755    for op in ops {
1756        match op {
1757            EditOp::Retain(n) => cursor += n,
1758            EditOp::Delete(n) => {
1759                text.remove_range(txn, cursor, *n);
1760                // cursor stays — content shifted left
1761            }
1762            EditOp::Insert(s) => {
1763                text.insert(txn, cursor, s);
1764                cursor += s.len() as u32;
1765            }
1766        }
1767    }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use super::*;
1773
1774    #[test]
1775    fn roundtrip_text() {
1776        let content = "Hello, world!\nLine two.\n";
1777        let doc = CrdtDoc::from_text(content);
1778        assert_eq!(doc.to_text(), content);
1779    }
1780
1781    // ---- #qnodemerge4: op-capture / evented reflection ----
1782
1783    #[test]
1784    fn replay_editor_ops_reconstructs_insert() {
1785        let base = "hello world\n";
1786        // user typed "!" after "hello"
1787        let ops = vec![EditorOp::Insert {
1788            offset: 5,
1789            text: "!".to_string(),
1790        }];
1791        assert_eq!(
1792            replay_editor_ops(base, &ops).as_deref(),
1793            Some("hello! world\n")
1794        );
1795    }
1796
1797    #[test]
1798    fn replay_editor_ops_reconstructs_delete_and_replace_sequence() {
1799        let base = "- do [#foo]\n";
1800        // user renamed #foo -> #foobar: delete "foo" then insert "foobar"
1801        let ops = vec![
1802            EditorOp::Delete { offset: 7, len: 3 },
1803            EditorOp::Insert {
1804                offset: 7,
1805                text: "foobar".to_string(),
1806            },
1807        ];
1808        assert_eq!(
1809            replay_editor_ops(base, &ops).as_deref(),
1810            Some("- do [#foobar]\n")
1811        );
1812    }
1813
1814    #[test]
1815    fn replay_editor_ops_sequential_offsets_are_post_prior_op() {
1816        // Each op's offset is absolute against the running buffer, like the
1817        // editor's own events: insert "X" at 0, then "Y" at 2 (after "Xa").
1818        let base = "ab";
1819        let ops = vec![
1820            EditorOp::Insert {
1821                offset: 0,
1822                text: "X".to_string(),
1823            },
1824            EditorOp::Insert {
1825                offset: 2,
1826                text: "Y".to_string(),
1827            },
1828        ];
1829        assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some("XaYb"));
1830    }
1831
1832    #[test]
1833    fn replay_editor_ops_out_of_bounds_is_none() {
1834        let base = "short";
1835        let insert = vec![EditorOp::Insert {
1836            offset: 99,
1837            text: "x".to_string(),
1838        }];
1839        assert_eq!(replay_editor_ops(base, &insert), None);
1840        let delete = vec![EditorOp::Delete { offset: 3, len: 99 }];
1841        assert_eq!(replay_editor_ops(base, &delete), None);
1842    }
1843
1844    #[test]
1845    fn replay_editor_ops_non_char_boundary_is_none() {
1846        // "é" is two bytes (0xC3 0xA9); offset 1 splits it.
1847        let base = "é";
1848        let ops = vec![EditorOp::Insert {
1849            offset: 1,
1850            text: "x".to_string(),
1851        }];
1852        assert_eq!(replay_editor_ops(base, &ops), None);
1853    }
1854
1855    #[test]
1856    fn editor_op_json_roundtrip() {
1857        let ops = vec![
1858            EditorOp::Insert {
1859                offset: 3,
1860                text: "hi".to_string(),
1861            },
1862            EditorOp::Delete { offset: 0, len: 2 },
1863        ];
1864        let json = serde_json::to_string(&ops).unwrap();
1865        let back: Vec<EditorOp> = serde_json::from_str(&json).unwrap();
1866        assert_eq!(ops, back);
1867    }
1868
1869    #[test]
1870    fn merge_with_editor_ops_gate_passes_and_preserves_user_edit() {
1871        // base: a queue item the user renames while the agent appends a response
1872        // to a different region. Editor op for theirs is exact.
1873        let base = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\n";
1874        let ours = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\nAGENT RESPONSE\n";
1875        let theirs = "<!-- agent:queue -->\n- do [#foobar]\n<!-- /agent:queue -->\n";
1876        let ops = vec![
1877            EditorOp::Delete { offset: 28, len: 3 },
1878            EditorOp::Insert {
1879                offset: 28,
1880                text: "foobar".to_string(),
1881            },
1882        ];
1883        // Verify the ops actually reconstruct theirs (the gate input).
1884        assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some(theirs));
1885
1886        let base_state = CrdtDoc::from_text(base).encode_state();
1887        let merged = merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&ops)).unwrap();
1888        // Both edits present, renamed item, no duplication.
1889        assert!(merged.contains("- do [#foobar]"), "merged: {merged}");
1890        assert!(merged.contains("AGENT RESPONSE"), "merged: {merged}");
1891        assert!(
1892            !merged.contains("- do [#foo]\n"),
1893            "stale item leaked: {merged}"
1894        );
1895        assert_eq!(
1896            merged.matches("do [#foobar]").count(),
1897            1,
1898            "duplicated: {merged}"
1899        );
1900    }
1901
1902    #[test]
1903    fn merge_with_editor_ops_stale_ops_fall_back_to_diff() {
1904        // Ops captured against a DIFFERENT base than the resolved merge base:
1905        // replay won't equal theirs, so the merge must fall back to the diff
1906        // path and still produce the same result as plain `merge`.
1907        let base = "Line A\nLine B\n";
1908        let ours = "Line A\nLine B\nAgent line\n";
1909        let theirs = "Line A\nLine B edited\n";
1910        // Bogus ops that do NOT reconstruct theirs from base.
1911        let bogus = vec![EditorOp::Insert {
1912            offset: 0,
1913            text: "ZZZ".to_string(),
1914        }];
1915        assert_ne!(replay_editor_ops(base, &bogus).as_deref(), Some(theirs));
1916
1917        let base_state = CrdtDoc::from_text(base).encode_state();
1918        let with_ops =
1919            merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&bogus)).unwrap();
1920        let diff_only = merge(Some(&base_state), ours, theirs).unwrap();
1921        assert_eq!(
1922            with_ops, diff_only,
1923            "stale ops must fall back to the diff-guess result"
1924        );
1925        // The bogus "ZZZ" must NOT have leaked into the merged output.
1926        assert!(!with_ops.contains("ZZZ"), "stale op leaked: {with_ops}");
1927    }
1928
1929    #[test]
1930    fn merge_with_editor_ops_none_matches_plain_merge() {
1931        // The op-aware entry point with None ops is byte-identical to `merge`.
1932        let base = "header\n\nbody\n";
1933        let ours = "header\n\nbody\nagent\n";
1934        let theirs = "header\n\nbody edited\n";
1935        let base_state = CrdtDoc::from_text(base).encode_state();
1936        assert_eq!(
1937            merge_with_editor_ops(Some(&base_state), ours, theirs, None).unwrap(),
1938            merge(Some(&base_state), ours, theirs).unwrap()
1939        );
1940    }
1941
1942    #[test]
1943    fn merge_with_editor_ops_empty_ops_fall_back() {
1944        let base = "x\ny\n";
1945        let ours = "x\ny\nagent\n";
1946        let theirs = "x\ny edited\n";
1947        let base_state = CrdtDoc::from_text(base).encode_state();
1948        let empty: Vec<EditorOp> = vec![];
1949        assert_eq!(
1950            merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&empty)).unwrap(),
1951            merge(Some(&base_state), ours, theirs).unwrap()
1952        );
1953    }
1954
1955    #[test]
1956    fn roundtrip_encode_decode() {
1957        let content = "Some document content.\n";
1958        let doc = CrdtDoc::from_text(content);
1959        let encoded = doc.encode_state();
1960        let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1961        assert_eq!(decoded.to_text(), content);
1962    }
1963
1964    #[test]
1965    fn apply_edit_insert() {
1966        let doc = CrdtDoc::from_text("Hello world");
1967        doc.apply_edit(5, 0, ",");
1968        assert_eq!(doc.to_text(), "Hello, world");
1969    }
1970
1971    #[test]
1972    fn apply_edit_delete() {
1973        let doc = CrdtDoc::from_text("Hello, world");
1974        doc.apply_edit(5, 1, "");
1975        assert_eq!(doc.to_text(), "Hello world");
1976    }
1977
1978    #[test]
1979    fn apply_edit_replace() {
1980        let doc = CrdtDoc::from_text("Hello world");
1981        doc.apply_edit(6, 5, "Rust");
1982        assert_eq!(doc.to_text(), "Hello Rust");
1983    }
1984
1985    #[test]
1986    fn concurrent_append_merge_no_conflict() {
1987        let base = "# Document\n\nBase content.\n";
1988        let base_doc = CrdtDoc::from_text(base);
1989        let base_state = base_doc.encode_state();
1990
1991        let ours = format!("{base}## Agent\n\nAgent response.\n");
1992        let theirs = format!("{base}## User\n\nUser addition.\n");
1993
1994        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1995
1996        // Both additions should be present
1997        assert!(merged.contains("Agent response."), "missing agent text");
1998        assert!(merged.contains("User addition."), "missing user text");
1999        assert!(merged.contains("Base content."), "missing base text");
2000        // No conflict markers
2001        assert!(!merged.contains("<<<<<<<"));
2002        assert!(!merged.contains(">>>>>>>"));
2003    }
2004
2005    #[test]
2006    fn concurrent_insert_same_position() {
2007        let base = "Line 1\nLine 3\n";
2008        let base_doc = CrdtDoc::from_text(base);
2009        let base_state = base_doc.encode_state();
2010
2011        let ours = "Line 1\nAgent line\nLine 3\n";
2012        let theirs = "Line 1\nUser line\nLine 3\n";
2013
2014        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2015
2016        // Both insertions preserved, no conflict
2017        assert!(merged.contains("Agent line"), "missing agent insertion");
2018        assert!(merged.contains("User line"), "missing user insertion");
2019        assert!(merged.contains("Line 1"), "missing line 1");
2020        assert!(merged.contains("Line 3"), "missing line 3");
2021    }
2022
2023    #[test]
2024    fn merge_no_base_state() {
2025        // When no base state exists, bootstrap from empty
2026        let ours = "Agent wrote this.\n";
2027        let theirs = "User wrote this.\n";
2028
2029        let merged = merge(None, ours, theirs).unwrap();
2030
2031        assert!(merged.contains("Agent wrote this."));
2032        assert!(merged.contains("User wrote this."));
2033    }
2034
2035    #[test]
2036    fn compact_preserves_content() {
2037        let doc = CrdtDoc::from_text("Hello");
2038        doc.apply_edit(5, 0, " world");
2039        doc.apply_edit(11, 0, "!");
2040
2041        let state = doc.encode_state();
2042        let compacted = compact(&state).unwrap();
2043        let restored = CrdtDoc::decode_state(&compacted).unwrap();
2044
2045        assert_eq!(restored.to_text(), "Hello world!");
2046        assert!(compacted.len() <= state.len());
2047    }
2048
2049    #[test]
2050    fn compact_reduces_size_after_edits() {
2051        let doc = CrdtDoc::from_text("aaaa");
2052        // Many small edits to build up tombstones
2053        for i in 0..20 {
2054            let c = ((b'a' + (i % 26)) as char).to_string();
2055            doc.apply_edit(0, 1, &c);
2056        }
2057        let state = doc.encode_state();
2058        let compacted = compact(&state).unwrap();
2059        let restored = CrdtDoc::decode_state(&compacted).unwrap();
2060        assert_eq!(restored.to_text(), doc.to_text());
2061    }
2062
2063    #[test]
2064    fn empty_document() {
2065        let doc = CrdtDoc::from_text("");
2066        assert_eq!(doc.to_text(), "");
2067
2068        let encoded = doc.encode_state();
2069        let decoded = CrdtDoc::decode_state(&encoded).unwrap();
2070        assert_eq!(decoded.to_text(), "");
2071    }
2072
2073    #[test]
2074    fn decode_invalid_bytes_errors() {
2075        let result = CrdtDoc::decode_state(&[0xff, 0xfe, 0xfd]);
2076        assert!(result.is_err());
2077    }
2078
2079    #[test]
2080    fn merge_identical_texts() {
2081        let base = "Same content.\n";
2082        let base_doc = CrdtDoc::from_text(base);
2083        let state = base_doc.encode_state();
2084
2085        let merged = merge(Some(&state), base, base).unwrap();
2086        assert_eq!(merged, base);
2087    }
2088
2089    #[test]
2090    fn merge_one_side_unchanged() {
2091        let base = "Original.\n";
2092        let base_doc = CrdtDoc::from_text(base);
2093        let state = base_doc.encode_state();
2094
2095        let ours = "Original.\nAgent added.\n";
2096        let merged = merge(Some(&state), ours, base).unwrap();
2097        assert_eq!(merged, ours);
2098    }
2099
2100    /// Regression test: CRDT merge should not duplicate user prompt when both
2101    /// ours and theirs contain the same text added since the base state.
2102    ///
2103    /// Scenario (brookebrodack-dev.md duplication bug):
2104    /// 1. CRDT base = exchange content from a previous cycle (no user prompt)
2105    /// 2. User adds prompt to exchange → saved as baseline
2106    /// 3. Agent generates response, content_ours = baseline + response (has user prompt)
2107    /// 4. User makes a small edit during response generation → content_current (has user prompt too)
2108    /// 5. CRDT merge: both ours and theirs have the user prompt relative to stale base
2109    /// 6. BUG: user prompt appears twice in merged output
2110    #[test]
2111    fn merge_stale_base_no_duplicate_user_prompt() {
2112        // CRDT base from a previous cycle — does NOT have the user's current prompt
2113        let base_content = "\
2114## Assistant
2115
2116Previous response content.
2117
2118Committed and pushed.
2119
2120";
2121        let base_doc = CrdtDoc::from_text(base_content);
2122        let base_state = base_doc.encode_state();
2123
2124        // User adds prompt after base was saved
2125        let user_prompt = "\
2126Opening a video a shows video a.
2127Closing video a then opening video b start video b but video b is hidden.
2128Closing video b then reopening video b starts and shows video b. video b is visible.
2129";
2130
2131        // content_ours: base + user prompt + agent response (from run_stream with full exchange)
2132        let ours = format!(
2133            "\
2134{}{}### Re: Close A → Open B still hidden
2135
2136Added explicit height and visibility reset.
2137
2138Committed and pushed.
2139
2140",
2141            base_content, user_prompt
2142        );
2143
2144        // content_current: base + user prompt + minor user edit (e.g., added a blank line)
2145        let theirs = format!(
2146            "\
2147{}{}
2148",
2149            base_content, user_prompt
2150        );
2151
2152        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2153
2154        // User prompt should appear exactly ONCE
2155        let prompt_count = merged.matches("Opening a video a shows video a.").count();
2156        assert_eq!(
2157            prompt_count, 1,
2158            "User prompt duplicated! Appeared {} times in:\n{}",
2159            prompt_count, merged
2160        );
2161
2162        // Agent response should be present
2163        assert!(
2164            merged.contains("### Re: Close A → Open B still hidden"),
2165            "Agent response missing from merge:\n{}",
2166            merged
2167        );
2168    }
2169
2170    /// Regression test: When CRDT base is stale and both sides added the same text
2171    /// at the same position, the merge should not duplicate it.
2172    #[test]
2173    fn merge_stale_base_same_insertion_both_sides() {
2174        let base_content = "Line 1\nLine 2\n";
2175        let base_doc = CrdtDoc::from_text(base_content);
2176        let base_state = base_doc.encode_state();
2177
2178        // Both sides added the same text (user prompt) + ours adds more
2179        let shared_addition = "User typed this.\n";
2180        let ours = format!("{}{}Agent response.\n", base_content, shared_addition);
2181        let theirs = format!("{}{}", base_content, shared_addition);
2182
2183        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2184
2185        let count = merged.matches("User typed this.").count();
2186        assert_eq!(
2187            count, 1,
2188            "Shared text duplicated! Appeared {} times in:\n{}",
2189            count, merged
2190        );
2191        assert!(
2192            merged.contains("Agent response."),
2193            "Agent text missing:\n{}",
2194            merged
2195        );
2196    }
2197
2198    /// Regression test: Character-level interleaving bug.
2199    ///
2200    /// When the user types in their editor while the agent is streaming,
2201    /// both sides insert text at the same position relative to the base.
2202    /// The CRDT base advancement logic used to snap to the shared prefix
2203    /// of ours/theirs, which could land mid-line on a shared formatting
2204    /// character (e.g., `*` from `*bold*` and `**bold**`). This caused
2205    /// the formatting character to be absorbed into the base, splitting
2206    /// it from the rest of the formatting sequence and producing garbled
2207    /// text like `*Soft-bristle brush only**` instead of
2208    /// `**Soft-bristle brush only**`.
2209    ///
2210    /// The fix: always snap the advanced base to a line boundary. If no
2211    /// suitable line boundary exists after the current base length, don't
2212    /// advance at all.
2213    #[test]
2214    fn merge_no_character_interleaving() {
2215        // Base: a document with some existing content
2216        let base = "# Doc\n\nPrevious content.\n\n";
2217        let base_doc = CrdtDoc::from_text(base);
2218        let base_state = base_doc.encode_state();
2219
2220        // Agent adds a response
2221        let ours = "# Doc\n\nPrevious content.\n\n*Compacted. Content archived to*\n";
2222        // User types something in their editor at the same position
2223        let theirs = "# Doc\n\nPrevious content.\n\n**Soft-bristle brush only**\n";
2224
2225        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2226
2227        // Both texts should be present as contiguous blocks, not interleaved
2228        assert!(
2229            merged.contains("*Compacted. Content archived to*"),
2230            "Agent text should be contiguous (not interleaved). Got:\n{}",
2231            merged
2232        );
2233        assert!(
2234            merged.contains("**Soft-bristle brush only**"),
2235            "User text should be contiguous (not interleaved). Got:\n{}",
2236            merged
2237        );
2238    }
2239
2240    /// Regression test: Concurrent edits within the same line should not
2241    /// produce character-level interleaving.
2242    #[test]
2243    fn merge_concurrent_same_line_no_garbling() {
2244        let base = "Some base text\n";
2245        let base_doc = CrdtDoc::from_text(base);
2246        let base_state = base_doc.encode_state();
2247
2248        // Both sides replace the line with different content
2249        let ours = "Agent wrote this line\n";
2250        let theirs = "User wrote different text\n";
2251
2252        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2253
2254        // At least one side's text should appear contiguously
2255        let has_agent_contiguous = merged.contains("Agent wrote this line");
2256        let has_user_contiguous = merged.contains("User wrote different text");
2257
2258        assert!(
2259            has_agent_contiguous || has_user_contiguous,
2260            "At least one side should have contiguous text (no char interleaving). Got:\n{}",
2261            merged
2262        );
2263    }
2264
2265    /// Regression test: Replace-vs-append corruption (lazily-rs.md bug).
2266    ///
2267    /// Pattern:
2268    /// - CRDT base is from a previous cycle (old exchange content)
2269    /// - Agent replaces exchange content entirely (template replace mode)
2270    /// - User appends new prompt text to exchange during response generation
2271    /// - CRDT interleaves agent's new content with user's old + new text,
2272    ///   causing mid-word splits like "key de" + [user text] + "cisions"
2273    ///
2274    /// Root cause: stale CRDT base doesn't match either side well enough
2275    /// for prefix advancement, so the CRDT does a raw character-level merge
2276    /// of the exchange section, interleaving replace and append operations.
2277    ///
2278    /// Fix: use baseline (not stored CRDT state) as merge base, so both
2279    /// sides' diffs are computed from the exact content they diverged from.
2280    #[test]
2281    fn merge_replace_vs_append_no_interleaving() {
2282        // Full document structure (template mode)
2283        let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2284        let footer = "\n<!-- /agent:exchange -->\n";
2285
2286        // Previous cycle's exchange content (what the CRDT state contains)
2287        let old_exchange = "\
2288### Committed, Pushed & Released
2289
2290**project (v0.1.0):**
2291- Committed initial implementation
2292- Tagged v0.1.0 and pushed
2293
2294Add a README.md to the project.
2295Also add AGENTS.md with a symlink CLAUDE.md
2296
2297**sub-project:**
2298- Committed fix + SPEC.md
2299- Pushed to remote
2300";
2301        let stale_base = format!("{header}{old_exchange}{footer}");
2302        let stale_state = CrdtDoc::from_text(&stale_base).encode_state();
2303
2304        // Baseline (what the file looked like when response generation started)
2305        // Same as stale_base in this case — no user edits between cycles
2306        let _baseline = stale_base.clone();
2307
2308        // Ours: agent replaces exchange content (template replace mode applied)
2309        let agent_exchange = "\
2310### Done
2311
2312Added to project and pushed:
2313
2314- **README.md** — overview, usage, design notes
2315- **AGENTS.md** — architecture, key decisions, commands, related projects
2316- **CLAUDE.md** → symlink to AGENTS.md
2317
2318All committed and pushed.
2319";
2320        let ours = format!("{header}{agent_exchange}{footer}");
2321
2322        // Theirs: user inserted new prompt IN THE MIDDLE of the exchange section
2323        // (after the existing user prompt, before the sub-project sections)
2324        // This is the critical difference — insertion within the range that ours deletes
2325        let theirs_exchange = "\
2326### Committed, Pushed & Released
2327
2328**project (v0.1.0):**
2329- Committed initial implementation
2330- Tagged v0.1.0 and pushed
2331
2332Add a README.md to the project.
2333Also add AGENTS.md with a symlink CLAUDE.md
2334
2335Please add tests.
2336Please comprehensively test adherence to the spec.
2337
2338**sub-project:**
2339- Committed fix + SPEC.md
2340- Pushed to remote
2341";
2342        let theirs = format!("{header}{theirs_exchange}{footer}");
2343
2344        // Using stale CRDT state (previous cycle) — this is what triggers the bug
2345        let merged = merge(Some(&stale_state), &ours, &theirs).unwrap();
2346
2347        // Agent's replacement text should be contiguous (no interleaving)
2348        assert!(
2349            merged.contains(
2350                "- **AGENTS.md** — architecture, key decisions, commands, related projects"
2351            ),
2352            "Agent text garbled (mid-word split). Got:\n{}",
2353            merged
2354        );
2355
2356        // User's addition should be preserved
2357        assert!(
2358            merged.contains("Please add tests."),
2359            "User addition missing. Got:\n{}",
2360            merged
2361        );
2362
2363        // No fragments of old content mixed into agent's new content
2364        assert!(
2365            !merged.contains("key deAdd") && !merged.contains("key de\n"),
2366            "Old content interleaved into agent text. Got:\n{}",
2367            merged
2368        );
2369    }
2370
2371    /// Same as merge_replace_vs_append_no_interleaving but using baseline
2372    /// as CRDT base instead of stale state. This is the fix verification.
2373    #[test]
2374    fn merge_replace_vs_append_with_baseline_base() {
2375        let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2376        let footer = "\n<!-- /agent:exchange -->\n";
2377
2378        let old_exchange = "\
2379### Previous Response
2380
2381Old content here.
2382
2383Add a README.md to the project.
2384Also add AGENTS.md with a symlink CLAUDE.md
2385";
2386        let baseline = format!("{header}{old_exchange}{footer}");
2387
2388        // Ours: agent replaces exchange
2389        let agent_exchange = "\
2390### Done
2391
2392- **README.md** — overview, usage, design notes
2393- **AGENTS.md** — architecture, key decisions, commands, related projects
2394- **CLAUDE.md** → symlink to AGENTS.md
2395
2396All committed and pushed.
2397";
2398        let ours = format!("{header}{agent_exchange}{footer}");
2399
2400        // Theirs: user appended new prompt
2401        let user_addition = "\nPlease add tests.\n";
2402        let theirs = format!("{header}{old_exchange}{user_addition}{footer}");
2403
2404        // Use baseline as CRDT base (the fix)
2405        let baseline_state = CrdtDoc::from_text(&baseline).encode_state();
2406        let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2407
2408        // Agent text should be contiguous
2409        assert!(
2410            merged.contains("key decisions, commands, related projects"),
2411            "Agent text garbled. Got:\n{}",
2412            merged
2413        );
2414
2415        // User addition preserved
2416        assert!(
2417            merged.contains("Please add tests."),
2418            "User addition missing. Got:\n{}",
2419            merged
2420        );
2421    }
2422
2423    /// Regression test: Simulates the exact scenario from the bug report.
2424    ///
2425    /// The agent streams a response into the exchange component while
2426    /// the user types in their editor. Both sides share a common prefix
2427    /// that includes markdown formatting characters. The CRDT merge must
2428    /// preserve formatting integrity for both sides.
2429    #[test]
2430    fn merge_streaming_concurrent_edit_preserves_formatting() {
2431        // Exchange component content after user's initial prompt
2432        let base = "commit and push all rappstack packages.\n\n";
2433        let base_doc = CrdtDoc::from_text(base);
2434        let base_state = base_doc.encode_state();
2435
2436        // Agent's response (content_ours = user prompt + agent response)
2437        let ours = "\
2438commit and push all rappstack packages.
2439
2440### Re: commit and push
2441
2442*Compacted. Content archived to `docs/`*
2443
2444Done — all packages pushed.
2445";
2446
2447        // User's concurrent edit (added a note at the bottom)
2448        let theirs = "\
2449commit and push all rappstack packages.
2450
2451**Soft-bristle brush only**
2452";
2453
2454        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2455
2456        // Agent formatting must be intact
2457        assert!(
2458            merged.contains("*Compacted. Content archived to `docs/`*"),
2459            "Agent formatting broken. Got:\n{}",
2460            merged
2461        );
2462        // User formatting must be intact
2463        assert!(
2464            merged.contains("**Soft-bristle brush only**"),
2465            "User formatting broken. Got:\n{}",
2466            merged
2467        );
2468        // No character-level interleaving
2469        assert!(
2470            !merged.contains("*C*C") && !merged.contains("**Sot"),
2471            "Character interleaving detected. Got:\n{}",
2472            merged
2473        );
2474    }
2475
2476    /// Regression test: Agent replaces multi-line block while user inserts within it.
2477    /// With from_chars, this produces ~20 scattered character-level ops that interleave
2478    /// with user edits. With from_lines, ops are contiguous line-level blocks.
2479    ///
2480    /// Uses a template document structure to match the real workflow where the baseline
2481    /// (common ancestor) contains the exchange component with original content.
2482    #[test]
2483    fn merge_replace_vs_insert_no_interleaving() {
2484        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n# Document Title\n\nSome preamble text that both sides share.\nThis provides enough common prefix to avoid stale detection.\n\n<!-- agent:exchange -->\n";
2485        let footer = "<!-- /agent:exchange -->\n";
2486
2487        let old_exchange =
2488            "Line one of old content\nLine two of old content\nLine three of old content\n";
2489        let baseline = format!("{header}{old_exchange}{footer}");
2490        let baseline_doc = CrdtDoc::from_text(&baseline);
2491        let baseline_state = baseline_doc.encode_state();
2492
2493        // Agent replaces exchange with completely new content
2494        let agent_exchange = "Completely new line one\nCompletely new line two\nCompletely new line three\nCompletely new line four\n";
2495        let ours = format!("{header}{agent_exchange}{footer}");
2496
2497        // User inserts a line in the middle of the original exchange
2498        let theirs = format!(
2499            "{header}Line one of old content\nUser inserted this line\nLine two of old content\nLine three of old content\n{footer}"
2500        );
2501
2502        let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2503
2504        // Agent text should be contiguous — no mid-word splits
2505        assert!(
2506            merged.contains("Completely new line one"),
2507            "Agent line 1 missing or garbled. Got:\n{}",
2508            merged
2509        );
2510        assert!(
2511            merged.contains("Completely new line two"),
2512            "Agent line 2 missing or garbled. Got:\n{}",
2513            merged
2514        );
2515
2516        // User text should be preserved
2517        assert!(
2518            merged.contains("User inserted this line"),
2519            "User insertion missing. Got:\n{}",
2520            merged
2521        );
2522
2523        // No character interleaving (e.g., "Complete" + user text + "ly")
2524        assert!(
2525            !merged.contains("CompleteUser") && !merged.contains("Complete\nUser"),
2526            "Character interleaving detected. Got:\n{}",
2527            merged
2528        );
2529    }
2530
2531    /// Test: agent content appears before human content when both append
2532    /// to the same position.
2533    #[test]
2534    fn reorder_agent_before_human_at_append_boundary() {
2535        let base = "# Document\n\nBase content.\n";
2536        let base_doc = CrdtDoc::from_text(base);
2537        let base_state = base_doc.encode_state();
2538
2539        // Agent appends response
2540        let ours = format!("{base}### Agent Response\n\nAgent wrote this.\n");
2541        // Human appends their own text
2542        let theirs = format!("{base}User added this line.\n");
2543
2544        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2545
2546        // Both should be present
2547        assert!(merged.contains("Agent wrote this."), "missing agent text");
2548        assert!(
2549            merged.contains("User added this line."),
2550            "missing user text"
2551        );
2552        assert!(merged.contains("Base content."), "missing base text");
2553
2554        // Agent content should appear before human content
2555        let agent_pos = merged.find("Agent wrote this.").unwrap();
2556        let human_pos = merged.find("User added this line.").unwrap();
2557        assert!(
2558            agent_pos < human_pos,
2559            "Agent content should appear before human content.\nAgent pos: {}, Human pos: {}\nMerged:\n{}",
2560            agent_pos,
2561            human_pos,
2562            merged
2563        );
2564    }
2565
2566    // -----------------------------------------------------------------------
2567    // dedup_adjacent_blocks tests (#15)
2568    // -----------------------------------------------------------------------
2569
2570    #[test]
2571    fn dedup_removes_identical_adjacent_blocks() {
2572        let text =
2573            "### Re: Question\nAnswer here.\n\n### Re: Question\nAnswer here.\n\nDifferent block.";
2574        let result = dedup_adjacent_blocks(text);
2575        assert_eq!(result.matches("### Re: Question").count(), 1);
2576        assert!(result.contains("Different block."));
2577    }
2578
2579    #[test]
2580    fn dedup_preserves_different_adjacent_blocks() {
2581        let text = "### Re: First\nAnswer one.\n\n### Re: Second\nAnswer two.";
2582        let result = dedup_adjacent_blocks(text);
2583        assert!(result.contains("### Re: First"));
2584        assert!(result.contains("### Re: Second"));
2585    }
2586
2587    #[test]
2588    fn dedup_preserves_structural_thematic_breaks() {
2589        let text = "---\n\n---\n\nContent.";
2590        let result = dedup_adjacent_blocks(text);
2591        assert_eq!(result, text);
2592
2593        let text2 = "***\n\n***\n\nContent.";
2594        assert_eq!(dedup_adjacent_blocks(text2), text2);
2595
2596        let text3 = "___\n\n___\n\nContent.";
2597        assert_eq!(dedup_adjacent_blocks(text3), text3);
2598    }
2599
2600    #[test]
2601    fn dedup_removes_single_line_content_duplicates() {
2602        let text = "### Re: topic — opus-4-6\n\n### Re: topic — opus-4-6\n\nDifferent.";
2603        let result = dedup_adjacent_blocks(text);
2604        assert_eq!(result.matches("### Re: topic").count(), 1);
2605        assert!(result.contains("Different."));
2606    }
2607
2608    #[test]
2609    fn dedup_removes_single_line_text_duplicates() {
2610        let text = "Some response text.\n\nSome response text.\n\nNext section.";
2611        let result = dedup_adjacent_blocks(text);
2612        assert_eq!(result.matches("Some response text.").count(), 1);
2613        assert!(result.contains("Next section."));
2614    }
2615
2616    #[test]
2617    fn dedup_preserves_different_single_line_blocks() {
2618        let text = "Line A.\n\nLine B.\n\nLine C.";
2619        let result = dedup_adjacent_blocks(text);
2620        assert_eq!(result, text);
2621    }
2622
2623    #[test]
2624    fn dedup_handles_empty_text() {
2625        assert_eq!(dedup_adjacent_blocks(""), "");
2626    }
2627
2628    #[test]
2629    fn dedup_no_change_when_no_duplicates() {
2630        let text = "Block A\nLine 2.\n\nBlock B\nLine 2.";
2631        let result = dedup_adjacent_blocks(text);
2632        assert_eq!(result, text);
2633    }
2634
2635    #[test]
2636    fn is_structural_block_detects_thematic_breaks() {
2637        assert!(is_structural_block("---"));
2638        assert!(is_structural_block("***"));
2639        assert!(is_structural_block("___"));
2640        assert!(is_structural_block("-----"));
2641        assert!(!is_structural_block("--"));
2642        assert!(!is_structural_block("### Heading"));
2643        assert!(!is_structural_block("some text"));
2644        assert!(!is_structural_block("---\nmore"));
2645    }
2646
2647    /// Regression test: empty-baseline CRDT merge duplicates last user prompt line.
2648    ///
2649    /// Scenario (claude-code-action.md bug):
2650    /// 1. Baseline = template document with empty exchange (just boundary marker)
2651    /// 2. content_ours = baseline + response patches (response replaces boundary)
2652    /// 3. content_current = user's file (user typed prompt before boundary)
2653    /// 4. CRDT merge: both sides added content to the empty exchange
2654    /// 5. BUG: last line of user's prompt appears twice (before and after response)
2655    #[test]
2656    fn merge_empty_exchange_no_duplicate_user_line() {
2657        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2658        let footer = "<!-- /agent:exchange -->\n\n## Pending\n\n<!-- agent:pending -->\n<!-- /agent:pending -->\n";
2659
2660        // Base: empty exchange with boundary marker
2661        let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2662
2663        // Ours: response applied via boundary-aware append (boundary replaced)
2664        let ours = format!(
2665            "{header}\n### Re: topic — opus-4-6\n\nResponse content here.\n\nMore response text.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2666        );
2667
2668        // Theirs: user typed prompt before the boundary
2669        let theirs = format!(
2670            "{header}User prompt line one.\nUser prompt line two.\nLast line of user prompt.\n<!-- agent:boundary:base-id -->\n{footer}"
2671        );
2672
2673        let base_doc = CrdtDoc::from_text(&base);
2674        let base_state = base_doc.encode_state();
2675
2676        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2677
2678        // Each user prompt line should appear exactly once
2679        let count1 = merged.matches("User prompt line one.").count();
2680        assert_eq!(
2681            count1, 1,
2682            "Line one duplicated (count={}). Merged:\n{}",
2683            count1, merged
2684        );
2685
2686        let count2 = merged.matches("User prompt line two.").count();
2687        assert_eq!(
2688            count2, 1,
2689            "Line two duplicated (count={}). Merged:\n{}",
2690            count2, merged
2691        );
2692
2693        let count3 = merged.matches("Last line of user prompt.").count();
2694        assert_eq!(
2695            count3, 1,
2696            "Last line duplicated (count={}). Merged:\n{}",
2697            count3, merged
2698        );
2699
2700        // Response should appear exactly once
2701        assert!(
2702            merged.contains("### Re: topic — opus-4-6"),
2703            "Response heading missing:\n{}",
2704            merged
2705        );
2706        assert!(
2707            merged.contains("Response content here."),
2708            "Response content missing:\n{}",
2709            merged
2710        );
2711
2712        // User prompt should come BEFORE response
2713        let prompt_pos = merged.find("User prompt line one.").unwrap();
2714        let response_pos = merged.find("### Re: topic").unwrap();
2715        assert!(
2716            prompt_pos < response_pos,
2717            "User prompt should precede response. prompt={} response={}\nMerged:\n{}",
2718            prompt_pos,
2719            response_pos,
2720            merged
2721        );
2722    }
2723
2724    /// Same as above but with ❯ prefixed user content (post-normalization).
2725    #[test]
2726    fn merge_empty_exchange_prefixed_prompts_no_duplicate() {
2727        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2728        let footer = "<!-- /agent:exchange -->\n";
2729
2730        let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2731
2732        // Ours: response + normalized ❯ prefix on user lines
2733        let ours = format!(
2734            "{header}❯ Review the code\n❯ What does it do?\n\n### Re: code review — opus-4-6\n\nThe code does X.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2735        );
2736
2737        // Theirs: user's file with same prompt (also ❯ prefixed by IPC normalization)
2738        let theirs = format!(
2739            "{header}❯ Review the code\n❯ What does it do?\n<!-- agent:boundary:base-id -->\n{footer}"
2740        );
2741
2742        let base_doc = CrdtDoc::from_text(&base);
2743        let base_state = base_doc.encode_state();
2744
2745        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2746
2747        let count = merged.matches("❯ Review the code").count();
2748        assert_eq!(
2749            count, 1,
2750            "Prompt duplicated (count={}). Merged:\n{}",
2751            count, merged
2752        );
2753
2754        assert!(
2755            merged.contains("The code does X."),
2756            "Response missing:\n{}",
2757            merged
2758        );
2759    }
2760
2761    /// Regression test (#ipc-crdt-response-drift): a concurrent **foreign**
2762    /// writer (another supervisor / queue toggle) appends to the document at
2763    /// the tail *during* response generation, growing `theirs` beyond `base`.
2764    /// The agent's new `### Re:` response (ours) must:
2765    ///   1. land with its heading intact (not dropped),
2766    ///   2. keep its lines in author order (not reversed),
2767    ///   3. appear AFTER the prior committed `### Re:` response (not hoisted
2768    ///      above it),
2769    ///   4. preserve the foreign tail append.
2770    #[test]
2771    fn merge_foreign_tail_append_preserves_response_order() {
2772        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2773        let exchange_committed = "\
2774❯ first question
2775
2776### Re: first question — opus-4-8
2777
2778First answer here. Already committed to HEAD.
2779
2780❯ second question
2781";
2782        let queue_open = "<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n";
2783        let queue_close = "<!-- /agent:queue -->\n";
2784
2785        // Base: prior response committed, new prompt typed, boundary at tail of
2786        // exchange, empty queue.
2787        let base = format!(
2788            "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}{queue_close}"
2789        );
2790
2791        // Ours: agent replaces boundary with the new response (multi-line).
2792        let agent_response = "\
2793### Re: second question — opus-4-8
2794
2795Second answer line one.
2796Second answer line two.
2797Second answer line three.
2798
2799";
2800        let ours = format!(
2801            "{header}{exchange_committed}{agent_response}<!-- agent:boundary:new-id -->\n{queue_open}{queue_close}"
2802        );
2803
2804        // Theirs: a foreign supervisor appended a queue item at the tail while
2805        // the response was being generated (file grew, base unchanged in body).
2806        let theirs = format!(
2807            "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}do [#foreign-task]\n{queue_close}"
2808        );
2809
2810        let base_state = CrdtDoc::from_text(&base).encode_state();
2811        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2812
2813        // 1. Heading intact.
2814        assert!(
2815            merged.contains("### Re: second question — opus-4-8"),
2816            "new response heading dropped:\n{}",
2817            merged
2818        );
2819        // 2. Lines in author order (not reversed).
2820        let l1 = merged
2821            .find("Second answer line one.")
2822            .expect("line one missing");
2823        let l2 = merged
2824            .find("Second answer line two.")
2825            .expect("line two missing");
2826        let l3 = merged
2827            .find("Second answer line three.")
2828            .expect("line three missing");
2829        assert!(
2830            l1 < l2 && l2 < l3,
2831            "response lines reversed/reordered (l1={l1} l2={l2} l3={l3}):\n{merged}"
2832        );
2833        // 3. New response appears AFTER the prior committed response.
2834        let prior = merged
2835            .find("### Re: first question")
2836            .expect("prior response missing");
2837        let current = merged.find("### Re: second question").unwrap();
2838        assert!(
2839            prior < current,
2840            "new response hoisted above prior HEAD response (prior={prior} current={current}):\n{merged}"
2841        );
2842        // 4. Foreign tail append preserved.
2843        assert!(
2844            merged.contains("do [#foreign-task]"),
2845            "foreign queue append lost:\n{}",
2846            merged
2847        );
2848    }
2849
2850    /// Regression test (#ipc-crdt-response-drift): a stale/divergent `theirs`
2851    /// (e.g. a stale editor live-buffer pushed by convergence, or a concurrent
2852    /// foreign supervisor) presents a document that has *lost* the prior
2853    /// committed `### Re:` response block. The merge must NOT honor that foreign
2854    /// deletion — the committed response and its heading must survive.
2855    #[test]
2856    fn merge_stale_theirs_cannot_delete_committed_response() {
2857        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2858        let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2859        let foot = "<!-- /agent:exchange -->\n";
2860        let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2861        let resp = "### Re: second question — opus-4-8\n\nLine one.\nLine two.\nLine three.\n\n";
2862        let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2863
2864        // Theirs: stale buffer that dropped the committed first Q&A entirely
2865        // (only the boundary id differs from ours — a transient marker).
2866        let theirs = format!("{header}❯ second question\n<!-- agent:boundary:base-id -->\n{foot}");
2867
2868        let base_state = CrdtDoc::from_text(&base).encode_state();
2869        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2870
2871        assert!(
2872            merged.contains("### Re: first question — opus-4-8"),
2873            "committed prior response heading deleted by stale theirs:\n{merged}"
2874        );
2875        assert!(
2876            merged.contains("First answer. Committed to HEAD."),
2877            "committed prior response body deleted by stale theirs:\n{merged}"
2878        );
2879        assert!(
2880            merged.contains("### Re: second question — opus-4-8"),
2881            "new response heading missing:\n{merged}"
2882        );
2883        // Order preserved: prior committed response before the new one.
2884        let prior = merged.find("### Re: first question").unwrap();
2885        let current = merged.find("### Re: second question").unwrap();
2886        assert!(prior < current, "response order inverted:\n{merged}");
2887    }
2888
2889    /// Negative case for the committed-response guard: when `theirs` carries
2890    /// genuinely new user input (a freshly typed prompt) the guard must NOT
2891    /// clobber it by preferring `ours`; both the new user prompt and the
2892    /// committed history must survive.
2893    #[test]
2894    fn merge_committed_guard_keeps_new_user_input() {
2895        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2896        let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2897        let foot = "<!-- /agent:exchange -->\n";
2898        let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2899        let resp = "### Re: second question — opus-4-8\n\nAnswer body.\n\n";
2900        let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2901
2902        // Theirs: user appended a brand-new prompt while the response generated.
2903        let theirs = format!(
2904            "{header}{committed}❯ a genuinely new third prompt\n<!-- agent:boundary:base-id -->\n{foot}"
2905        );
2906
2907        let base_state = CrdtDoc::from_text(&base).encode_state();
2908        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2909
2910        assert!(
2911            merged.contains("❯ a genuinely new third prompt"),
2912            "new user prompt clobbered by committed-response guard:\n{merged}"
2913        );
2914        assert!(
2915            merged.contains("### Re: first question"),
2916            "committed prior response lost:\n{merged}"
2917        );
2918        assert!(
2919            merged.contains("### Re: second question"),
2920            "new response lost:\n{merged}"
2921        );
2922    }
2923
2924    // ---- #qcellmerge1: component-scoped merge -----------------------------
2925
2926    /// Build a template doc with an `exchange` and a `queue` component.
2927    fn doc_with_exchange_queue(exchange: &str, queue: &str) -> String {
2928        format!(
2929            "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\n{exchange}\n<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n{queue}\n<!-- /agent:queue -->\n"
2930        )
2931    }
2932
2933    #[test]
2934    fn merge_by_component_isolates_concurrent_cross_component_edits() {
2935        // Side A (agent) appends to `exchange`; side B (operator) appends a
2936        // `queue` item. They must BOTH survive with ZERO cross-component splice.
2937        let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2938        let base_state = CrdtDoc::from_text(&base).encode_state();
2939
2940        let ours = doc_with_exchange_queue(
2941            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2942            "- do [#a1]",
2943        );
2944        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2945
2946        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2947
2948        // Both edits present.
2949        assert!(
2950            merged.contains("### Re: existing"),
2951            "agent exchange edit lost:\n{merged}"
2952        );
2953        assert!(
2954            merged.contains("[#b2]"),
2955            "operator queue edit lost:\n{merged}"
2956        );
2957
2958        // ZERO cross-component splice: the agent response body must not appear
2959        // inside the queue component, and the queue item must not appear inside
2960        // exchange.
2961        let queue_body = merged
2962            .split("<!-- agent:queue -->")
2963            .nth(1)
2964            .and_then(|s| s.split("<!-- /agent:queue -->").next())
2965            .unwrap_or("");
2966        let exchange_body = merged
2967            .split("<!-- agent:exchange -->")
2968            .nth(1)
2969            .and_then(|s| s.split("<!-- /agent:exchange -->").next())
2970            .unwrap_or("");
2971        assert!(
2972            !queue_body.contains("Agent response body"),
2973            "exchange content spliced into queue:\n{queue_body}"
2974        );
2975        assert!(
2976            !exchange_body.contains("[#b2]"),
2977            "queue content spliced into exchange:\n{exchange_body}"
2978        );
2979    }
2980
2981    #[test]
2982    fn merge_by_component_preserves_operator_marker_attribute_qmarkerauth() {
2983        // #qmarkerauth: the operator adds attributes to the `agent:queue` marker
2984        // (`<!-- agent:queue priority go -->`). The agent side (ours) still has the
2985        // base marker. The merge MUST keep the operator's marker attributes — never
2986        // revert them to ours' marker (the "adding an attribute deletes it" bug).
2987        let mk = |marker: &str| -> String {
2988            format!(
2989                "---\nagent_doc_format: template\n---\n\n## Queue\n\n{marker}\n- do [#a1]\n<!-- /agent:queue -->\n"
2990            )
2991        };
2992        let base = mk("<!-- agent:queue -->");
2993        let base_state = CrdtDoc::from_text(&base).encode_state();
2994        let ours = mk("<!-- agent:queue -->");
2995        let theirs = mk("<!-- agent:queue priority go -->");
2996
2997        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2998        assert!(
2999            merged.contains("<!-- agent:queue priority go -->"),
3000            "operator marker attribute lost (reverted to ours' marker):\n{merged}"
3001        );
3002    }
3003
3004    #[test]
3005    fn merge_by_component_legacy_operator_queue_delete_wins_over_agent_update() {
3006        // With the cell-merge kill-switch off, the legacy keyed-child fallback must
3007        // still treat an operator-side queue delete as authoritative. The stale
3008        // agent side may have updated/struck the item, but it must not resurrect it.
3009        let _guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3010        // SAFETY: serialized under the lock and restored before assertions.
3011        unsafe {
3012            std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "0");
3013        }
3014
3015        let base = doc_with_exchange_queue("Q.", "- do [#a1] keep\n- do [#b2] delete me");
3016        let base_state = CrdtDoc::from_text(&base).encode_state();
3017        let ours = doc_with_exchange_queue("Q.", "- do [#a1] keep\n- ~~do [#b2] delete me~~");
3018        let theirs = doc_with_exchange_queue("Q.", "- do [#a1] keep");
3019        let result = merge_by_component(Some(&base_state), &ours, &theirs);
3020
3021        // SAFETY: still holding the lock.
3022        unsafe {
3023            std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3024        }
3025
3026        let merged = result.unwrap();
3027        assert!(
3028            merged.contains("[#a1]"),
3029            "untouched queue item lost:\n{merged}"
3030        );
3031        assert!(
3032            !merged.contains("[#b2]"),
3033            "operator-deleted queue item resurrected by legacy fallback:\n{merged}"
3034        );
3035    }
3036
3037    #[test]
3038    fn reconcile_marker_operator_authoritative_resolution() {
3039        // Operator changed the marker (theirs != base) → operator wins.
3040        assert_eq!(
3041            reconcile_marker_operator_authoritative(
3042                Some("<!-- agent:queue -->"),
3043                "<!-- agent:queue -->",
3044                "<!-- agent:queue priority -->"
3045            ),
3046            "<!-- agent:queue priority -->"
3047        );
3048        // Operator left it at base; only the agent changed it → agent wins.
3049        assert_eq!(
3050            reconcile_marker_operator_authoritative(
3051                Some("<!-- agent:queue -->"),
3052                "<!-- agent:queue go -->",
3053                "<!-- agent:queue -->"
3054            ),
3055            "<!-- agent:queue go -->"
3056        );
3057        // No base epoch → operator (theirs) is authoritative.
3058        assert_eq!(
3059            reconcile_marker_operator_authoritative(
3060                None,
3061                "<!-- agent:queue -->",
3062                "<!-- agent:queue preset=\"#x\" -->"
3063            ),
3064            "<!-- agent:queue preset=\"#x\" -->"
3065        );
3066        // Identical markers → unchanged.
3067        assert_eq!(
3068            reconcile_marker_operator_authoritative(None, "<!-- x -->", "<!-- x -->"),
3069            "<!-- x -->"
3070        );
3071    }
3072
3073    #[test]
3074    fn merge_by_component_blocks_console_output_in_queue_corruption() {
3075        // The live 2026-06-20 corruption: agent console output ("Supervisor is
3076        // now fresh…") merged INTO agent:queue as a fenced block while the
3077        // operator typed a queue prompt. Model it as: ours = agent wrote console
3078        // text into `exchange`, theirs = operator typed a queue prompt. The
3079        // console text must never land in the queue node.
3080        let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
3081        let base = doc_with_exchange_queue("Q.", "- :pushpin: typing here");
3082        let base_state = CrdtDoc::from_text(&base).encode_state();
3083
3084        let ours = doc_with_exchange_queue(
3085            &format!("Q.\n\n### Re: q\n\n{console}"),
3086            "- :pushpin: typing here",
3087        );
3088        let theirs = doc_with_exchange_queue("Q.", "- :pushpin: typing here now extended");
3089
3090        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3091
3092        let queue_body = merged
3093            .split("<!-- agent:queue -->")
3094            .nth(1)
3095            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3096            .unwrap_or("");
3097        assert!(
3098            !queue_body.contains("Supervisor is now fresh"),
3099            "console output corrupted the queue node:\n{queue_body}"
3100        );
3101        assert!(
3102            queue_body.contains("extended"),
3103            "operator queue edit lost:\n{queue_body}"
3104        );
3105        assert!(
3106            merged.contains("Supervisor is now fresh"),
3107            "console output must remain in its own (exchange) node:\n{merged}"
3108        );
3109    }
3110
3111    #[test]
3112    fn merge_by_component_degenerates_to_whole_doc_without_components() {
3113        // Inline-mode docs (no agent:* components) must behave exactly like the
3114        // legacy whole-doc merge.
3115        let base = "# Document\n\nBase content.\n";
3116        let base_state = CrdtDoc::from_text(base).encode_state();
3117        let ours = format!("{base}## Agent\n\nAgent response.\n");
3118        let theirs = format!("{base}## User\n\nUser addition.\n");
3119
3120        let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3121        let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
3122        assert_eq!(
3123            by_component, whole_doc,
3124            "component merge must equal whole-doc merge for a component-less doc"
3125        );
3126    }
3127
3128    #[test]
3129    fn merge_by_component_falls_back_on_structural_divergence() {
3130        // theirs removes the queue component entirely → structural divergence →
3131        // whole-doc fallback (no panic, content preserved deterministically).
3132        let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3133        let base_state = CrdtDoc::from_text(&base).encode_state();
3134        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
3135        // theirs: no queue component at all.
3136        let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
3137
3138        // Must not panic and must equal the whole-doc merge fallback.
3139        let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3140        let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
3141        assert_eq!(
3142            by_component, whole_doc,
3143            "structural divergence must use whole-doc fallback"
3144        );
3145    }
3146
3147    // ---- #qnodemerge2: per-node CRDT state persistence --------------------
3148
3149    #[test]
3150    fn multinode_state_roundtrip_encode_decode() {
3151        // Per-component encode/decode round-trips through the container format and
3152        // reconstructs the exact document text.
3153        let doc =
3154            doc_with_exchange_queue("Prompt.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3155        let state = MultiNodeState::from_text(&doc).unwrap();
3156        let encoded = state.encode();
3157        let decoded = MultiNodeState::decode(&encoded).unwrap();
3158        assert_eq!(
3159            decoded, state,
3160            "decode must reproduce the encoded per-node state"
3161        );
3162        assert_eq!(
3163            decoded.to_text().unwrap(),
3164            doc,
3165            "per-node state must reconstruct the original document text"
3166        );
3167        // The container is self-describing — one node per interstitial+component.
3168        assert!(
3169            decoded.nodes.len() >= 4,
3170            "expected ≥4 nodes, got {}",
3171            decoded.nodes.len()
3172        );
3173        assert!(
3174            decoded
3175                .nodes
3176                .iter()
3177                .any(|n| n.name.as_deref() == Some("exchange"))
3178        );
3179        assert!(
3180            decoded
3181                .nodes
3182                .iter()
3183                .any(|n| n.name.as_deref() == Some("queue"))
3184        );
3185    }
3186
3187    #[test]
3188    fn multinode_state_decode_rejects_non_container() {
3189        // A legacy whole-doc Yrs blob is not a per-node container — decode must
3190        // error (not panic) so the caller can migrate it.
3191        let legacy = CrdtDoc::from_text("# Doc\n\nlegacy whole-doc state\n").encode_state();
3192        assert!(
3193            MultiNodeState::decode(&legacy).is_err(),
3194            "legacy whole-doc state must not decode as a per-node container"
3195        );
3196    }
3197
3198    #[test]
3199    fn multinode_state_migrates_legacy_whole_doc() {
3200        // decode_or_migrate must split a legacy whole-doc state into per-node states.
3201        let doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3202        let legacy = CrdtDoc::from_text(&doc).encode_state();
3203        let migrated = MultiNodeState::decode_or_migrate(&legacy, "");
3204        assert_eq!(
3205            migrated.to_text().unwrap(),
3206            doc,
3207            "legacy migration must preserve the full document text"
3208        );
3209        assert!(
3210            migrated
3211                .nodes
3212                .iter()
3213                .any(|n| n.name.as_deref() == Some("queue")),
3214            "legacy migration must recover component nodes"
3215        );
3216    }
3217
3218    #[test]
3219    fn multinode_merge_advances_node_base_independently() {
3220        // The core #qnodemerge2 property: when only the `exchange` node changes,
3221        // the `queue` node's persisted base is byte-identical across the cycle,
3222        // while the `exchange` node's base advances.
3223        let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3224        let base = MultiNodeState::from_text(&base_doc).unwrap();
3225
3226        // Agent appends to exchange; operator leaves queue untouched.
3227        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nAgent body.", "- do [#a1]");
3228        let theirs = base_doc.clone();
3229
3230        let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3231        assert!(
3232            merged.contains("Agent body."),
3233            "exchange edit lost:\n{merged}"
3234        );
3235
3236        let queue_before = base
3237            .nodes
3238            .iter()
3239            .find(|n| n.name.as_deref() == Some("queue"))
3240            .map(|n| &n.state);
3241        let queue_after = advanced
3242            .nodes
3243            .iter()
3244            .find(|n| n.name.as_deref() == Some("queue"))
3245            .map(|n| &n.state);
3246        assert_eq!(
3247            queue_before, queue_after,
3248            "untouched queue node base must be byte-identical across the cycle"
3249        );
3250
3251        let exchange_before = base
3252            .nodes
3253            .iter()
3254            .find(|n| n.name.as_deref() == Some("exchange"))
3255            .map(|n| &n.state);
3256        let exchange_after = advanced
3257            .nodes
3258            .iter()
3259            .find(|n| n.name.as_deref() == Some("exchange"))
3260            .map(|n| &n.state);
3261        assert_ne!(
3262            exchange_before, exchange_after,
3263            "changed exchange node base must advance"
3264        );
3265    }
3266
3267    #[test]
3268    fn multinode_merge_isolates_concurrent_cross_component_edits() {
3269        // Parallel to merge_by_component: concurrent exchange + queue edits both
3270        // survive with zero cross-component splice, via per-node bases.
3271        let base_doc = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3272        let base = MultiNodeState::from_text(&base_doc).unwrap();
3273
3274        let ours = doc_with_exchange_queue(
3275            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3276            "- do [#a1]",
3277        );
3278        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3279
3280        let (merged, _advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3281        assert!(
3282            merged.contains("### Re: existing"),
3283            "exchange edit lost:\n{merged}"
3284        );
3285        assert!(merged.contains("[#b2]"), "queue edit lost:\n{merged}");
3286
3287        let queue_body = merged
3288            .split("<!-- agent:queue -->")
3289            .nth(1)
3290            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3291            .unwrap_or("");
3292        assert!(
3293            !queue_body.contains("Agent response body"),
3294            "exchange content spliced into queue:\n{queue_body}"
3295        );
3296    }
3297
3298    #[test]
3299    fn multinode_merge_falls_back_on_structural_divergence() {
3300        // theirs drops the queue component → whole-doc fallback, still producing a
3301        // valid per-node state that round-trips the merged text.
3302        let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3303        let base = MultiNodeState::from_text(&base_doc).unwrap();
3304        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
3305        let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
3306
3307        let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3308        let whole_doc = {
3309            let base_state = CrdtDoc::from_text(&base_doc).encode_state();
3310            merge(Some(&base_state), &ours, &theirs).unwrap()
3311        };
3312        assert_eq!(
3313            merged, whole_doc,
3314            "structural divergence must use whole-doc fallback"
3315        );
3316        assert_eq!(
3317            advanced.to_text().unwrap(),
3318            merged,
3319            "fallback must still yield a per-node state round-tripping the merged text"
3320        );
3321    }
3322
3323    #[test]
3324    fn multinode_merge_bootstraps_without_base() {
3325        // No prior per-node state (first cycle) still merges and yields state.
3326        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]");
3327        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3328        let (merged, state) = MultiNodeState::merge(None, &ours, &theirs).unwrap();
3329        assert!(merged.contains("Body."));
3330        assert!(merged.contains("[#b2]"));
3331        assert_eq!(state.to_text().unwrap(), merged);
3332    }
3333
3334    // ---- #qcellmerge1 persistence round-trip: MultiNodeState::merge seam ----
3335    //
3336    // These prove the live-persistence path (`MultiNodeState::merge`, which
3337    // returns BOTH the merged text AND the advanced per-node state persisted to
3338    // `<hash>.nodes.yrs`) routes through the SAME per-cell merge as the text path
3339    // (`merge_by_component`) under the flag, so the persisted base reflects the
3340    // cell-merge winner and the round-trip is consistent across cycles.
3341
3342    /// Scoped guard: set `AGENT_DOC_CELL_MERGE=1` for the duration, restore on
3343    /// drop. Serialized through the shared crate lock so it can't race the
3344    /// `cell_doc` flag tests.
3345    struct CellMergeFlagOn {
3346        _guard: std::sync::MutexGuard<'static, ()>,
3347    }
3348    impl CellMergeFlagOn {
3349        fn on() -> Self {
3350            let guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3351            // SAFETY: single-threaded under the lock; restored on drop.
3352            unsafe {
3353                std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "1");
3354            }
3355            assert!(crate::cell_doc::cell_merge_enabled());
3356            CellMergeFlagOn { _guard: guard }
3357        }
3358    }
3359    impl Drop for CellMergeFlagOn {
3360        fn drop(&mut self) {
3361            // SAFETY: still holding the lock.
3362            unsafe {
3363                std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3364            }
3365        }
3366    }
3367
3368    #[test]
3369    fn multinode_merge_conflict_persists_cell_winner_under_flag() {
3370        // A same-item conflict (both sides edit the SAME queue line differently).
3371        // With the flag ON, MultiNodeState::merge returns text T and state S; the
3372        // persisted state S must re-encode to a doc whose queue component matches
3373        // T (the persisted base reflects the cell-merge winner, NOT a legacy
3374        // per-node winner that could diverge from T). queue is operator-owned, so
3375        // the winner is THEIRS (#provauth1/#provauth4).
3376        let _flag = CellMergeFlagOn::on();
3377
3378        let base = doc_with_exchange_queue("Q.", "- do [#a1] original");
3379        let base_state = MultiNodeState::from_text(&base).unwrap();
3380        let ours = doc_with_exchange_queue("Q.", "- do [#a1] OURS edit");
3381        let theirs = doc_with_exchange_queue("Q.", "- do [#a1] THEIRS edit");
3382
3383        let (merged, state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3384
3385        // The persisted state must round-trip to the SAME text that was returned.
3386        assert_eq!(
3387            state.to_text().unwrap(),
3388            merged,
3389            "persisted state diverges from returned merged text under conflict"
3390        );
3391        // The returned text reflects the cell-merge winner — theirs, since queue
3392        // is operator-owned.
3393        assert!(
3394            merged.contains("THEIRS edit"),
3395            "cell-merge operator-owned theirs-wins winner lost:\n{merged}"
3396        );
3397        // The persisted queue component must hold the winner, not the legacy text.
3398        let persisted = state.to_text().unwrap();
3399        let queue_body = persisted
3400            .split("<!-- agent:queue -->")
3401            .nth(1)
3402            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3403            .unwrap_or("");
3404        assert!(
3405            queue_body.contains("THEIRS edit"),
3406            "persisted base lost the cell-merge winner:\n{queue_body}"
3407        );
3408    }
3409
3410    #[test]
3411    fn multinode_merge_struck_head_stable_across_persistence_cycles() {
3412        // Multi-cycle no-churn under persistence: a struck head must stay struck
3413        // across cycles when the advanced MultiNodeState is carried forward as the
3414        // next cycle's base AND a stale un-strike keeps arriving. The persisted
3415        // state must not oscillate — a no-new-edit cycle is idempotent.
3416        let _flag = CellMergeFlagOn::on();
3417
3418        // Cycle 0: strike lands (ours strikes, theirs is the pre-strike base).
3419        let base0 = doc_with_exchange_queue("Q.", "- do [#z9] answered");
3420        let base0_state = MultiNodeState::from_text(&base0).unwrap();
3421        let ours0 = doc_with_exchange_queue("Q.", "- ~~do [#z9] answered~~");
3422        let theirs0 = doc_with_exchange_queue("Q.", "- do [#z9] answered");
3423        let (text0, state0) = MultiNodeState::merge(Some(&base0_state), &ours0, &theirs0).unwrap();
3424        assert!(
3425            text0.contains("~~do [#z9] answered~~"),
3426            "strike lost:\n{text0}"
3427        );
3428        assert_eq!(
3429            state0.to_text().unwrap(),
3430            text0,
3431            "cycle0 round-trip diverges"
3432        );
3433
3434        // Cycle 1: carry state0 forward as base; a STALE un-strike side arrives.
3435        // The struck head must stay struck (anti-resurrection), and the persisted
3436        // state must round-trip the returned text.
3437        let ours1 = doc_with_exchange_queue("Q.", "- do [#z9] answered"); // stale un-strike
3438        let theirs1 = doc_with_exchange_queue("Q.", "- ~~do [#z9] answered~~");
3439        let (text1, state1) = MultiNodeState::merge(Some(&state0), &ours1, &theirs1).unwrap();
3440        assert!(
3441            text1.contains("~~do [#z9] answered~~"),
3442            "stale un-strike resurrected the head:\n{text1}"
3443        );
3444        let live1 = {
3445            let body = text1
3446                .split("<!-- agent:queue -->")
3447                .nth(1)
3448                .and_then(|s| s.split("<!-- /agent:queue -->").next())
3449                .unwrap_or("");
3450            body.lines()
3451                .filter(|l| l.contains("do [#z9]") && !l.contains("~~"))
3452                .count()
3453        };
3454        assert_eq!(
3455            live1, 0,
3456            "head resurrected LIVE across persistence:\n{text1}"
3457        );
3458        assert_eq!(
3459            state1.to_text().unwrap(),
3460            text1,
3461            "cycle1 round-trip diverges"
3462        );
3463
3464        // Cycle 2: NO new edits (ours == theirs == carried text). Must be
3465        // idempotent — identical text AND identical persisted state to cycle 1.
3466        let (text2, state2) = MultiNodeState::merge(Some(&state1), &text1, &text1).unwrap();
3467        assert_eq!(text2, text1, "no-edit cycle oscillated the text");
3468        assert_eq!(
3469            state2.encode(),
3470            state1.encode(),
3471            "no-edit cycle oscillated the persisted state"
3472        );
3473    }
3474
3475    #[test]
3476    fn multinode_merge_flag_off_byte_identical_to_pre_rung() {
3477        // Flag-OFF parity: with the kill-switch explicitly set, MultiNodeState::merge
3478        // output must be byte-identical to the legacy per-node path (the seam is a
3479        // strict no-op). We compute the legacy result by constructing the same
3480        // per-node merge the function would run without the seam. Per-cell merge is
3481        // default-ON, so the legacy path is exercised via the explicit kill-switch.
3482        let _guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3483        // SAFETY: serialized under the lock.
3484        unsafe {
3485            std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "0");
3486        }
3487        assert!(!crate::cell_doc::cell_merge_enabled(), "must be OFF");
3488
3489        let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3490        let base_state = MultiNodeState::from_text(&base).unwrap();
3491        let ours = doc_with_exchange_queue(
3492            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3493            "- do [#a1]",
3494        );
3495        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3496
3497        // Two flag-OFF runs are deterministic and identical.
3498        let (off1, off1_state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3499        let (off2, off2_state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3500        assert_eq!(off1, off2, "flag-OFF text not deterministic");
3501        assert_eq!(
3502            off1_state.encode(),
3503            off2_state.encode(),
3504            "flag-OFF state not deterministic"
3505        );
3506        // The legacy per-node path produced the expected isolated merge.
3507        assert!(off1.contains("### Re: existing"));
3508        assert!(off1.contains("[#b2]"));
3509
3510        // Restore the default (ON) so a leaked kill-switch can't poison sibling
3511        // tests that read the master gate without holding the lock.
3512        // SAFETY: still holding the lock.
3513        unsafe {
3514            std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3515        }
3516    }
3517
3518    #[test]
3519    fn cell_seams_produce_identical_text_under_flag() {
3520        // Round-trip equality: for a non-conflicting concurrent edit, the text
3521        // from merge_by_component (flag ON) must equal the text from
3522        // MultiNodeState::merge (flag ON) for the SAME inputs — the two seams
3523        // agree, so the persisted base never diverges from the committed doc.
3524        let _flag = CellMergeFlagOn::on();
3525
3526        let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3527        let ours = doc_with_exchange_queue(
3528            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3529            "- do [#a1]",
3530        );
3531        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3532
3533        let text_seam = {
3534            let base_state = CrdtDoc::from_text(&base).encode_state();
3535            merge_by_component(Some(&base_state), &ours, &theirs).unwrap()
3536        };
3537        let (persist_seam, _state) = {
3538            let base_state = MultiNodeState::from_text(&base).unwrap();
3539            MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap()
3540        };
3541        assert_eq!(
3542            text_seam, persist_seam,
3543            "the two cell-merge seams disagree on merged text:\n--- text path ---\n{text_seam}\n--- persist path ---\n{persist_seam}"
3544        );
3545    }
3546
3547    // ---- #qnodemerge3: recursive AST-node keyed reconciliation ------------
3548
3549    #[test]
3550    fn reconcile_queue_item_edit_does_not_touch_sibling_item() {
3551        // Acceptance (a): the operator edits queue item B (#b2) while item A
3552        // (#a1) is the running head and the agent appends an exchange response.
3553        // Item A must survive byte-for-byte (no drift, no duplication); item B
3554        // carries the operator edit. Two edits to different keyed children must
3555        // reconcile in separate sub-trees.
3556        let base = doc_with_exchange_queue("Q.", "- :pushpin: do [#a1]\n- :pushpin: do [#b2]");
3557        let base_state = CrdtDoc::from_text(&base).encode_state();
3558        let ours = doc_with_exchange_queue(
3559            "Q.\n\n### Re: q — opus\n\nBody.",
3560            "- :pushpin: do [#a1]\n- :pushpin: do [#b2]",
3561        );
3562        let theirs = doc_with_exchange_queue(
3563            "Q.",
3564            "- :pushpin: do [#a1]\n- :pushpin: do [#b2] with operator note",
3565        );
3566
3567        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3568        assert!(
3569            merged.contains("### Re: q"),
3570            "exchange edit lost:\n{merged}"
3571        );
3572        assert_eq!(
3573            merged.matches("do [#a1]").count(),
3574            1,
3575            "sibling item A drifted/duplicated:\n{merged}"
3576        );
3577        assert!(
3578            merged.contains("- :pushpin: do [#a1]\n"),
3579            "sibling item A text drifted:\n{merged}"
3580        );
3581        assert!(
3582            merged.contains("operator note"),
3583            "item B operator edit lost:\n{merged}"
3584        );
3585    }
3586
3587    #[test]
3588    fn reconcile_exchange_prompt_between_blocks_converges_with_new_block() {
3589        // Acceptance (b): a user prompt inserted between two committed `### Re:`
3590        // blocks while the agent appends a new block must converge with all three
3591        // blocks present and zero cross-block splice (each heading exactly once).
3592        let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3593        let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3594        let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3595        let base_state = CrdtDoc::from_text(&base).encode_state();
3596
3597        let r3 = "### Re: third — opus\n\nThird answer.\n\n";
3598        let ours = doc_with_exchange_queue(&format!("{r1}{r2}{r3}"), "- do [#a1]");
3599        let theirs = doc_with_exchange_queue(
3600            &format!("{r1}❯ a prompt typed between blocks\n\n{r2}"),
3601            "- do [#a1]",
3602        );
3603
3604        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3605        assert!(merged.contains("### Re: first"), "block 1 lost:\n{merged}");
3606        assert!(merged.contains("### Re: second"), "block 2 lost:\n{merged}");
3607        assert!(
3608            merged.contains("### Re: third"),
3609            "appended block 3 lost:\n{merged}"
3610        );
3611        assert!(
3612            merged.contains("a prompt typed between blocks"),
3613            "interleaved operator prompt lost:\n{merged}"
3614        );
3615        assert_eq!(
3616            merged.matches("### Re: first").count(),
3617            1,
3618            "block 1 cross-spliced/duplicated:\n{merged}"
3619        );
3620        assert_eq!(
3621            merged.matches("### Re: third").count(),
3622            1,
3623            "block 3 cross-spliced/duplicated:\n{merged}"
3624        );
3625    }
3626
3627    #[test]
3628    fn reconcile_queue_preserves_concurrent_user_item_addition() {
3629        // The #queue-user-edit-overwrite class: the operator adds a new queue item
3630        // (theirs) while the agent edits a different item (ours). Keyed
3631        // reconciliation treats the new item as a pure insert — never dropped.
3632        let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3633        let base_state = CrdtDoc::from_text(&base).encode_state();
3634        let ours = doc_with_exchange_queue("Q.", "- do [#a1] agent touched");
3635        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#user-added]");
3636
3637        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3638        assert!(
3639            merged.contains("[#user-added]"),
3640            "concurrent user queue addition dropped:\n{merged}"
3641        );
3642        assert!(
3643            merged.contains("agent touched"),
3644            "concurrent agent item edit lost:\n{merged}"
3645        );
3646    }
3647
3648    #[test]
3649    fn reconcile_queue_honors_clean_item_deletion() {
3650        // theirs removes item B (clean delete: ours left B == base). The delete is
3651        // honored while item A and the agent's exchange edit survive.
3652        let base = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3653        let base_state = CrdtDoc::from_text(&base).encode_state();
3654        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3655        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]");
3656
3657        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3658        assert!(merged.contains("Body."), "exchange edit lost:\n{merged}");
3659        let queue_body = merged
3660            .split("<!-- agent:queue -->")
3661            .nth(1)
3662            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3663            .unwrap_or("");
3664        assert!(queue_body.contains("[#a1]"), "item A lost:\n{queue_body}");
3665        assert!(
3666            !queue_body.contains("[#b2]"),
3667            "clean deletion of item B not honored:\n{queue_body}"
3668        );
3669    }
3670
3671    #[test]
3672    fn reconcile_exchange_never_drops_committed_block_on_stale_side() {
3673        // A stale `theirs` that lost a committed `### Re:` block must not delete it
3674        // — committed exchange history is append-only (the per-block
3675        // #ipc-crdt-response-drift guard).
3676        let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3677        let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3678        let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3679        let base_state = CrdtDoc::from_text(&base).encode_state();
3680        // ours keeps both committed blocks; theirs is stale and dropped block 2.
3681        let ours = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]\n- do [#new]");
3682        let theirs = doc_with_exchange_queue(&r1.to_string(), "- do [#a1]");
3683
3684        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3685        assert!(
3686            merged.contains("### Re: second"),
3687            "stale side deleted a committed block:\n{merged}"
3688        );
3689        assert!(
3690            merged.contains("[#new]"),
3691            "concurrent queue add lost:\n{merged}"
3692        );
3693    }
3694
3695    #[test]
3696    fn reconcile_falls_back_on_duplicate_item_keys() {
3697        // Two identical free-text queue items make keys ambiguous → fall back to
3698        // the flat whole-component merge (no panic, deterministic, content kept).
3699        let base = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3700        let base_state = CrdtDoc::from_text(&base).encode_state();
3701        let ours = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item\n- new one");
3702        let theirs = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3703
3704        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3705        assert!(
3706            merged.contains("new one"),
3707            "duplicate-key fallback dropped the new item:\n{merged}"
3708        );
3709    }
3710
3711    #[test]
3712    fn first_hash_id_extracts_durable_id() {
3713        assert_eq!(
3714            first_hash_id("- :pushpin: do [#qnodemerge3]"),
3715            Some("qnodemerge3".to_string())
3716        );
3717        assert_eq!(
3718            first_hash_id("- [ ] [#6b5hwire] some text"),
3719            Some("6b5hwire".to_string())
3720        );
3721        assert_eq!(first_hash_id("- free text no id"), None);
3722    }
3723
3724    #[test]
3725    fn list_item_key_stable_across_strike_and_glyphs() {
3726        // A struck or pin-glyphed item keys the same as its plain form, so a
3727        // strike reconciles as a content edit, not a delete+insert.
3728        assert_eq!(list_item_key("- do [#a1]"), list_item_key("- ~~do [#a1]~~"));
3729        assert_eq!(
3730            list_item_key("- :pushpin: hello world"),
3731            list_item_key("- ~~hello world~~")
3732        );
3733    }
3734
3735    #[test]
3736    fn order_union_weaves_theirs_only_inserts() {
3737        let ours = ["a", "b", "c"];
3738        let theirs = ["a", "x", "b", "c", "y"];
3739        assert_eq!(
3740            order_union(&ours, &theirs),
3741            vec![
3742                "a".to_string(),
3743                "x".into(),
3744                "b".into(),
3745                "c".into(),
3746                "y".into()
3747            ]
3748        );
3749    }
3750
3751    #[test]
3752    fn split_list_children_is_lossless() {
3753        // The segmentation must be exact: concatenating child texts reproduces the
3754        // body byte-for-byte (no content gained or lost).
3755        let body = "\n- do [#a1]\n  continuation line\n- :pushpin: do [#b2]\n\n";
3756        let children = split_list_children(body).unwrap();
3757        let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3758        assert_eq!(joined, body, "list segmentation is not lossless");
3759    }
3760
3761    #[test]
3762    fn split_exchange_children_is_lossless() {
3763        let body = "\n### Re: a — opus\n\nbody a\n\n### Re: b — opus\n\nbody b\n";
3764        let children = split_exchange_children(body).unwrap();
3765        let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3766        assert_eq!(joined, body, "exchange segmentation is not lossless");
3767    }
3768
3769    /// #qheadresidue / #queuestatemachine2: a free-text queue head answered and
3770    /// STRUCK in the committed doc (`ours`) must NOT be resurrected un-struck by a
3771    /// stale persisted-CRDT side (`theirs`) that still carries the head live.
3772    ///
3773    /// This drives the real `merge_by_component` convergence path. The struck and
3774    /// un-struck lines share the same `list_item_key` (strike markers are
3775    /// normalized away), so the per-item reconciler leaf-merges them — and the
3776    /// stale live text resurrects.
3777    #[test]
3778    fn merge_by_component_struck_free_text_head_not_resurrected_by_stale_crdt() {
3779        // Base: the head is a live free-text queue item.
3780        let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3781<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3782        // Ours (committed doc, authoritative): the agent answered and STRUCK it.
3783        let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3784<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3785        // Theirs (persisted CRDT, stale): still carries the head un-struck.
3786        let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3787<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3788
3789        let base_state = CrdtDoc::from_text(base).encode_state();
3790        let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3791
3792        let queue_body = merged
3793            .split("<!-- agent:queue -->")
3794            .nth(1)
3795            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3796            .unwrap();
3797        // The head must remain struck — never reappear as a fresh live head.
3798        assert!(
3799            queue_body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
3800            "struck head must survive struck:\n{queue_body}"
3801        );
3802        let live_occurrences = queue_body
3803            .lines()
3804            .filter(|l| {
3805                l.contains("Still getting JB File Cache Conflict dialogs.") && !l.contains("~~")
3806            })
3807            .count();
3808        assert_eq!(
3809            live_occurrences, 0,
3810            "stale CRDT resurrected the struck head as a live queue line:\n{queue_body}"
3811        );
3812    }
3813
3814    /// #qheadresidue variant: the answered head was REAPED (removed) from the
3815    /// committed doc (`ours`), but the stale persisted CRDT (`theirs`) still
3816    /// carries it live. The merge must NOT re-add the reaped head.
3817    ///
3818    /// This is the canonical "answered head reappears every preflight" residue:
3819    /// the committed queue is empty, the persisted `.yrs` still has the line, and
3820    /// the per-item reconciler treats theirs-only as a clean insert.
3821    #[test]
3822    fn merge_by_component_reaped_free_text_head_not_readded_by_stale_crdt() {
3823        let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3824<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3825        // Ours (committed): the head is gone — reaped after answering.
3826        let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3827<!-- agent:queue -->\n<!-- /agent:queue -->\n";
3828        // Theirs (stale persisted CRDT): still carries the head live.
3829        let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3830<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3831
3832        let base_state = CrdtDoc::from_text(base).encode_state();
3833        let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3834
3835        let queue_body = merged
3836            .split("<!-- agent:queue -->")
3837            .nth(1)
3838            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3839            .unwrap();
3840        assert!(
3841            !queue_body.contains("Still getting JB File Cache Conflict dialogs."),
3842            "stale CRDT re-added the reaped head:\n{queue_body}"
3843        );
3844    }
3845
3846    /// #qheadresidue / #queuestatemachine2 — THE genuinely-red resurrection case.
3847    ///
3848    /// This is the exact live churn: a queue head that was already answered+struck
3849    /// (persisted into the `.yrs` BASE as `~~...~~`) is "un-struck" by the stale
3850    /// agent side (`ours`), which still carries the head LIVE because the seam
3851    /// strike was a direct file edit the CRDT base never saw advance. The
3852    /// committed disk side (`theirs`) is correctly struck. A plain Yrs leaf merge
3853    /// reads `ours` as having DELETED the `~~` markers (an edit vs base) while
3854    /// `theirs` matches base, so the un-strike "edit" wins and the head reappears
3855    /// LIVE every cycle.
3856    ///
3857    /// Before the per-item lifecycle wiring this asserts FAILED (merged dropped
3858    /// the strike → live head). The fix joins each side through the queue-item
3859    /// lifecycle SM: `Struck` outranks `Authored`/`Mirrored`, so a stale un-strike
3860    /// can never regress a struck head.
3861    #[test]
3862    fn merge_by_component_struck_id_head_not_resurrected_by_stale_unstrike() {
3863        // BASE: already struck (the answered state persisted into the .yrs).
3864        let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3865<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
3866        // OURS (agent / content_ours side): still LIVE — un-strikes vs base.
3867        let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3868<!-- agent:queue -->\n- do [#abcd] fix the thing\n<!-- /agent:queue -->\n";
3869        // THEIRS (committed disk): correctly struck.
3870        let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3871<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
3872
3873        let base_state = CrdtDoc::from_text(base).encode_state();
3874        let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3875
3876        let queue_body = merged
3877            .split("<!-- agent:queue -->")
3878            .nth(1)
3879            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3880            .unwrap();
3881        assert!(
3882            queue_body.contains("~~do [#abcd] fix the thing~~"),
3883            "struck head must stay struck:\n{queue_body}"
3884        );
3885        let live = queue_body
3886            .lines()
3887            .filter(|l| l.contains("do [#abcd]") && !l.contains("~~"))
3888            .count();
3889        assert_eq!(
3890            live, 0,
3891            "stale un-strike resurrected the struck head LIVE:\n{queue_body}"
3892        );
3893    }
3894
3895    /// Free-text variant of the resurrection: same residue shape, but the head is
3896    /// an operator free-text line (no `#id`), so the fix must also reconcile
3897    /// free-text identities (`#qdedupsync` was free-text-blind).
3898    #[test]
3899    fn merge_by_component_struck_free_text_head_not_resurrected_by_stale_unstrike() {
3900        let base = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3901        let ours = "<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3902        let theirs = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3903
3904        let base_state = CrdtDoc::from_text(base).encode_state();
3905        let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3906
3907        let queue_body = merged
3908            .split("<!-- agent:queue -->")
3909            .nth(1)
3910            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3911            .unwrap();
3912        assert!(
3913            queue_body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
3914            "struck free-text head must stay struck:\n{queue_body}"
3915        );
3916        let live = queue_body
3917            .lines()
3918            .filter(|l| {
3919                l.contains("Still getting JB File Cache Conflict dialogs.") && !l.contains("~~")
3920            })
3921            .count();
3922        assert_eq!(
3923            live, 0,
3924            "stale un-strike resurrected the struck free-text head LIVE:\n{queue_body}"
3925        );
3926    }
3927
3928    /// Multi-cycle no-churn proof (`#qheadresidue`): strike a head, persist the
3929    /// struck state as the next cycle's base, then re-merge with a stale un-strike
3930    /// agent side every cycle. The head must stay struck across every cycle with
3931    /// no re-emit churn (struck stays struck, never re-added live then re-struck).
3932    #[test]
3933    fn merge_by_component_struck_head_stable_across_cycles_no_churn() {
3934        // Cycle 0 starts from a struck base.
3935        let mut base =
3936            "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n".to_string();
3937        for cycle in 0..3 {
3938            let base_state = CrdtDoc::from_text(&base).encode_state();
3939            // Agent side keeps regenerating the head LIVE (stale un-strike).
3940            let ours = "<!-- agent:queue -->\n- do [#abcd] answered\n<!-- /agent:queue -->\n";
3941            // Disk stays correctly struck.
3942            let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n";
3943            let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3944            let queue_body = merged
3945                .split("<!-- agent:queue -->")
3946                .nth(1)
3947                .and_then(|s| s.split("<!-- /agent:queue -->").next())
3948                .unwrap();
3949            let live = queue_body
3950                .lines()
3951                .filter(|l| l.contains("do [#abcd]") && !l.contains("~~"))
3952                .count();
3953            assert_eq!(
3954                live, 0,
3955                "cycle {cycle}: head resurrected LIVE (churn):\n{queue_body}"
3956            );
3957            assert!(
3958                queue_body.contains("~~do [#abcd] answered~~"),
3959                "cycle {cycle}: struck head lost:\n{queue_body}"
3960            );
3961            assert_eq!(
3962                queue_body.matches("do [#abcd] answered").count(),
3963                1,
3964                "cycle {cycle}: head duplicated:\n{queue_body}"
3965            );
3966            // Persist the merged (lawful, struck) state as the next cycle's base.
3967            base = merged;
3968        }
3969    }
3970
3971    // ---- #qcellmerge1: cross-cell contamination is impossible by construction --
3972
3973    /// Build a 3-component template doc (`status`, `exchange`, `queue`) for the
3974    /// multi-component concurrent-edit case.
3975    fn doc_with_status_exchange_queue(status: &str, exchange: &str, queue: &str) -> String {
3976        format!(
3977            "---\nagent_doc_format: template\n---\n\n## Status\n\n<!-- agent:status -->\n{status}\n<!-- /agent:status -->\n\n## Exchange\n\n<!-- agent:exchange -->\n{exchange}\n<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n{queue}\n<!-- /agent:queue -->\n"
3978        )
3979    }
3980
3981    fn body_of<'a>(merged: &'a str, name: &str) -> &'a str {
3982        let open = format!("<!-- agent:{name} -->");
3983        let close = format!("<!-- /agent:{name} -->");
3984        merged
3985            .split(&open)
3986            .nth(1)
3987            .and_then(|s| s.split(&close).next())
3988            .unwrap_or("")
3989    }
3990
3991    /// The headline `#qcellmerge1` invariant, asserted as one test in all three
3992    /// required directions: forward, inverse, and a multi-component concurrent
3993    /// edit. Each `agent:*` component is its own CRDT merge unit, so content from
3994    /// one component can NEVER splice into another — the live 2026-06-20
3995    /// console-output-in-`agent:queue` corruption class is unreproducible by
3996    /// construction.
3997    #[test]
3998    fn cross_cell_contamination_is_impossible() {
3999        let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
4000
4001        // --- Forward: extra content in A (exchange) must NOT leak into B (queue).
4002        // The documented real failure shape: an `agent:exchange` console block
4003        // must never appear inside `agent:queue`. The agent appends the console
4004        // block to `exchange` while the operator concurrently types in `queue`.
4005        {
4006            let base = doc_with_exchange_queue("Q.", "- operator typing");
4007            let base_state = CrdtDoc::from_text(&base).encode_state();
4008            let ours = doc_with_exchange_queue(
4009                &format!("Q.\n\n### Re: q — opus\n\n{console}"),
4010                "- operator typing",
4011            );
4012            let theirs = doc_with_exchange_queue("Q.", "- operator typing more");
4013
4014            let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4015            assert!(
4016                !body_of(&merged, "queue").contains("Supervisor is now fresh"),
4017                "FORWARD: exchange console block spliced into queue:\n{}",
4018                body_of(&merged, "queue")
4019            );
4020            // Both real edits survived, in their own cells.
4021            assert!(
4022                body_of(&merged, "exchange").contains("Supervisor is now fresh"),
4023                "FORWARD: console block lost from its own (exchange) cell:\n{merged}"
4024            );
4025            assert!(
4026                body_of(&merged, "queue").contains("typing more"),
4027                "FORWARD: operator queue edit lost:\n{merged}"
4028            );
4029        }
4030
4031        // --- Inverse: extra content in B (queue) must NOT leak into A (exchange).
4032        // The operator pastes a multi-line fenced block into a `queue` item while
4033        // the agent concurrently appends a `### Re:` block to `exchange`. The
4034        // queue paste must never appear inside the exchange cell.
4035        {
4036            let queue_paste = "- here is my log:\n  ```\n  panic at line 9000\n  ```";
4037            let base = doc_with_exchange_queue("Q.", "- placeholder");
4038            let base_state = CrdtDoc::from_text(&base).encode_state();
4039            let ours =
4040                doc_with_exchange_queue("Q.\n\n### Re: q — opus\n\nAgent answer.", "- placeholder");
4041            let theirs = doc_with_exchange_queue("Q.", queue_paste);
4042
4043            let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4044            assert!(
4045                !body_of(&merged, "exchange").contains("panic at line 9000"),
4046                "INVERSE: queue paste spliced into exchange:\n{}",
4047                body_of(&merged, "exchange")
4048            );
4049            assert!(
4050                body_of(&merged, "queue").contains("panic at line 9000"),
4051                "INVERSE: queue paste lost from its own cell:\n{merged}"
4052            );
4053            assert!(
4054                body_of(&merged, "exchange").contains("Agent answer."),
4055                "INVERSE: agent exchange edit lost:\n{merged}"
4056            );
4057        }
4058
4059        // --- Multi-component concurrent edit: three cells (status, exchange,
4060        // queue) edited concurrently across ours/theirs. Each cell keeps exactly
4061        // its own edit and nothing crosses a cell boundary.
4062        {
4063            let base = doc_with_status_exchange_queue("idle", "Q.", "- do [#a1]");
4064            let base_state = CrdtDoc::from_text(&base).encode_state();
4065            // ours: agent flips status + appends an exchange response block.
4066            let ours = doc_with_status_exchange_queue(
4067                "running OURS_STATUS",
4068                &format!("Q.\n\n### Re: q — opus\n\n{console}"),
4069                "- do [#a1]",
4070            );
4071            // theirs: operator adds a queue item (and leaves status/exchange alone).
4072            let theirs =
4073                doc_with_status_exchange_queue("idle", "Q.", "- do [#a1]\n- do [#THEIRS_ITEM]");
4074
4075            let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4076
4077            let status_body = body_of(&merged, "status");
4078            let exchange_body = body_of(&merged, "exchange");
4079            let queue_body = body_of(&merged, "queue");
4080
4081            // Each edit landed in its own cell.
4082            assert!(
4083                status_body.contains("OURS_STATUS"),
4084                "MULTI: status edit lost:\n{merged}"
4085            );
4086            assert!(
4087                exchange_body.contains("Supervisor is now fresh"),
4088                "MULTI: exchange edit lost:\n{merged}"
4089            );
4090            assert!(
4091                queue_body.contains("[#THEIRS_ITEM]"),
4092                "MULTI: queue edit lost:\n{merged}"
4093            );
4094
4095            // Nothing crossed a cell boundary, in any direction.
4096            assert!(
4097                !status_body.contains("Supervisor is now fresh")
4098                    && !status_body.contains("[#THEIRS_ITEM]"),
4099                "MULTI: foreign content spliced into status:\n{status_body}"
4100            );
4101            assert!(
4102                !exchange_body.contains("OURS_STATUS") && !exchange_body.contains("[#THEIRS_ITEM]"),
4103                "MULTI: foreign content spliced into exchange:\n{exchange_body}"
4104            );
4105            assert!(
4106                !queue_body.contains("Supervisor is now fresh")
4107                    && !queue_body.contains("OURS_STATUS"),
4108                "MULTI: foreign content spliced into queue:\n{queue_body}"
4109            );
4110        }
4111    }
4112}