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    eprintln!(
181        "[crdt] merge: base_len={} ours_len={} theirs_len={}",
182        base_text.len(),
183        ours_text.len(),
184        theirs_text.len()
185    );
186
187    // Capture committed assistant response headings from the *original* base,
188    // before stale-base detection / prefix advancement can rewrite `base_text`.
189    // Committed `### Re:` blocks are append-only history; a stale or divergent
190    // `theirs` must not be able to delete them out of the merged result.
191    // (#ipc-crdt-response-drift)
192    let committed_response_headings = response_headings(&base_text);
193
194    // Stale base detection: if the base text doesn't share enough content
195    // with both sides, it's stale. Use ours as the base instead.
196    // This prevents duplicate insertions when both sides contain text
197    // that the stale base doesn't have.
198    //
199    // We check both common prefix AND common suffix. Template documents
200    // have structural content (frontmatter, component markers, pending
201    // sections) that bookend the exchange. When the exchange is short or
202    // empty, only checking the prefix classifies a correct base as stale
203    // (the suffix — closing markers + pending — goes uncounted).
204    let ours_prefix = common_prefix_len(&base_text, ours_text);
205    let ours_suffix = common_suffix_len(&base_text, ours_text);
206    let theirs_prefix = common_prefix_len(&base_text, theirs_text);
207    let theirs_suffix = common_suffix_len(&base_text, theirs_text);
208    let base_len = base_text.len();
209
210    // Clamp shared bytes to base_len (prefix + suffix can overlap for short strings)
211    let ours_shared = (ours_prefix + ours_suffix).min(base_len);
212    let theirs_shared = (theirs_prefix + theirs_suffix).min(base_len);
213
214    if base_len > 0
215        && (ours_shared as f64 / base_len as f64) < 0.5
216        && (theirs_shared as f64 / base_len as f64) < 0.5
217    {
218        eprintln!(
219            "[crdt] Stale CRDT base detected (shared: ours={}%, theirs={}%). Using ours as base.",
220            (ours_shared * 100) / base_len,
221            (theirs_shared * 100) / base_len
222        );
223        base_text = ours_text.to_string();
224    }
225
226    // Advance base to the common prefix of ours and theirs when it extends
227    // beyond the point where the base diverges from either side.
228    //
229    // When both ours and theirs independently added the same text beyond the
230    // base (e.g., both contain a user prompt that the base doesn't have),
231    // the CRDT treats each insertion as independent and includes both, causing
232    // duplication. Fix: use the common prefix of ours and theirs as the effective
233    // base, so shared additions are not treated as independent insertions.
234    //
235    // The divergence point (`base_diverge`) is where the base stops matching
236    // either side. Content shared by ours and theirs beyond this point was
237    // added by both sides independently and must be promoted into the base.
238    //
239    // This handles two patterns:
240    //   Pattern 1 (mutual prefix > base length):
241    //     base   = "old content"
242    //     ours   = "old content + user prompt + agent response"
243    //     theirs = "old content + user prompt + small edit"
244    //   Pattern 2 (base has trailing structure beyond divergence):
245    //     base   = "header + empty exchange + footer"
246    //     ours   = "header + prompt + response + footer"
247    //     theirs = "header + prompt + boundary + footer"
248    //   Both diverge from base at "header", but share "prompt" beyond it.
249    let mutual_prefix = common_prefix_len(ours_text, theirs_text);
250    let base_diverge =
251        common_prefix_len(&base_text, ours_text).max(common_prefix_len(&base_text, theirs_text));
252    if mutual_prefix > base_diverge {
253        // Snap to a line boundary to avoid splitting mid-line/mid-word.
254        // Without this, the shared prefix can include partial formatting
255        // sequences (e.g., a leading `*` from `**bold**`), causing the
256        // CRDT merge to separate that character from the rest of the
257        // formatting, producing garbled text like `*Soft-bristle brush only**`
258        // instead of `**Soft-bristle brush only**`.
259        let snap = &ours_text[..mutual_prefix];
260        let snapped = match snap.rfind('\n') {
261            Some(pos) if pos >= base_diverge => pos + 1,
262            _ => base_diverge, // no suitable line boundary — don't advance
263        };
264        if snapped > base_diverge {
265            eprintln!(
266                "[crdt] Advancing base to shared prefix (diverge={} → {})",
267                base_diverge, snapped
268            );
269            base_text = ours_text[..snapped].to_string();
270        }
271    }
272
273    // Op-capture gate (#qnodemerge4): prefer the editor's real ops for `theirs`
274    // when replaying them onto the resolved base reproduces `theirs_text`
275    // exactly. Otherwise (no ops, or ops captured against a divergent base) fall
276    // back to the diff-guess. Validated against the *resolved* `base_text` so a
277    // stale/advanced base correctly disqualifies the captured ops.
278    let theirs_replay_ops = match theirs_editor_ops {
279        // Conservative offset-safety guard: feed real ops to Yrs only when the
280        // base and theirs are ASCII. The op byte-offsets match `apply_ops`'s
281        // byte-cursor convention, but Yrs index/offset semantics for non-ASCII
282        // text are not asserted here, so unicode regions fall back to the
283        // diff-guess (no regression). Per-component merge (#qnodemerge3) means
284        // an emoji in `queue` never disables op-replay for ASCII `exchange`
285        // prose. (#qnodemerge4)
286        Some(ops) if !ops.is_empty() && base_text.is_ascii() && theirs_text.is_ascii() => {
287            match replay_editor_ops(&base_text, ops) {
288                Some(replayed) if replayed == theirs_text => {
289                    eprintln!(
290                        "[crdt] editor_ops_replayed: {} captured op(s) reproduce theirs exactly (#qnodemerge4)",
291                        ops.len()
292                    );
293                    Some(ops)
294                }
295                _ => {
296                    eprintln!(
297                        "[crdt] editor_ops_fallback: {} captured op(s) do not replay to theirs from the resolved base (stale/misaligned) — using diff-guess (#qnodemerge4)",
298                        ops.len()
299                    );
300                    None
301                }
302            }
303        }
304        _ => None,
305    };
306
307    // Compute diffs from base to each side
308    let ours_ops = compute_edit_ops(&base_text, ours_text);
309    let theirs_ops = compute_edit_ops(&base_text, theirs_text);
310
311    // Create two independent docs from the base state.
312    // If base was overridden (stale detection), rebuild from the new base_text.
313    let base_encoded = if base_text == base_doc.to_text() {
314        base_doc.encode_state()
315    } else {
316        CrdtDoc::from_text(&base_text).encode_state()
317    };
318
319    // Agent gets lower client ID (1) so Yrs natively places agent content
320    // BEFORE human content when both insert at the same position.
321    // Yrs orders concurrent inserts by client ID: lower client ID goes first.
322    let ours_doc = Doc::with_client_id(1);
323    {
324        let update =
325            Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
326        let mut txn = ours_doc.transact_mut();
327        txn.apply_update(update)
328            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
329    }
330
331    let theirs_doc = Doc::with_client_id(2);
332    {
333        let update =
334            Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
335        let mut txn = theirs_doc.transact_mut();
336        txn.apply_update(update)
337            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
338    }
339
340    // Apply ours edits
341    {
342        let text = ours_doc.get_or_insert_text(TEXT_KEY);
343        let mut txn = ours_doc.transact_mut();
344        apply_ops(&text, &mut txn, &ours_ops);
345    }
346
347    // Apply theirs edits — from the editor's real ops when the gate passed,
348    // otherwise from the diff-guess (#qnodemerge4).
349    {
350        let text = theirs_doc.get_or_insert_text(TEXT_KEY);
351        let mut txn = theirs_doc.transact_mut();
352        match theirs_replay_ops {
353            Some(ops) => apply_editor_ops(&text, &mut txn, ops),
354            None => apply_ops(&text, &mut txn, &theirs_ops),
355        }
356    }
357
358    // Merge: apply theirs' changes into ours
359    let ours_sv = {
360        let txn = ours_doc.transact();
361        txn.state_vector()
362    };
363    let theirs_update = {
364        let txn = theirs_doc.transact();
365        txn.encode_state_as_update_v1(&ours_sv)
366    };
367    {
368        let update = Update::decode_v1(&theirs_update)
369            .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
370        let mut txn = ours_doc.transact_mut();
371        txn.apply_update(update)
372            .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
373    }
374
375    // Read merged result. With agent/ours=client_id(1) and theirs=client_id(2)
376    // (see the client-id assignment above), Yrs natively orders concurrent
377    // inserts at the same position by ascending client id, so agent content
378    // lands before foreign content at the append boundary. No post-merge
379    // reorder needed.
380    let merged = {
381        let text = ours_doc.get_or_insert_text(TEXT_KEY);
382        let txn = ours_doc.transact();
383        text.get_string(&txn)
384    };
385
386    // Committed-response protection (#ipc-crdt-response-drift): a stale or
387    // divergent `theirs` (e.g. a concurrent foreign supervisor, or a stale
388    // editor live-buffer pushed by convergence) can present a document that has
389    // *lost* a `### Re:` response block which was already committed (present in
390    // `base`) and is still present in `ours`. The line-diff merge would honor
391    // that foreign deletion and strip the committed response from the result
392    // ("### Re: header missing" / prior response hoisted away). Foreign writers
393    // must not delete committed exchange history.
394    let merged =
395        guard_committed_responses(merged, &committed_response_headings, ours_text, theirs_text);
396
397    // Post-merge dedup: remove identical adjacent blocks (#15)
398    Ok(dedup_adjacent_blocks(&merged))
399}
400
401/// One node of the per-component document segmentation (`#qcellmerge1`).
402///
403/// A document is decomposed into an alternating sequence of interstitial text
404/// (preamble, gaps between components, trailing residue) and `<!-- agent:* -->`
405/// component regions. Each node merges against its own base independently so
406/// unrelated nodes (e.g. `exchange` vs `queue`) never contend and content can
407/// never splice across components.
408#[derive(Debug, Clone)]
409enum Node {
410    /// Free text outside any component marker (always present between/around
411    /// components, possibly empty, to keep ours/theirs node sequences aligned).
412    Interstitial(String),
413    /// A full `<!-- agent:name -->...<!-- /agent:name -->` region, markers included.
414    Component { name: String, text: String },
415}
416
417impl Node {
418    fn component_name(&self) -> Option<&str> {
419        match self {
420            Node::Component { name, .. } => Some(name.as_str()),
421            Node::Interstitial(_) => None,
422        }
423    }
424
425    fn text(&self) -> &str {
426        match self {
427            Node::Interstitial(t) => t,
428            Node::Component { text, .. } => text,
429        }
430    }
431}
432
433/// Segment `text` into top-level nodes: `I0 C0 I1 C1 … C(n-1) In`.
434///
435/// Interstitial slots are always emitted (even when empty) so two documents
436/// with the same component-name sequence produce structurally-aligned node
437/// vectors that can be paired by index.
438fn segment_into_nodes(text: &str) -> Result<Vec<Node>> {
439    let comps = crate::component::parse(text)?;
440    // Top-level components only: a component is top-level if it is not nested
441    // inside any other component's span.
442    let mut top: Vec<&crate::component::Component> = comps
443        .iter()
444        .filter(|c| {
445            !comps.iter().any(|o| {
446                !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
447            })
448        })
449        .collect();
450    top.sort_by_key(|c| c.open_start);
451
452    let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
453    let mut cursor = 0usize;
454    for c in top {
455        // Interstitial slot before this component (possibly empty).
456        nodes.push(Node::Interstitial(text[cursor..c.open_start].to_string()));
457        nodes.push(Node::Component {
458            name: c.name.clone(),
459            text: text[c.open_start..c.close_end].to_string(),
460        });
461        cursor = c.close_end;
462    }
463    // Trailing interstitial slot (possibly empty).
464    nodes.push(Node::Interstitial(text[cursor..].to_string()));
465    Ok(nodes)
466}
467
468/// Component-scoped three-way CRDT merge (`#qcellmerge1`) — the anti-corruption rung.
469///
470/// Instead of reconciling the whole document as one text blob (which lets
471/// unrelated edits contend and content splice across components — e.g. agent
472/// console output merged INTO `agent:queue`), this splits ours/theirs/base into
473/// per-component nodes and runs [`merge`] on each node against its own base.
474///
475/// - Nodes are paired by their component-name sequence. Component base text is
476///   resolved by name (components are unique per document in practice), so the
477///   `exchange` committed-response guard inside [`merge`] still sees its real base.
478/// - **Structural divergence** — when ours and theirs disagree on which
479///   components exist (a component added/removed on one side) — falls back to the
480///   whole-document [`merge`] for the entire document, logged, never silently.
481/// - A document with no `agent:*` components (inline mode) delegates directly to
482///   [`merge`] with the original base state, preserving legacy behavior exactly.
483pub fn merge_by_component(
484    base_state: Option<&[u8]>,
485    ours_text: &str,
486    theirs_text: &str,
487) -> Result<String> {
488    if ours_text == theirs_text {
489        return Ok(ours_text.to_string());
490    }
491
492    let ours_nodes = match segment_into_nodes(ours_text) {
493        Ok(c) => c,
494        Err(e) => {
495            eprintln!(
496                "[crdt] merge_by_component: failed to segment ours ({e}); falling back to whole-doc merge"
497            );
498            return merge(base_state, ours_text, theirs_text);
499        }
500    };
501    let theirs_nodes = match segment_into_nodes(theirs_text) {
502        Ok(c) => c,
503        Err(e) => {
504            eprintln!(
505                "[crdt] merge_by_component: failed to segment theirs ({e}); falling back to whole-doc merge"
506            );
507            return merge(base_state, ours_text, theirs_text);
508        }
509    };
510
511    let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
512    let theirs_names: Vec<&str> = theirs_nodes
513        .iter()
514        .filter_map(Node::component_name)
515        .collect();
516
517    // No components on either side (inline-mode doc): preserve exact legacy
518    // behavior by delegating to the whole-doc merge with the original state.
519    if ours_names.is_empty() && theirs_names.is_empty() {
520        return merge(base_state, ours_text, theirs_text);
521    }
522
523    // Structural divergence: the set/order of components differs. A per-node
524    // pairing is unsound, so fall back to the whole-doc merge (logged).
525    if ours_names != theirs_names {
526        eprintln!(
527            "[crdt] merge_by_component: structural divergence (ours components {ours_names:?} != theirs {theirs_names:?}); falling back to whole-doc merge"
528        );
529        return merge(base_state, ours_text, theirs_text);
530    }
531
532    // Resolve base text and a name→content map for per-node base alignment.
533    let base_text = match base_state {
534        Some(bytes) => match CrdtDoc::decode_state(bytes) {
535            Ok(doc) => doc.to_text(),
536            Err(e) => {
537                eprintln!(
538                    "[crdt] merge_by_component: failed to decode base state ({e}); merging nodes without a base"
539                );
540                String::new()
541            }
542        },
543        None => String::new(),
544    };
545    let base_nodes = segment_into_nodes(&base_text).unwrap_or_default();
546    let mut base_by_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
547    for node in &base_nodes {
548        if let Node::Component { name, text } = node {
549            base_by_name.entry(name.as_str()).or_insert(text.as_str());
550        }
551    }
552    // Interstitial base slots, paired positionally with ours/theirs interstitials.
553    let base_interstitials: Vec<&str> = base_nodes
554        .iter()
555        .filter_map(|c| match c {
556            Node::Interstitial(t) => Some(t.as_str()),
557            Node::Component { .. } => None,
558        })
559        .collect();
560
561    let merged_nodes = merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
562        Some(n) => base_by_name.get(n).map(|s| s.to_string()),
563        None => base_interstitials.get(idx).map(|s| s.to_string()),
564    })?;
565    let merged: String = merged_nodes.into_iter().map(|(_, text)| text).collect();
566
567    // Structural safety net (#hap7): a per-node leaf merge can, for some shapes
568    // (e.g. a compacted-exchange body whose prose mentions the component markers,
569    // or an interstitial carrying a stray heading), reassemble into a document
570    // whose top-level component framing no longer round-trips — a duplicated or
571    // dropped `<!-- /agent:NAME -->` close, or a changed component-name sequence.
572    // That malformed shape would then trip the downstream exchange-tail / boundary
573    // repair. Rather than emit it, fall back to the proven whole-doc `merge`
574    // (logged, never silent) — the same "never worse than today" contract the
575    // op-capture gate and the structural-divergence branch above already honor.
576    let merged_names: Vec<String> = match segment_into_nodes(&merged) {
577        Ok(nodes) => nodes
578            .iter()
579            .filter_map(|n| n.component_name().map(str::to_string))
580            .collect(),
581        Err(e) => {
582            eprintln!(
583                "[crdt] merge_by_component: merged result failed to re-segment ({e}); falling back to whole-doc merge"
584            );
585            return merge(base_state, ours_text, theirs_text);
586        }
587    };
588    let names_match = merged_names.len() == ours_names.len()
589        && merged_names
590            .iter()
591            .zip(ours_names.iter())
592            .all(|(m, o)| m.as_str() == *o);
593    if !names_match {
594        eprintln!(
595            "[crdt] merge_by_component: merged component framing diverged (ours {ours_names:?} != merged {merged_names:?}); falling back to whole-doc merge"
596        );
597        return merge(base_state, ours_text, theirs_text);
598    }
599
600    Ok(merged)
601}
602
603/// Merge aligned `ours`/`theirs` node vectors (same component-name sequence),
604/// resolving each node's base text via `resolve_base`. `resolve_base` is called
605/// with `(component_name, interstitial_index)` — `name` is `Some` for a
606/// component node (and `interstitial_index` is meaningless), `None` for an
607/// interstitial node (paired positionally by `interstitial_index`). Returns the
608/// merged `(component_name, text)` per node in document order.
609///
610/// This is the shared per-node reconciliation loop behind both
611/// [`merge_by_component`] (base resolved from a decoded whole-doc state) and
612/// [`MultiNodeState::merge`] (base resolved from per-node persisted states).
613fn merge_aligned_nodes<F>(
614    ours_nodes: &[Node],
615    theirs_nodes: &[Node],
616    mut resolve_base: F,
617) -> Result<Vec<(Option<String>, String)>>
618where
619    F: FnMut(Option<&str>, usize) -> Option<String>,
620{
621    let mut merged = Vec::with_capacity(ours_nodes.len());
622    let mut interstitial_idx = 0usize;
623    for (ours_node, theirs_node) in ours_nodes.iter().zip(theirs_nodes.iter()) {
624        let name = ours_node.component_name();
625        let node_base = resolve_base(name, interstitial_idx);
626        if name.is_none() {
627            interstitial_idx += 1;
628        }
629
630        let ours_slice = ours_node.text();
631        let theirs_slice = theirs_node.text();
632
633        let merged_text = if ours_slice == theirs_slice {
634            ours_slice.to_string()
635        } else if let Some(component_name) = name
636            && let Some(merged) = reconcile_component(
637                component_name,
638                node_base.as_deref(),
639                ours_slice,
640                theirs_slice,
641            )
642        {
643            // Phase 3 (#qnodemerge3): recursive keyed reconciliation drilled
644            // *inside* the component, so two edits to different child nodes (queue
645            // items, `### Re:` blocks) reconcile in separate sub-trees and never
646            // contend. Falls back to the flat whole-component `merge` below when
647            // the component has no keyed child structure or keys are ambiguous.
648            merged
649        } else {
650            let node_base_state = node_base
651                .as_deref()
652                .map(|t| CrdtDoc::from_text(t).encode_state());
653            merge(node_base_state.as_deref(), ours_slice, theirs_slice)?
654        };
655        merged.push((name.map(str::to_string), merged_text));
656    }
657    Ok(merged)
658}
659
660// ---------------------------------------------------------------------------
661// Phase 3 (#qnodemerge3): recursive AST-node keyed reconciliation.
662//
663// The Phase 1/2 layers reconcile a document into top-level nodes (components +
664// interstitials) and run the leaf text `merge` on each whole component. Phase 3
665// drills the *same* keyed reconciliation one level deeper, *inside* a component
666// whose body is a sequence of keyed children:
667//   - `queue`/`backlog`/`review`/`done`: each `- …` list item is a child, keyed
668//     by its durable `#id` (the strike paths' identity) or normalized text.
669//   - `exchange`: each `### Re:` response block is a child, keyed by its heading.
670// Children are matched by key (React-VDOM style): a key present on both sides
671// reconciles against its own base child via the leaf `merge`; a key on only one
672// side is a pure insert (kept) or delete (honored only on a clean
673// delete-vs-unchanged, never for committed `exchange` blocks). Two edits that
674// touch different keyed children therefore can never contend, and an in-progress
675// edit to one queue item cannot stall or drift another.
676// ---------------------------------------------------------------------------
677
678/// Reserved key for the leading text inside a component body before its first
679/// keyed child (e.g. the blank line / preamble before the first list item or
680/// `### Re:` block). Matched positionally-by-key like any other child.
681const PREAMBLE_KEY: &str = "\u{0}::preamble";
682
683/// True for component kinds whose body is a markdown list of keyed items
684/// (`queue`/`backlog`/`review`/`done`), reconciled per item in Phase 3.
685fn is_list_component(name: &str) -> bool {
686    matches!(name, "queue" | "backlog" | "review" | "done")
687}
688
689/// One keyed child within a component body. `text` is the exact source slice so
690/// that `children.iter().map(|c| &c.text).collect::<String>() == body` (lossless
691/// segmentation); `key` is the child's durable identity within the component.
692struct KeyedChild {
693    key: String,
694    text: String,
695}
696
697/// First `[#id]` token in `s` (e.g. `do [#qnodemerge3]` → `qnodemerge3`), the
698/// durable identity the queue/backlog strike paths key off. `None` for a
699/// free-text child with no id.
700fn first_hash_id(s: &str) -> Option<String> {
701    let bytes = s.as_bytes();
702    let mut i = 0;
703    while i + 1 < bytes.len() {
704        // `[` and `#` are ASCII, so byte indexing stays on char boundaries.
705        if bytes[i] == b'[' && bytes[i + 1] == b'#' {
706            let rest = &s[i + 2..];
707            if let Some(close) = rest.find(']') {
708                let id = &rest[..close];
709                if !id.is_empty()
710                    && id
711                        .chars()
712                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
713                {
714                    return Some(id.to_string());
715                }
716            }
717        }
718        i += 1;
719    }
720    None
721}
722
723/// Normalize a list item's identity line for free-text keying: drop the list
724/// marker, checkbox, strike markers, and pin glyphs, and collapse whitespace, so
725/// a struck/edited item still keys to the same child.
726fn normalize_item_text(item: &str) -> String {
727    let first = item.lines().next().unwrap_or("").trim();
728    let mut s = first.strip_prefix("- ").unwrap_or(first).trim_start();
729    for cb in ["[ ]", "[x]", "[X]", "[/]"] {
730        if let Some(r) = s.strip_prefix(cb) {
731            s = r.trim_start();
732            break;
733        }
734    }
735    s.replace("~~", "")
736        .replace(":pushpin:", "")
737        .replace('📌', "")
738        .split_whitespace()
739        .collect::<Vec<_>>()
740        .join(" ")
741}
742
743/// Durable key for a list item: its `#id` when present (stable across strike /
744/// text edits), else its normalized text.
745fn list_item_key(item: &str) -> String {
746    match first_hash_id(item) {
747        Some(id) => format!("id:{id}"),
748        None => format!("txt:{}", normalize_item_text(item)),
749    }
750}
751
752/// Durable key for an `### Re:` exchange block: its heading line minus the
753/// working-tree-only ` (HEAD)` annotation.
754fn exchange_heading_key(block: &str) -> String {
755    let heading = block.lines().next().unwrap_or("").trim();
756    heading
757        .strip_suffix(" (HEAD)")
758        .unwrap_or(heading)
759        .trim_end()
760        .to_string()
761}
762
763/// Split a markdown-list component body into keyed children, one per `- …` item
764/// (continuation lines attach to the preceding item; leading text becomes a
765/// [`PREAMBLE_KEY`] child). Returns `None` when the body has no list item, so
766/// the caller falls back to the flat whole-component merge.
767fn split_list_children(body: &str) -> Option<Vec<KeyedChild>> {
768    let mut starts = Vec::new();
769    let mut pos = 0usize;
770    for line in body.split_inclusive('\n') {
771        let trimmed = line.trim_start();
772        if trimmed.starts_with("- ") || trimmed.trim_end() == "-" {
773            starts.push(pos);
774        }
775        pos += line.len();
776    }
777    if starts.is_empty() {
778        return None;
779    }
780    Some(segment_by_starts(body, &starts, list_item_key))
781}
782
783/// Split an `exchange` component body into keyed children, one per `### Re:`
784/// block (leading text becomes a [`PREAMBLE_KEY`] child). `None` when there is
785/// no `### Re:` heading.
786fn split_exchange_children(body: &str) -> Option<Vec<KeyedChild>> {
787    let mut starts = Vec::new();
788    let mut pos = 0usize;
789    for line in body.split_inclusive('\n') {
790        if line.trim_start().starts_with("### Re:") {
791            starts.push(pos);
792        }
793        pos += line.len();
794    }
795    if starts.is_empty() {
796        return None;
797    }
798    Some(segment_by_starts(body, &starts, exchange_heading_key))
799}
800
801/// Build lossless keyed children from child-start byte offsets: a preamble child
802/// for any leading text, then one child per `[start, next_start)` slice keyed by
803/// `key_of`.
804fn segment_by_starts(body: &str, starts: &[usize], key_of: fn(&str) -> String) -> Vec<KeyedChild> {
805    let mut children = Vec::with_capacity(starts.len() + 1);
806    if starts[0] > 0 {
807        children.push(KeyedChild {
808            key: PREAMBLE_KEY.to_string(),
809            text: body[..starts[0]].to_string(),
810        });
811    }
812    for (i, &s) in starts.iter().enumerate() {
813        let e = starts.get(i + 1).copied().unwrap_or(body.len());
814        let slice = &body[s..e];
815        children.push(KeyedChild {
816            key: key_of(slice),
817            text: slice.to_string(),
818        });
819    }
820    children
821}
822
823/// True when every child key is unique. Keyed reconciliation is only sound with
824/// unique keys; a duplicate (e.g. two identical free-text queue items) makes the
825/// caller fall back to the flat whole-component merge.
826fn keys_unique(children: &[KeyedChild]) -> bool {
827    let mut seen = std::collections::HashSet::new();
828    children.iter().all(|c| seen.insert(c.key.as_str()))
829}
830
831/// Frame a single-component node `text` into `(open_marker, body, close_marker)`
832/// where `body` is the content between the markers. `None` when the text does
833/// not parse as a component (caller falls back to flat merge).
834fn component_framing(text: &str) -> Option<(&str, &str, &str)> {
835    let comps = crate::component::parse(text).ok()?;
836    let comp = comps.iter().min_by_key(|c| c.open_start)?;
837    Some((
838        &text[..comp.open_end],
839        &text[comp.open_end..comp.close_start],
840        &text[comp.close_start..],
841    ))
842}
843
844/// Recursive keyed reconciliation of one component (`#qnodemerge3`). Splits the
845/// component body into keyed children, three-way reconciles them by key, and
846/// reassembles the body inside ours' marker framing. Returns `None` to signal
847/// "fall back to the flat whole-component `merge`" — for an unsplittable
848/// component, malformed framing, or ambiguous (duplicate) keys.
849fn reconcile_component(
850    name: &str,
851    base_text: Option<&str>,
852    ours_text: &str,
853    theirs_text: &str,
854) -> Option<String> {
855    if name != "exchange" && !is_list_component(name) {
856        return None;
857    }
858    let (ours_open, ours_body, ours_close) = component_framing(ours_text)?;
859    let (_, theirs_body, _) = component_framing(theirs_text)?;
860    let base_body = base_text.and_then(|t| component_framing(t).map(|(_, b, _)| b));
861
862    let merged_body = reconcile_component_body(name, base_body, ours_body, theirs_body)?;
863    Some(format!("{ours_open}{merged_body}{ours_close}"))
864}
865
866/// Three-way reconcile a component body's keyed children. `None` falls back to
867/// the flat whole-component merge.
868fn reconcile_component_body(
869    name: &str,
870    base_body: Option<&str>,
871    ours_body: &str,
872    theirs_body: &str,
873) -> Option<String> {
874    let split = |b: &str| -> Option<Vec<KeyedChild>> {
875        if name == "exchange" {
876            split_exchange_children(b)
877        } else {
878            split_list_children(b)
879        }
880    };
881    let ours_children = split(ours_body)?;
882    let theirs_children = split(theirs_body)?;
883    let base_children = base_body.and_then(split).unwrap_or_default();
884
885    if !keys_unique(&ours_children)
886        || !keys_unique(&theirs_children)
887        || !keys_unique(&base_children)
888    {
889        return None;
890    }
891
892    let ours_map: std::collections::HashMap<&str, &KeyedChild> =
893        ours_children.iter().map(|c| (c.key.as_str(), c)).collect();
894    let theirs_map: std::collections::HashMap<&str, &KeyedChild> = theirs_children
895        .iter()
896        .map(|c| (c.key.as_str(), c))
897        .collect();
898    let base_map: std::collections::HashMap<&str, &KeyedChild> =
899        base_children.iter().map(|c| (c.key.as_str(), c)).collect();
900
901    let ours_keys: Vec<&str> = ours_children.iter().map(|c| c.key.as_str()).collect();
902    let theirs_keys: Vec<&str> = theirs_children.iter().map(|c| c.key.as_str()).collect();
903    // Spine selection (#hap7/#qauthorder): for `exchange`, `ours` (the agent side)
904    // carries the append-only committed-response history, so it stays the order
905    // spine. For the operator-ordered list components (queue/backlog/review/done),
906    // `theirs` — the live editor / disk side — carries the operator-authored order,
907    // which is authoritative; using it as the spine lets an operator reorder (e.g.
908    // moving an interspersed priority prompt to the queue tail) survive the merge
909    // instead of being reverted to the snapshot order. Agent-only keys weave in
910    // after their nearest preceding placed key either way.
911    let order = if name == "exchange" {
912        order_union(&ours_keys, &theirs_keys)
913    } else {
914        order_union(&theirs_keys, &ours_keys)
915    };
916
917    // `exchange` is append-only committed history: a `### Re:` block present in
918    // the base must never be dropped by a stale/divergent side (the
919    // #ipc-crdt-response-drift guard, applied here per block).
920    let protect_deletes = name == "exchange";
921
922    let mut out = String::new();
923    for key in &order {
924        let in_o = ours_map.get(key.as_str());
925        let in_t = theirs_map.get(key.as_str());
926        let in_b = base_map.get(key.as_str());
927        let resolved: Option<String> = match (in_o, in_t) {
928            (Some(o), Some(t)) => {
929                if o.text == t.text {
930                    Some(o.text.clone())
931                } else {
932                    // Matched-but-different child: leaf text merge against its
933                    // own base child. A leaf merge error falls the whole
934                    // component back to the flat merge (returns None).
935                    let base_state = in_b.map(|b| CrdtDoc::from_text(&b.text).encode_state());
936                    Some(merge(base_state.as_deref(), &o.text, &t.text).ok()?)
937                }
938            }
939            // Present only in ours: insert by ours, or delete by theirs.
940            (Some(o), None) => match in_b {
941                None => Some(o.text.clone()),
942                Some(b) if protect_deletes || o.text != b.text => Some(o.text.clone()),
943                Some(_) => None,
944            },
945            // Present only in theirs: insert by theirs, or delete by ours.
946            (None, Some(t)) => match in_b {
947                None => Some(t.text.clone()),
948                Some(b) if protect_deletes || t.text != b.text => Some(t.text.clone()),
949                Some(_) => None,
950            },
951            (None, None) => None,
952        };
953        if let Some(text) = resolved {
954            out.push_str(&text);
955        }
956    }
957    Some(out)
958}
959
960/// Merge two ordered key sequences into a supersequence: ours' order is the
961/// spine, and each theirs-only key is woven in immediately after its nearest
962/// preceding theirs key that is already placed (or at the front). Deterministic
963/// and order-preserving for the common append/insert-on-one-side cases.
964fn order_union(ours_keys: &[&str], theirs_keys: &[&str]) -> Vec<String> {
965    let ours_set: std::collections::HashSet<&str> = ours_keys.iter().copied().collect();
966    let mut result: Vec<String> = ours_keys.iter().map(|s| s.to_string()).collect();
967    for (i, &k) in theirs_keys.iter().enumerate() {
968        if ours_set.contains(k) || result.iter().any(|r| r == k) {
969            continue;
970        }
971        let mut insert_pos = 0usize;
972        for j in (0..i).rev() {
973            if let Some(p) = result.iter().position(|r| r == theirs_keys[j]) {
974                insert_pos = p + 1;
975                break;
976            }
977        }
978        result.insert(insert_pos, k.to_string());
979    }
980    result
981}
982
983/// Encode `text` as a Yrs state with a **fixed** client id, so identical text
984/// always produces byte-identical state. [`CrdtDoc::from_text`] uses
985/// `Doc::new()`, which assigns a random client id, making every re-encode of the
986/// same text differ — unsuitable for stable per-node persistence and no-change
987/// detection. The base client id is irrelevant to [`merge`] (which reads only the
988/// base text), so a fixed id is safe here.
989fn encode_text_deterministic(text: &str) -> Vec<u8> {
990    let doc = Doc::with_client_id(0);
991    let yrs_text = doc.get_or_insert_text(TEXT_KEY);
992    {
993        let mut txn = doc.transact_mut();
994        yrs_text.insert(&mut txn, 0, text);
995    }
996    let txn = doc.transact();
997    txn.encode_state_as_update_v1(&yrs::StateVector::default())
998}
999
1000/// Magic prefix identifying a [`MultiNodeState`] container on disk, so a per-node
1001/// state file (`<hash>.nodes.yrs`) is unambiguously distinguishable from a legacy
1002/// whole-doc Yrs update blob (`<hash>.yrs`), whose first bytes are a Yrs v1
1003/// update header rather than this ASCII tag.
1004const MULTINODE_MAGIC: &[u8; 4] = b"ADN1";
1005
1006/// A document's CRDT merge state split into one independent Yrs state per
1007/// top-level node — the durable per-node base of `#qnodemerge2`.
1008///
1009/// The legacy whole-doc `<hash>.yrs` blob reconciles every node against one
1010/// shared base, so a node's base can never advance independently and unrelated
1011/// edits share a single Yrs clock. `MultiNodeState` instead persists one Yrs
1012/// state per node (each `agent:*` component plus the interstitial text around
1013/// them) in document order, serialized into a single structured container file
1014/// (`<hash>.nodes.yrs`). Each node therefore carries its own stable base across
1015/// cycles: a node untouched this cycle re-encodes to byte-identical state while a
1016/// changed node's state advances on its own, with no cross-node contention.
1017#[derive(Debug, Clone, Default, PartialEq, Eq)]
1018pub struct MultiNodeState {
1019    nodes: Vec<PersistedNode>,
1020}
1021
1022/// One persisted node within a [`MultiNodeState`], in document order.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024struct PersistedNode {
1025    /// `Some(name)` for an `agent:*` component node; `None` for interstitial text.
1026    name: Option<String>,
1027    /// This node's own independent Yrs state (a [`CrdtDoc`] encoded state).
1028    state: Vec<u8>,
1029}
1030
1031impl MultiNodeState {
1032    /// Build a per-node state by segmenting `text` into top-level nodes and
1033    /// encoding each node's text as its own independent Yrs state.
1034    pub fn from_text(text: &str) -> Result<Self> {
1035        let nodes = segment_into_nodes(text)?
1036            .iter()
1037            .map(|node| PersistedNode {
1038                name: node.component_name().map(str::to_string),
1039                state: encode_text_deterministic(node.text()),
1040            })
1041            .collect();
1042        Ok(MultiNodeState { nodes })
1043    }
1044
1045    /// Build a per-node state directly from already-merged `(name, text)` nodes.
1046    fn from_merged_nodes(merged: &[(Option<String>, String)]) -> Self {
1047        MultiNodeState {
1048            nodes: merged
1049                .iter()
1050                .map(|(name, text)| PersistedNode {
1051                    name: name.clone(),
1052                    state: encode_text_deterministic(text),
1053                })
1054                .collect(),
1055        }
1056    }
1057
1058    /// Decode a single node's persisted Yrs state back to its text.
1059    fn node_text(node: &PersistedNode) -> Result<String> {
1060        Ok(CrdtDoc::decode_state(&node.state)?.to_text())
1061    }
1062
1063    /// Reconstruct the full document text by concatenating node texts in order.
1064    pub fn to_text(&self) -> Result<String> {
1065        let mut text = String::new();
1066        for node in &self.nodes {
1067            text.push_str(&Self::node_text(node)?);
1068        }
1069        Ok(text)
1070    }
1071
1072    /// Resolve this state's per-node base into a `name → text` map (components)
1073    /// and an ordered list of interstitial texts, mirroring the alignment
1074    /// [`merge_aligned_nodes`] expects.
1075    fn base_lookup(&self) -> Result<(std::collections::HashMap<String, String>, Vec<String>)> {
1076        let mut by_name = std::collections::HashMap::new();
1077        let mut interstitials = Vec::new();
1078        for node in &self.nodes {
1079            let text = Self::node_text(node)?;
1080            match &node.name {
1081                Some(name) => {
1082                    by_name.entry(name.clone()).or_insert(text);
1083                }
1084                None => interstitials.push(text),
1085            }
1086        }
1087        Ok((by_name, interstitials))
1088    }
1089
1090    /// Serialize into the `<hash>.nodes.yrs` container format:
1091    /// `MAGIC(4) | version(1) | node_count(u32 LE)` then per node
1092    /// `name_len(u32 LE) | name bytes | state_len(u32 LE) | state bytes`,
1093    /// where `name_len == u32::MAX` marks an interstitial node (no name).
1094    pub fn encode(&self) -> Vec<u8> {
1095        let mut out = Vec::new();
1096        out.extend_from_slice(MULTINODE_MAGIC);
1097        out.push(1u8); // container version
1098        out.extend_from_slice(&(self.nodes.len() as u32).to_le_bytes());
1099        for node in &self.nodes {
1100            match &node.name {
1101                Some(name) => {
1102                    let bytes = name.as_bytes();
1103                    out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1104                    out.extend_from_slice(bytes);
1105                }
1106                None => out.extend_from_slice(&u32::MAX.to_le_bytes()),
1107            }
1108            out.extend_from_slice(&(node.state.len() as u32).to_le_bytes());
1109            out.extend_from_slice(&node.state);
1110        }
1111        out
1112    }
1113
1114    /// Decode a `<hash>.nodes.yrs` container. Returns an error (never a panic) on
1115    /// a missing magic tag or any truncation, so callers can fall back to
1116    /// migration via [`MultiNodeState::decode_or_migrate`].
1117    pub fn decode(bytes: &[u8]) -> Result<Self> {
1118        let mut cursor = 0usize;
1119        let take = |cursor: &mut usize, n: usize| -> Result<&[u8]> {
1120            let end = cursor
1121                .checked_add(n)
1122                .filter(|end| *end <= bytes.len())
1123                .ok_or_else(|| anyhow::anyhow!("multinode state truncated"))?;
1124            let slice = &bytes[*cursor..end];
1125            *cursor = end;
1126            Ok(slice)
1127        };
1128        let read_u32 = |cursor: &mut usize| -> Result<u32> {
1129            let b = take(cursor, 4)?;
1130            Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1131        };
1132
1133        if take(&mut cursor, 4)? != MULTINODE_MAGIC {
1134            anyhow::bail!("not a multinode state container (magic mismatch)");
1135        }
1136        let version = take(&mut cursor, 1)?[0];
1137        if version != 1 {
1138            anyhow::bail!("unsupported multinode state version {version}");
1139        }
1140        let count = read_u32(&mut cursor)? as usize;
1141        let mut nodes = Vec::with_capacity(count);
1142        for _ in 0..count {
1143            let name_len = read_u32(&mut cursor)?;
1144            let name = if name_len == u32::MAX {
1145                None
1146            } else {
1147                let bytes = take(&mut cursor, name_len as usize)?;
1148                Some(
1149                    std::str::from_utf8(bytes)
1150                        .map_err(|e| anyhow::anyhow!("invalid node name UTF-8: {e}"))?
1151                        .to_string(),
1152                )
1153            };
1154            let state_len = read_u32(&mut cursor)? as usize;
1155            let state = take(&mut cursor, state_len)?.to_vec();
1156            nodes.push(PersistedNode { name, state });
1157        }
1158        Ok(MultiNodeState { nodes })
1159    }
1160
1161    /// Decode `bytes` as a per-node container, migrating older formats:
1162    /// 1. A `<hash>.nodes.yrs` container decodes directly.
1163    /// 2. Otherwise treat `bytes` as a legacy whole-doc `<hash>.yrs` Yrs state,
1164    ///    decode it to text, and split into per-node states.
1165    /// 3. If neither works, fall back to `fallback_text`.
1166    ///
1167    /// Never panics; logs the migration path it took.
1168    pub fn decode_or_migrate(bytes: &[u8], fallback_text: &str) -> Self {
1169        match Self::decode(bytes) {
1170            Ok(state) => return state,
1171            Err(_) => {
1172                // Not a per-node container — try the legacy whole-doc blob.
1173                if let Ok(doc) = CrdtDoc::decode_state(bytes)
1174                    && let Ok(state) = Self::from_text(&doc.to_text())
1175                {
1176                    eprintln!(
1177                        "[crdt] MultiNodeState: migrated legacy whole-doc state into per-node state"
1178                    );
1179                    return state;
1180                }
1181            }
1182        }
1183        eprintln!(
1184            "[crdt] MultiNodeState: state unreadable; rebuilding per-node state from fallback text"
1185        );
1186        Self::from_text(fallback_text).unwrap_or_default()
1187    }
1188
1189    /// Per-node component-scoped three-way merge (`#qnodemerge2`).
1190    ///
1191    /// Like [`merge_by_component`] but each node reconciles against **its own
1192    /// persisted base** (carried in `base`) rather than a slice of one decoded
1193    /// whole-doc base, and the merged result is returned as a fresh
1194    /// `MultiNodeState` where each node's base has advanced independently. An
1195    /// unchanged node re-encodes to byte-identical state.
1196    ///
1197    /// Falls back to the whole-document [`merge`] (against `base`'s projected
1198    /// text) on structural divergence or a component-less document, preserving
1199    /// the same safety net as [`merge_by_component`].
1200    pub fn merge(
1201        base: Option<&MultiNodeState>,
1202        ours_text: &str,
1203        theirs_text: &str,
1204    ) -> Result<(String, MultiNodeState)> {
1205        let whole_doc_fallback = |reason: &str| -> Result<(String, MultiNodeState)> {
1206            let base_text = match base {
1207                Some(b) => b.to_text().unwrap_or_default(),
1208                None => String::new(),
1209            };
1210            let base_state = base.map(|_| CrdtDoc::from_text(&base_text).encode_state());
1211            if !reason.is_empty() {
1212                eprintln!(
1213                    "[crdt] MultiNodeState::merge: {reason}; falling back to whole-doc merge"
1214                );
1215            }
1216            let merged = merge(base_state.as_deref(), ours_text, theirs_text)?;
1217            let state = MultiNodeState::from_text(&merged)?;
1218            Ok((merged, state))
1219        };
1220
1221        if ours_text == theirs_text {
1222            return Ok((ours_text.to_string(), MultiNodeState::from_text(ours_text)?));
1223        }
1224
1225        let ours_nodes = match segment_into_nodes(ours_text) {
1226            Ok(c) => c,
1227            Err(e) => return whole_doc_fallback(&format!("failed to segment ours ({e})")),
1228        };
1229        let theirs_nodes = match segment_into_nodes(theirs_text) {
1230            Ok(c) => c,
1231            Err(e) => return whole_doc_fallback(&format!("failed to segment theirs ({e})")),
1232        };
1233
1234        let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
1235        let theirs_names: Vec<&str> = theirs_nodes
1236            .iter()
1237            .filter_map(Node::component_name)
1238            .collect();
1239
1240        if ours_names.is_empty() && theirs_names.is_empty() {
1241            return whole_doc_fallback("");
1242        }
1243        if ours_names != theirs_names {
1244            return whole_doc_fallback(&format!(
1245                "structural divergence (ours {ours_names:?} != theirs {theirs_names:?})"
1246            ));
1247        }
1248
1249        let (base_by_name, base_interstitials) = match base {
1250            Some(b) => b.base_lookup()?,
1251            None => (std::collections::HashMap::new(), Vec::new()),
1252        };
1253
1254        let merged_nodes =
1255            merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
1256                Some(n) => base_by_name.get(n).cloned(),
1257                None => base_interstitials.get(idx).cloned(),
1258            })?;
1259
1260        let merged_text: String = merged_nodes.iter().map(|(_, text)| text.as_str()).collect();
1261        let new_state = MultiNodeState::from_merged_nodes(&merged_nodes);
1262        Ok((merged_text, new_state))
1263    }
1264}
1265
1266/// True for transient/structural lines that must not count as "new content"
1267/// introduced by `theirs` (boundary markers carry per-cycle ids and `(HEAD)`
1268/// annotations are working-tree-only artifacts).
1269fn is_transient_merge_line(line: &str) -> bool {
1270    let t = line.trim();
1271    t.starts_with("<!-- agent:boundary:")
1272}
1273
1274/// Collect normalized `### Re:` heading lines from a document, ignoring the
1275/// working-tree-only ` (HEAD)` annotation and surrounding whitespace. Matches
1276/// how the rest of the binary treats committed response headings.
1277fn response_headings(text: &str) -> Vec<String> {
1278    text.lines()
1279        .map(str::trim)
1280        .filter(|l| l.starts_with("### Re:"))
1281        .map(|l| {
1282            l.strip_suffix(" (HEAD)")
1283                .unwrap_or(l)
1284                .trim_end()
1285                .to_string()
1286        })
1287        .collect()
1288}
1289
1290/// Restore committed response history when a stale/divergent `theirs` deleted
1291/// it. If the merge dropped a `### Re:` heading that exists in both `base` and
1292/// `ours`, and `theirs` contributed no new non-transient content beyond what
1293/// `ours` already has (i.e. `theirs` is a stale subset that only *lost*
1294/// committed content), the foreign view is stale — return `ours` so the
1295/// committed response is preserved. If `theirs` did introduce genuinely new
1296/// content, keep the merged result (never drop real user input) but log a
1297/// forensic warning. (#ipc-crdt-response-drift)
1298fn guard_committed_responses(
1299    merged: String,
1300    committed_response_headings: &[String],
1301    ours_text: &str,
1302    theirs_text: &str,
1303) -> String {
1304    if committed_response_headings.is_empty() {
1305        return merged;
1306    }
1307    let ours_headings = response_headings(ours_text);
1308    let merged_headings = response_headings(&merged);
1309    let lost: Vec<&String> = committed_response_headings
1310        .iter()
1311        .filter(|h| ours_headings.contains(h) && !merged_headings.contains(h))
1312        .collect();
1313    if lost.is_empty() {
1314        return merged;
1315    }
1316
1317    // Does `theirs` introduce any new non-empty, non-transient line that `ours`
1318    // does not already contain? If so, it carries real user input we must keep.
1319    let ours_lines: std::collections::HashSet<&str> = ours_text
1320        .lines()
1321        .map(str::trim)
1322        .filter(|l| !l.is_empty())
1323        .collect();
1324    let theirs_adds_new = theirs_text
1325        .lines()
1326        .map(str::trim)
1327        .any(|l| !l.is_empty() && !is_transient_merge_line(l) && !ours_lines.contains(l));
1328
1329    if theirs_adds_new {
1330        eprintln!(
1331            "[crdt] WARNING: merge dropped committed response heading(s) {:?} while theirs added new content; keeping merge (#ipc-crdt-response-drift)",
1332            lost
1333        );
1334        merged
1335    } else {
1336        eprintln!(
1337            "[crdt] stale theirs dropped committed response heading(s) {:?}; preferring ours (#ipc-crdt-response-drift)",
1338            lost
1339        );
1340        ours_text.to_string()
1341    }
1342}
1343
1344/// Remove identical adjacent text blocks separated by blank lines.
1345///
1346/// After a CRDT merge, both sides may independently append the same content
1347/// (e.g., a `### Re:` section), resulting in duplicate adjacent blocks.
1348/// This pass deduplicates blocks with ≥1 non-empty line, exempting structural
1349/// blocks (thematic breaks like `---`) that may legitimately repeat.
1350pub fn dedup_adjacent_blocks(text: &str) -> String {
1351    let blocks: Vec<&str> = text.split("\n\n").collect();
1352    if blocks.len() < 2 {
1353        return text.to_string();
1354    }
1355
1356    let mut result: Vec<&str> = Vec::with_capacity(blocks.len());
1357    for block in &blocks {
1358        let trimmed = block.trim();
1359        let non_empty_lines = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
1360        if non_empty_lines >= 1
1361            && !is_structural_block(trimmed)
1362            && let Some(prev) = result.last()
1363            && prev.trim() == trimmed
1364        {
1365            eprintln!(
1366                "[crdt] dedup: removed duplicate block ({} lines)",
1367                non_empty_lines
1368            );
1369            continue;
1370        }
1371        result.push(*block);
1372    }
1373
1374    result.join("\n\n")
1375}
1376
1377/// Returns true for blocks that may legitimately repeat adjacent to themselves.
1378/// Currently covers thematic break markers (`---`, `***`, `___`).
1379fn is_structural_block(trimmed: &str) -> bool {
1380    let mut lines = trimmed.lines();
1381    let first = match lines.next() {
1382        Some(l) => l.trim(),
1383        None => return false,
1384    };
1385    if lines.next().is_some() {
1386        return false;
1387    }
1388    first.len() >= 3 && first.chars().all(|c| c == '-' || c == '*' || c == '_')
1389}
1390
1391/// Compact a CRDT state by re-encoding (GC tombstones where possible).
1392pub fn compact(state: &[u8]) -> Result<Vec<u8>> {
1393    let doc = CrdtDoc::decode_state(state)?;
1394    Ok(doc.encode_state())
1395}
1396
1397/// Count the number of bytes in the common prefix of two strings.
1398fn common_prefix_len(a: &str, b: &str) -> usize {
1399    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
1400}
1401
1402/// Count the number of bytes in the common suffix of two strings.
1403fn common_suffix_len(a: &str, b: &str) -> usize {
1404    a.bytes()
1405        .rev()
1406        .zip(b.bytes().rev())
1407        .take_while(|(x, y)| x == y)
1408        .count()
1409}
1410
1411/// Edit operation for replaying diffs onto a CRDT text.
1412#[derive(Debug)]
1413enum EditOp {
1414    Retain(u32),
1415    Delete(u32),
1416    Insert(String),
1417}
1418
1419/// Compute edit operations to transform `from` into `to` using `similar` diff.
1420fn compute_edit_ops(from: &str, to: &str) -> Vec<EditOp> {
1421    use similar::{ChangeTag, TextDiff};
1422
1423    let diff = TextDiff::from_lines(from, to);
1424    let mut ops = Vec::new();
1425
1426    for change in diff.iter_all_changes() {
1427        match change.tag() {
1428            ChangeTag::Equal => {
1429                let len = change.value().len() as u32;
1430                if let Some(EditOp::Retain(n)) = ops.last_mut() {
1431                    *n += len;
1432                } else {
1433                    ops.push(EditOp::Retain(len));
1434                }
1435            }
1436            ChangeTag::Delete => {
1437                let len = change.value().len() as u32;
1438                if let Some(EditOp::Delete(n)) = ops.last_mut() {
1439                    *n += len;
1440                } else {
1441                    ops.push(EditOp::Delete(len));
1442                }
1443            }
1444            ChangeTag::Insert => {
1445                let s = change.value().to_string();
1446                if let Some(EditOp::Insert(existing)) = ops.last_mut() {
1447                    existing.push_str(&s);
1448                } else {
1449                    ops.push(EditOp::Insert(s));
1450                }
1451            }
1452        }
1453    }
1454
1455    ops
1456}
1457
1458/// A real editor operation captured from the editor's change event
1459/// (`DocumentListener.documentChanged` / `onDidChangeTextDocument`),
1460/// expressed as an absolute-offset mutation in **byte** units — consistent
1461/// with the byte-offset cursor convention used throughout this module's Yrs
1462/// apply path ([`apply_ops`]).
1463///
1464/// A replacement (the editor reports an old fragment + a new fragment at the
1465/// same offset) is captured as a [`EditorOp::Delete`] of the old length
1466/// followed by an [`EditorOp::Insert`] of the new text at the same offset; the
1467/// reporter is responsible for that split so replay stays a flat, ordered
1468/// sequence. Ops are recorded and replayed in the order the editor performed
1469/// them, so each op's offset is absolute against the document state *after* all
1470/// prior ops in the sequence (exactly how the editor's own events behave).
1471#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1472#[serde(tag = "kind", rename_all = "lowercase")]
1473pub enum EditorOp {
1474    /// Insert `text` at byte `offset`.
1475    Insert { offset: usize, text: String },
1476    /// Delete `len` bytes starting at byte `offset`.
1477    Delete { offset: usize, len: usize },
1478}
1479
1480/// Replay captured editor ops onto `base`, reconstructing the editor's final
1481/// text. Ops apply in sequence with each offset absolute against the running
1482/// buffer (matching the editor's own event semantics).
1483///
1484/// Returns `None` if any op is out of bounds or would land on a non-UTF-8
1485/// char boundary — the caller treats `None` as "these ops are stale or
1486/// misaligned, fall back to the text diff" (the `#qnodemerge4` safety gate).
1487/// Returning the reconstructed text lets the merge assert it equals the
1488/// editor-observed `theirs` before trusting the ops.
1489pub fn replay_editor_ops(base: &str, ops: &[EditorOp]) -> Option<String> {
1490    let mut buf = base.to_string();
1491    for op in ops {
1492        match op {
1493            EditorOp::Insert { offset, text } => {
1494                if *offset > buf.len() || !buf.is_char_boundary(*offset) {
1495                    return None;
1496                }
1497                buf.insert_str(*offset, text);
1498            }
1499            EditorOp::Delete { offset, len } => {
1500                let end = offset.checked_add(*len)?;
1501                if end > buf.len() || !buf.is_char_boundary(*offset) || !buf.is_char_boundary(end) {
1502                    return None;
1503                }
1504                buf.replace_range(*offset..end, "");
1505            }
1506        }
1507    }
1508    Some(buf)
1509}
1510
1511/// Apply captured editor ops directly to a Yrs text type, in order, as
1512/// absolute-offset mutations (insert@offset / remove len@offset). This feeds
1513/// the editor's *real* operation sequence into the CRDT so a concurrent agent
1514/// edit merges against the user's actual edit boundaries rather than against a
1515/// Myers-diff reconstruction (`#qnodemerge4`). Offsets are byte offsets,
1516/// consistent with [`apply_ops`]. Callers must validate the ops with
1517/// [`replay_editor_ops`] first; this assumes in-bounds, char-aligned offsets.
1518fn apply_editor_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditorOp]) {
1519    for op in ops {
1520        match op {
1521            EditorOp::Insert { offset, text: s } => {
1522                text.insert(txn, *offset as u32, s);
1523            }
1524            EditorOp::Delete { offset, len } => {
1525                text.remove_range(txn, *offset as u32, *len as u32);
1526            }
1527        }
1528    }
1529}
1530
1531/// Apply edit operations to a Yrs text type within a transaction.
1532fn apply_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditOp]) {
1533    let mut cursor: u32 = 0;
1534    for op in ops {
1535        match op {
1536            EditOp::Retain(n) => cursor += n,
1537            EditOp::Delete(n) => {
1538                text.remove_range(txn, cursor, *n);
1539                // cursor stays — content shifted left
1540            }
1541            EditOp::Insert(s) => {
1542                text.insert(txn, cursor, s);
1543                cursor += s.len() as u32;
1544            }
1545        }
1546    }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552
1553    #[test]
1554    fn roundtrip_text() {
1555        let content = "Hello, world!\nLine two.\n";
1556        let doc = CrdtDoc::from_text(content);
1557        assert_eq!(doc.to_text(), content);
1558    }
1559
1560    // ---- #qnodemerge4: op-capture / evented reflection ----
1561
1562    #[test]
1563    fn replay_editor_ops_reconstructs_insert() {
1564        let base = "hello world\n";
1565        // user typed "!" after "hello"
1566        let ops = vec![EditorOp::Insert {
1567            offset: 5,
1568            text: "!".to_string(),
1569        }];
1570        assert_eq!(
1571            replay_editor_ops(base, &ops).as_deref(),
1572            Some("hello! world\n")
1573        );
1574    }
1575
1576    #[test]
1577    fn replay_editor_ops_reconstructs_delete_and_replace_sequence() {
1578        let base = "- do [#foo]\n";
1579        // user renamed #foo -> #foobar: delete "foo" then insert "foobar"
1580        let ops = vec![
1581            EditorOp::Delete { offset: 7, len: 3 },
1582            EditorOp::Insert {
1583                offset: 7,
1584                text: "foobar".to_string(),
1585            },
1586        ];
1587        assert_eq!(
1588            replay_editor_ops(base, &ops).as_deref(),
1589            Some("- do [#foobar]\n")
1590        );
1591    }
1592
1593    #[test]
1594    fn replay_editor_ops_sequential_offsets_are_post_prior_op() {
1595        // Each op's offset is absolute against the running buffer, like the
1596        // editor's own events: insert "X" at 0, then "Y" at 2 (after "Xa").
1597        let base = "ab";
1598        let ops = vec![
1599            EditorOp::Insert {
1600                offset: 0,
1601                text: "X".to_string(),
1602            },
1603            EditorOp::Insert {
1604                offset: 2,
1605                text: "Y".to_string(),
1606            },
1607        ];
1608        assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some("XaYb"));
1609    }
1610
1611    #[test]
1612    fn replay_editor_ops_out_of_bounds_is_none() {
1613        let base = "short";
1614        let insert = vec![EditorOp::Insert {
1615            offset: 99,
1616            text: "x".to_string(),
1617        }];
1618        assert_eq!(replay_editor_ops(base, &insert), None);
1619        let delete = vec![EditorOp::Delete { offset: 3, len: 99 }];
1620        assert_eq!(replay_editor_ops(base, &delete), None);
1621    }
1622
1623    #[test]
1624    fn replay_editor_ops_non_char_boundary_is_none() {
1625        // "é" is two bytes (0xC3 0xA9); offset 1 splits it.
1626        let base = "é";
1627        let ops = vec![EditorOp::Insert {
1628            offset: 1,
1629            text: "x".to_string(),
1630        }];
1631        assert_eq!(replay_editor_ops(base, &ops), None);
1632    }
1633
1634    #[test]
1635    fn editor_op_json_roundtrip() {
1636        let ops = vec![
1637            EditorOp::Insert {
1638                offset: 3,
1639                text: "hi".to_string(),
1640            },
1641            EditorOp::Delete { offset: 0, len: 2 },
1642        ];
1643        let json = serde_json::to_string(&ops).unwrap();
1644        let back: Vec<EditorOp> = serde_json::from_str(&json).unwrap();
1645        assert_eq!(ops, back);
1646    }
1647
1648    #[test]
1649    fn merge_with_editor_ops_gate_passes_and_preserves_user_edit() {
1650        // base: a queue item the user renames while the agent appends a response
1651        // to a different region. Editor op for theirs is exact.
1652        let base = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\n";
1653        let ours = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\nAGENT RESPONSE\n";
1654        let theirs = "<!-- agent:queue -->\n- do [#foobar]\n<!-- /agent:queue -->\n";
1655        let ops = vec![
1656            EditorOp::Delete { offset: 28, len: 3 },
1657            EditorOp::Insert {
1658                offset: 28,
1659                text: "foobar".to_string(),
1660            },
1661        ];
1662        // Verify the ops actually reconstruct theirs (the gate input).
1663        assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some(theirs));
1664
1665        let base_state = CrdtDoc::from_text(base).encode_state();
1666        let merged = merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&ops)).unwrap();
1667        // Both edits present, renamed item, no duplication.
1668        assert!(merged.contains("- do [#foobar]"), "merged: {merged}");
1669        assert!(merged.contains("AGENT RESPONSE"), "merged: {merged}");
1670        assert!(
1671            !merged.contains("- do [#foo]\n"),
1672            "stale item leaked: {merged}"
1673        );
1674        assert_eq!(
1675            merged.matches("do [#foobar]").count(),
1676            1,
1677            "duplicated: {merged}"
1678        );
1679    }
1680
1681    #[test]
1682    fn merge_with_editor_ops_stale_ops_fall_back_to_diff() {
1683        // Ops captured against a DIFFERENT base than the resolved merge base:
1684        // replay won't equal theirs, so the merge must fall back to the diff
1685        // path and still produce the same result as plain `merge`.
1686        let base = "Line A\nLine B\n";
1687        let ours = "Line A\nLine B\nAgent line\n";
1688        let theirs = "Line A\nLine B edited\n";
1689        // Bogus ops that do NOT reconstruct theirs from base.
1690        let bogus = vec![EditorOp::Insert {
1691            offset: 0,
1692            text: "ZZZ".to_string(),
1693        }];
1694        assert_ne!(replay_editor_ops(base, &bogus).as_deref(), Some(theirs));
1695
1696        let base_state = CrdtDoc::from_text(base).encode_state();
1697        let with_ops =
1698            merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&bogus)).unwrap();
1699        let diff_only = merge(Some(&base_state), ours, theirs).unwrap();
1700        assert_eq!(
1701            with_ops, diff_only,
1702            "stale ops must fall back to the diff-guess result"
1703        );
1704        // The bogus "ZZZ" must NOT have leaked into the merged output.
1705        assert!(!with_ops.contains("ZZZ"), "stale op leaked: {with_ops}");
1706    }
1707
1708    #[test]
1709    fn merge_with_editor_ops_none_matches_plain_merge() {
1710        // The op-aware entry point with None ops is byte-identical to `merge`.
1711        let base = "header\n\nbody\n";
1712        let ours = "header\n\nbody\nagent\n";
1713        let theirs = "header\n\nbody edited\n";
1714        let base_state = CrdtDoc::from_text(base).encode_state();
1715        assert_eq!(
1716            merge_with_editor_ops(Some(&base_state), ours, theirs, None).unwrap(),
1717            merge(Some(&base_state), ours, theirs).unwrap()
1718        );
1719    }
1720
1721    #[test]
1722    fn merge_with_editor_ops_empty_ops_fall_back() {
1723        let base = "x\ny\n";
1724        let ours = "x\ny\nagent\n";
1725        let theirs = "x\ny edited\n";
1726        let base_state = CrdtDoc::from_text(base).encode_state();
1727        let empty: Vec<EditorOp> = vec![];
1728        assert_eq!(
1729            merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&empty)).unwrap(),
1730            merge(Some(&base_state), ours, theirs).unwrap()
1731        );
1732    }
1733
1734    #[test]
1735    fn roundtrip_encode_decode() {
1736        let content = "Some document content.\n";
1737        let doc = CrdtDoc::from_text(content);
1738        let encoded = doc.encode_state();
1739        let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1740        assert_eq!(decoded.to_text(), content);
1741    }
1742
1743    #[test]
1744    fn apply_edit_insert() {
1745        let doc = CrdtDoc::from_text("Hello world");
1746        doc.apply_edit(5, 0, ",");
1747        assert_eq!(doc.to_text(), "Hello, world");
1748    }
1749
1750    #[test]
1751    fn apply_edit_delete() {
1752        let doc = CrdtDoc::from_text("Hello, world");
1753        doc.apply_edit(5, 1, "");
1754        assert_eq!(doc.to_text(), "Hello world");
1755    }
1756
1757    #[test]
1758    fn apply_edit_replace() {
1759        let doc = CrdtDoc::from_text("Hello world");
1760        doc.apply_edit(6, 5, "Rust");
1761        assert_eq!(doc.to_text(), "Hello Rust");
1762    }
1763
1764    #[test]
1765    fn concurrent_append_merge_no_conflict() {
1766        let base = "# Document\n\nBase content.\n";
1767        let base_doc = CrdtDoc::from_text(base);
1768        let base_state = base_doc.encode_state();
1769
1770        let ours = format!("{base}## Agent\n\nAgent response.\n");
1771        let theirs = format!("{base}## User\n\nUser addition.\n");
1772
1773        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1774
1775        // Both additions should be present
1776        assert!(merged.contains("Agent response."), "missing agent text");
1777        assert!(merged.contains("User addition."), "missing user text");
1778        assert!(merged.contains("Base content."), "missing base text");
1779        // No conflict markers
1780        assert!(!merged.contains("<<<<<<<"));
1781        assert!(!merged.contains(">>>>>>>"));
1782    }
1783
1784    #[test]
1785    fn concurrent_insert_same_position() {
1786        let base = "Line 1\nLine 3\n";
1787        let base_doc = CrdtDoc::from_text(base);
1788        let base_state = base_doc.encode_state();
1789
1790        let ours = "Line 1\nAgent line\nLine 3\n";
1791        let theirs = "Line 1\nUser line\nLine 3\n";
1792
1793        let merged = merge(Some(&base_state), ours, theirs).unwrap();
1794
1795        // Both insertions preserved, no conflict
1796        assert!(merged.contains("Agent line"), "missing agent insertion");
1797        assert!(merged.contains("User line"), "missing user insertion");
1798        assert!(merged.contains("Line 1"), "missing line 1");
1799        assert!(merged.contains("Line 3"), "missing line 3");
1800    }
1801
1802    #[test]
1803    fn merge_no_base_state() {
1804        // When no base state exists, bootstrap from empty
1805        let ours = "Agent wrote this.\n";
1806        let theirs = "User wrote this.\n";
1807
1808        let merged = merge(None, ours, theirs).unwrap();
1809
1810        assert!(merged.contains("Agent wrote this."));
1811        assert!(merged.contains("User wrote this."));
1812    }
1813
1814    #[test]
1815    fn compact_preserves_content() {
1816        let doc = CrdtDoc::from_text("Hello");
1817        doc.apply_edit(5, 0, " world");
1818        doc.apply_edit(11, 0, "!");
1819
1820        let state = doc.encode_state();
1821        let compacted = compact(&state).unwrap();
1822        let restored = CrdtDoc::decode_state(&compacted).unwrap();
1823
1824        assert_eq!(restored.to_text(), "Hello world!");
1825        assert!(compacted.len() <= state.len());
1826    }
1827
1828    #[test]
1829    fn compact_reduces_size_after_edits() {
1830        let doc = CrdtDoc::from_text("aaaa");
1831        // Many small edits to build up tombstones
1832        for i in 0..20 {
1833            let c = ((b'a' + (i % 26)) as char).to_string();
1834            doc.apply_edit(0, 1, &c);
1835        }
1836        let state = doc.encode_state();
1837        let compacted = compact(&state).unwrap();
1838        let restored = CrdtDoc::decode_state(&compacted).unwrap();
1839        assert_eq!(restored.to_text(), doc.to_text());
1840    }
1841
1842    #[test]
1843    fn empty_document() {
1844        let doc = CrdtDoc::from_text("");
1845        assert_eq!(doc.to_text(), "");
1846
1847        let encoded = doc.encode_state();
1848        let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1849        assert_eq!(decoded.to_text(), "");
1850    }
1851
1852    #[test]
1853    fn decode_invalid_bytes_errors() {
1854        let result = CrdtDoc::decode_state(&[0xff, 0xfe, 0xfd]);
1855        assert!(result.is_err());
1856    }
1857
1858    #[test]
1859    fn merge_identical_texts() {
1860        let base = "Same content.\n";
1861        let base_doc = CrdtDoc::from_text(base);
1862        let state = base_doc.encode_state();
1863
1864        let merged = merge(Some(&state), base, base).unwrap();
1865        assert_eq!(merged, base);
1866    }
1867
1868    #[test]
1869    fn merge_one_side_unchanged() {
1870        let base = "Original.\n";
1871        let base_doc = CrdtDoc::from_text(base);
1872        let state = base_doc.encode_state();
1873
1874        let ours = "Original.\nAgent added.\n";
1875        let merged = merge(Some(&state), ours, base).unwrap();
1876        assert_eq!(merged, ours);
1877    }
1878
1879    /// Regression test: CRDT merge should not duplicate user prompt when both
1880    /// ours and theirs contain the same text added since the base state.
1881    ///
1882    /// Scenario (brookebrodack-dev.md duplication bug):
1883    /// 1. CRDT base = exchange content from a previous cycle (no user prompt)
1884    /// 2. User adds prompt to exchange → saved as baseline
1885    /// 3. Agent generates response, content_ours = baseline + response (has user prompt)
1886    /// 4. User makes a small edit during response generation → content_current (has user prompt too)
1887    /// 5. CRDT merge: both ours and theirs have the user prompt relative to stale base
1888    /// 6. BUG: user prompt appears twice in merged output
1889    #[test]
1890    fn merge_stale_base_no_duplicate_user_prompt() {
1891        // CRDT base from a previous cycle — does NOT have the user's current prompt
1892        let base_content = "\
1893## Assistant
1894
1895Previous response content.
1896
1897Committed and pushed.
1898
1899";
1900        let base_doc = CrdtDoc::from_text(base_content);
1901        let base_state = base_doc.encode_state();
1902
1903        // User adds prompt after base was saved
1904        let user_prompt = "\
1905Opening a video a shows video a.
1906Closing video a then opening video b start video b but video b is hidden.
1907Closing video b then reopening video b starts and shows video b. video b is visible.
1908";
1909
1910        // content_ours: base + user prompt + agent response (from run_stream with full exchange)
1911        let ours = format!(
1912            "\
1913{}{}### Re: Close A → Open B still hidden
1914
1915Added explicit height and visibility reset.
1916
1917Committed and pushed.
1918
1919",
1920            base_content, user_prompt
1921        );
1922
1923        // content_current: base + user prompt + minor user edit (e.g., added a blank line)
1924        let theirs = format!(
1925            "\
1926{}{}
1927",
1928            base_content, user_prompt
1929        );
1930
1931        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1932
1933        // User prompt should appear exactly ONCE
1934        let prompt_count = merged.matches("Opening a video a shows video a.").count();
1935        assert_eq!(
1936            prompt_count, 1,
1937            "User prompt duplicated! Appeared {} times in:\n{}",
1938            prompt_count, merged
1939        );
1940
1941        // Agent response should be present
1942        assert!(
1943            merged.contains("### Re: Close A → Open B still hidden"),
1944            "Agent response missing from merge:\n{}",
1945            merged
1946        );
1947    }
1948
1949    /// Regression test: When CRDT base is stale and both sides added the same text
1950    /// at the same position, the merge should not duplicate it.
1951    #[test]
1952    fn merge_stale_base_same_insertion_both_sides() {
1953        let base_content = "Line 1\nLine 2\n";
1954        let base_doc = CrdtDoc::from_text(base_content);
1955        let base_state = base_doc.encode_state();
1956
1957        // Both sides added the same text (user prompt) + ours adds more
1958        let shared_addition = "User typed this.\n";
1959        let ours = format!("{}{}Agent response.\n", base_content, shared_addition);
1960        let theirs = format!("{}{}", base_content, shared_addition);
1961
1962        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1963
1964        let count = merged.matches("User typed this.").count();
1965        assert_eq!(
1966            count, 1,
1967            "Shared text duplicated! Appeared {} times in:\n{}",
1968            count, merged
1969        );
1970        assert!(
1971            merged.contains("Agent response."),
1972            "Agent text missing:\n{}",
1973            merged
1974        );
1975    }
1976
1977    /// Regression test: Character-level interleaving bug.
1978    ///
1979    /// When the user types in their editor while the agent is streaming,
1980    /// both sides insert text at the same position relative to the base.
1981    /// The CRDT base advancement logic used to snap to the shared prefix
1982    /// of ours/theirs, which could land mid-line on a shared formatting
1983    /// character (e.g., `*` from `*bold*` and `**bold**`). This caused
1984    /// the formatting character to be absorbed into the base, splitting
1985    /// it from the rest of the formatting sequence and producing garbled
1986    /// text like `*Soft-bristle brush only**` instead of
1987    /// `**Soft-bristle brush only**`.
1988    ///
1989    /// The fix: always snap the advanced base to a line boundary. If no
1990    /// suitable line boundary exists after the current base length, don't
1991    /// advance at all.
1992    #[test]
1993    fn merge_no_character_interleaving() {
1994        // Base: a document with some existing content
1995        let base = "# Doc\n\nPrevious content.\n\n";
1996        let base_doc = CrdtDoc::from_text(base);
1997        let base_state = base_doc.encode_state();
1998
1999        // Agent adds a response
2000        let ours = "# Doc\n\nPrevious content.\n\n*Compacted. Content archived to*\n";
2001        // User types something in their editor at the same position
2002        let theirs = "# Doc\n\nPrevious content.\n\n**Soft-bristle brush only**\n";
2003
2004        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2005
2006        // Both texts should be present as contiguous blocks, not interleaved
2007        assert!(
2008            merged.contains("*Compacted. Content archived to*"),
2009            "Agent text should be contiguous (not interleaved). Got:\n{}",
2010            merged
2011        );
2012        assert!(
2013            merged.contains("**Soft-bristle brush only**"),
2014            "User text should be contiguous (not interleaved). Got:\n{}",
2015            merged
2016        );
2017    }
2018
2019    /// Regression test: Concurrent edits within the same line should not
2020    /// produce character-level interleaving.
2021    #[test]
2022    fn merge_concurrent_same_line_no_garbling() {
2023        let base = "Some base text\n";
2024        let base_doc = CrdtDoc::from_text(base);
2025        let base_state = base_doc.encode_state();
2026
2027        // Both sides replace the line with different content
2028        let ours = "Agent wrote this line\n";
2029        let theirs = "User wrote different text\n";
2030
2031        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2032
2033        // At least one side's text should appear contiguously
2034        let has_agent_contiguous = merged.contains("Agent wrote this line");
2035        let has_user_contiguous = merged.contains("User wrote different text");
2036
2037        assert!(
2038            has_agent_contiguous || has_user_contiguous,
2039            "At least one side should have contiguous text (no char interleaving). Got:\n{}",
2040            merged
2041        );
2042    }
2043
2044    /// Regression test: Replace-vs-append corruption (lazily-rs.md bug).
2045    ///
2046    /// Pattern:
2047    /// - CRDT base is from a previous cycle (old exchange content)
2048    /// - Agent replaces exchange content entirely (template replace mode)
2049    /// - User appends new prompt text to exchange during response generation
2050    /// - CRDT interleaves agent's new content with user's old + new text,
2051    ///   causing mid-word splits like "key de" + [user text] + "cisions"
2052    ///
2053    /// Root cause: stale CRDT base doesn't match either side well enough
2054    /// for prefix advancement, so the CRDT does a raw character-level merge
2055    /// of the exchange section, interleaving replace and append operations.
2056    ///
2057    /// Fix: use baseline (not stored CRDT state) as merge base, so both
2058    /// sides' diffs are computed from the exact content they diverged from.
2059    #[test]
2060    fn merge_replace_vs_append_no_interleaving() {
2061        // Full document structure (template mode)
2062        let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2063        let footer = "\n<!-- /agent:exchange -->\n";
2064
2065        // Previous cycle's exchange content (what the CRDT state contains)
2066        let old_exchange = "\
2067### Committed, Pushed & Released
2068
2069**project (v0.1.0):**
2070- Committed initial implementation
2071- Tagged v0.1.0 and pushed
2072
2073Add a README.md to the project.
2074Also add AGENTS.md with a symlink CLAUDE.md
2075
2076**sub-project:**
2077- Committed fix + SPEC.md
2078- Pushed to remote
2079";
2080        let stale_base = format!("{header}{old_exchange}{footer}");
2081        let stale_state = CrdtDoc::from_text(&stale_base).encode_state();
2082
2083        // Baseline (what the file looked like when response generation started)
2084        // Same as stale_base in this case — no user edits between cycles
2085        let _baseline = stale_base.clone();
2086
2087        // Ours: agent replaces exchange content (template replace mode applied)
2088        let agent_exchange = "\
2089### Done
2090
2091Added to project and pushed:
2092
2093- **README.md** — overview, usage, design notes
2094- **AGENTS.md** — architecture, key decisions, commands, related projects
2095- **CLAUDE.md** → symlink to AGENTS.md
2096
2097All committed and pushed.
2098";
2099        let ours = format!("{header}{agent_exchange}{footer}");
2100
2101        // Theirs: user inserted new prompt IN THE MIDDLE of the exchange section
2102        // (after the existing user prompt, before the sub-project sections)
2103        // This is the critical difference — insertion within the range that ours deletes
2104        let theirs_exchange = "\
2105### Committed, Pushed & Released
2106
2107**project (v0.1.0):**
2108- Committed initial implementation
2109- Tagged v0.1.0 and pushed
2110
2111Add a README.md to the project.
2112Also add AGENTS.md with a symlink CLAUDE.md
2113
2114Please add tests.
2115Please comprehensively test adherence to the spec.
2116
2117**sub-project:**
2118- Committed fix + SPEC.md
2119- Pushed to remote
2120";
2121        let theirs = format!("{header}{theirs_exchange}{footer}");
2122
2123        // Using stale CRDT state (previous cycle) — this is what triggers the bug
2124        let merged = merge(Some(&stale_state), &ours, &theirs).unwrap();
2125
2126        // Agent's replacement text should be contiguous (no interleaving)
2127        assert!(
2128            merged.contains(
2129                "- **AGENTS.md** — architecture, key decisions, commands, related projects"
2130            ),
2131            "Agent text garbled (mid-word split). Got:\n{}",
2132            merged
2133        );
2134
2135        // User's addition should be preserved
2136        assert!(
2137            merged.contains("Please add tests."),
2138            "User addition missing. Got:\n{}",
2139            merged
2140        );
2141
2142        // No fragments of old content mixed into agent's new content
2143        assert!(
2144            !merged.contains("key deAdd") && !merged.contains("key de\n"),
2145            "Old content interleaved into agent text. Got:\n{}",
2146            merged
2147        );
2148    }
2149
2150    /// Same as merge_replace_vs_append_no_interleaving but using baseline
2151    /// as CRDT base instead of stale state. This is the fix verification.
2152    #[test]
2153    fn merge_replace_vs_append_with_baseline_base() {
2154        let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2155        let footer = "\n<!-- /agent:exchange -->\n";
2156
2157        let old_exchange = "\
2158### Previous Response
2159
2160Old content here.
2161
2162Add a README.md to the project.
2163Also add AGENTS.md with a symlink CLAUDE.md
2164";
2165        let baseline = format!("{header}{old_exchange}{footer}");
2166
2167        // Ours: agent replaces exchange
2168        let agent_exchange = "\
2169### Done
2170
2171- **README.md** — overview, usage, design notes
2172- **AGENTS.md** — architecture, key decisions, commands, related projects
2173- **CLAUDE.md** → symlink to AGENTS.md
2174
2175All committed and pushed.
2176";
2177        let ours = format!("{header}{agent_exchange}{footer}");
2178
2179        // Theirs: user appended new prompt
2180        let user_addition = "\nPlease add tests.\n";
2181        let theirs = format!("{header}{old_exchange}{user_addition}{footer}");
2182
2183        // Use baseline as CRDT base (the fix)
2184        let baseline_state = CrdtDoc::from_text(&baseline).encode_state();
2185        let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2186
2187        // Agent text should be contiguous
2188        assert!(
2189            merged.contains("key decisions, commands, related projects"),
2190            "Agent text garbled. Got:\n{}",
2191            merged
2192        );
2193
2194        // User addition preserved
2195        assert!(
2196            merged.contains("Please add tests."),
2197            "User addition missing. Got:\n{}",
2198            merged
2199        );
2200    }
2201
2202    /// Regression test: Simulates the exact scenario from the bug report.
2203    ///
2204    /// The agent streams a response into the exchange component while
2205    /// the user types in their editor. Both sides share a common prefix
2206    /// that includes markdown formatting characters. The CRDT merge must
2207    /// preserve formatting integrity for both sides.
2208    #[test]
2209    fn merge_streaming_concurrent_edit_preserves_formatting() {
2210        // Exchange component content after user's initial prompt
2211        let base = "commit and push all rappstack packages.\n\n";
2212        let base_doc = CrdtDoc::from_text(base);
2213        let base_state = base_doc.encode_state();
2214
2215        // Agent's response (content_ours = user prompt + agent response)
2216        let ours = "\
2217commit and push all rappstack packages.
2218
2219### Re: commit and push
2220
2221*Compacted. Content archived to `docs/`*
2222
2223Done — all packages pushed.
2224";
2225
2226        // User's concurrent edit (added a note at the bottom)
2227        let theirs = "\
2228commit and push all rappstack packages.
2229
2230**Soft-bristle brush only**
2231";
2232
2233        let merged = merge(Some(&base_state), ours, theirs).unwrap();
2234
2235        // Agent formatting must be intact
2236        assert!(
2237            merged.contains("*Compacted. Content archived to `docs/`*"),
2238            "Agent formatting broken. Got:\n{}",
2239            merged
2240        );
2241        // User formatting must be intact
2242        assert!(
2243            merged.contains("**Soft-bristle brush only**"),
2244            "User formatting broken. Got:\n{}",
2245            merged
2246        );
2247        // No character-level interleaving
2248        assert!(
2249            !merged.contains("*C*C") && !merged.contains("**Sot"),
2250            "Character interleaving detected. Got:\n{}",
2251            merged
2252        );
2253    }
2254
2255    /// Regression test: Agent replaces multi-line block while user inserts within it.
2256    /// With from_chars, this produces ~20 scattered character-level ops that interleave
2257    /// with user edits. With from_lines, ops are contiguous line-level blocks.
2258    ///
2259    /// Uses a template document structure to match the real workflow where the baseline
2260    /// (common ancestor) contains the exchange component with original content.
2261    #[test]
2262    fn merge_replace_vs_insert_no_interleaving() {
2263        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";
2264        let footer = "<!-- /agent:exchange -->\n";
2265
2266        let old_exchange =
2267            "Line one of old content\nLine two of old content\nLine three of old content\n";
2268        let baseline = format!("{header}{old_exchange}{footer}");
2269        let baseline_doc = CrdtDoc::from_text(&baseline);
2270        let baseline_state = baseline_doc.encode_state();
2271
2272        // Agent replaces exchange with completely new content
2273        let agent_exchange = "Completely new line one\nCompletely new line two\nCompletely new line three\nCompletely new line four\n";
2274        let ours = format!("{header}{agent_exchange}{footer}");
2275
2276        // User inserts a line in the middle of the original exchange
2277        let theirs = format!(
2278            "{header}Line one of old content\nUser inserted this line\nLine two of old content\nLine three of old content\n{footer}"
2279        );
2280
2281        let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2282
2283        // Agent text should be contiguous — no mid-word splits
2284        assert!(
2285            merged.contains("Completely new line one"),
2286            "Agent line 1 missing or garbled. Got:\n{}",
2287            merged
2288        );
2289        assert!(
2290            merged.contains("Completely new line two"),
2291            "Agent line 2 missing or garbled. Got:\n{}",
2292            merged
2293        );
2294
2295        // User text should be preserved
2296        assert!(
2297            merged.contains("User inserted this line"),
2298            "User insertion missing. Got:\n{}",
2299            merged
2300        );
2301
2302        // No character interleaving (e.g., "Complete" + user text + "ly")
2303        assert!(
2304            !merged.contains("CompleteUser") && !merged.contains("Complete\nUser"),
2305            "Character interleaving detected. Got:\n{}",
2306            merged
2307        );
2308    }
2309
2310    /// Test: agent content appears before human content when both append
2311    /// to the same position.
2312    #[test]
2313    fn reorder_agent_before_human_at_append_boundary() {
2314        let base = "# Document\n\nBase content.\n";
2315        let base_doc = CrdtDoc::from_text(base);
2316        let base_state = base_doc.encode_state();
2317
2318        // Agent appends response
2319        let ours = format!("{base}### Agent Response\n\nAgent wrote this.\n");
2320        // Human appends their own text
2321        let theirs = format!("{base}User added this line.\n");
2322
2323        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2324
2325        // Both should be present
2326        assert!(merged.contains("Agent wrote this."), "missing agent text");
2327        assert!(
2328            merged.contains("User added this line."),
2329            "missing user text"
2330        );
2331        assert!(merged.contains("Base content."), "missing base text");
2332
2333        // Agent content should appear before human content
2334        let agent_pos = merged.find("Agent wrote this.").unwrap();
2335        let human_pos = merged.find("User added this line.").unwrap();
2336        assert!(
2337            agent_pos < human_pos,
2338            "Agent content should appear before human content.\nAgent pos: {}, Human pos: {}\nMerged:\n{}",
2339            agent_pos,
2340            human_pos,
2341            merged
2342        );
2343    }
2344
2345    // -----------------------------------------------------------------------
2346    // dedup_adjacent_blocks tests (#15)
2347    // -----------------------------------------------------------------------
2348
2349    #[test]
2350    fn dedup_removes_identical_adjacent_blocks() {
2351        let text =
2352            "### Re: Question\nAnswer here.\n\n### Re: Question\nAnswer here.\n\nDifferent block.";
2353        let result = dedup_adjacent_blocks(text);
2354        assert_eq!(result.matches("### Re: Question").count(), 1);
2355        assert!(result.contains("Different block."));
2356    }
2357
2358    #[test]
2359    fn dedup_preserves_different_adjacent_blocks() {
2360        let text = "### Re: First\nAnswer one.\n\n### Re: Second\nAnswer two.";
2361        let result = dedup_adjacent_blocks(text);
2362        assert!(result.contains("### Re: First"));
2363        assert!(result.contains("### Re: Second"));
2364    }
2365
2366    #[test]
2367    fn dedup_preserves_structural_thematic_breaks() {
2368        let text = "---\n\n---\n\nContent.";
2369        let result = dedup_adjacent_blocks(text);
2370        assert_eq!(result, text);
2371
2372        let text2 = "***\n\n***\n\nContent.";
2373        assert_eq!(dedup_adjacent_blocks(text2), text2);
2374
2375        let text3 = "___\n\n___\n\nContent.";
2376        assert_eq!(dedup_adjacent_blocks(text3), text3);
2377    }
2378
2379    #[test]
2380    fn dedup_removes_single_line_content_duplicates() {
2381        let text = "### Re: topic — opus-4-6\n\n### Re: topic — opus-4-6\n\nDifferent.";
2382        let result = dedup_adjacent_blocks(text);
2383        assert_eq!(result.matches("### Re: topic").count(), 1);
2384        assert!(result.contains("Different."));
2385    }
2386
2387    #[test]
2388    fn dedup_removes_single_line_text_duplicates() {
2389        let text = "Some response text.\n\nSome response text.\n\nNext section.";
2390        let result = dedup_adjacent_blocks(text);
2391        assert_eq!(result.matches("Some response text.").count(), 1);
2392        assert!(result.contains("Next section."));
2393    }
2394
2395    #[test]
2396    fn dedup_preserves_different_single_line_blocks() {
2397        let text = "Line A.\n\nLine B.\n\nLine C.";
2398        let result = dedup_adjacent_blocks(text);
2399        assert_eq!(result, text);
2400    }
2401
2402    #[test]
2403    fn dedup_handles_empty_text() {
2404        assert_eq!(dedup_adjacent_blocks(""), "");
2405    }
2406
2407    #[test]
2408    fn dedup_no_change_when_no_duplicates() {
2409        let text = "Block A\nLine 2.\n\nBlock B\nLine 2.";
2410        let result = dedup_adjacent_blocks(text);
2411        assert_eq!(result, text);
2412    }
2413
2414    #[test]
2415    fn is_structural_block_detects_thematic_breaks() {
2416        assert!(is_structural_block("---"));
2417        assert!(is_structural_block("***"));
2418        assert!(is_structural_block("___"));
2419        assert!(is_structural_block("-----"));
2420        assert!(!is_structural_block("--"));
2421        assert!(!is_structural_block("### Heading"));
2422        assert!(!is_structural_block("some text"));
2423        assert!(!is_structural_block("---\nmore"));
2424    }
2425
2426    /// Regression test: empty-baseline CRDT merge duplicates last user prompt line.
2427    ///
2428    /// Scenario (claude-code-action.md bug):
2429    /// 1. Baseline = template document with empty exchange (just boundary marker)
2430    /// 2. content_ours = baseline + response patches (response replaces boundary)
2431    /// 3. content_current = user's file (user typed prompt before boundary)
2432    /// 4. CRDT merge: both sides added content to the empty exchange
2433    /// 5. BUG: last line of user's prompt appears twice (before and after response)
2434    #[test]
2435    fn merge_empty_exchange_no_duplicate_user_line() {
2436        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2437        let footer = "<!-- /agent:exchange -->\n\n## Pending\n\n<!-- agent:pending -->\n<!-- /agent:pending -->\n";
2438
2439        // Base: empty exchange with boundary marker
2440        let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2441
2442        // Ours: response applied via boundary-aware append (boundary replaced)
2443        let ours = format!(
2444            "{header}\n### Re: topic — opus-4-6\n\nResponse content here.\n\nMore response text.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2445        );
2446
2447        // Theirs: user typed prompt before the boundary
2448        let theirs = format!(
2449            "{header}User prompt line one.\nUser prompt line two.\nLast line of user prompt.\n<!-- agent:boundary:base-id -->\n{footer}"
2450        );
2451
2452        let base_doc = CrdtDoc::from_text(&base);
2453        let base_state = base_doc.encode_state();
2454
2455        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2456
2457        // Each user prompt line should appear exactly once
2458        let count1 = merged.matches("User prompt line one.").count();
2459        assert_eq!(
2460            count1, 1,
2461            "Line one duplicated (count={}). Merged:\n{}",
2462            count1, merged
2463        );
2464
2465        let count2 = merged.matches("User prompt line two.").count();
2466        assert_eq!(
2467            count2, 1,
2468            "Line two duplicated (count={}). Merged:\n{}",
2469            count2, merged
2470        );
2471
2472        let count3 = merged.matches("Last line of user prompt.").count();
2473        assert_eq!(
2474            count3, 1,
2475            "Last line duplicated (count={}). Merged:\n{}",
2476            count3, merged
2477        );
2478
2479        // Response should appear exactly once
2480        assert!(
2481            merged.contains("### Re: topic — opus-4-6"),
2482            "Response heading missing:\n{}",
2483            merged
2484        );
2485        assert!(
2486            merged.contains("Response content here."),
2487            "Response content missing:\n{}",
2488            merged
2489        );
2490
2491        // User prompt should come BEFORE response
2492        let prompt_pos = merged.find("User prompt line one.").unwrap();
2493        let response_pos = merged.find("### Re: topic").unwrap();
2494        assert!(
2495            prompt_pos < response_pos,
2496            "User prompt should precede response. prompt={} response={}\nMerged:\n{}",
2497            prompt_pos,
2498            response_pos,
2499            merged
2500        );
2501    }
2502
2503    /// Same as above but with ❯ prefixed user content (post-normalization).
2504    #[test]
2505    fn merge_empty_exchange_prefixed_prompts_no_duplicate() {
2506        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2507        let footer = "<!-- /agent:exchange -->\n";
2508
2509        let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2510
2511        // Ours: response + normalized ❯ prefix on user lines
2512        let ours = format!(
2513            "{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}"
2514        );
2515
2516        // Theirs: user's file with same prompt (also ❯ prefixed by IPC normalization)
2517        let theirs = format!(
2518            "{header}❯ Review the code\n❯ What does it do?\n<!-- agent:boundary:base-id -->\n{footer}"
2519        );
2520
2521        let base_doc = CrdtDoc::from_text(&base);
2522        let base_state = base_doc.encode_state();
2523
2524        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2525
2526        let count = merged.matches("❯ Review the code").count();
2527        assert_eq!(
2528            count, 1,
2529            "Prompt duplicated (count={}). Merged:\n{}",
2530            count, merged
2531        );
2532
2533        assert!(
2534            merged.contains("The code does X."),
2535            "Response missing:\n{}",
2536            merged
2537        );
2538    }
2539
2540    /// Regression test (#ipc-crdt-response-drift): a concurrent **foreign**
2541    /// writer (another supervisor / queue toggle) appends to the document at
2542    /// the tail *during* response generation, growing `theirs` beyond `base`.
2543    /// The agent's new `### Re:` response (ours) must:
2544    ///   1. land with its heading intact (not dropped),
2545    ///   2. keep its lines in author order (not reversed),
2546    ///   3. appear AFTER the prior committed `### Re:` response (not hoisted
2547    ///      above it),
2548    ///   4. preserve the foreign tail append.
2549    #[test]
2550    fn merge_foreign_tail_append_preserves_response_order() {
2551        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2552        let exchange_committed = "\
2553❯ first question
2554
2555### Re: first question — opus-4-8
2556
2557First answer here. Already committed to HEAD.
2558
2559❯ second question
2560";
2561        let queue_open = "<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n";
2562        let queue_close = "<!-- /agent:queue -->\n";
2563
2564        // Base: prior response committed, new prompt typed, boundary at tail of
2565        // exchange, empty queue.
2566        let base = format!(
2567            "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}{queue_close}"
2568        );
2569
2570        // Ours: agent replaces boundary with the new response (multi-line).
2571        let agent_response = "\
2572### Re: second question — opus-4-8
2573
2574Second answer line one.
2575Second answer line two.
2576Second answer line three.
2577
2578";
2579        let ours = format!(
2580            "{header}{exchange_committed}{agent_response}<!-- agent:boundary:new-id -->\n{queue_open}{queue_close}"
2581        );
2582
2583        // Theirs: a foreign supervisor appended a queue item at the tail while
2584        // the response was being generated (file grew, base unchanged in body).
2585        let theirs = format!(
2586            "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}do [#foreign-task]\n{queue_close}"
2587        );
2588
2589        let base_state = CrdtDoc::from_text(&base).encode_state();
2590        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2591
2592        // 1. Heading intact.
2593        assert!(
2594            merged.contains("### Re: second question — opus-4-8"),
2595            "new response heading dropped:\n{}",
2596            merged
2597        );
2598        // 2. Lines in author order (not reversed).
2599        let l1 = merged
2600            .find("Second answer line one.")
2601            .expect("line one missing");
2602        let l2 = merged
2603            .find("Second answer line two.")
2604            .expect("line two missing");
2605        let l3 = merged
2606            .find("Second answer line three.")
2607            .expect("line three missing");
2608        assert!(
2609            l1 < l2 && l2 < l3,
2610            "response lines reversed/reordered (l1={l1} l2={l2} l3={l3}):\n{merged}"
2611        );
2612        // 3. New response appears AFTER the prior committed response.
2613        let prior = merged
2614            .find("### Re: first question")
2615            .expect("prior response missing");
2616        let current = merged.find("### Re: second question").unwrap();
2617        assert!(
2618            prior < current,
2619            "new response hoisted above prior HEAD response (prior={prior} current={current}):\n{merged}"
2620        );
2621        // 4. Foreign tail append preserved.
2622        assert!(
2623            merged.contains("do [#foreign-task]"),
2624            "foreign queue append lost:\n{}",
2625            merged
2626        );
2627    }
2628
2629    /// Regression test (#ipc-crdt-response-drift): a stale/divergent `theirs`
2630    /// (e.g. a stale editor live-buffer pushed by convergence, or a concurrent
2631    /// foreign supervisor) presents a document that has *lost* the prior
2632    /// committed `### Re:` response block. The merge must NOT honor that foreign
2633    /// deletion — the committed response and its heading must survive.
2634    #[test]
2635    fn merge_stale_theirs_cannot_delete_committed_response() {
2636        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2637        let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2638        let foot = "<!-- /agent:exchange -->\n";
2639        let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2640        let resp = "### Re: second question — opus-4-8\n\nLine one.\nLine two.\nLine three.\n\n";
2641        let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2642
2643        // Theirs: stale buffer that dropped the committed first Q&A entirely
2644        // (only the boundary id differs from ours — a transient marker).
2645        let theirs = format!("{header}❯ second question\n<!-- agent:boundary:base-id -->\n{foot}");
2646
2647        let base_state = CrdtDoc::from_text(&base).encode_state();
2648        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2649
2650        assert!(
2651            merged.contains("### Re: first question — opus-4-8"),
2652            "committed prior response heading deleted by stale theirs:\n{merged}"
2653        );
2654        assert!(
2655            merged.contains("First answer. Committed to HEAD."),
2656            "committed prior response body deleted by stale theirs:\n{merged}"
2657        );
2658        assert!(
2659            merged.contains("### Re: second question — opus-4-8"),
2660            "new response heading missing:\n{merged}"
2661        );
2662        // Order preserved: prior committed response before the new one.
2663        let prior = merged.find("### Re: first question").unwrap();
2664        let current = merged.find("### Re: second question").unwrap();
2665        assert!(prior < current, "response order inverted:\n{merged}");
2666    }
2667
2668    /// Negative case for the committed-response guard: when `theirs` carries
2669    /// genuinely new user input (a freshly typed prompt) the guard must NOT
2670    /// clobber it by preferring `ours`; both the new user prompt and the
2671    /// committed history must survive.
2672    #[test]
2673    fn merge_committed_guard_keeps_new_user_input() {
2674        let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2675        let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2676        let foot = "<!-- /agent:exchange -->\n";
2677        let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2678        let resp = "### Re: second question — opus-4-8\n\nAnswer body.\n\n";
2679        let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2680
2681        // Theirs: user appended a brand-new prompt while the response generated.
2682        let theirs = format!(
2683            "{header}{committed}❯ a genuinely new third prompt\n<!-- agent:boundary:base-id -->\n{foot}"
2684        );
2685
2686        let base_state = CrdtDoc::from_text(&base).encode_state();
2687        let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2688
2689        assert!(
2690            merged.contains("❯ a genuinely new third prompt"),
2691            "new user prompt clobbered by committed-response guard:\n{merged}"
2692        );
2693        assert!(
2694            merged.contains("### Re: first question"),
2695            "committed prior response lost:\n{merged}"
2696        );
2697        assert!(
2698            merged.contains("### Re: second question"),
2699            "new response lost:\n{merged}"
2700        );
2701    }
2702
2703    // ---- #qcellmerge1: component-scoped merge -----------------------------
2704
2705    /// Build a template doc with an `exchange` and a `queue` component.
2706    fn doc_with_exchange_queue(exchange: &str, queue: &str) -> String {
2707        format!(
2708            "---\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"
2709        )
2710    }
2711
2712    #[test]
2713    fn merge_by_component_isolates_concurrent_cross_component_edits() {
2714        // Side A (agent) appends to `exchange`; side B (operator) appends a
2715        // `queue` item. They must BOTH survive with ZERO cross-component splice.
2716        let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2717        let base_state = CrdtDoc::from_text(&base).encode_state();
2718
2719        let ours = doc_with_exchange_queue(
2720            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2721            "- do [#a1]",
2722        );
2723        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2724
2725        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2726
2727        // Both edits present.
2728        assert!(
2729            merged.contains("### Re: existing"),
2730            "agent exchange edit lost:\n{merged}"
2731        );
2732        assert!(
2733            merged.contains("[#b2]"),
2734            "operator queue edit lost:\n{merged}"
2735        );
2736
2737        // ZERO cross-component splice: the agent response body must not appear
2738        // inside the queue component, and the queue item must not appear inside
2739        // exchange.
2740        let queue_body = merged
2741            .split("<!-- agent:queue -->")
2742            .nth(1)
2743            .and_then(|s| s.split("<!-- /agent:queue -->").next())
2744            .unwrap_or("");
2745        let exchange_body = merged
2746            .split("<!-- agent:exchange -->")
2747            .nth(1)
2748            .and_then(|s| s.split("<!-- /agent:exchange -->").next())
2749            .unwrap_or("");
2750        assert!(
2751            !queue_body.contains("Agent response body"),
2752            "exchange content spliced into queue:\n{queue_body}"
2753        );
2754        assert!(
2755            !exchange_body.contains("[#b2]"),
2756            "queue content spliced into exchange:\n{exchange_body}"
2757        );
2758    }
2759
2760    #[test]
2761    fn merge_by_component_blocks_console_output_in_queue_corruption() {
2762        // The live 2026-06-20 corruption: agent console output ("Supervisor is
2763        // now fresh…") merged INTO agent:queue as a fenced block while the
2764        // operator typed a queue prompt. Model it as: ours = agent wrote console
2765        // text into `exchange`, theirs = operator typed a queue prompt. The
2766        // console text must never land in the queue node.
2767        let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
2768        let base = doc_with_exchange_queue("Q.", "- :pushpin: typing here");
2769        let base_state = CrdtDoc::from_text(&base).encode_state();
2770
2771        let ours = doc_with_exchange_queue(
2772            &format!("Q.\n\n### Re: q\n\n{console}"),
2773            "- :pushpin: typing here",
2774        );
2775        let theirs = doc_with_exchange_queue("Q.", "- :pushpin: typing here now extended");
2776
2777        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2778
2779        let queue_body = merged
2780            .split("<!-- agent:queue -->")
2781            .nth(1)
2782            .and_then(|s| s.split("<!-- /agent:queue -->").next())
2783            .unwrap_or("");
2784        assert!(
2785            !queue_body.contains("Supervisor is now fresh"),
2786            "console output corrupted the queue node:\n{queue_body}"
2787        );
2788        assert!(
2789            queue_body.contains("extended"),
2790            "operator queue edit lost:\n{queue_body}"
2791        );
2792        assert!(
2793            merged.contains("Supervisor is now fresh"),
2794            "console output must remain in its own (exchange) node:\n{merged}"
2795        );
2796    }
2797
2798    #[test]
2799    fn merge_by_component_degenerates_to_whole_doc_without_components() {
2800        // Inline-mode docs (no agent:* components) must behave exactly like the
2801        // legacy whole-doc merge.
2802        let base = "# Document\n\nBase content.\n";
2803        let base_state = CrdtDoc::from_text(base).encode_state();
2804        let ours = format!("{base}## Agent\n\nAgent response.\n");
2805        let theirs = format!("{base}## User\n\nUser addition.\n");
2806
2807        let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2808        let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
2809        assert_eq!(
2810            by_component, whole_doc,
2811            "component merge must equal whole-doc merge for a component-less doc"
2812        );
2813    }
2814
2815    #[test]
2816    fn merge_by_component_falls_back_on_structural_divergence() {
2817        // theirs removes the queue component entirely → structural divergence →
2818        // whole-doc fallback (no panic, content preserved deterministically).
2819        let base = doc_with_exchange_queue("Q.", "- do [#a1]");
2820        let base_state = CrdtDoc::from_text(&base).encode_state();
2821        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
2822        // theirs: no queue component at all.
2823        let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
2824
2825        // Must not panic and must equal the whole-doc merge fallback.
2826        let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2827        let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
2828        assert_eq!(
2829            by_component, whole_doc,
2830            "structural divergence must use whole-doc fallback"
2831        );
2832    }
2833
2834    // ---- #qnodemerge2: per-node CRDT state persistence --------------------
2835
2836    #[test]
2837    fn multinode_state_roundtrip_encode_decode() {
2838        // Per-component encode/decode round-trips through the container format and
2839        // reconstructs the exact document text.
2840        let doc =
2841            doc_with_exchange_queue("Prompt.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
2842        let state = MultiNodeState::from_text(&doc).unwrap();
2843        let encoded = state.encode();
2844        let decoded = MultiNodeState::decode(&encoded).unwrap();
2845        assert_eq!(
2846            decoded, state,
2847            "decode must reproduce the encoded per-node state"
2848        );
2849        assert_eq!(
2850            decoded.to_text().unwrap(),
2851            doc,
2852            "per-node state must reconstruct the original document text"
2853        );
2854        // The container is self-describing — one node per interstitial+component.
2855        assert!(
2856            decoded.nodes.len() >= 4,
2857            "expected ≥4 nodes, got {}",
2858            decoded.nodes.len()
2859        );
2860        assert!(
2861            decoded
2862                .nodes
2863                .iter()
2864                .any(|n| n.name.as_deref() == Some("exchange"))
2865        );
2866        assert!(
2867            decoded
2868                .nodes
2869                .iter()
2870                .any(|n| n.name.as_deref() == Some("queue"))
2871        );
2872    }
2873
2874    #[test]
2875    fn multinode_state_decode_rejects_non_container() {
2876        // A legacy whole-doc Yrs blob is not a per-node container — decode must
2877        // error (not panic) so the caller can migrate it.
2878        let legacy = CrdtDoc::from_text("# Doc\n\nlegacy whole-doc state\n").encode_state();
2879        assert!(
2880            MultiNodeState::decode(&legacy).is_err(),
2881            "legacy whole-doc state must not decode as a per-node container"
2882        );
2883    }
2884
2885    #[test]
2886    fn multinode_state_migrates_legacy_whole_doc() {
2887        // decode_or_migrate must split a legacy whole-doc state into per-node states.
2888        let doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2889        let legacy = CrdtDoc::from_text(&doc).encode_state();
2890        let migrated = MultiNodeState::decode_or_migrate(&legacy, "");
2891        assert_eq!(
2892            migrated.to_text().unwrap(),
2893            doc,
2894            "legacy migration must preserve the full document text"
2895        );
2896        assert!(
2897            migrated
2898                .nodes
2899                .iter()
2900                .any(|n| n.name.as_deref() == Some("queue")),
2901            "legacy migration must recover component nodes"
2902        );
2903    }
2904
2905    #[test]
2906    fn multinode_merge_advances_node_base_independently() {
2907        // The core #qnodemerge2 property: when only the `exchange` node changes,
2908        // the `queue` node's persisted base is byte-identical across the cycle,
2909        // while the `exchange` node's base advances.
2910        let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2911        let base = MultiNodeState::from_text(&base_doc).unwrap();
2912
2913        // Agent appends to exchange; operator leaves queue untouched.
2914        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nAgent body.", "- do [#a1]");
2915        let theirs = base_doc.clone();
2916
2917        let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2918        assert!(
2919            merged.contains("Agent body."),
2920            "exchange edit lost:\n{merged}"
2921        );
2922
2923        let queue_before = base
2924            .nodes
2925            .iter()
2926            .find(|n| n.name.as_deref() == Some("queue"))
2927            .map(|n| &n.state);
2928        let queue_after = advanced
2929            .nodes
2930            .iter()
2931            .find(|n| n.name.as_deref() == Some("queue"))
2932            .map(|n| &n.state);
2933        assert_eq!(
2934            queue_before, queue_after,
2935            "untouched queue node base must be byte-identical across the cycle"
2936        );
2937
2938        let exchange_before = base
2939            .nodes
2940            .iter()
2941            .find(|n| n.name.as_deref() == Some("exchange"))
2942            .map(|n| &n.state);
2943        let exchange_after = advanced
2944            .nodes
2945            .iter()
2946            .find(|n| n.name.as_deref() == Some("exchange"))
2947            .map(|n| &n.state);
2948        assert_ne!(
2949            exchange_before, exchange_after,
2950            "changed exchange node base must advance"
2951        );
2952    }
2953
2954    #[test]
2955    fn multinode_merge_isolates_concurrent_cross_component_edits() {
2956        // Parallel to merge_by_component: concurrent exchange + queue edits both
2957        // survive with zero cross-component splice, via per-node bases.
2958        let base_doc = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2959        let base = MultiNodeState::from_text(&base_doc).unwrap();
2960
2961        let ours = doc_with_exchange_queue(
2962            "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2963            "- do [#a1]",
2964        );
2965        let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2966
2967        let (merged, _advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2968        assert!(
2969            merged.contains("### Re: existing"),
2970            "exchange edit lost:\n{merged}"
2971        );
2972        assert!(merged.contains("[#b2]"), "queue edit lost:\n{merged}");
2973
2974        let queue_body = merged
2975            .split("<!-- agent:queue -->")
2976            .nth(1)
2977            .and_then(|s| s.split("<!-- /agent:queue -->").next())
2978            .unwrap_or("");
2979        assert!(
2980            !queue_body.contains("Agent response body"),
2981            "exchange content spliced into queue:\n{queue_body}"
2982        );
2983    }
2984
2985    #[test]
2986    fn multinode_merge_falls_back_on_structural_divergence() {
2987        // theirs drops the queue component → whole-doc fallback, still producing a
2988        // valid per-node state that round-trips the merged text.
2989        let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2990        let base = MultiNodeState::from_text(&base_doc).unwrap();
2991        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
2992        let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
2993
2994        let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2995        let whole_doc = {
2996            let base_state = CrdtDoc::from_text(&base_doc).encode_state();
2997            merge(Some(&base_state), &ours, &theirs).unwrap()
2998        };
2999        assert_eq!(
3000            merged, whole_doc,
3001            "structural divergence must use whole-doc fallback"
3002        );
3003        assert_eq!(
3004            advanced.to_text().unwrap(),
3005            merged,
3006            "fallback must still yield a per-node state round-tripping the merged text"
3007        );
3008    }
3009
3010    #[test]
3011    fn multinode_merge_bootstraps_without_base() {
3012        // No prior per-node state (first cycle) still merges and yields state.
3013        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]");
3014        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3015        let (merged, state) = MultiNodeState::merge(None, &ours, &theirs).unwrap();
3016        assert!(merged.contains("Body."));
3017        assert!(merged.contains("[#b2]"));
3018        assert_eq!(state.to_text().unwrap(), merged);
3019    }
3020
3021    // ---- #qnodemerge3: recursive AST-node keyed reconciliation ------------
3022
3023    #[test]
3024    fn reconcile_queue_item_edit_does_not_touch_sibling_item() {
3025        // Acceptance (a): the operator edits queue item B (#b2) while item A
3026        // (#a1) is the running head and the agent appends an exchange response.
3027        // Item A must survive byte-for-byte (no drift, no duplication); item B
3028        // carries the operator edit. Two edits to different keyed children must
3029        // reconcile in separate sub-trees.
3030        let base = doc_with_exchange_queue("Q.", "- :pushpin: do [#a1]\n- :pushpin: do [#b2]");
3031        let base_state = CrdtDoc::from_text(&base).encode_state();
3032        let ours = doc_with_exchange_queue(
3033            "Q.\n\n### Re: q — opus\n\nBody.",
3034            "- :pushpin: do [#a1]\n- :pushpin: do [#b2]",
3035        );
3036        let theirs = doc_with_exchange_queue(
3037            "Q.",
3038            "- :pushpin: do [#a1]\n- :pushpin: do [#b2] with operator note",
3039        );
3040
3041        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3042        assert!(
3043            merged.contains("### Re: q"),
3044            "exchange edit lost:\n{merged}"
3045        );
3046        assert_eq!(
3047            merged.matches("do [#a1]").count(),
3048            1,
3049            "sibling item A drifted/duplicated:\n{merged}"
3050        );
3051        assert!(
3052            merged.contains("- :pushpin: do [#a1]\n"),
3053            "sibling item A text drifted:\n{merged}"
3054        );
3055        assert!(
3056            merged.contains("operator note"),
3057            "item B operator edit lost:\n{merged}"
3058        );
3059    }
3060
3061    #[test]
3062    fn reconcile_exchange_prompt_between_blocks_converges_with_new_block() {
3063        // Acceptance (b): a user prompt inserted between two committed `### Re:`
3064        // blocks while the agent appends a new block must converge with all three
3065        // blocks present and zero cross-block splice (each heading exactly once).
3066        let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3067        let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3068        let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3069        let base_state = CrdtDoc::from_text(&base).encode_state();
3070
3071        let r3 = "### Re: third — opus\n\nThird answer.\n\n";
3072        let ours = doc_with_exchange_queue(&format!("{r1}{r2}{r3}"), "- do [#a1]");
3073        let theirs = doc_with_exchange_queue(
3074            &format!("{r1}❯ a prompt typed between blocks\n\n{r2}"),
3075            "- do [#a1]",
3076        );
3077
3078        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3079        assert!(merged.contains("### Re: first"), "block 1 lost:\n{merged}");
3080        assert!(merged.contains("### Re: second"), "block 2 lost:\n{merged}");
3081        assert!(
3082            merged.contains("### Re: third"),
3083            "appended block 3 lost:\n{merged}"
3084        );
3085        assert!(
3086            merged.contains("a prompt typed between blocks"),
3087            "interleaved operator prompt lost:\n{merged}"
3088        );
3089        assert_eq!(
3090            merged.matches("### Re: first").count(),
3091            1,
3092            "block 1 cross-spliced/duplicated:\n{merged}"
3093        );
3094        assert_eq!(
3095            merged.matches("### Re: third").count(),
3096            1,
3097            "block 3 cross-spliced/duplicated:\n{merged}"
3098        );
3099    }
3100
3101    #[test]
3102    fn reconcile_queue_preserves_concurrent_user_item_addition() {
3103        // The #queue-user-edit-overwrite class: the operator adds a new queue item
3104        // (theirs) while the agent edits a different item (ours). Keyed
3105        // reconciliation treats the new item as a pure insert — never dropped.
3106        let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3107        let base_state = CrdtDoc::from_text(&base).encode_state();
3108        let ours = doc_with_exchange_queue("Q.", "- do [#a1] agent touched");
3109        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#user-added]");
3110
3111        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3112        assert!(
3113            merged.contains("[#user-added]"),
3114            "concurrent user queue addition dropped:\n{merged}"
3115        );
3116        assert!(
3117            merged.contains("agent touched"),
3118            "concurrent agent item edit lost:\n{merged}"
3119        );
3120    }
3121
3122    #[test]
3123    fn reconcile_queue_honors_clean_item_deletion() {
3124        // theirs removes item B (clean delete: ours left B == base). The delete is
3125        // honored while item A and the agent's exchange edit survive.
3126        let base = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3127        let base_state = CrdtDoc::from_text(&base).encode_state();
3128        let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3129        let theirs = doc_with_exchange_queue("Q.", "- do [#a1]");
3130
3131        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3132        assert!(merged.contains("Body."), "exchange edit lost:\n{merged}");
3133        let queue_body = merged
3134            .split("<!-- agent:queue -->")
3135            .nth(1)
3136            .and_then(|s| s.split("<!-- /agent:queue -->").next())
3137            .unwrap_or("");
3138        assert!(queue_body.contains("[#a1]"), "item A lost:\n{queue_body}");
3139        assert!(
3140            !queue_body.contains("[#b2]"),
3141            "clean deletion of item B not honored:\n{queue_body}"
3142        );
3143    }
3144
3145    #[test]
3146    fn reconcile_exchange_never_drops_committed_block_on_stale_side() {
3147        // A stale `theirs` that lost a committed `### Re:` block must not delete it
3148        // — committed exchange history is append-only (the per-block
3149        // #ipc-crdt-response-drift guard).
3150        let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3151        let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3152        let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3153        let base_state = CrdtDoc::from_text(&base).encode_state();
3154        // ours keeps both committed blocks; theirs is stale and dropped block 2.
3155        let ours = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]\n- do [#new]");
3156        let theirs = doc_with_exchange_queue(&r1.to_string(), "- do [#a1]");
3157
3158        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3159        assert!(
3160            merged.contains("### Re: second"),
3161            "stale side deleted a committed block:\n{merged}"
3162        );
3163        assert!(
3164            merged.contains("[#new]"),
3165            "concurrent queue add lost:\n{merged}"
3166        );
3167    }
3168
3169    #[test]
3170    fn reconcile_falls_back_on_duplicate_item_keys() {
3171        // Two identical free-text queue items make keys ambiguous → fall back to
3172        // the flat whole-component merge (no panic, deterministic, content kept).
3173        let base = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3174        let base_state = CrdtDoc::from_text(&base).encode_state();
3175        let ours = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item\n- new one");
3176        let theirs = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3177
3178        let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3179        assert!(
3180            merged.contains("new one"),
3181            "duplicate-key fallback dropped the new item:\n{merged}"
3182        );
3183    }
3184
3185    #[test]
3186    fn first_hash_id_extracts_durable_id() {
3187        assert_eq!(
3188            first_hash_id("- :pushpin: do [#qnodemerge3]"),
3189            Some("qnodemerge3".to_string())
3190        );
3191        assert_eq!(
3192            first_hash_id("- [ ] [#6b5hwire] some text"),
3193            Some("6b5hwire".to_string())
3194        );
3195        assert_eq!(first_hash_id("- free text no id"), None);
3196    }
3197
3198    #[test]
3199    fn list_item_key_stable_across_strike_and_glyphs() {
3200        // A struck or pin-glyphed item keys the same as its plain form, so a
3201        // strike reconciles as a content edit, not a delete+insert.
3202        assert_eq!(list_item_key("- do [#a1]"), list_item_key("- ~~do [#a1]~~"));
3203        assert_eq!(
3204            list_item_key("- :pushpin: hello world"),
3205            list_item_key("- ~~hello world~~")
3206        );
3207    }
3208
3209    #[test]
3210    fn order_union_weaves_theirs_only_inserts() {
3211        let ours = ["a", "b", "c"];
3212        let theirs = ["a", "x", "b", "c", "y"];
3213        assert_eq!(
3214            order_union(&ours, &theirs),
3215            vec![
3216                "a".to_string(),
3217                "x".into(),
3218                "b".into(),
3219                "c".into(),
3220                "y".into()
3221            ]
3222        );
3223    }
3224
3225    #[test]
3226    fn split_list_children_is_lossless() {
3227        // The segmentation must be exact: concatenating child texts reproduces the
3228        // body byte-for-byte (no content gained or lost).
3229        let body = "\n- do [#a1]\n  continuation line\n- :pushpin: do [#b2]\n\n";
3230        let children = split_list_children(body).unwrap();
3231        let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3232        assert_eq!(joined, body, "list segmentation is not lossless");
3233    }
3234
3235    #[test]
3236    fn split_exchange_children_is_lossless() {
3237        let body = "\n### Re: a — opus\n\nbody a\n\n### Re: b — opus\n\nbody b\n";
3238        let children = split_exchange_children(body).unwrap();
3239        let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3240        assert_eq!(joined, body, "exchange segmentation is not lossless");
3241    }
3242}