Skip to main content

agent_doc/
merge.rs

1//! # Module: merge
2//!
3//! ## Spec
4//! - `merge_contents_crdt(base_state, ours, theirs)`: conflict-free merge using Yrs CRDT.
5//!   Delegates to `crdt::merge`, then encodes a fresh CRDT state from the merged result.
6//!   Agent content (client_id=1) is ordered before human content (client_id=2) at the same
7//!   insertion point by Yrs' native client-ID ordering — no post-merge reorder needed.
8//!   Returns `(merged_text, new_crdt_state_bytes)`.
9//! - `merge_contents(base, ours, theirs)`: 3-way merge via `git merge-file --diff3`.
10//!   Auto-resolves append-only conflicts (empty original section) by concatenating ours then
11//!   theirs. Preserves standard conflict markers only for true conflicts where existing lines
12//!   were modified differently by both sides. Returns the merged content string.
13//! - `resolve_append_conflicts(merged)` (private): post-processes `--diff3` conflict blocks.
14//!   Scans each `<<<<<<< / ||||||| / ======= / >>>>>>>` block; if the `|||||||` section is
15//!   empty/whitespace-only, auto-resolves by emitting ours then theirs. Otherwise keeps all
16//!   markers intact. Returns `(resolved_content, has_remaining_conflicts)`.
17//!
18//! ## Agentic Contracts
19//! - `merge_contents_crdt` never returns conflict markers — CRDT guarantees a conflict-free
20//!   result for all concurrent append/edit combinations.
21//! - `merge_contents_crdt` always returns a non-empty `state` vec usable as `base_state` in
22//!   the next merge cycle; state encodes the full merged text, including user edits.
23//! - `merge_contents` returns `Ok` even when conflicts remain — callers must inspect the
24//!   content for `<<<<<<<` markers if they need to detect unresolved conflicts.
25//! - When `base_state` is `None`, `merge_contents_crdt` bootstraps from an empty CRDT doc
26//!   and still produces a valid merged result.
27//! - Agent content always appears before user content at the same insertion point.
28//! - Returned CRDT state after a merge includes all user edits from the merge; using it as
29//!   the base for the next cycle will not duplicate those edits.
30//!
31//! ## Evals
32//! - `resolve_append_only_conflict`: both sides append at same point, original empty →
33//!   markers removed, agent content before user content, no `<<<<<<<` in output.
34//! - `preserve_true_conflict`: both sides modify same original line →
35//!   conflict markers preserved, original section present in output.
36//! - `mixed_append_and_true_conflicts`: one append-only block + one true-conflict block →
37//!   append-only resolved, true-conflict keeps markers.
38//! - `no_conflicts_passthrough`: no conflict markers in input → output identical to input.
39//! - `multiline_append_conflict`: multi-line append-only block → all lines preserved,
40//!   agent lines before user lines.
41//! - `merge_contents_clean`: ours adds a line, theirs unchanged → agent addition present,
42//!   no markers.
43//! - `merge_contents_both_append`: both sides add different lines → both present, no markers.
44//! - `crdt_merge_agent_and_user_append`: agent and user both append different content →
45//!   both preserved, no conflict markers, base content intact.
46//! - `crdt_merge_concurrent_same_line`: both sides insert at same position →
47//!   both preserved, deterministic order, no conflict.
48//! - `crdt_merge_no_base_state_bootstrap`: `None` base state → valid merge, non-empty state.
49//! - `crdt_merge_one_side_unchanged`: only agent appended, theirs = base →
50//!   merged equals ours exactly.
51//! - `crdt_state_includes_user_edits_no_duplicates`: two consecutive merge cycles with
52//!   concurrent user edit in cycle 1 → user edit appears exactly once in cycle 2 output.
53//! - `crdt_multi_flush_no_duplicates`: multiple streaming flush cycles with concurrent user
54//!   notes → each content piece appears exactly once across all flushes.
55
56use anyhow::{Context, Result};
57use std::process::Command;
58
59/// CRDT-based merge: conflict-free merge using Yrs CRDT.
60///
61/// Returns (merged_text, new_crdt_state).
62/// `base_state` is the CRDT state from the last write (None on first use).
63/// Agent content (client_id=2) naturally appears before human content (client_id=1)
64/// at the same insertion point — no post-merge reorder needed.
65pub fn merge_contents_crdt(
66    base_state: Option<&[u8]>,
67    ours: &str,
68    theirs: &str,
69) -> Result<(String, Vec<u8>)> {
70    let merged = crate::crdt::merge(base_state, ours, theirs)
71        .context("CRDT merge failed")?;
72    // Build fresh CRDT state from the merged result
73    let doc = crate::crdt::CrdtDoc::from_text(&merged);
74    let state = doc.encode_state();
75    eprintln!("[write] CRDT merge successful — no conflicts possible.");
76    Ok((merged, state))
77}
78
79/// 3-way merge using `git merge-file --diff3`.
80///
81/// Returns merged content. Append-only conflicts are auto-resolved by
82/// concatenating both additions (ours first, then theirs).
83/// True conflicts (where existing content was modified differently)
84/// retain standard conflict markers.
85pub fn merge_contents(base: &str, ours: &str, theirs: &str) -> Result<String> {
86    let tmp = tempfile::TempDir::new()
87        .context("failed to create temp dir for merge")?;
88
89    let base_path = tmp.path().join("base");
90    let ours_path = tmp.path().join("ours");
91    let theirs_path = tmp.path().join("theirs");
92
93    std::fs::write(&base_path, base)?;
94    std::fs::write(&ours_path, ours)?;
95    std::fs::write(&theirs_path, theirs)?;
96
97    let output = Command::new("git")
98        .current_dir(tmp.path())
99        .args([
100            "merge-file",
101            "-p",
102            "--diff3",
103            "-L", "agent-response",
104            "-L", "original",
105            "-L", "your-edits",
106            &ours_path.to_string_lossy(),
107            &base_path.to_string_lossy(),
108            &theirs_path.to_string_lossy(),
109        ])
110        .output()?;
111
112    let merged = String::from_utf8(output.stdout)
113        .map_err(|e| anyhow::anyhow!("merge produced invalid UTF-8: {}", e))?;
114
115    if output.status.success() {
116        eprintln!("[write] Merge successful — user edits preserved.");
117        return Ok(merged);
118    }
119
120    if output.status.code() == Some(1) {
121        // Conflicts detected — try append-friendly resolution
122        let (resolved, remaining_conflicts) = resolve_append_conflicts(&merged);
123        if remaining_conflicts {
124            eprintln!("[write] WARNING: True merge conflicts remain. Please resolve conflict markers manually.");
125        } else {
126            eprintln!("[write] Merge conflicts auto-resolved (append-friendly).");
127        }
128        return Ok(resolved);
129    }
130
131    anyhow::bail!(
132        "git merge-file failed: {}",
133        String::from_utf8_lossy(&output.stderr)
134    )
135}
136
137/// Resolve append-only conflicts in `git merge-file --diff3` output.
138///
139/// With `--diff3`, conflict blocks look like:
140/// ```text
141/// <<<<<<< agent-response
142/// content added by agent
143/// ||||||| original
144/// (empty if both sides only appended)
145/// =======
146/// content added by user
147/// >>>>>>> your-edits
148/// ```
149///
150/// When the "original" section is empty (both sides added at the same
151/// insertion point without modifying existing content), auto-resolve by
152/// concatenating: ours (agent) first, then theirs (user).
153///
154/// Returns (resolved_content, has_remaining_conflicts).
155fn resolve_append_conflicts(merged: &str) -> (String, bool) {
156    let mut result = String::new();
157    let mut has_remaining = false;
158    let lines: Vec<&str> = merged.lines().collect();
159    let len = lines.len();
160    let mut i = 0;
161
162    while i < len {
163        if !lines[i].starts_with("<<<<<<< ") {
164            result.push_str(lines[i]);
165            result.push('\n');
166            i += 1;
167            continue;
168        }
169
170        // Parse conflict block
171        let conflict_start = i;
172        i += 1; // skip <<<<<<< marker
173
174        // Collect "ours" section
175        let mut ours_lines: Vec<&str> = Vec::new();
176        while i < len && !lines[i].starts_with("||||||| ") && !lines[i].starts_with("=======") {
177            ours_lines.push(lines[i]);
178            i += 1;
179        }
180
181        // Collect "original" section (diff3)
182        let mut original_lines: Vec<&str> = Vec::new();
183        if i < len && lines[i].starts_with("||||||| ") {
184            i += 1; // skip ||||||| marker
185            while i < len && !lines[i].starts_with("=======") {
186                original_lines.push(lines[i]);
187                i += 1;
188            }
189        }
190
191        // Skip ======= marker
192        if i < len && lines[i].starts_with("=======") {
193            i += 1;
194        }
195
196        // Collect "theirs" section
197        let mut theirs_lines: Vec<&str> = Vec::new();
198        while i < len && !lines[i].starts_with(">>>>>>> ") {
199            theirs_lines.push(lines[i]);
200            i += 1;
201        }
202
203        // Skip >>>>>>> marker
204        if i < len && lines[i].starts_with(">>>>>>> ") {
205            i += 1;
206        }
207
208        // Check if append-only: original section is empty or whitespace-only
209        let is_append_only = original_lines.iter().all(|l| l.trim().is_empty());
210
211        if is_append_only {
212            // Auto-resolve: ours (agent) first, then theirs (user)
213            for line in &ours_lines {
214                result.push_str(line);
215                result.push('\n');
216            }
217            for line in &theirs_lines {
218                result.push_str(line);
219                result.push('\n');
220            }
221        } else {
222            // True conflict — preserve markers
223            has_remaining = true;
224            result.push_str(lines[conflict_start]);
225            result.push('\n');
226            for line in &ours_lines {
227                result.push_str(line);
228                result.push('\n');
229            }
230            // Reconstruct ||||||| section
231            if !original_lines.is_empty() {
232                result.push_str("||||||| original\n");
233                for line in &original_lines {
234                    result.push_str(line);
235                    result.push('\n');
236                }
237            }
238            result.push_str("=======\n");
239            for line in &theirs_lines {
240                result.push_str(line);
241                result.push('\n');
242            }
243            result.push_str(">>>>>>> your-edits\n");
244        }
245    }
246
247    // Handle trailing: if original didn't end with newline but we added one
248    if !merged.ends_with('\n') && result.ends_with('\n') {
249        result.pop();
250    }
251
252    (result, has_remaining)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn resolve_append_only_conflict() {
261        let merged = "\
262Before conflict
263<<<<<<< agent-response
264Agent added this line.
265||||||| original
266=======
267User added this line.
268>>>>>>> your-edits
269After conflict
270";
271        let (resolved, has_remaining) = resolve_append_conflicts(merged);
272        assert!(!has_remaining);
273        assert!(resolved.contains("Agent added this line."));
274        assert!(resolved.contains("User added this line."));
275        assert!(!resolved.contains("<<<<<<<"));
276        assert!(!resolved.contains(">>>>>>>"));
277        // Agent content comes before user content
278        let agent_pos = resolved.find("Agent added this line.").unwrap();
279        let user_pos = resolved.find("User added this line.").unwrap();
280        assert!(agent_pos < user_pos);
281    }
282
283    #[test]
284    fn preserve_true_conflict() {
285        let merged = "\
286<<<<<<< agent-response
287Agent changed this.
288||||||| original
289Original line that both sides modified.
290=======
291User changed this differently.
292>>>>>>> your-edits
293";
294        let (resolved, has_remaining) = resolve_append_conflicts(merged);
295        assert!(has_remaining);
296        assert!(resolved.contains("<<<<<<<"));
297        assert!(resolved.contains(">>>>>>>"));
298        assert!(resolved.contains("Original line that both sides modified."));
299    }
300
301    #[test]
302    fn mixed_append_and_true_conflicts() {
303        let merged = "\
304Clean line.
305<<<<<<< agent-response
306Agent appended here.
307||||||| original
308=======
309User appended here.
310>>>>>>> your-edits
311Middle line.
312<<<<<<< agent-response
313Agent rewrote this.
314||||||| original
315Was originally this.
316=======
317User rewrote this differently.
318>>>>>>> your-edits
319End line.
320";
321        let (resolved, has_remaining) = resolve_append_conflicts(merged);
322        assert!(has_remaining);
323        // Append-only conflict was resolved
324        assert!(resolved.contains("Agent appended here."));
325        assert!(resolved.contains("User appended here."));
326        // True conflict kept markers
327        assert!(resolved.contains("<<<<<<<"));
328        assert!(resolved.contains("Was originally this."));
329    }
330
331    #[test]
332    fn no_conflicts_passthrough() {
333        let merged = "Line one.\nLine two.\nLine three.\n";
334        let (resolved, has_remaining) = resolve_append_conflicts(merged);
335        assert!(!has_remaining);
336        assert_eq!(resolved, merged);
337    }
338
339    #[test]
340    fn multiline_append_conflict() {
341        let merged = "\
342<<<<<<< agent-response
343Agent line 1.
344Agent line 2.
345Agent line 3.
346||||||| original
347=======
348User line 1.
349User line 2.
350>>>>>>> your-edits
351";
352        let (resolved, has_remaining) = resolve_append_conflicts(merged);
353        assert!(!has_remaining);
354        assert!(resolved.contains("Agent line 1.\nAgent line 2.\nAgent line 3.\n"));
355        assert!(resolved.contains("User line 1.\nUser line 2.\n"));
356        // Agent before user
357        assert!(resolved.find("Agent line 1.").unwrap() < resolved.find("User line 1.").unwrap());
358    }
359
360    #[test]
361    fn merge_contents_clean() {
362        let base = "Line 1\nLine 2\n";
363        let ours = "Line 1\nLine 2\nAgent added\n";
364        let theirs = "Line 1\nLine 2\n";
365        let result = merge_contents(base, ours, theirs).unwrap();
366        assert!(result.contains("Agent added"));
367    }
368
369    #[test]
370    fn crdt_merge_agent_and_user_append() {
371        let base = "# Doc\n\nBase content.\n";
372        let ours = "# Doc\n\nBase content.\n\nAgent response.\n";
373        let theirs = "# Doc\n\nBase content.\n\nUser addition.\n";
374
375        let base_doc = crate::crdt::CrdtDoc::from_text(base);
376        let base_state = base_doc.encode_state();
377
378        let (merged, _state) = merge_contents_crdt(Some(&base_state), ours, theirs).unwrap();
379        assert!(merged.contains("Agent response."));
380        assert!(merged.contains("User addition."));
381        assert!(merged.contains("Base content."));
382        assert!(!merged.contains("<<<<<<<"));
383    }
384
385    #[test]
386    fn crdt_merge_concurrent_same_line() {
387        let base = "Line 1\nLine 3\n";
388        let ours = "Line 1\nAgent\nLine 3\n";
389        let theirs = "Line 1\nUser\nLine 3\n";
390
391        let base_doc = crate::crdt::CrdtDoc::from_text(base);
392        let base_state = base_doc.encode_state();
393
394        let (merged, _state) = merge_contents_crdt(Some(&base_state), ours, theirs).unwrap();
395        // Both preserved, deterministic ordering, no conflict
396        assert!(merged.contains("Agent"));
397        assert!(merged.contains("User"));
398        assert!(merged.contains("Line 1"));
399        assert!(merged.contains("Line 3"));
400    }
401
402    #[test]
403    fn crdt_merge_no_base_state_bootstrap() {
404        let ours = "Agent content.\n";
405        let theirs = "User content.\n";
406
407        let (merged, state) = merge_contents_crdt(None, ours, theirs).unwrap();
408        assert!(merged.contains("Agent content."));
409        assert!(merged.contains("User content."));
410        assert!(!state.is_empty());
411    }
412
413    #[test]
414    fn crdt_merge_one_side_unchanged() {
415        let base = "Original.\n";
416        let base_doc = crate::crdt::CrdtDoc::from_text(base);
417        let base_state = base_doc.encode_state();
418
419        let ours = "Original.\nAgent added.\n";
420        let (merged, _) = merge_contents_crdt(Some(&base_state), ours, base).unwrap();
421        assert_eq!(merged, ours);
422    }
423
424    #[test]
425    fn merge_contents_both_append() {
426        let base = "Line 1\n";
427        let ours = "Line 1\nAgent response\n";
428        let theirs = "Line 1\nUser edit\n";
429        let result = merge_contents(base, ours, theirs).unwrap();
430        // Both should be present, no conflict markers
431        assert!(result.contains("Agent response"));
432        assert!(result.contains("User edit"));
433        assert!(!result.contains("<<<<<<<"));
434    }
435
436    /// Regression test: CRDT state must include user edits from the merge.
437    ///
438    /// Bug: After a merge cycle where the user edited concurrently, the CRDT
439    /// state was rebuilt from `content_ours` (agent-only) instead of the merged
440    /// state. On the next cycle, the merge saw user edits as new insertions
441    /// relative to the stale base, producing duplicate text.
442    ///
443    /// This test simulates two consecutive merge cycles:
444    /// 1. Agent writes response while user edits concurrently → merge
445    /// 2. Agent writes another response using the CRDT state from cycle 1
446    ///
447    /// With the bug, cycle 2 would duplicate the user's edit from cycle 1.
448    #[test]
449    fn crdt_state_includes_user_edits_no_duplicates() {
450        // --- Cycle 1: Initial state, agent responds, user edits concurrently ---
451        let initial = "Why were the videos not public?\n";
452        let initial_doc = crate::crdt::CrdtDoc::from_text(initial);
453        let initial_state = initial_doc.encode_state();
454
455        // Agent appends a response
456        let ours_cycle1 = "Why were the videos not public?\nAlways publish public videos.\n";
457        // User also edits concurrently (adds a line)
458        let theirs_cycle1 = "Why were the videos not public?\nuser-edit-abc\n";
459
460        let (merged1, state1) = merge_contents_crdt(
461            Some(&initial_state), ours_cycle1, theirs_cycle1
462        ).unwrap();
463
464        // Both edits present after cycle 1
465        assert!(merged1.contains("Always publish public videos."), "missing agent response");
466        assert!(merged1.contains("user-edit-abc"), "missing user edit");
467
468        // --- Cycle 2: Agent writes another response, no concurrent user edits ---
469        // The agent's new content_ours includes the full merged result + new text
470        let ours_cycle2 = format!("{}...unless explicitly set to private.\n", merged1);
471        // No user edits this time — theirs is the same as what was written to disk
472        let theirs_cycle2 = merged1.clone();
473
474        let (merged2, _state2) = merge_contents_crdt(
475            Some(&state1), &ours_cycle2, &theirs_cycle2
476        ).unwrap();
477
478        // The user's edit should appear exactly ONCE, not duplicated
479        let edit_count = merged2.matches("user-edit-abc").count();
480        assert_eq!(
481            edit_count, 1,
482            "User edit duplicated! Appeared {} times in:\n{}",
483            edit_count, merged2
484        );
485
486        // Agent's content from both cycles should be present
487        assert!(merged2.contains("Always publish public videos."));
488        assert!(merged2.contains("...unless explicitly set to private."));
489    }
490
491    /// Regression test: Multiple flush cycles with concurrent user edits.
492    ///
493    /// Simulates the streaming checkpoint pattern where the agent flushes
494    /// partial responses multiple times while the user keeps editing.
495    #[test]
496    fn crdt_multi_flush_no_duplicates() {
497        let base = "# Doc\n\nQuestion here.\n";
498        let base_doc = crate::crdt::CrdtDoc::from_text(base);
499        let state0 = base_doc.encode_state();
500
501        // Flush 1: Agent starts responding, user adds a note
502        let ours1 = "# Doc\n\nQuestion here.\n\n### Re: Answer\n\nFirst paragraph.\n";
503        let theirs1 = "# Doc\n\nQuestion here.\n\n> user note\n";
504        let (merged1, state1) = merge_contents_crdt(Some(&state0), ours1, theirs1).unwrap();
505        assert!(merged1.contains("First paragraph."));
506        assert!(merged1.contains("> user note"));
507
508        // Flush 2: Agent continues, user adds another note
509        let ours2 = format!("{}\nSecond paragraph.\n", merged1);
510        let theirs2 = format!("{}\n> another note\n", merged1);
511        let (merged2, _state2) = merge_contents_crdt(Some(&state1), &ours2, &theirs2).unwrap();
512
513        // Each piece of content appears exactly once
514        assert_eq!(merged2.matches("First paragraph.").count(), 1,
515            "First paragraph duplicated in:\n{}", merged2);
516        assert_eq!(merged2.matches("> user note").count(), 1,
517            "User note duplicated in:\n{}", merged2);
518        assert!(merged2.contains("Second paragraph."));
519        assert!(merged2.contains("> another note"));
520    }
521}