1use anyhow::{Context, Result};
60use yrs::updates::decoder::Decode;
61use yrs::{Doc, GetString, ReadTxn, Text, TextRef, Transact, Update};
62
63const TEXT_KEY: &str = "content";
64
65pub struct CrdtDoc {
67 doc: Doc,
68}
69
70impl CrdtDoc {
71 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 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 #[allow(dead_code)] 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 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 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
120pub 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
133pub 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 if ours_text == theirs_text {
168 eprintln!("[crdt] ours == theirs, skipping merge");
169 return Ok(ours_text.to_string());
170 }
171
172 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 if let Some(ops) = theirs_editor_ops
194 && !ops.is_empty()
195 && crate::cell_doc::cell_merge_enabled()
196 {
197 let outcome = crate::cell_doc::merge_3way_with_ops(&base_text, ours_text, theirs_text, ops);
198 if !outcome.fell_back {
199 if !outcome.conflicts.is_empty() {
200 eprintln!(
201 "[crdt] cell_merge(op-routed): {} conflict(s) surfaced (policy=ours-wins)",
202 outcome.conflicts.len()
203 );
204 }
205 eprintln!(
206 "[crdt] cell_merge(op-routed): {} captured op(s) routed to cells and replayed",
207 ops.len()
208 );
209 return Ok(outcome.merged_text);
210 }
211 eprintln!(
212 "[crdt] cell_merge(op-routed): fell back to legacy merge_inner path (boundary-crossing/framing/stale ops)"
213 );
214 }
215
216 eprintln!(
217 "[crdt] merge: base_len={} ours_len={} theirs_len={}",
218 base_text.len(),
219 ours_text.len(),
220 theirs_text.len()
221 );
222
223 let committed_response_headings = response_headings(&base_text);
229
230 let ours_prefix = common_prefix_len(&base_text, ours_text);
241 let ours_suffix = common_suffix_len(&base_text, ours_text);
242 let theirs_prefix = common_prefix_len(&base_text, theirs_text);
243 let theirs_suffix = common_suffix_len(&base_text, theirs_text);
244 let base_len = base_text.len();
245
246 let ours_shared = (ours_prefix + ours_suffix).min(base_len);
248 let theirs_shared = (theirs_prefix + theirs_suffix).min(base_len);
249
250 if base_len > 0
251 && (ours_shared as f64 / base_len as f64) < 0.5
252 && (theirs_shared as f64 / base_len as f64) < 0.5
253 {
254 eprintln!(
255 "[crdt] Stale CRDT base detected (shared: ours={}%, theirs={}%). Using ours as base.",
256 (ours_shared * 100) / base_len,
257 (theirs_shared * 100) / base_len
258 );
259 base_text = ours_text.to_string();
260 }
261
262 let mutual_prefix = common_prefix_len(ours_text, theirs_text);
286 let base_diverge =
287 common_prefix_len(&base_text, ours_text).max(common_prefix_len(&base_text, theirs_text));
288 if mutual_prefix > base_diverge {
289 let snap = &ours_text[..mutual_prefix];
296 let snapped = match snap.rfind('\n') {
297 Some(pos) if pos >= base_diverge => pos + 1,
298 _ => base_diverge, };
300 if snapped > base_diverge {
301 eprintln!(
302 "[crdt] Advancing base to shared prefix (diverge={} → {})",
303 base_diverge, snapped
304 );
305 base_text = ours_text[..snapped].to_string();
306 }
307 }
308
309 let theirs_replay_ops = match theirs_editor_ops {
315 Some(ops) if !ops.is_empty() && base_text.is_ascii() && theirs_text.is_ascii() => {
323 match replay_editor_ops(&base_text, ops) {
324 Some(replayed) if replayed == theirs_text => {
325 eprintln!(
326 "[crdt] editor_ops_replayed: {} captured op(s) reproduce theirs exactly (#qnodemerge4)",
327 ops.len()
328 );
329 Some(ops)
330 }
331 _ => {
332 eprintln!(
333 "[crdt] editor_ops_fallback: {} captured op(s) do not replay to theirs from the resolved base (stale/misaligned) — using diff-guess (#qnodemerge4)",
334 ops.len()
335 );
336 None
337 }
338 }
339 }
340 _ => None,
341 };
342
343 let ours_ops = compute_edit_ops(&base_text, ours_text);
345 let theirs_ops = compute_edit_ops(&base_text, theirs_text);
346
347 let base_encoded = if base_text == base_doc.to_text() {
350 base_doc.encode_state()
351 } else {
352 CrdtDoc::from_text(&base_text).encode_state()
353 };
354
355 let ours_doc = Doc::with_client_id(1);
359 {
360 let update =
361 Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
362 let mut txn = ours_doc.transact_mut();
363 txn.apply_update(update)
364 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
365 }
366
367 let theirs_doc = Doc::with_client_id(2);
368 {
369 let update =
370 Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
371 let mut txn = theirs_doc.transact_mut();
372 txn.apply_update(update)
373 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
374 }
375
376 {
378 let text = ours_doc.get_or_insert_text(TEXT_KEY);
379 let mut txn = ours_doc.transact_mut();
380 apply_ops(&text, &mut txn, &ours_ops);
381 }
382
383 {
386 let text = theirs_doc.get_or_insert_text(TEXT_KEY);
387 let mut txn = theirs_doc.transact_mut();
388 match theirs_replay_ops {
389 Some(ops) => apply_editor_ops(&text, &mut txn, ops),
390 None => apply_ops(&text, &mut txn, &theirs_ops),
391 }
392 }
393
394 let ours_sv = {
396 let txn = ours_doc.transact();
397 txn.state_vector()
398 };
399 let theirs_update = {
400 let txn = theirs_doc.transact();
401 txn.encode_state_as_update_v1(&ours_sv)
402 };
403 {
404 let update = Update::decode_v1(&theirs_update)
405 .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
406 let mut txn = ours_doc.transact_mut();
407 txn.apply_update(update)
408 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
409 }
410
411 let merged = {
417 let text = ours_doc.get_or_insert_text(TEXT_KEY);
418 let txn = ours_doc.transact();
419 text.get_string(&txn)
420 };
421
422 let merged =
431 guard_committed_responses(merged, &committed_response_headings, ours_text, theirs_text);
432
433 Ok(dedup_adjacent_blocks(&merged))
435}
436
437#[derive(Debug, Clone)]
445enum Node {
446 Interstitial(String),
449 Component { name: String, text: String },
451}
452
453impl Node {
454 fn component_name(&self) -> Option<&str> {
455 match self {
456 Node::Component { name, .. } => Some(name.as_str()),
457 Node::Interstitial(_) => None,
458 }
459 }
460
461 fn text(&self) -> &str {
462 match self {
463 Node::Interstitial(t) => t,
464 Node::Component { text, .. } => text,
465 }
466 }
467}
468
469fn segment_into_nodes(text: &str) -> Result<Vec<Node>> {
475 let comps = crate::component::parse(text)?;
476 let mut top: Vec<&crate::component::Component> = comps
479 .iter()
480 .filter(|c| {
481 !comps.iter().any(|o| {
482 !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
483 })
484 })
485 .collect();
486 top.sort_by_key(|c| c.open_start);
487
488 let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
489 let mut cursor = 0usize;
490 for c in top {
491 nodes.push(Node::Interstitial(text[cursor..c.open_start].to_string()));
493 nodes.push(Node::Component {
494 name: c.name.clone(),
495 text: text[c.open_start..c.close_end].to_string(),
496 });
497 cursor = c.close_end;
498 }
499 nodes.push(Node::Interstitial(text[cursor..].to_string()));
501 Ok(nodes)
502}
503
504fn try_cell_merge_text(
515 base_text: &str,
516 ours_text: &str,
517 theirs_text: &str,
518 site: &str,
519) -> Option<String> {
520 let outcome = crate::cell_doc::merge_3way(base_text, ours_text, theirs_text);
521 if outcome.fell_back {
522 eprintln!("[crdt] cell_merge: fell back to legacy {site} path");
523 return None;
524 }
525 if !outcome.conflicts.is_empty() {
526 eprintln!(
527 "[crdt] cell_merge: {} conflict(s) surfaced (policy=ours-wins)",
528 outcome.conflicts.len()
529 );
530 }
531 Some(outcome.merged_text)
532}
533
534pub fn merge_by_component(
550 base_state: Option<&[u8]>,
551 ours_text: &str,
552 theirs_text: &str,
553) -> Result<String> {
554 if ours_text == theirs_text {
555 return Ok(ours_text.to_string());
556 }
557
558 if crate::cell_doc::cell_merge_enabled() {
564 let base_text = match base_state {
565 Some(bytes) => CrdtDoc::decode_state(bytes)
566 .map(|d| d.to_text())
567 .unwrap_or_default(),
568 None => String::new(),
569 };
570 if let Some(merged) =
571 try_cell_merge_text(&base_text, ours_text, theirs_text, "merge_by_component")
572 {
573 return Ok(merged);
574 }
575 }
576
577 let ours_nodes = match segment_into_nodes(ours_text) {
578 Ok(c) => c,
579 Err(e) => {
580 eprintln!(
581 "[crdt] merge_by_component: failed to segment ours ({e}); falling back to whole-doc merge"
582 );
583 return merge(base_state, ours_text, theirs_text);
584 }
585 };
586 let theirs_nodes = match segment_into_nodes(theirs_text) {
587 Ok(c) => c,
588 Err(e) => {
589 eprintln!(
590 "[crdt] merge_by_component: failed to segment theirs ({e}); falling back to whole-doc merge"
591 );
592 return merge(base_state, ours_text, theirs_text);
593 }
594 };
595
596 let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
597 let theirs_names: Vec<&str> = theirs_nodes
598 .iter()
599 .filter_map(Node::component_name)
600 .collect();
601
602 if ours_names.is_empty() && theirs_names.is_empty() {
605 return merge(base_state, ours_text, theirs_text);
606 }
607
608 if ours_names != theirs_names {
611 eprintln!(
612 "[crdt] merge_by_component: structural divergence (ours components {ours_names:?} != theirs {theirs_names:?}); falling back to whole-doc merge"
613 );
614 return merge(base_state, ours_text, theirs_text);
615 }
616
617 let base_text = match base_state {
619 Some(bytes) => match CrdtDoc::decode_state(bytes) {
620 Ok(doc) => doc.to_text(),
621 Err(e) => {
622 eprintln!(
623 "[crdt] merge_by_component: failed to decode base state ({e}); merging nodes without a base"
624 );
625 String::new()
626 }
627 },
628 None => String::new(),
629 };
630 let base_nodes = segment_into_nodes(&base_text).unwrap_or_default();
631 let mut base_by_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
632 for node in &base_nodes {
633 if let Node::Component { name, text } = node {
634 base_by_name.entry(name.as_str()).or_insert(text.as_str());
635 }
636 }
637 let base_interstitials: Vec<&str> = base_nodes
639 .iter()
640 .filter_map(|c| match c {
641 Node::Interstitial(t) => Some(t.as_str()),
642 Node::Component { .. } => None,
643 })
644 .collect();
645
646 let merged_nodes = merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
647 Some(n) => base_by_name.get(n).map(|s| s.to_string()),
648 None => base_interstitials.get(idx).map(|s| s.to_string()),
649 })?;
650 let merged: String = merged_nodes.into_iter().map(|(_, text)| text).collect();
651
652 let merged_names: Vec<String> = match segment_into_nodes(&merged) {
662 Ok(nodes) => nodes
663 .iter()
664 .filter_map(|n| n.component_name().map(str::to_string))
665 .collect(),
666 Err(e) => {
667 eprintln!(
668 "[crdt] merge_by_component: merged result failed to re-segment ({e}); falling back to whole-doc merge"
669 );
670 return merge(base_state, ours_text, theirs_text);
671 }
672 };
673 let names_match = merged_names.len() == ours_names.len()
674 && merged_names
675 .iter()
676 .zip(ours_names.iter())
677 .all(|(m, o)| m.as_str() == *o);
678 if !names_match {
679 eprintln!(
680 "[crdt] merge_by_component: merged component framing diverged (ours {ours_names:?} != merged {merged_names:?}); falling back to whole-doc merge"
681 );
682 return merge(base_state, ours_text, theirs_text);
683 }
684
685 Ok(merged)
686}
687
688fn merge_aligned_nodes<F>(
699 ours_nodes: &[Node],
700 theirs_nodes: &[Node],
701 mut resolve_base: F,
702) -> Result<Vec<(Option<String>, String)>>
703where
704 F: FnMut(Option<&str>, usize) -> Option<String>,
705{
706 let mut merged = Vec::with_capacity(ours_nodes.len());
707 let mut interstitial_idx = 0usize;
708 for (ours_node, theirs_node) in ours_nodes.iter().zip(theirs_nodes.iter()) {
709 let name = ours_node.component_name();
710 let node_base = resolve_base(name, interstitial_idx);
711 if name.is_none() {
712 interstitial_idx += 1;
713 }
714
715 let ours_slice = ours_node.text();
716 let theirs_slice = theirs_node.text();
717
718 let merged_text = if ours_slice == theirs_slice {
719 ours_slice.to_string()
720 } else if let Some(component_name) = name
721 && let Some(merged) = reconcile_component(
722 component_name,
723 node_base.as_deref(),
724 ours_slice,
725 theirs_slice,
726 )
727 {
728 merged
734 } else {
735 let node_base_state = node_base
736 .as_deref()
737 .map(|t| CrdtDoc::from_text(t).encode_state());
738 merge(node_base_state.as_deref(), ours_slice, theirs_slice)?
739 };
740 merged.push((name.map(str::to_string), merged_text));
741 }
742 Ok(merged)
743}
744
745pub(crate) const PREAMBLE_KEY: &str = "\u{0}::preamble";
767
768pub(crate) fn is_list_component(name: &str) -> bool {
771 matches!(name, "queue" | "backlog" | "review" | "done")
772}
773
774pub(crate) struct KeyedChild {
778 pub(crate) key: String,
779 pub(crate) text: String,
780}
781
782fn first_hash_id(s: &str) -> Option<String> {
786 let bytes = s.as_bytes();
787 let mut i = 0;
788 while i + 1 < bytes.len() {
789 if bytes[i] == b'[' && bytes[i + 1] == b'#' {
791 let rest = &s[i + 2..];
792 if let Some(close) = rest.find(']') {
793 let id = &rest[..close];
794 if !id.is_empty()
795 && id
796 .chars()
797 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
798 {
799 return Some(id.to_string());
800 }
801 }
802 }
803 i += 1;
804 }
805 None
806}
807
808fn normalize_item_text(item: &str) -> String {
812 let first = item.lines().next().unwrap_or("").trim();
813 let mut s = first.strip_prefix("- ").unwrap_or(first).trim_start();
814 for cb in ["[ ]", "[x]", "[X]", "[/]"] {
815 if let Some(r) = s.strip_prefix(cb) {
816 s = r.trim_start();
817 break;
818 }
819 }
820 s.replace("~~", "")
821 .replace(":pushpin:", "")
822 .replace('📌', "")
823 .split_whitespace()
824 .collect::<Vec<_>>()
825 .join(" ")
826}
827
828fn list_item_key(item: &str) -> String {
831 match first_hash_id(item) {
832 Some(id) => format!("id:{id}"),
833 None => format!("txt:{}", normalize_item_text(item)),
834 }
835}
836
837fn exchange_heading_key(block: &str) -> String {
840 let heading = block.lines().next().unwrap_or("").trim();
841 heading
842 .strip_suffix(" (HEAD)")
843 .unwrap_or(heading)
844 .trim_end()
845 .to_string()
846}
847
848pub(crate) fn split_list_children(body: &str) -> Option<Vec<KeyedChild>> {
853 let mut starts = Vec::new();
854 let mut pos = 0usize;
855 for line in body.split_inclusive('\n') {
856 let trimmed = line.trim_start();
857 if trimmed.starts_with("- ") || trimmed.trim_end() == "-" {
858 starts.push(pos);
859 }
860 pos += line.len();
861 }
862 if starts.is_empty() {
863 return None;
864 }
865 Some(segment_by_starts(body, &starts, list_item_key))
866}
867
868pub(crate) fn split_exchange_children(body: &str) -> Option<Vec<KeyedChild>> {
872 let mut starts = Vec::new();
873 let mut pos = 0usize;
874 for line in body.split_inclusive('\n') {
875 if line.trim_start().starts_with("### Re:") {
876 starts.push(pos);
877 }
878 pos += line.len();
879 }
880 if starts.is_empty() {
881 return None;
882 }
883 Some(segment_by_starts(body, &starts, exchange_heading_key))
884}
885
886fn segment_by_starts(body: &str, starts: &[usize], key_of: fn(&str) -> String) -> Vec<KeyedChild> {
890 let mut children = Vec::with_capacity(starts.len() + 1);
891 if starts[0] > 0 {
892 children.push(KeyedChild {
893 key: PREAMBLE_KEY.to_string(),
894 text: body[..starts[0]].to_string(),
895 });
896 }
897 for (i, &s) in starts.iter().enumerate() {
898 let e = starts.get(i + 1).copied().unwrap_or(body.len());
899 let slice = &body[s..e];
900 children.push(KeyedChild {
901 key: key_of(slice),
902 text: slice.to_string(),
903 });
904 }
905 children
906}
907
908fn keys_unique(children: &[KeyedChild]) -> bool {
912 let mut seen = std::collections::HashSet::new();
913 children.iter().all(|c| seen.insert(c.key.as_str()))
914}
915
916fn component_framing(text: &str) -> Option<(&str, &str, &str)> {
920 let comps = crate::component::parse(text).ok()?;
921 let comp = comps.iter().min_by_key(|c| c.open_start)?;
922 Some((
923 &text[..comp.open_end],
924 &text[comp.open_end..comp.close_start],
925 &text[comp.close_start..],
926 ))
927}
928
929fn reconcile_component(
935 name: &str,
936 base_text: Option<&str>,
937 ours_text: &str,
938 theirs_text: &str,
939) -> Option<String> {
940 if name != "exchange" && !is_list_component(name) {
941 return None;
942 }
943 let (ours_open, ours_body, ours_close) = component_framing(ours_text)?;
944 let (theirs_open, theirs_body, theirs_close) = component_framing(theirs_text)?;
945 let base_framing = base_text.and_then(component_framing);
946 let base_body = base_framing.map(|(_, b, _)| b);
947
948 let merged_body = reconcile_component_body(name, base_body, ours_body, theirs_body)?;
949 let merged_open = reconcile_marker_operator_authoritative(
959 base_framing.map(|(o, _, _)| o),
960 ours_open,
961 theirs_open,
962 );
963 let merged_close = reconcile_marker_operator_authoritative(
964 base_framing.map(|(_, _, c)| c),
965 ours_close,
966 theirs_close,
967 );
968 Some(format!("{merged_open}{merged_body}{merged_close}"))
969}
970
971fn reconcile_marker_operator_authoritative<'a>(
981 base: Option<&str>,
982 ours: &'a str,
983 theirs: &'a str,
984) -> &'a str {
985 if ours == theirs {
986 return ours;
987 }
988 match base {
989 Some(b) if theirs != b => theirs,
991 Some(_) => ours,
993 None => theirs,
995 }
996}
997
998fn reconcile_component_body(
1001 name: &str,
1002 base_body: Option<&str>,
1003 ours_body: &str,
1004 theirs_body: &str,
1005) -> Option<String> {
1006 let split = |b: &str| -> Option<Vec<KeyedChild>> {
1007 if name == "exchange" {
1008 split_exchange_children(b)
1009 } else {
1010 split_list_children(b)
1011 }
1012 };
1013 let ours_children = split(ours_body)?;
1014 let theirs_children = split(theirs_body)?;
1015 let base_children = base_body.and_then(split).unwrap_or_default();
1016
1017 if !keys_unique(&ours_children)
1018 || !keys_unique(&theirs_children)
1019 || !keys_unique(&base_children)
1020 {
1021 return None;
1022 }
1023
1024 let ours_map: std::collections::HashMap<&str, &KeyedChild> =
1025 ours_children.iter().map(|c| (c.key.as_str(), c)).collect();
1026 let theirs_map: std::collections::HashMap<&str, &KeyedChild> = theirs_children
1027 .iter()
1028 .map(|c| (c.key.as_str(), c))
1029 .collect();
1030 let base_map: std::collections::HashMap<&str, &KeyedChild> =
1031 base_children.iter().map(|c| (c.key.as_str(), c)).collect();
1032
1033 let ours_keys: Vec<&str> = ours_children.iter().map(|c| c.key.as_str()).collect();
1034 let theirs_keys: Vec<&str> = theirs_children.iter().map(|c| c.key.as_str()).collect();
1035 let order = if name == "exchange" {
1044 order_union(&ours_keys, &theirs_keys)
1045 } else {
1046 order_union(&theirs_keys, &ours_keys)
1047 };
1048
1049 let protect_deletes = name == "exchange";
1053
1054 let lifecycle_governed = is_list_component(name);
1060
1061 let mut out = String::new();
1062 for key in &order {
1063 let in_o = ours_map.get(key.as_str());
1064 let in_t = theirs_map.get(key.as_str());
1065 let in_b = base_map.get(key.as_str());
1066 let resolved: Option<String> = match (in_o, in_t) {
1067 (Some(o), Some(t)) => {
1068 if o.text == t.text {
1069 Some(o.text.clone())
1070 } else if lifecycle_governed {
1071 Some(reconcile_list_item_lifecycle(o, t, in_b.copied()))
1078 } else {
1079 let base_state = in_b.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1083 Some(merge(base_state.as_deref(), &o.text, &t.text).ok()?)
1084 }
1085 }
1086 (Some(o), None) => match in_b {
1090 None => Some(o.text.clone()),
1091 Some(_) if lifecycle_governed => None,
1092 Some(b) if protect_deletes || o.text != b.text => Some(o.text.clone()),
1093 Some(_) => None,
1094 },
1095 (None, Some(t)) => match in_b {
1097 None => Some(t.text.clone()),
1098 Some(b) if protect_deletes || t.text != b.text => Some(t.text.clone()),
1099 Some(_) => None,
1100 },
1101 (None, None) => None,
1102 };
1103 if let Some(text) = resolved {
1104 out.push_str(&text);
1105 }
1106 }
1107 Some(out)
1108}
1109
1110fn reconcile_list_item_lifecycle(
1124 ours: &KeyedChild,
1125 theirs: &KeyedChild,
1126 base: Option<&KeyedChild>,
1127) -> String {
1128 use crate::queue_item_lifecycle::QueueItemLifecycle;
1129
1130 if ours.key == PREAMBLE_KEY {
1133 let base_state = base.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1134 return merge(base_state.as_deref(), &ours.text, &theirs.text)
1135 .unwrap_or_else(|_| ours.text.clone());
1136 }
1137
1138 let ours_state = QueueItemLifecycle::classify(&ours.text);
1139 let theirs_state = QueueItemLifecycle::classify(&theirs.text);
1140 let joined = ours_state.join(theirs_state);
1141
1142 match (ours_state == joined, theirs_state == joined) {
1143 (true, false) => ours.text.clone(),
1147 (false, true) => theirs.text.clone(),
1148 _ => {
1152 let base_state = base.map(|b| CrdtDoc::from_text(&b.text).encode_state());
1153 merge(base_state.as_deref(), &ours.text, &theirs.text)
1154 .unwrap_or_else(|_| ours.text.clone())
1155 }
1156 }
1157}
1158
1159fn order_union(ours_keys: &[&str], theirs_keys: &[&str]) -> Vec<String> {
1164 let ours_set: std::collections::HashSet<&str> = ours_keys.iter().copied().collect();
1165 let mut result: Vec<String> = ours_keys.iter().map(|s| s.to_string()).collect();
1166 for (i, &k) in theirs_keys.iter().enumerate() {
1167 if ours_set.contains(k) || result.iter().any(|r| r == k) {
1168 continue;
1169 }
1170 let mut insert_pos = 0usize;
1171 for j in (0..i).rev() {
1172 if let Some(p) = result.iter().position(|r| r == theirs_keys[j]) {
1173 insert_pos = p + 1;
1174 break;
1175 }
1176 }
1177 result.insert(insert_pos, k.to_string());
1178 }
1179 result
1180}
1181
1182fn encode_text_deterministic(text: &str) -> Vec<u8> {
1189 let doc = Doc::with_client_id(0);
1190 let yrs_text = doc.get_or_insert_text(TEXT_KEY);
1191 {
1192 let mut txn = doc.transact_mut();
1193 yrs_text.insert(&mut txn, 0, text);
1194 }
1195 let txn = doc.transact();
1196 txn.encode_state_as_update_v1(&yrs::StateVector::default())
1197}
1198
1199const MULTINODE_MAGIC: &[u8; 4] = b"ADN1";
1204
1205#[derive(Debug, Clone, Default, PartialEq, Eq)]
1217pub struct MultiNodeState {
1218 nodes: Vec<PersistedNode>,
1219}
1220
1221#[derive(Debug, Clone, PartialEq, Eq)]
1223struct PersistedNode {
1224 name: Option<String>,
1226 state: Vec<u8>,
1228}
1229
1230impl MultiNodeState {
1231 pub fn from_text(text: &str) -> Result<Self> {
1234 let nodes = segment_into_nodes(text)?
1235 .iter()
1236 .map(|node| PersistedNode {
1237 name: node.component_name().map(str::to_string),
1238 state: encode_text_deterministic(node.text()),
1239 })
1240 .collect();
1241 Ok(MultiNodeState { nodes })
1242 }
1243
1244 fn from_merged_nodes(merged: &[(Option<String>, String)]) -> Self {
1246 MultiNodeState {
1247 nodes: merged
1248 .iter()
1249 .map(|(name, text)| PersistedNode {
1250 name: name.clone(),
1251 state: encode_text_deterministic(text),
1252 })
1253 .collect(),
1254 }
1255 }
1256
1257 fn node_text(node: &PersistedNode) -> Result<String> {
1259 Ok(CrdtDoc::decode_state(&node.state)?.to_text())
1260 }
1261
1262 pub fn to_text(&self) -> Result<String> {
1264 let mut text = String::new();
1265 for node in &self.nodes {
1266 text.push_str(&Self::node_text(node)?);
1267 }
1268 Ok(text)
1269 }
1270
1271 fn base_lookup(&self) -> Result<(std::collections::HashMap<String, String>, Vec<String>)> {
1275 let mut by_name = std::collections::HashMap::new();
1276 let mut interstitials = Vec::new();
1277 for node in &self.nodes {
1278 let text = Self::node_text(node)?;
1279 match &node.name {
1280 Some(name) => {
1281 by_name.entry(name.clone()).or_insert(text);
1282 }
1283 None => interstitials.push(text),
1284 }
1285 }
1286 Ok((by_name, interstitials))
1287 }
1288
1289 pub fn encode(&self) -> Vec<u8> {
1294 let mut out = Vec::new();
1295 out.extend_from_slice(MULTINODE_MAGIC);
1296 out.push(1u8); out.extend_from_slice(&(self.nodes.len() as u32).to_le_bytes());
1298 for node in &self.nodes {
1299 match &node.name {
1300 Some(name) => {
1301 let bytes = name.as_bytes();
1302 out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1303 out.extend_from_slice(bytes);
1304 }
1305 None => out.extend_from_slice(&u32::MAX.to_le_bytes()),
1306 }
1307 out.extend_from_slice(&(node.state.len() as u32).to_le_bytes());
1308 out.extend_from_slice(&node.state);
1309 }
1310 out
1311 }
1312
1313 pub fn decode(bytes: &[u8]) -> Result<Self> {
1317 let mut cursor = 0usize;
1318 let take = |cursor: &mut usize, n: usize| -> Result<&[u8]> {
1319 let end = cursor
1320 .checked_add(n)
1321 .filter(|end| *end <= bytes.len())
1322 .ok_or_else(|| anyhow::anyhow!("multinode state truncated"))?;
1323 let slice = &bytes[*cursor..end];
1324 *cursor = end;
1325 Ok(slice)
1326 };
1327 let read_u32 = |cursor: &mut usize| -> Result<u32> {
1328 let b = take(cursor, 4)?;
1329 Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1330 };
1331
1332 if take(&mut cursor, 4)? != MULTINODE_MAGIC {
1333 anyhow::bail!("not a multinode state container (magic mismatch)");
1334 }
1335 let version = take(&mut cursor, 1)?[0];
1336 if version != 1 {
1337 anyhow::bail!("unsupported multinode state version {version}");
1338 }
1339 let count = read_u32(&mut cursor)? as usize;
1340 let mut nodes = Vec::with_capacity(count);
1341 for _ in 0..count {
1342 let name_len = read_u32(&mut cursor)?;
1343 let name = if name_len == u32::MAX {
1344 None
1345 } else {
1346 let bytes = take(&mut cursor, name_len as usize)?;
1347 Some(
1348 std::str::from_utf8(bytes)
1349 .map_err(|e| anyhow::anyhow!("invalid node name UTF-8: {e}"))?
1350 .to_string(),
1351 )
1352 };
1353 let state_len = read_u32(&mut cursor)? as usize;
1354 let state = take(&mut cursor, state_len)?.to_vec();
1355 nodes.push(PersistedNode { name, state });
1356 }
1357 Ok(MultiNodeState { nodes })
1358 }
1359
1360 pub fn decode_or_migrate(bytes: &[u8], fallback_text: &str) -> Self {
1368 match Self::decode(bytes) {
1369 Ok(state) => return state,
1370 Err(_) => {
1371 if let Ok(doc) = CrdtDoc::decode_state(bytes)
1373 && let Ok(state) = Self::from_text(&doc.to_text())
1374 {
1375 eprintln!(
1376 "[crdt] MultiNodeState: migrated legacy whole-doc state into per-node state"
1377 );
1378 return state;
1379 }
1380 }
1381 }
1382 eprintln!(
1383 "[crdt] MultiNodeState: state unreadable; rebuilding per-node state from fallback text"
1384 );
1385 Self::from_text(fallback_text).unwrap_or_default()
1386 }
1387
1388 pub fn merge(
1400 base: Option<&MultiNodeState>,
1401 ours_text: &str,
1402 theirs_text: &str,
1403 ) -> Result<(String, MultiNodeState)> {
1404 let whole_doc_fallback = |reason: &str| -> Result<(String, MultiNodeState)> {
1405 let base_text = match base {
1406 Some(b) => b.to_text().unwrap_or_default(),
1407 None => String::new(),
1408 };
1409 let base_state = base.map(|_| CrdtDoc::from_text(&base_text).encode_state());
1410 if !reason.is_empty() {
1411 eprintln!(
1412 "[crdt] MultiNodeState::merge: {reason}; falling back to whole-doc merge"
1413 );
1414 }
1415 let merged = merge(base_state.as_deref(), ours_text, theirs_text)?;
1416 let state = MultiNodeState::from_text(&merged)?;
1417 Ok((merged, state))
1418 };
1419
1420 if ours_text == theirs_text {
1421 return Ok((ours_text.to_string(), MultiNodeState::from_text(ours_text)?));
1422 }
1423
1424 if crate::cell_doc::cell_merge_enabled() {
1434 let base_text = match base {
1435 Some(b) => b.to_text().unwrap_or_default(),
1436 None => String::new(),
1437 };
1438 if let Some(merged) =
1439 try_cell_merge_text(&base_text, ours_text, theirs_text, "MultiNodeState::merge")
1440 {
1441 let state = MultiNodeState::from_text(&merged)?;
1442 return Ok((merged, state));
1443 }
1444 }
1445
1446 let ours_nodes = match segment_into_nodes(ours_text) {
1447 Ok(c) => c,
1448 Err(e) => return whole_doc_fallback(&format!("failed to segment ours ({e})")),
1449 };
1450 let theirs_nodes = match segment_into_nodes(theirs_text) {
1451 Ok(c) => c,
1452 Err(e) => return whole_doc_fallback(&format!("failed to segment theirs ({e})")),
1453 };
1454
1455 let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
1456 let theirs_names: Vec<&str> = theirs_nodes
1457 .iter()
1458 .filter_map(Node::component_name)
1459 .collect();
1460
1461 if ours_names.is_empty() && theirs_names.is_empty() {
1462 return whole_doc_fallback("");
1463 }
1464 if ours_names != theirs_names {
1465 return whole_doc_fallback(&format!(
1466 "structural divergence (ours {ours_names:?} != theirs {theirs_names:?})"
1467 ));
1468 }
1469
1470 let (base_by_name, base_interstitials) = match base {
1471 Some(b) => b.base_lookup()?,
1472 None => (std::collections::HashMap::new(), Vec::new()),
1473 };
1474
1475 let merged_nodes =
1476 merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
1477 Some(n) => base_by_name.get(n).cloned(),
1478 None => base_interstitials.get(idx).cloned(),
1479 })?;
1480
1481 let merged_text: String = merged_nodes.iter().map(|(_, text)| text.as_str()).collect();
1482 let new_state = MultiNodeState::from_merged_nodes(&merged_nodes);
1483 Ok((merged_text, new_state))
1484 }
1485}
1486
1487fn is_transient_merge_line(line: &str) -> bool {
1491 let t = line.trim();
1492 t.starts_with("<!-- agent:boundary:")
1493}
1494
1495fn response_headings(text: &str) -> Vec<String> {
1499 text.lines()
1500 .map(str::trim)
1501 .filter(|l| l.starts_with("### Re:"))
1502 .map(|l| {
1503 l.strip_suffix(" (HEAD)")
1504 .unwrap_or(l)
1505 .trim_end()
1506 .to_string()
1507 })
1508 .collect()
1509}
1510
1511fn guard_committed_responses(
1520 merged: String,
1521 committed_response_headings: &[String],
1522 ours_text: &str,
1523 theirs_text: &str,
1524) -> String {
1525 if committed_response_headings.is_empty() {
1526 return merged;
1527 }
1528 let ours_headings = response_headings(ours_text);
1529 let merged_headings = response_headings(&merged);
1530 let lost: Vec<&String> = committed_response_headings
1531 .iter()
1532 .filter(|h| ours_headings.contains(h) && !merged_headings.contains(h))
1533 .collect();
1534 if lost.is_empty() {
1535 return merged;
1536 }
1537
1538 let ours_lines: std::collections::HashSet<&str> = ours_text
1541 .lines()
1542 .map(str::trim)
1543 .filter(|l| !l.is_empty())
1544 .collect();
1545 let theirs_adds_new = theirs_text
1546 .lines()
1547 .map(str::trim)
1548 .any(|l| !l.is_empty() && !is_transient_merge_line(l) && !ours_lines.contains(l));
1549
1550 if theirs_adds_new {
1551 eprintln!(
1552 "[crdt] WARNING: merge dropped committed response heading(s) {:?} while theirs added new content; keeping merge (#ipc-crdt-response-drift)",
1553 lost
1554 );
1555 merged
1556 } else {
1557 eprintln!(
1558 "[crdt] stale theirs dropped committed response heading(s) {:?}; preferring ours (#ipc-crdt-response-drift)",
1559 lost
1560 );
1561 ours_text.to_string()
1562 }
1563}
1564
1565pub fn dedup_adjacent_blocks(text: &str) -> String {
1572 let blocks: Vec<&str> = text.split("\n\n").collect();
1573 if blocks.len() < 2 {
1574 return text.to_string();
1575 }
1576
1577 let mut result: Vec<&str> = Vec::with_capacity(blocks.len());
1578 for block in &blocks {
1579 let trimmed = block.trim();
1580 let non_empty_lines = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
1581 if non_empty_lines >= 1
1582 && !is_structural_block(trimmed)
1583 && let Some(prev) = result.last()
1584 && prev.trim() == trimmed
1585 {
1586 eprintln!(
1587 "[crdt] dedup: removed duplicate block ({} lines)",
1588 non_empty_lines
1589 );
1590 continue;
1591 }
1592 result.push(*block);
1593 }
1594
1595 result.join("\n\n")
1596}
1597
1598fn is_structural_block(trimmed: &str) -> bool {
1601 let mut lines = trimmed.lines();
1602 let first = match lines.next() {
1603 Some(l) => l.trim(),
1604 None => return false,
1605 };
1606 if lines.next().is_some() {
1607 return false;
1608 }
1609 first.len() >= 3 && first.chars().all(|c| c == '-' || c == '*' || c == '_')
1610}
1611
1612pub fn compact(state: &[u8]) -> Result<Vec<u8>> {
1614 let doc = CrdtDoc::decode_state(state)?;
1615 Ok(doc.encode_state())
1616}
1617
1618fn common_prefix_len(a: &str, b: &str) -> usize {
1620 a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
1621}
1622
1623fn common_suffix_len(a: &str, b: &str) -> usize {
1625 a.bytes()
1626 .rev()
1627 .zip(b.bytes().rev())
1628 .take_while(|(x, y)| x == y)
1629 .count()
1630}
1631
1632#[derive(Debug)]
1634enum EditOp {
1635 Retain(u32),
1636 Delete(u32),
1637 Insert(String),
1638}
1639
1640fn compute_edit_ops(from: &str, to: &str) -> Vec<EditOp> {
1642 use similar::{ChangeTag, TextDiff};
1643
1644 let diff = TextDiff::from_lines(from, to);
1645 let mut ops = Vec::new();
1646
1647 for change in diff.iter_all_changes() {
1648 match change.tag() {
1649 ChangeTag::Equal => {
1650 let len = change.value().len() as u32;
1651 if let Some(EditOp::Retain(n)) = ops.last_mut() {
1652 *n += len;
1653 } else {
1654 ops.push(EditOp::Retain(len));
1655 }
1656 }
1657 ChangeTag::Delete => {
1658 let len = change.value().len() as u32;
1659 if let Some(EditOp::Delete(n)) = ops.last_mut() {
1660 *n += len;
1661 } else {
1662 ops.push(EditOp::Delete(len));
1663 }
1664 }
1665 ChangeTag::Insert => {
1666 let s = change.value().to_string();
1667 if let Some(EditOp::Insert(existing)) = ops.last_mut() {
1668 existing.push_str(&s);
1669 } else {
1670 ops.push(EditOp::Insert(s));
1671 }
1672 }
1673 }
1674 }
1675
1676 ops
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1693#[serde(tag = "kind", rename_all = "lowercase")]
1694pub enum EditorOp {
1695 Insert { offset: usize, text: String },
1697 Delete { offset: usize, len: usize },
1699}
1700
1701pub fn replay_editor_ops(base: &str, ops: &[EditorOp]) -> Option<String> {
1711 let mut buf = base.to_string();
1712 for op in ops {
1713 match op {
1714 EditorOp::Insert { offset, text } => {
1715 if *offset > buf.len() || !buf.is_char_boundary(*offset) {
1716 return None;
1717 }
1718 buf.insert_str(*offset, text);
1719 }
1720 EditorOp::Delete { offset, len } => {
1721 let end = offset.checked_add(*len)?;
1722 if end > buf.len() || !buf.is_char_boundary(*offset) || !buf.is_char_boundary(end) {
1723 return None;
1724 }
1725 buf.replace_range(*offset..end, "");
1726 }
1727 }
1728 }
1729 Some(buf)
1730}
1731
1732fn apply_editor_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditorOp]) {
1740 for op in ops {
1741 match op {
1742 EditorOp::Insert { offset, text: s } => {
1743 text.insert(txn, *offset as u32, s);
1744 }
1745 EditorOp::Delete { offset, len } => {
1746 text.remove_range(txn, *offset as u32, *len as u32);
1747 }
1748 }
1749 }
1750}
1751
1752fn apply_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditOp]) {
1754 let mut cursor: u32 = 0;
1755 for op in ops {
1756 match op {
1757 EditOp::Retain(n) => cursor += n,
1758 EditOp::Delete(n) => {
1759 text.remove_range(txn, cursor, *n);
1760 }
1762 EditOp::Insert(s) => {
1763 text.insert(txn, cursor, s);
1764 cursor += s.len() as u32;
1765 }
1766 }
1767 }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772 use super::*;
1773
1774 #[test]
1775 fn roundtrip_text() {
1776 let content = "Hello, world!\nLine two.\n";
1777 let doc = CrdtDoc::from_text(content);
1778 assert_eq!(doc.to_text(), content);
1779 }
1780
1781 #[test]
1784 fn replay_editor_ops_reconstructs_insert() {
1785 let base = "hello world\n";
1786 let ops = vec![EditorOp::Insert {
1788 offset: 5,
1789 text: "!".to_string(),
1790 }];
1791 assert_eq!(
1792 replay_editor_ops(base, &ops).as_deref(),
1793 Some("hello! world\n")
1794 );
1795 }
1796
1797 #[test]
1798 fn replay_editor_ops_reconstructs_delete_and_replace_sequence() {
1799 let base = "- do [#foo]\n";
1800 let ops = vec![
1802 EditorOp::Delete { offset: 7, len: 3 },
1803 EditorOp::Insert {
1804 offset: 7,
1805 text: "foobar".to_string(),
1806 },
1807 ];
1808 assert_eq!(
1809 replay_editor_ops(base, &ops).as_deref(),
1810 Some("- do [#foobar]\n")
1811 );
1812 }
1813
1814 #[test]
1815 fn replay_editor_ops_sequential_offsets_are_post_prior_op() {
1816 let base = "ab";
1819 let ops = vec![
1820 EditorOp::Insert {
1821 offset: 0,
1822 text: "X".to_string(),
1823 },
1824 EditorOp::Insert {
1825 offset: 2,
1826 text: "Y".to_string(),
1827 },
1828 ];
1829 assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some("XaYb"));
1830 }
1831
1832 #[test]
1833 fn replay_editor_ops_out_of_bounds_is_none() {
1834 let base = "short";
1835 let insert = vec![EditorOp::Insert {
1836 offset: 99,
1837 text: "x".to_string(),
1838 }];
1839 assert_eq!(replay_editor_ops(base, &insert), None);
1840 let delete = vec![EditorOp::Delete { offset: 3, len: 99 }];
1841 assert_eq!(replay_editor_ops(base, &delete), None);
1842 }
1843
1844 #[test]
1845 fn replay_editor_ops_non_char_boundary_is_none() {
1846 let base = "é";
1848 let ops = vec![EditorOp::Insert {
1849 offset: 1,
1850 text: "x".to_string(),
1851 }];
1852 assert_eq!(replay_editor_ops(base, &ops), None);
1853 }
1854
1855 #[test]
1856 fn editor_op_json_roundtrip() {
1857 let ops = vec![
1858 EditorOp::Insert {
1859 offset: 3,
1860 text: "hi".to_string(),
1861 },
1862 EditorOp::Delete { offset: 0, len: 2 },
1863 ];
1864 let json = serde_json::to_string(&ops).unwrap();
1865 let back: Vec<EditorOp> = serde_json::from_str(&json).unwrap();
1866 assert_eq!(ops, back);
1867 }
1868
1869 #[test]
1870 fn merge_with_editor_ops_gate_passes_and_preserves_user_edit() {
1871 let base = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\n";
1874 let ours = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\nAGENT RESPONSE\n";
1875 let theirs = "<!-- agent:queue -->\n- do [#foobar]\n<!-- /agent:queue -->\n";
1876 let ops = vec![
1877 EditorOp::Delete { offset: 28, len: 3 },
1878 EditorOp::Insert {
1879 offset: 28,
1880 text: "foobar".to_string(),
1881 },
1882 ];
1883 assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some(theirs));
1885
1886 let base_state = CrdtDoc::from_text(base).encode_state();
1887 let merged = merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&ops)).unwrap();
1888 assert!(merged.contains("- do [#foobar]"), "merged: {merged}");
1890 assert!(merged.contains("AGENT RESPONSE"), "merged: {merged}");
1891 assert!(
1892 !merged.contains("- do [#foo]\n"),
1893 "stale item leaked: {merged}"
1894 );
1895 assert_eq!(
1896 merged.matches("do [#foobar]").count(),
1897 1,
1898 "duplicated: {merged}"
1899 );
1900 }
1901
1902 #[test]
1903 fn merge_with_editor_ops_stale_ops_fall_back_to_diff() {
1904 let base = "Line A\nLine B\n";
1908 let ours = "Line A\nLine B\nAgent line\n";
1909 let theirs = "Line A\nLine B edited\n";
1910 let bogus = vec![EditorOp::Insert {
1912 offset: 0,
1913 text: "ZZZ".to_string(),
1914 }];
1915 assert_ne!(replay_editor_ops(base, &bogus).as_deref(), Some(theirs));
1916
1917 let base_state = CrdtDoc::from_text(base).encode_state();
1918 let with_ops =
1919 merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&bogus)).unwrap();
1920 let diff_only = merge(Some(&base_state), ours, theirs).unwrap();
1921 assert_eq!(
1922 with_ops, diff_only,
1923 "stale ops must fall back to the diff-guess result"
1924 );
1925 assert!(!with_ops.contains("ZZZ"), "stale op leaked: {with_ops}");
1927 }
1928
1929 #[test]
1930 fn merge_with_editor_ops_none_matches_plain_merge() {
1931 let base = "header\n\nbody\n";
1933 let ours = "header\n\nbody\nagent\n";
1934 let theirs = "header\n\nbody edited\n";
1935 let base_state = CrdtDoc::from_text(base).encode_state();
1936 assert_eq!(
1937 merge_with_editor_ops(Some(&base_state), ours, theirs, None).unwrap(),
1938 merge(Some(&base_state), ours, theirs).unwrap()
1939 );
1940 }
1941
1942 #[test]
1943 fn merge_with_editor_ops_empty_ops_fall_back() {
1944 let base = "x\ny\n";
1945 let ours = "x\ny\nagent\n";
1946 let theirs = "x\ny edited\n";
1947 let base_state = CrdtDoc::from_text(base).encode_state();
1948 let empty: Vec<EditorOp> = vec![];
1949 assert_eq!(
1950 merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&empty)).unwrap(),
1951 merge(Some(&base_state), ours, theirs).unwrap()
1952 );
1953 }
1954
1955 #[test]
1956 fn roundtrip_encode_decode() {
1957 let content = "Some document content.\n";
1958 let doc = CrdtDoc::from_text(content);
1959 let encoded = doc.encode_state();
1960 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1961 assert_eq!(decoded.to_text(), content);
1962 }
1963
1964 #[test]
1965 fn apply_edit_insert() {
1966 let doc = CrdtDoc::from_text("Hello world");
1967 doc.apply_edit(5, 0, ",");
1968 assert_eq!(doc.to_text(), "Hello, world");
1969 }
1970
1971 #[test]
1972 fn apply_edit_delete() {
1973 let doc = CrdtDoc::from_text("Hello, world");
1974 doc.apply_edit(5, 1, "");
1975 assert_eq!(doc.to_text(), "Hello world");
1976 }
1977
1978 #[test]
1979 fn apply_edit_replace() {
1980 let doc = CrdtDoc::from_text("Hello world");
1981 doc.apply_edit(6, 5, "Rust");
1982 assert_eq!(doc.to_text(), "Hello Rust");
1983 }
1984
1985 #[test]
1986 fn concurrent_append_merge_no_conflict() {
1987 let base = "# Document\n\nBase content.\n";
1988 let base_doc = CrdtDoc::from_text(base);
1989 let base_state = base_doc.encode_state();
1990
1991 let ours = format!("{base}## Agent\n\nAgent response.\n");
1992 let theirs = format!("{base}## User\n\nUser addition.\n");
1993
1994 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1995
1996 assert!(merged.contains("Agent response."), "missing agent text");
1998 assert!(merged.contains("User addition."), "missing user text");
1999 assert!(merged.contains("Base content."), "missing base text");
2000 assert!(!merged.contains("<<<<<<<"));
2002 assert!(!merged.contains(">>>>>>>"));
2003 }
2004
2005 #[test]
2006 fn concurrent_insert_same_position() {
2007 let base = "Line 1\nLine 3\n";
2008 let base_doc = CrdtDoc::from_text(base);
2009 let base_state = base_doc.encode_state();
2010
2011 let ours = "Line 1\nAgent line\nLine 3\n";
2012 let theirs = "Line 1\nUser line\nLine 3\n";
2013
2014 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2015
2016 assert!(merged.contains("Agent line"), "missing agent insertion");
2018 assert!(merged.contains("User line"), "missing user insertion");
2019 assert!(merged.contains("Line 1"), "missing line 1");
2020 assert!(merged.contains("Line 3"), "missing line 3");
2021 }
2022
2023 #[test]
2024 fn merge_no_base_state() {
2025 let ours = "Agent wrote this.\n";
2027 let theirs = "User wrote this.\n";
2028
2029 let merged = merge(None, ours, theirs).unwrap();
2030
2031 assert!(merged.contains("Agent wrote this."));
2032 assert!(merged.contains("User wrote this."));
2033 }
2034
2035 #[test]
2036 fn compact_preserves_content() {
2037 let doc = CrdtDoc::from_text("Hello");
2038 doc.apply_edit(5, 0, " world");
2039 doc.apply_edit(11, 0, "!");
2040
2041 let state = doc.encode_state();
2042 let compacted = compact(&state).unwrap();
2043 let restored = CrdtDoc::decode_state(&compacted).unwrap();
2044
2045 assert_eq!(restored.to_text(), "Hello world!");
2046 assert!(compacted.len() <= state.len());
2047 }
2048
2049 #[test]
2050 fn compact_reduces_size_after_edits() {
2051 let doc = CrdtDoc::from_text("aaaa");
2052 for i in 0..20 {
2054 let c = ((b'a' + (i % 26)) as char).to_string();
2055 doc.apply_edit(0, 1, &c);
2056 }
2057 let state = doc.encode_state();
2058 let compacted = compact(&state).unwrap();
2059 let restored = CrdtDoc::decode_state(&compacted).unwrap();
2060 assert_eq!(restored.to_text(), doc.to_text());
2061 }
2062
2063 #[test]
2064 fn empty_document() {
2065 let doc = CrdtDoc::from_text("");
2066 assert_eq!(doc.to_text(), "");
2067
2068 let encoded = doc.encode_state();
2069 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
2070 assert_eq!(decoded.to_text(), "");
2071 }
2072
2073 #[test]
2074 fn decode_invalid_bytes_errors() {
2075 let result = CrdtDoc::decode_state(&[0xff, 0xfe, 0xfd]);
2076 assert!(result.is_err());
2077 }
2078
2079 #[test]
2080 fn merge_identical_texts() {
2081 let base = "Same content.\n";
2082 let base_doc = CrdtDoc::from_text(base);
2083 let state = base_doc.encode_state();
2084
2085 let merged = merge(Some(&state), base, base).unwrap();
2086 assert_eq!(merged, base);
2087 }
2088
2089 #[test]
2090 fn merge_one_side_unchanged() {
2091 let base = "Original.\n";
2092 let base_doc = CrdtDoc::from_text(base);
2093 let state = base_doc.encode_state();
2094
2095 let ours = "Original.\nAgent added.\n";
2096 let merged = merge(Some(&state), ours, base).unwrap();
2097 assert_eq!(merged, ours);
2098 }
2099
2100 #[test]
2111 fn merge_stale_base_no_duplicate_user_prompt() {
2112 let base_content = "\
2114## Assistant
2115
2116Previous response content.
2117
2118Committed and pushed.
2119
2120";
2121 let base_doc = CrdtDoc::from_text(base_content);
2122 let base_state = base_doc.encode_state();
2123
2124 let user_prompt = "\
2126Opening a video a shows video a.
2127Closing video a then opening video b start video b but video b is hidden.
2128Closing video b then reopening video b starts and shows video b. video b is visible.
2129";
2130
2131 let ours = format!(
2133 "\
2134{}{}### Re: Close A → Open B still hidden
2135
2136Added explicit height and visibility reset.
2137
2138Committed and pushed.
2139
2140",
2141 base_content, user_prompt
2142 );
2143
2144 let theirs = format!(
2146 "\
2147{}{}
2148",
2149 base_content, user_prompt
2150 );
2151
2152 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2153
2154 let prompt_count = merged.matches("Opening a video a shows video a.").count();
2156 assert_eq!(
2157 prompt_count, 1,
2158 "User prompt duplicated! Appeared {} times in:\n{}",
2159 prompt_count, merged
2160 );
2161
2162 assert!(
2164 merged.contains("### Re: Close A → Open B still hidden"),
2165 "Agent response missing from merge:\n{}",
2166 merged
2167 );
2168 }
2169
2170 #[test]
2173 fn merge_stale_base_same_insertion_both_sides() {
2174 let base_content = "Line 1\nLine 2\n";
2175 let base_doc = CrdtDoc::from_text(base_content);
2176 let base_state = base_doc.encode_state();
2177
2178 let shared_addition = "User typed this.\n";
2180 let ours = format!("{}{}Agent response.\n", base_content, shared_addition);
2181 let theirs = format!("{}{}", base_content, shared_addition);
2182
2183 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2184
2185 let count = merged.matches("User typed this.").count();
2186 assert_eq!(
2187 count, 1,
2188 "Shared text duplicated! Appeared {} times in:\n{}",
2189 count, merged
2190 );
2191 assert!(
2192 merged.contains("Agent response."),
2193 "Agent text missing:\n{}",
2194 merged
2195 );
2196 }
2197
2198 #[test]
2214 fn merge_no_character_interleaving() {
2215 let base = "# Doc\n\nPrevious content.\n\n";
2217 let base_doc = CrdtDoc::from_text(base);
2218 let base_state = base_doc.encode_state();
2219
2220 let ours = "# Doc\n\nPrevious content.\n\n*Compacted. Content archived to*\n";
2222 let theirs = "# Doc\n\nPrevious content.\n\n**Soft-bristle brush only**\n";
2224
2225 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2226
2227 assert!(
2229 merged.contains("*Compacted. Content archived to*"),
2230 "Agent text should be contiguous (not interleaved). Got:\n{}",
2231 merged
2232 );
2233 assert!(
2234 merged.contains("**Soft-bristle brush only**"),
2235 "User text should be contiguous (not interleaved). Got:\n{}",
2236 merged
2237 );
2238 }
2239
2240 #[test]
2243 fn merge_concurrent_same_line_no_garbling() {
2244 let base = "Some base text\n";
2245 let base_doc = CrdtDoc::from_text(base);
2246 let base_state = base_doc.encode_state();
2247
2248 let ours = "Agent wrote this line\n";
2250 let theirs = "User wrote different text\n";
2251
2252 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2253
2254 let has_agent_contiguous = merged.contains("Agent wrote this line");
2256 let has_user_contiguous = merged.contains("User wrote different text");
2257
2258 assert!(
2259 has_agent_contiguous || has_user_contiguous,
2260 "At least one side should have contiguous text (no char interleaving). Got:\n{}",
2261 merged
2262 );
2263 }
2264
2265 #[test]
2281 fn merge_replace_vs_append_no_interleaving() {
2282 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2284 let footer = "\n<!-- /agent:exchange -->\n";
2285
2286 let old_exchange = "\
2288### Committed, Pushed & Released
2289
2290**project (v0.1.0):**
2291- Committed initial implementation
2292- Tagged v0.1.0 and pushed
2293
2294Add a README.md to the project.
2295Also add AGENTS.md with a symlink CLAUDE.md
2296
2297**sub-project:**
2298- Committed fix + SPEC.md
2299- Pushed to remote
2300";
2301 let stale_base = format!("{header}{old_exchange}{footer}");
2302 let stale_state = CrdtDoc::from_text(&stale_base).encode_state();
2303
2304 let _baseline = stale_base.clone();
2307
2308 let agent_exchange = "\
2310### Done
2311
2312Added to project and pushed:
2313
2314- **README.md** — overview, usage, design notes
2315- **AGENTS.md** — architecture, key decisions, commands, related projects
2316- **CLAUDE.md** → symlink to AGENTS.md
2317
2318All committed and pushed.
2319";
2320 let ours = format!("{header}{agent_exchange}{footer}");
2321
2322 let theirs_exchange = "\
2326### Committed, Pushed & Released
2327
2328**project (v0.1.0):**
2329- Committed initial implementation
2330- Tagged v0.1.0 and pushed
2331
2332Add a README.md to the project.
2333Also add AGENTS.md with a symlink CLAUDE.md
2334
2335Please add tests.
2336Please comprehensively test adherence to the spec.
2337
2338**sub-project:**
2339- Committed fix + SPEC.md
2340- Pushed to remote
2341";
2342 let theirs = format!("{header}{theirs_exchange}{footer}");
2343
2344 let merged = merge(Some(&stale_state), &ours, &theirs).unwrap();
2346
2347 assert!(
2349 merged.contains(
2350 "- **AGENTS.md** — architecture, key decisions, commands, related projects"
2351 ),
2352 "Agent text garbled (mid-word split). Got:\n{}",
2353 merged
2354 );
2355
2356 assert!(
2358 merged.contains("Please add tests."),
2359 "User addition missing. Got:\n{}",
2360 merged
2361 );
2362
2363 assert!(
2365 !merged.contains("key deAdd") && !merged.contains("key de\n"),
2366 "Old content interleaved into agent text. Got:\n{}",
2367 merged
2368 );
2369 }
2370
2371 #[test]
2374 fn merge_replace_vs_append_with_baseline_base() {
2375 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2376 let footer = "\n<!-- /agent:exchange -->\n";
2377
2378 let old_exchange = "\
2379### Previous Response
2380
2381Old content here.
2382
2383Add a README.md to the project.
2384Also add AGENTS.md with a symlink CLAUDE.md
2385";
2386 let baseline = format!("{header}{old_exchange}{footer}");
2387
2388 let agent_exchange = "\
2390### Done
2391
2392- **README.md** — overview, usage, design notes
2393- **AGENTS.md** — architecture, key decisions, commands, related projects
2394- **CLAUDE.md** → symlink to AGENTS.md
2395
2396All committed and pushed.
2397";
2398 let ours = format!("{header}{agent_exchange}{footer}");
2399
2400 let user_addition = "\nPlease add tests.\n";
2402 let theirs = format!("{header}{old_exchange}{user_addition}{footer}");
2403
2404 let baseline_state = CrdtDoc::from_text(&baseline).encode_state();
2406 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2407
2408 assert!(
2410 merged.contains("key decisions, commands, related projects"),
2411 "Agent text garbled. Got:\n{}",
2412 merged
2413 );
2414
2415 assert!(
2417 merged.contains("Please add tests."),
2418 "User addition missing. Got:\n{}",
2419 merged
2420 );
2421 }
2422
2423 #[test]
2430 fn merge_streaming_concurrent_edit_preserves_formatting() {
2431 let base = "commit and push all rappstack packages.\n\n";
2433 let base_doc = CrdtDoc::from_text(base);
2434 let base_state = base_doc.encode_state();
2435
2436 let ours = "\
2438commit and push all rappstack packages.
2439
2440### Re: commit and push
2441
2442*Compacted. Content archived to `docs/`*
2443
2444Done — all packages pushed.
2445";
2446
2447 let theirs = "\
2449commit and push all rappstack packages.
2450
2451**Soft-bristle brush only**
2452";
2453
2454 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2455
2456 assert!(
2458 merged.contains("*Compacted. Content archived to `docs/`*"),
2459 "Agent formatting broken. Got:\n{}",
2460 merged
2461 );
2462 assert!(
2464 merged.contains("**Soft-bristle brush only**"),
2465 "User formatting broken. Got:\n{}",
2466 merged
2467 );
2468 assert!(
2470 !merged.contains("*C*C") && !merged.contains("**Sot"),
2471 "Character interleaving detected. Got:\n{}",
2472 merged
2473 );
2474 }
2475
2476 #[test]
2483 fn merge_replace_vs_insert_no_interleaving() {
2484 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n# Document Title\n\nSome preamble text that both sides share.\nThis provides enough common prefix to avoid stale detection.\n\n<!-- agent:exchange -->\n";
2485 let footer = "<!-- /agent:exchange -->\n";
2486
2487 let old_exchange =
2488 "Line one of old content\nLine two of old content\nLine three of old content\n";
2489 let baseline = format!("{header}{old_exchange}{footer}");
2490 let baseline_doc = CrdtDoc::from_text(&baseline);
2491 let baseline_state = baseline_doc.encode_state();
2492
2493 let agent_exchange = "Completely new line one\nCompletely new line two\nCompletely new line three\nCompletely new line four\n";
2495 let ours = format!("{header}{agent_exchange}{footer}");
2496
2497 let theirs = format!(
2499 "{header}Line one of old content\nUser inserted this line\nLine two of old content\nLine three of old content\n{footer}"
2500 );
2501
2502 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2503
2504 assert!(
2506 merged.contains("Completely new line one"),
2507 "Agent line 1 missing or garbled. Got:\n{}",
2508 merged
2509 );
2510 assert!(
2511 merged.contains("Completely new line two"),
2512 "Agent line 2 missing or garbled. Got:\n{}",
2513 merged
2514 );
2515
2516 assert!(
2518 merged.contains("User inserted this line"),
2519 "User insertion missing. Got:\n{}",
2520 merged
2521 );
2522
2523 assert!(
2525 !merged.contains("CompleteUser") && !merged.contains("Complete\nUser"),
2526 "Character interleaving detected. Got:\n{}",
2527 merged
2528 );
2529 }
2530
2531 #[test]
2534 fn reorder_agent_before_human_at_append_boundary() {
2535 let base = "# Document\n\nBase content.\n";
2536 let base_doc = CrdtDoc::from_text(base);
2537 let base_state = base_doc.encode_state();
2538
2539 let ours = format!("{base}### Agent Response\n\nAgent wrote this.\n");
2541 let theirs = format!("{base}User added this line.\n");
2543
2544 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2545
2546 assert!(merged.contains("Agent wrote this."), "missing agent text");
2548 assert!(
2549 merged.contains("User added this line."),
2550 "missing user text"
2551 );
2552 assert!(merged.contains("Base content."), "missing base text");
2553
2554 let agent_pos = merged.find("Agent wrote this.").unwrap();
2556 let human_pos = merged.find("User added this line.").unwrap();
2557 assert!(
2558 agent_pos < human_pos,
2559 "Agent content should appear before human content.\nAgent pos: {}, Human pos: {}\nMerged:\n{}",
2560 agent_pos,
2561 human_pos,
2562 merged
2563 );
2564 }
2565
2566 #[test]
2571 fn dedup_removes_identical_adjacent_blocks() {
2572 let text =
2573 "### Re: Question\nAnswer here.\n\n### Re: Question\nAnswer here.\n\nDifferent block.";
2574 let result = dedup_adjacent_blocks(text);
2575 assert_eq!(result.matches("### Re: Question").count(), 1);
2576 assert!(result.contains("Different block."));
2577 }
2578
2579 #[test]
2580 fn dedup_preserves_different_adjacent_blocks() {
2581 let text = "### Re: First\nAnswer one.\n\n### Re: Second\nAnswer two.";
2582 let result = dedup_adjacent_blocks(text);
2583 assert!(result.contains("### Re: First"));
2584 assert!(result.contains("### Re: Second"));
2585 }
2586
2587 #[test]
2588 fn dedup_preserves_structural_thematic_breaks() {
2589 let text = "---\n\n---\n\nContent.";
2590 let result = dedup_adjacent_blocks(text);
2591 assert_eq!(result, text);
2592
2593 let text2 = "***\n\n***\n\nContent.";
2594 assert_eq!(dedup_adjacent_blocks(text2), text2);
2595
2596 let text3 = "___\n\n___\n\nContent.";
2597 assert_eq!(dedup_adjacent_blocks(text3), text3);
2598 }
2599
2600 #[test]
2601 fn dedup_removes_single_line_content_duplicates() {
2602 let text = "### Re: topic — opus-4-6\n\n### Re: topic — opus-4-6\n\nDifferent.";
2603 let result = dedup_adjacent_blocks(text);
2604 assert_eq!(result.matches("### Re: topic").count(), 1);
2605 assert!(result.contains("Different."));
2606 }
2607
2608 #[test]
2609 fn dedup_removes_single_line_text_duplicates() {
2610 let text = "Some response text.\n\nSome response text.\n\nNext section.";
2611 let result = dedup_adjacent_blocks(text);
2612 assert_eq!(result.matches("Some response text.").count(), 1);
2613 assert!(result.contains("Next section."));
2614 }
2615
2616 #[test]
2617 fn dedup_preserves_different_single_line_blocks() {
2618 let text = "Line A.\n\nLine B.\n\nLine C.";
2619 let result = dedup_adjacent_blocks(text);
2620 assert_eq!(result, text);
2621 }
2622
2623 #[test]
2624 fn dedup_handles_empty_text() {
2625 assert_eq!(dedup_adjacent_blocks(""), "");
2626 }
2627
2628 #[test]
2629 fn dedup_no_change_when_no_duplicates() {
2630 let text = "Block A\nLine 2.\n\nBlock B\nLine 2.";
2631 let result = dedup_adjacent_blocks(text);
2632 assert_eq!(result, text);
2633 }
2634
2635 #[test]
2636 fn is_structural_block_detects_thematic_breaks() {
2637 assert!(is_structural_block("---"));
2638 assert!(is_structural_block("***"));
2639 assert!(is_structural_block("___"));
2640 assert!(is_structural_block("-----"));
2641 assert!(!is_structural_block("--"));
2642 assert!(!is_structural_block("### Heading"));
2643 assert!(!is_structural_block("some text"));
2644 assert!(!is_structural_block("---\nmore"));
2645 }
2646
2647 #[test]
2656 fn merge_empty_exchange_no_duplicate_user_line() {
2657 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2658 let footer = "<!-- /agent:exchange -->\n\n## Pending\n\n<!-- agent:pending -->\n<!-- /agent:pending -->\n";
2659
2660 let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2662
2663 let ours = format!(
2665 "{header}\n### Re: topic — opus-4-6\n\nResponse content here.\n\nMore response text.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2666 );
2667
2668 let theirs = format!(
2670 "{header}User prompt line one.\nUser prompt line two.\nLast line of user prompt.\n<!-- agent:boundary:base-id -->\n{footer}"
2671 );
2672
2673 let base_doc = CrdtDoc::from_text(&base);
2674 let base_state = base_doc.encode_state();
2675
2676 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2677
2678 let count1 = merged.matches("User prompt line one.").count();
2680 assert_eq!(
2681 count1, 1,
2682 "Line one duplicated (count={}). Merged:\n{}",
2683 count1, merged
2684 );
2685
2686 let count2 = merged.matches("User prompt line two.").count();
2687 assert_eq!(
2688 count2, 1,
2689 "Line two duplicated (count={}). Merged:\n{}",
2690 count2, merged
2691 );
2692
2693 let count3 = merged.matches("Last line of user prompt.").count();
2694 assert_eq!(
2695 count3, 1,
2696 "Last line duplicated (count={}). Merged:\n{}",
2697 count3, merged
2698 );
2699
2700 assert!(
2702 merged.contains("### Re: topic — opus-4-6"),
2703 "Response heading missing:\n{}",
2704 merged
2705 );
2706 assert!(
2707 merged.contains("Response content here."),
2708 "Response content missing:\n{}",
2709 merged
2710 );
2711
2712 let prompt_pos = merged.find("User prompt line one.").unwrap();
2714 let response_pos = merged.find("### Re: topic").unwrap();
2715 assert!(
2716 prompt_pos < response_pos,
2717 "User prompt should precede response. prompt={} response={}\nMerged:\n{}",
2718 prompt_pos,
2719 response_pos,
2720 merged
2721 );
2722 }
2723
2724 #[test]
2726 fn merge_empty_exchange_prefixed_prompts_no_duplicate() {
2727 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2728 let footer = "<!-- /agent:exchange -->\n";
2729
2730 let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2731
2732 let ours = format!(
2734 "{header}❯ Review the code\n❯ What does it do?\n\n### Re: code review — opus-4-6\n\nThe code does X.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2735 );
2736
2737 let theirs = format!(
2739 "{header}❯ Review the code\n❯ What does it do?\n<!-- agent:boundary:base-id -->\n{footer}"
2740 );
2741
2742 let base_doc = CrdtDoc::from_text(&base);
2743 let base_state = base_doc.encode_state();
2744
2745 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2746
2747 let count = merged.matches("❯ Review the code").count();
2748 assert_eq!(
2749 count, 1,
2750 "Prompt duplicated (count={}). Merged:\n{}",
2751 count, merged
2752 );
2753
2754 assert!(
2755 merged.contains("The code does X."),
2756 "Response missing:\n{}",
2757 merged
2758 );
2759 }
2760
2761 #[test]
2771 fn merge_foreign_tail_append_preserves_response_order() {
2772 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2773 let exchange_committed = "\
2774❯ first question
2775
2776### Re: first question — opus-4-8
2777
2778First answer here. Already committed to HEAD.
2779
2780❯ second question
2781";
2782 let queue_open = "<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n";
2783 let queue_close = "<!-- /agent:queue -->\n";
2784
2785 let base = format!(
2788 "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}{queue_close}"
2789 );
2790
2791 let agent_response = "\
2793### Re: second question — opus-4-8
2794
2795Second answer line one.
2796Second answer line two.
2797Second answer line three.
2798
2799";
2800 let ours = format!(
2801 "{header}{exchange_committed}{agent_response}<!-- agent:boundary:new-id -->\n{queue_open}{queue_close}"
2802 );
2803
2804 let theirs = format!(
2807 "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}do [#foreign-task]\n{queue_close}"
2808 );
2809
2810 let base_state = CrdtDoc::from_text(&base).encode_state();
2811 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2812
2813 assert!(
2815 merged.contains("### Re: second question — opus-4-8"),
2816 "new response heading dropped:\n{}",
2817 merged
2818 );
2819 let l1 = merged
2821 .find("Second answer line one.")
2822 .expect("line one missing");
2823 let l2 = merged
2824 .find("Second answer line two.")
2825 .expect("line two missing");
2826 let l3 = merged
2827 .find("Second answer line three.")
2828 .expect("line three missing");
2829 assert!(
2830 l1 < l2 && l2 < l3,
2831 "response lines reversed/reordered (l1={l1} l2={l2} l3={l3}):\n{merged}"
2832 );
2833 let prior = merged
2835 .find("### Re: first question")
2836 .expect("prior response missing");
2837 let current = merged.find("### Re: second question").unwrap();
2838 assert!(
2839 prior < current,
2840 "new response hoisted above prior HEAD response (prior={prior} current={current}):\n{merged}"
2841 );
2842 assert!(
2844 merged.contains("do [#foreign-task]"),
2845 "foreign queue append lost:\n{}",
2846 merged
2847 );
2848 }
2849
2850 #[test]
2856 fn merge_stale_theirs_cannot_delete_committed_response() {
2857 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2858 let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2859 let foot = "<!-- /agent:exchange -->\n";
2860 let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2861 let resp = "### Re: second question — opus-4-8\n\nLine one.\nLine two.\nLine three.\n\n";
2862 let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2863
2864 let theirs = format!("{header}❯ second question\n<!-- agent:boundary:base-id -->\n{foot}");
2867
2868 let base_state = CrdtDoc::from_text(&base).encode_state();
2869 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2870
2871 assert!(
2872 merged.contains("### Re: first question — opus-4-8"),
2873 "committed prior response heading deleted by stale theirs:\n{merged}"
2874 );
2875 assert!(
2876 merged.contains("First answer. Committed to HEAD."),
2877 "committed prior response body deleted by stale theirs:\n{merged}"
2878 );
2879 assert!(
2880 merged.contains("### Re: second question — opus-4-8"),
2881 "new response heading missing:\n{merged}"
2882 );
2883 let prior = merged.find("### Re: first question").unwrap();
2885 let current = merged.find("### Re: second question").unwrap();
2886 assert!(prior < current, "response order inverted:\n{merged}");
2887 }
2888
2889 #[test]
2894 fn merge_committed_guard_keeps_new_user_input() {
2895 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2896 let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2897 let foot = "<!-- /agent:exchange -->\n";
2898 let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2899 let resp = "### Re: second question — opus-4-8\n\nAnswer body.\n\n";
2900 let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2901
2902 let theirs = format!(
2904 "{header}{committed}❯ a genuinely new third prompt\n<!-- agent:boundary:base-id -->\n{foot}"
2905 );
2906
2907 let base_state = CrdtDoc::from_text(&base).encode_state();
2908 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2909
2910 assert!(
2911 merged.contains("❯ a genuinely new third prompt"),
2912 "new user prompt clobbered by committed-response guard:\n{merged}"
2913 );
2914 assert!(
2915 merged.contains("### Re: first question"),
2916 "committed prior response lost:\n{merged}"
2917 );
2918 assert!(
2919 merged.contains("### Re: second question"),
2920 "new response lost:\n{merged}"
2921 );
2922 }
2923
2924 fn doc_with_exchange_queue(exchange: &str, queue: &str) -> String {
2928 format!(
2929 "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\n{exchange}\n<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n{queue}\n<!-- /agent:queue -->\n"
2930 )
2931 }
2932
2933 #[test]
2934 fn merge_by_component_isolates_concurrent_cross_component_edits() {
2935 let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2938 let base_state = CrdtDoc::from_text(&base).encode_state();
2939
2940 let ours = doc_with_exchange_queue(
2941 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2942 "- do [#a1]",
2943 );
2944 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2945
2946 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2947
2948 assert!(
2950 merged.contains("### Re: existing"),
2951 "agent exchange edit lost:\n{merged}"
2952 );
2953 assert!(
2954 merged.contains("[#b2]"),
2955 "operator queue edit lost:\n{merged}"
2956 );
2957
2958 let queue_body = merged
2962 .split("<!-- agent:queue -->")
2963 .nth(1)
2964 .and_then(|s| s.split("<!-- /agent:queue -->").next())
2965 .unwrap_or("");
2966 let exchange_body = merged
2967 .split("<!-- agent:exchange -->")
2968 .nth(1)
2969 .and_then(|s| s.split("<!-- /agent:exchange -->").next())
2970 .unwrap_or("");
2971 assert!(
2972 !queue_body.contains("Agent response body"),
2973 "exchange content spliced into queue:\n{queue_body}"
2974 );
2975 assert!(
2976 !exchange_body.contains("[#b2]"),
2977 "queue content spliced into exchange:\n{exchange_body}"
2978 );
2979 }
2980
2981 #[test]
2982 fn merge_by_component_preserves_operator_marker_attribute_qmarkerauth() {
2983 let mk = |marker: &str| -> String {
2988 format!(
2989 "---\nagent_doc_format: template\n---\n\n## Queue\n\n{marker}\n- do [#a1]\n<!-- /agent:queue -->\n"
2990 )
2991 };
2992 let base = mk("<!-- agent:queue -->");
2993 let base_state = CrdtDoc::from_text(&base).encode_state();
2994 let ours = mk("<!-- agent:queue -->");
2995 let theirs = mk("<!-- agent:queue priority go -->");
2996
2997 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2998 assert!(
2999 merged.contains("<!-- agent:queue priority go -->"),
3000 "operator marker attribute lost (reverted to ours' marker):\n{merged}"
3001 );
3002 }
3003
3004 #[test]
3005 fn merge_by_component_legacy_operator_queue_delete_wins_over_agent_update() {
3006 let _guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3010 unsafe {
3012 std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "0");
3013 }
3014
3015 let base = doc_with_exchange_queue("Q.", "- do [#a1] keep\n- do [#b2] delete me");
3016 let base_state = CrdtDoc::from_text(&base).encode_state();
3017 let ours = doc_with_exchange_queue("Q.", "- do [#a1] keep\n- ~~do [#b2] delete me~~");
3018 let theirs = doc_with_exchange_queue("Q.", "- do [#a1] keep");
3019 let result = merge_by_component(Some(&base_state), &ours, &theirs);
3020
3021 unsafe {
3023 std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3024 }
3025
3026 let merged = result.unwrap();
3027 assert!(
3028 merged.contains("[#a1]"),
3029 "untouched queue item lost:\n{merged}"
3030 );
3031 assert!(
3032 !merged.contains("[#b2]"),
3033 "operator-deleted queue item resurrected by legacy fallback:\n{merged}"
3034 );
3035 }
3036
3037 #[test]
3038 fn reconcile_marker_operator_authoritative_resolution() {
3039 assert_eq!(
3041 reconcile_marker_operator_authoritative(
3042 Some("<!-- agent:queue -->"),
3043 "<!-- agent:queue -->",
3044 "<!-- agent:queue priority -->"
3045 ),
3046 "<!-- agent:queue priority -->"
3047 );
3048 assert_eq!(
3050 reconcile_marker_operator_authoritative(
3051 Some("<!-- agent:queue -->"),
3052 "<!-- agent:queue go -->",
3053 "<!-- agent:queue -->"
3054 ),
3055 "<!-- agent:queue go -->"
3056 );
3057 assert_eq!(
3059 reconcile_marker_operator_authoritative(
3060 None,
3061 "<!-- agent:queue -->",
3062 "<!-- agent:queue preset=\"#x\" -->"
3063 ),
3064 "<!-- agent:queue preset=\"#x\" -->"
3065 );
3066 assert_eq!(
3068 reconcile_marker_operator_authoritative(None, "<!-- x -->", "<!-- x -->"),
3069 "<!-- x -->"
3070 );
3071 }
3072
3073 #[test]
3074 fn merge_by_component_blocks_console_output_in_queue_corruption() {
3075 let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
3081 let base = doc_with_exchange_queue("Q.", "- :pushpin: typing here");
3082 let base_state = CrdtDoc::from_text(&base).encode_state();
3083
3084 let ours = doc_with_exchange_queue(
3085 &format!("Q.\n\n### Re: q\n\n{console}"),
3086 "- :pushpin: typing here",
3087 );
3088 let theirs = doc_with_exchange_queue("Q.", "- :pushpin: typing here now extended");
3089
3090 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3091
3092 let queue_body = merged
3093 .split("<!-- agent:queue -->")
3094 .nth(1)
3095 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3096 .unwrap_or("");
3097 assert!(
3098 !queue_body.contains("Supervisor is now fresh"),
3099 "console output corrupted the queue node:\n{queue_body}"
3100 );
3101 assert!(
3102 queue_body.contains("extended"),
3103 "operator queue edit lost:\n{queue_body}"
3104 );
3105 assert!(
3106 merged.contains("Supervisor is now fresh"),
3107 "console output must remain in its own (exchange) node:\n{merged}"
3108 );
3109 }
3110
3111 #[test]
3112 fn merge_by_component_degenerates_to_whole_doc_without_components() {
3113 let base = "# Document\n\nBase content.\n";
3116 let base_state = CrdtDoc::from_text(base).encode_state();
3117 let ours = format!("{base}## Agent\n\nAgent response.\n");
3118 let theirs = format!("{base}## User\n\nUser addition.\n");
3119
3120 let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3121 let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
3122 assert_eq!(
3123 by_component, whole_doc,
3124 "component merge must equal whole-doc merge for a component-less doc"
3125 );
3126 }
3127
3128 #[test]
3129 fn merge_by_component_falls_back_on_structural_divergence() {
3130 let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3133 let base_state = CrdtDoc::from_text(&base).encode_state();
3134 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
3135 let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
3137
3138 let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3140 let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
3141 assert_eq!(
3142 by_component, whole_doc,
3143 "structural divergence must use whole-doc fallback"
3144 );
3145 }
3146
3147 #[test]
3150 fn multinode_state_roundtrip_encode_decode() {
3151 let doc =
3154 doc_with_exchange_queue("Prompt.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3155 let state = MultiNodeState::from_text(&doc).unwrap();
3156 let encoded = state.encode();
3157 let decoded = MultiNodeState::decode(&encoded).unwrap();
3158 assert_eq!(
3159 decoded, state,
3160 "decode must reproduce the encoded per-node state"
3161 );
3162 assert_eq!(
3163 decoded.to_text().unwrap(),
3164 doc,
3165 "per-node state must reconstruct the original document text"
3166 );
3167 assert!(
3169 decoded.nodes.len() >= 4,
3170 "expected ≥4 nodes, got {}",
3171 decoded.nodes.len()
3172 );
3173 assert!(
3174 decoded
3175 .nodes
3176 .iter()
3177 .any(|n| n.name.as_deref() == Some("exchange"))
3178 );
3179 assert!(
3180 decoded
3181 .nodes
3182 .iter()
3183 .any(|n| n.name.as_deref() == Some("queue"))
3184 );
3185 }
3186
3187 #[test]
3188 fn multinode_state_decode_rejects_non_container() {
3189 let legacy = CrdtDoc::from_text("# Doc\n\nlegacy whole-doc state\n").encode_state();
3192 assert!(
3193 MultiNodeState::decode(&legacy).is_err(),
3194 "legacy whole-doc state must not decode as a per-node container"
3195 );
3196 }
3197
3198 #[test]
3199 fn multinode_state_migrates_legacy_whole_doc() {
3200 let doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3202 let legacy = CrdtDoc::from_text(&doc).encode_state();
3203 let migrated = MultiNodeState::decode_or_migrate(&legacy, "");
3204 assert_eq!(
3205 migrated.to_text().unwrap(),
3206 doc,
3207 "legacy migration must preserve the full document text"
3208 );
3209 assert!(
3210 migrated
3211 .nodes
3212 .iter()
3213 .any(|n| n.name.as_deref() == Some("queue")),
3214 "legacy migration must recover component nodes"
3215 );
3216 }
3217
3218 #[test]
3219 fn multinode_merge_advances_node_base_independently() {
3220 let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3224 let base = MultiNodeState::from_text(&base_doc).unwrap();
3225
3226 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nAgent body.", "- do [#a1]");
3228 let theirs = base_doc.clone();
3229
3230 let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3231 assert!(
3232 merged.contains("Agent body."),
3233 "exchange edit lost:\n{merged}"
3234 );
3235
3236 let queue_before = base
3237 .nodes
3238 .iter()
3239 .find(|n| n.name.as_deref() == Some("queue"))
3240 .map(|n| &n.state);
3241 let queue_after = advanced
3242 .nodes
3243 .iter()
3244 .find(|n| n.name.as_deref() == Some("queue"))
3245 .map(|n| &n.state);
3246 assert_eq!(
3247 queue_before, queue_after,
3248 "untouched queue node base must be byte-identical across the cycle"
3249 );
3250
3251 let exchange_before = base
3252 .nodes
3253 .iter()
3254 .find(|n| n.name.as_deref() == Some("exchange"))
3255 .map(|n| &n.state);
3256 let exchange_after = advanced
3257 .nodes
3258 .iter()
3259 .find(|n| n.name.as_deref() == Some("exchange"))
3260 .map(|n| &n.state);
3261 assert_ne!(
3262 exchange_before, exchange_after,
3263 "changed exchange node base must advance"
3264 );
3265 }
3266
3267 #[test]
3268 fn multinode_merge_isolates_concurrent_cross_component_edits() {
3269 let base_doc = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3272 let base = MultiNodeState::from_text(&base_doc).unwrap();
3273
3274 let ours = doc_with_exchange_queue(
3275 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3276 "- do [#a1]",
3277 );
3278 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3279
3280 let (merged, _advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3281 assert!(
3282 merged.contains("### Re: existing"),
3283 "exchange edit lost:\n{merged}"
3284 );
3285 assert!(merged.contains("[#b2]"), "queue edit lost:\n{merged}");
3286
3287 let queue_body = merged
3288 .split("<!-- agent:queue -->")
3289 .nth(1)
3290 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3291 .unwrap_or("");
3292 assert!(
3293 !queue_body.contains("Agent response body"),
3294 "exchange content spliced into queue:\n{queue_body}"
3295 );
3296 }
3297
3298 #[test]
3299 fn multinode_merge_falls_back_on_structural_divergence() {
3300 let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
3303 let base = MultiNodeState::from_text(&base_doc).unwrap();
3304 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
3305 let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
3306
3307 let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
3308 let whole_doc = {
3309 let base_state = CrdtDoc::from_text(&base_doc).encode_state();
3310 merge(Some(&base_state), &ours, &theirs).unwrap()
3311 };
3312 assert_eq!(
3313 merged, whole_doc,
3314 "structural divergence must use whole-doc fallback"
3315 );
3316 assert_eq!(
3317 advanced.to_text().unwrap(),
3318 merged,
3319 "fallback must still yield a per-node state round-tripping the merged text"
3320 );
3321 }
3322
3323 #[test]
3324 fn multinode_merge_bootstraps_without_base() {
3325 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]");
3327 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3328 let (merged, state) = MultiNodeState::merge(None, &ours, &theirs).unwrap();
3329 assert!(merged.contains("Body."));
3330 assert!(merged.contains("[#b2]"));
3331 assert_eq!(state.to_text().unwrap(), merged);
3332 }
3333
3334 struct CellMergeFlagOn {
3346 _guard: std::sync::MutexGuard<'static, ()>,
3347 }
3348 impl CellMergeFlagOn {
3349 fn on() -> Self {
3350 let guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3351 unsafe {
3353 std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "1");
3354 }
3355 assert!(crate::cell_doc::cell_merge_enabled());
3356 CellMergeFlagOn { _guard: guard }
3357 }
3358 }
3359 impl Drop for CellMergeFlagOn {
3360 fn drop(&mut self) {
3361 unsafe {
3363 std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3364 }
3365 }
3366 }
3367
3368 #[test]
3369 fn multinode_merge_conflict_persists_cell_winner_under_flag() {
3370 let _flag = CellMergeFlagOn::on();
3377
3378 let base = doc_with_exchange_queue("Q.", "- do [#a1] original");
3379 let base_state = MultiNodeState::from_text(&base).unwrap();
3380 let ours = doc_with_exchange_queue("Q.", "- do [#a1] OURS edit");
3381 let theirs = doc_with_exchange_queue("Q.", "- do [#a1] THEIRS edit");
3382
3383 let (merged, state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3384
3385 assert_eq!(
3387 state.to_text().unwrap(),
3388 merged,
3389 "persisted state diverges from returned merged text under conflict"
3390 );
3391 assert!(
3394 merged.contains("THEIRS edit"),
3395 "cell-merge operator-owned theirs-wins winner lost:\n{merged}"
3396 );
3397 let persisted = state.to_text().unwrap();
3399 let queue_body = persisted
3400 .split("<!-- agent:queue -->")
3401 .nth(1)
3402 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3403 .unwrap_or("");
3404 assert!(
3405 queue_body.contains("THEIRS edit"),
3406 "persisted base lost the cell-merge winner:\n{queue_body}"
3407 );
3408 }
3409
3410 #[test]
3411 fn multinode_merge_struck_head_stable_across_persistence_cycles() {
3412 let _flag = CellMergeFlagOn::on();
3417
3418 let base0 = doc_with_exchange_queue("Q.", "- do [#z9] answered");
3420 let base0_state = MultiNodeState::from_text(&base0).unwrap();
3421 let ours0 = doc_with_exchange_queue("Q.", "- ~~do [#z9] answered~~");
3422 let theirs0 = doc_with_exchange_queue("Q.", "- do [#z9] answered");
3423 let (text0, state0) = MultiNodeState::merge(Some(&base0_state), &ours0, &theirs0).unwrap();
3424 assert!(
3425 text0.contains("~~do [#z9] answered~~"),
3426 "strike lost:\n{text0}"
3427 );
3428 assert_eq!(
3429 state0.to_text().unwrap(),
3430 text0,
3431 "cycle0 round-trip diverges"
3432 );
3433
3434 let ours1 = doc_with_exchange_queue("Q.", "- do [#z9] answered"); let theirs1 = doc_with_exchange_queue("Q.", "- ~~do [#z9] answered~~");
3439 let (text1, state1) = MultiNodeState::merge(Some(&state0), &ours1, &theirs1).unwrap();
3440 assert!(
3441 text1.contains("~~do [#z9] answered~~"),
3442 "stale un-strike resurrected the head:\n{text1}"
3443 );
3444 let live1 = {
3445 let body = text1
3446 .split("<!-- agent:queue -->")
3447 .nth(1)
3448 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3449 .unwrap_or("");
3450 body.lines()
3451 .filter(|l| l.contains("do [#z9]") && !l.contains("~~"))
3452 .count()
3453 };
3454 assert_eq!(
3455 live1, 0,
3456 "head resurrected LIVE across persistence:\n{text1}"
3457 );
3458 assert_eq!(
3459 state1.to_text().unwrap(),
3460 text1,
3461 "cycle1 round-trip diverges"
3462 );
3463
3464 let (text2, state2) = MultiNodeState::merge(Some(&state1), &text1, &text1).unwrap();
3467 assert_eq!(text2, text1, "no-edit cycle oscillated the text");
3468 assert_eq!(
3469 state2.encode(),
3470 state1.encode(),
3471 "no-edit cycle oscillated the persisted state"
3472 );
3473 }
3474
3475 #[test]
3476 fn multinode_merge_flag_off_byte_identical_to_pre_rung() {
3477 let _guard = crate::cell_doc::CELL_MERGE_ENV_LOCK.lock().unwrap();
3483 unsafe {
3485 std::env::set_var(crate::cell_doc::CELL_MERGE_ENV, "0");
3486 }
3487 assert!(!crate::cell_doc::cell_merge_enabled(), "must be OFF");
3488
3489 let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3490 let base_state = MultiNodeState::from_text(&base).unwrap();
3491 let ours = doc_with_exchange_queue(
3492 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3493 "- do [#a1]",
3494 );
3495 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3496
3497 let (off1, off1_state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3499 let (off2, off2_state) = MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap();
3500 assert_eq!(off1, off2, "flag-OFF text not deterministic");
3501 assert_eq!(
3502 off1_state.encode(),
3503 off2_state.encode(),
3504 "flag-OFF state not deterministic"
3505 );
3506 assert!(off1.contains("### Re: existing"));
3508 assert!(off1.contains("[#b2]"));
3509
3510 unsafe {
3514 std::env::remove_var(crate::cell_doc::CELL_MERGE_ENV);
3515 }
3516 }
3517
3518 #[test]
3519 fn cell_seams_produce_identical_text_under_flag() {
3520 let _flag = CellMergeFlagOn::on();
3525
3526 let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
3527 let ours = doc_with_exchange_queue(
3528 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
3529 "- do [#a1]",
3530 );
3531 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
3532
3533 let text_seam = {
3534 let base_state = CrdtDoc::from_text(&base).encode_state();
3535 merge_by_component(Some(&base_state), &ours, &theirs).unwrap()
3536 };
3537 let (persist_seam, _state) = {
3538 let base_state = MultiNodeState::from_text(&base).unwrap();
3539 MultiNodeState::merge(Some(&base_state), &ours, &theirs).unwrap()
3540 };
3541 assert_eq!(
3542 text_seam, persist_seam,
3543 "the two cell-merge seams disagree on merged text:\n--- text path ---\n{text_seam}\n--- persist path ---\n{persist_seam}"
3544 );
3545 }
3546
3547 #[test]
3550 fn reconcile_queue_item_edit_does_not_touch_sibling_item() {
3551 let base = doc_with_exchange_queue("Q.", "- :pushpin: do [#a1]\n- :pushpin: do [#b2]");
3557 let base_state = CrdtDoc::from_text(&base).encode_state();
3558 let ours = doc_with_exchange_queue(
3559 "Q.\n\n### Re: q — opus\n\nBody.",
3560 "- :pushpin: do [#a1]\n- :pushpin: do [#b2]",
3561 );
3562 let theirs = doc_with_exchange_queue(
3563 "Q.",
3564 "- :pushpin: do [#a1]\n- :pushpin: do [#b2] with operator note",
3565 );
3566
3567 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3568 assert!(
3569 merged.contains("### Re: q"),
3570 "exchange edit lost:\n{merged}"
3571 );
3572 assert_eq!(
3573 merged.matches("do [#a1]").count(),
3574 1,
3575 "sibling item A drifted/duplicated:\n{merged}"
3576 );
3577 assert!(
3578 merged.contains("- :pushpin: do [#a1]\n"),
3579 "sibling item A text drifted:\n{merged}"
3580 );
3581 assert!(
3582 merged.contains("operator note"),
3583 "item B operator edit lost:\n{merged}"
3584 );
3585 }
3586
3587 #[test]
3588 fn reconcile_exchange_prompt_between_blocks_converges_with_new_block() {
3589 let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3593 let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3594 let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3595 let base_state = CrdtDoc::from_text(&base).encode_state();
3596
3597 let r3 = "### Re: third — opus\n\nThird answer.\n\n";
3598 let ours = doc_with_exchange_queue(&format!("{r1}{r2}{r3}"), "- do [#a1]");
3599 let theirs = doc_with_exchange_queue(
3600 &format!("{r1}❯ a prompt typed between blocks\n\n{r2}"),
3601 "- do [#a1]",
3602 );
3603
3604 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3605 assert!(merged.contains("### Re: first"), "block 1 lost:\n{merged}");
3606 assert!(merged.contains("### Re: second"), "block 2 lost:\n{merged}");
3607 assert!(
3608 merged.contains("### Re: third"),
3609 "appended block 3 lost:\n{merged}"
3610 );
3611 assert!(
3612 merged.contains("a prompt typed between blocks"),
3613 "interleaved operator prompt lost:\n{merged}"
3614 );
3615 assert_eq!(
3616 merged.matches("### Re: first").count(),
3617 1,
3618 "block 1 cross-spliced/duplicated:\n{merged}"
3619 );
3620 assert_eq!(
3621 merged.matches("### Re: third").count(),
3622 1,
3623 "block 3 cross-spliced/duplicated:\n{merged}"
3624 );
3625 }
3626
3627 #[test]
3628 fn reconcile_queue_preserves_concurrent_user_item_addition() {
3629 let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3633 let base_state = CrdtDoc::from_text(&base).encode_state();
3634 let ours = doc_with_exchange_queue("Q.", "- do [#a1] agent touched");
3635 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#user-added]");
3636
3637 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3638 assert!(
3639 merged.contains("[#user-added]"),
3640 "concurrent user queue addition dropped:\n{merged}"
3641 );
3642 assert!(
3643 merged.contains("agent touched"),
3644 "concurrent agent item edit lost:\n{merged}"
3645 );
3646 }
3647
3648 #[test]
3649 fn reconcile_queue_honors_clean_item_deletion() {
3650 let base = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3653 let base_state = CrdtDoc::from_text(&base).encode_state();
3654 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3655 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]");
3656
3657 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3658 assert!(merged.contains("Body."), "exchange edit lost:\n{merged}");
3659 let queue_body = merged
3660 .split("<!-- agent:queue -->")
3661 .nth(1)
3662 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3663 .unwrap_or("");
3664 assert!(queue_body.contains("[#a1]"), "item A lost:\n{queue_body}");
3665 assert!(
3666 !queue_body.contains("[#b2]"),
3667 "clean deletion of item B not honored:\n{queue_body}"
3668 );
3669 }
3670
3671 #[test]
3672 fn reconcile_exchange_never_drops_committed_block_on_stale_side() {
3673 let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3677 let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3678 let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3679 let base_state = CrdtDoc::from_text(&base).encode_state();
3680 let ours = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]\n- do [#new]");
3682 let theirs = doc_with_exchange_queue(&r1.to_string(), "- do [#a1]");
3683
3684 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3685 assert!(
3686 merged.contains("### Re: second"),
3687 "stale side deleted a committed block:\n{merged}"
3688 );
3689 assert!(
3690 merged.contains("[#new]"),
3691 "concurrent queue add lost:\n{merged}"
3692 );
3693 }
3694
3695 #[test]
3696 fn reconcile_falls_back_on_duplicate_item_keys() {
3697 let base = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3700 let base_state = CrdtDoc::from_text(&base).encode_state();
3701 let ours = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item\n- new one");
3702 let theirs = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3703
3704 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3705 assert!(
3706 merged.contains("new one"),
3707 "duplicate-key fallback dropped the new item:\n{merged}"
3708 );
3709 }
3710
3711 #[test]
3712 fn first_hash_id_extracts_durable_id() {
3713 assert_eq!(
3714 first_hash_id("- :pushpin: do [#qnodemerge3]"),
3715 Some("qnodemerge3".to_string())
3716 );
3717 assert_eq!(
3718 first_hash_id("- [ ] [#6b5hwire] some text"),
3719 Some("6b5hwire".to_string())
3720 );
3721 assert_eq!(first_hash_id("- free text no id"), None);
3722 }
3723
3724 #[test]
3725 fn list_item_key_stable_across_strike_and_glyphs() {
3726 assert_eq!(list_item_key("- do [#a1]"), list_item_key("- ~~do [#a1]~~"));
3729 assert_eq!(
3730 list_item_key("- :pushpin: hello world"),
3731 list_item_key("- ~~hello world~~")
3732 );
3733 }
3734
3735 #[test]
3736 fn order_union_weaves_theirs_only_inserts() {
3737 let ours = ["a", "b", "c"];
3738 let theirs = ["a", "x", "b", "c", "y"];
3739 assert_eq!(
3740 order_union(&ours, &theirs),
3741 vec![
3742 "a".to_string(),
3743 "x".into(),
3744 "b".into(),
3745 "c".into(),
3746 "y".into()
3747 ]
3748 );
3749 }
3750
3751 #[test]
3752 fn split_list_children_is_lossless() {
3753 let body = "\n- do [#a1]\n continuation line\n- :pushpin: do [#b2]\n\n";
3756 let children = split_list_children(body).unwrap();
3757 let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3758 assert_eq!(joined, body, "list segmentation is not lossless");
3759 }
3760
3761 #[test]
3762 fn split_exchange_children_is_lossless() {
3763 let body = "\n### Re: a — opus\n\nbody a\n\n### Re: b — opus\n\nbody b\n";
3764 let children = split_exchange_children(body).unwrap();
3765 let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3766 assert_eq!(joined, body, "exchange segmentation is not lossless");
3767 }
3768
3769 #[test]
3778 fn merge_by_component_struck_free_text_head_not_resurrected_by_stale_crdt() {
3779 let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3781<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3782 let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3784<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3785 let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3787<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3788
3789 let base_state = CrdtDoc::from_text(base).encode_state();
3790 let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3791
3792 let queue_body = merged
3793 .split("<!-- agent:queue -->")
3794 .nth(1)
3795 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3796 .unwrap();
3797 assert!(
3799 queue_body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
3800 "struck head must survive struck:\n{queue_body}"
3801 );
3802 let live_occurrences = queue_body
3803 .lines()
3804 .filter(|l| {
3805 l.contains("Still getting JB File Cache Conflict dialogs.") && !l.contains("~~")
3806 })
3807 .count();
3808 assert_eq!(
3809 live_occurrences, 0,
3810 "stale CRDT resurrected the struck head as a live queue line:\n{queue_body}"
3811 );
3812 }
3813
3814 #[test]
3822 fn merge_by_component_reaped_free_text_head_not_readded_by_stale_crdt() {
3823 let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3824<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3825 let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3827<!-- agent:queue -->\n<!-- /agent:queue -->\n";
3828 let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3830<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3831
3832 let base_state = CrdtDoc::from_text(base).encode_state();
3833 let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3834
3835 let queue_body = merged
3836 .split("<!-- agent:queue -->")
3837 .nth(1)
3838 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3839 .unwrap();
3840 assert!(
3841 !queue_body.contains("Still getting JB File Cache Conflict dialogs."),
3842 "stale CRDT re-added the reaped head:\n{queue_body}"
3843 );
3844 }
3845
3846 #[test]
3862 fn merge_by_component_struck_id_head_not_resurrected_by_stale_unstrike() {
3863 let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3865<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
3866 let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
3868<!-- agent:queue -->\n- do [#abcd] fix the thing\n<!-- /agent:queue -->\n";
3869 let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
3871<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
3872
3873 let base_state = CrdtDoc::from_text(base).encode_state();
3874 let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3875
3876 let queue_body = merged
3877 .split("<!-- agent:queue -->")
3878 .nth(1)
3879 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3880 .unwrap();
3881 assert!(
3882 queue_body.contains("~~do [#abcd] fix the thing~~"),
3883 "struck head must stay struck:\n{queue_body}"
3884 );
3885 let live = queue_body
3886 .lines()
3887 .filter(|l| l.contains("do [#abcd]") && !l.contains("~~"))
3888 .count();
3889 assert_eq!(
3890 live, 0,
3891 "stale un-strike resurrected the struck head LIVE:\n{queue_body}"
3892 );
3893 }
3894
3895 #[test]
3899 fn merge_by_component_struck_free_text_head_not_resurrected_by_stale_unstrike() {
3900 let base = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3901 let ours = "<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
3902 let theirs = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
3903
3904 let base_state = CrdtDoc::from_text(base).encode_state();
3905 let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3906
3907 let queue_body = merged
3908 .split("<!-- agent:queue -->")
3909 .nth(1)
3910 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3911 .unwrap();
3912 assert!(
3913 queue_body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
3914 "struck free-text head must stay struck:\n{queue_body}"
3915 );
3916 let live = queue_body
3917 .lines()
3918 .filter(|l| {
3919 l.contains("Still getting JB File Cache Conflict dialogs.") && !l.contains("~~")
3920 })
3921 .count();
3922 assert_eq!(
3923 live, 0,
3924 "stale un-strike resurrected the struck free-text head LIVE:\n{queue_body}"
3925 );
3926 }
3927
3928 #[test]
3933 fn merge_by_component_struck_head_stable_across_cycles_no_churn() {
3934 let mut base =
3936 "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n".to_string();
3937 for cycle in 0..3 {
3938 let base_state = CrdtDoc::from_text(&base).encode_state();
3939 let ours = "<!-- agent:queue -->\n- do [#abcd] answered\n<!-- /agent:queue -->\n";
3941 let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n";
3943 let merged = merge_by_component(Some(&base_state), ours, theirs).unwrap();
3944 let queue_body = merged
3945 .split("<!-- agent:queue -->")
3946 .nth(1)
3947 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3948 .unwrap();
3949 let live = queue_body
3950 .lines()
3951 .filter(|l| l.contains("do [#abcd]") && !l.contains("~~"))
3952 .count();
3953 assert_eq!(
3954 live, 0,
3955 "cycle {cycle}: head resurrected LIVE (churn):\n{queue_body}"
3956 );
3957 assert!(
3958 queue_body.contains("~~do [#abcd] answered~~"),
3959 "cycle {cycle}: struck head lost:\n{queue_body}"
3960 );
3961 assert_eq!(
3962 queue_body.matches("do [#abcd] answered").count(),
3963 1,
3964 "cycle {cycle}: head duplicated:\n{queue_body}"
3965 );
3966 base = merged;
3968 }
3969 }
3970
3971 fn doc_with_status_exchange_queue(status: &str, exchange: &str, queue: &str) -> String {
3976 format!(
3977 "---\nagent_doc_format: template\n---\n\n## Status\n\n<!-- agent:status -->\n{status}\n<!-- /agent:status -->\n\n## Exchange\n\n<!-- agent:exchange -->\n{exchange}\n<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n{queue}\n<!-- /agent:queue -->\n"
3978 )
3979 }
3980
3981 fn body_of<'a>(merged: &'a str, name: &str) -> &'a str {
3982 let open = format!("<!-- agent:{name} -->");
3983 let close = format!("<!-- /agent:{name} -->");
3984 merged
3985 .split(&open)
3986 .nth(1)
3987 .and_then(|s| s.split(&close).next())
3988 .unwrap_or("")
3989 }
3990
3991 #[test]
3998 fn cross_cell_contamination_is_impossible() {
3999 let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
4000
4001 {
4006 let base = doc_with_exchange_queue("Q.", "- operator typing");
4007 let base_state = CrdtDoc::from_text(&base).encode_state();
4008 let ours = doc_with_exchange_queue(
4009 &format!("Q.\n\n### Re: q — opus\n\n{console}"),
4010 "- operator typing",
4011 );
4012 let theirs = doc_with_exchange_queue("Q.", "- operator typing more");
4013
4014 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4015 assert!(
4016 !body_of(&merged, "queue").contains("Supervisor is now fresh"),
4017 "FORWARD: exchange console block spliced into queue:\n{}",
4018 body_of(&merged, "queue")
4019 );
4020 assert!(
4022 body_of(&merged, "exchange").contains("Supervisor is now fresh"),
4023 "FORWARD: console block lost from its own (exchange) cell:\n{merged}"
4024 );
4025 assert!(
4026 body_of(&merged, "queue").contains("typing more"),
4027 "FORWARD: operator queue edit lost:\n{merged}"
4028 );
4029 }
4030
4031 {
4036 let queue_paste = "- here is my log:\n ```\n panic at line 9000\n ```";
4037 let base = doc_with_exchange_queue("Q.", "- placeholder");
4038 let base_state = CrdtDoc::from_text(&base).encode_state();
4039 let ours =
4040 doc_with_exchange_queue("Q.\n\n### Re: q — opus\n\nAgent answer.", "- placeholder");
4041 let theirs = doc_with_exchange_queue("Q.", queue_paste);
4042
4043 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4044 assert!(
4045 !body_of(&merged, "exchange").contains("panic at line 9000"),
4046 "INVERSE: queue paste spliced into exchange:\n{}",
4047 body_of(&merged, "exchange")
4048 );
4049 assert!(
4050 body_of(&merged, "queue").contains("panic at line 9000"),
4051 "INVERSE: queue paste lost from its own cell:\n{merged}"
4052 );
4053 assert!(
4054 body_of(&merged, "exchange").contains("Agent answer."),
4055 "INVERSE: agent exchange edit lost:\n{merged}"
4056 );
4057 }
4058
4059 {
4063 let base = doc_with_status_exchange_queue("idle", "Q.", "- do [#a1]");
4064 let base_state = CrdtDoc::from_text(&base).encode_state();
4065 let ours = doc_with_status_exchange_queue(
4067 "running OURS_STATUS",
4068 &format!("Q.\n\n### Re: q — opus\n\n{console}"),
4069 "- do [#a1]",
4070 );
4071 let theirs =
4073 doc_with_status_exchange_queue("idle", "Q.", "- do [#a1]\n- do [#THEIRS_ITEM]");
4074
4075 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
4076
4077 let status_body = body_of(&merged, "status");
4078 let exchange_body = body_of(&merged, "exchange");
4079 let queue_body = body_of(&merged, "queue");
4080
4081 assert!(
4083 status_body.contains("OURS_STATUS"),
4084 "MULTI: status edit lost:\n{merged}"
4085 );
4086 assert!(
4087 exchange_body.contains("Supervisor is now fresh"),
4088 "MULTI: exchange edit lost:\n{merged}"
4089 );
4090 assert!(
4091 queue_body.contains("[#THEIRS_ITEM]"),
4092 "MULTI: queue edit lost:\n{merged}"
4093 );
4094
4095 assert!(
4097 !status_body.contains("Supervisor is now fresh")
4098 && !status_body.contains("[#THEIRS_ITEM]"),
4099 "MULTI: foreign content spliced into status:\n{status_body}"
4100 );
4101 assert!(
4102 !exchange_body.contains("OURS_STATUS") && !exchange_body.contains("[#THEIRS_ITEM]"),
4103 "MULTI: foreign content spliced into exchange:\n{exchange_body}"
4104 );
4105 assert!(
4106 !queue_body.contains("Supervisor is now fresh")
4107 && !queue_body.contains("OURS_STATUS"),
4108 "MULTI: foreign content spliced into queue:\n{queue_body}"
4109 );
4110 }
4111 }
4112}