amalgm 0.1.152 → 0.1.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal three-way line-based merge (diff3) for live-document reconcile.
5
+ *
6
+ * merge3(base, ours, theirs) — all three are whole-file strings — answers:
7
+ * given that both OURS and THEIRS evolved from BASE, what single text keeps
8
+ * both sides' changes? Hunks (line regions changed relative to BASE) that
9
+ * touch different base regions merge automatically; hunks that overlap are
10
+ * conflicts. On conflict this module NEVER picks a winner silently: the
11
+ * merged text keeps OURS for the conflicted region and the conflict is
12
+ * reported to the caller, which is responsible for preserving THEIRS
13
+ * durably (docs.js writes a sidecar file).
14
+ *
15
+ * No dependencies by design — this ships in the published package. The diff
16
+ * is a plain LCS over lines (common prefix/suffix trimmed first); regions too
17
+ * large to LCS degrade to a single whole-region hunk, which can only ever
18
+ * OVER-report a conflict, never lose an edit.
19
+ */
20
+
21
+ // Cap on the LCS DP table (cells = changed-lines² after prefix/suffix trim).
22
+ // 4M cells ≈ 16MB of Int32 — beyond it the changed region collapses into one
23
+ // hunk per side, trading merge granularity for bounded memory.
24
+ const MAX_LCS_CELLS = 4 * 1024 * 1024;
25
+
26
+ /** Split into lines with their terminators kept, so join('') reassembles exactly. */
27
+ function splitLines(text) {
28
+ if (text === '') return [];
29
+ return text.split(/(?<=\n)/);
30
+ }
31
+
32
+ /**
33
+ * Longest common subsequence between a[aStart..aEnd) and b[bStart..bEnd),
34
+ * returned as matched index pairs [aIndex, bIndex] in increasing order.
35
+ */
36
+ function lcsMatches(a, aStart, aEnd, b, bStart, bEnd) {
37
+ const n = aEnd - aStart;
38
+ const m = bEnd - bStart;
39
+ const width = m + 1;
40
+ const table = new Int32Array((n + 1) * width);
41
+ for (let i = 1; i <= n; i += 1) {
42
+ const line = a[aStart + i - 1];
43
+ for (let j = 1; j <= m; j += 1) {
44
+ table[i * width + j] = line === b[bStart + j - 1]
45
+ ? table[(i - 1) * width + (j - 1)] + 1
46
+ : Math.max(table[(i - 1) * width + j], table[i * width + (j - 1)]);
47
+ }
48
+ }
49
+ const matches = [];
50
+ let i = n;
51
+ let j = m;
52
+ while (i > 0 && j > 0) {
53
+ if (a[aStart + i - 1] === b[bStart + j - 1]
54
+ && table[i * width + j] === table[(i - 1) * width + (j - 1)] + 1) {
55
+ matches.push([aStart + i - 1, bStart + j - 1]);
56
+ i -= 1;
57
+ j -= 1;
58
+ } else if (table[(i - 1) * width + j] >= table[i * width + (j - 1)]) {
59
+ i -= 1;
60
+ } else {
61
+ j -= 1;
62
+ }
63
+ }
64
+ matches.reverse();
65
+ return matches;
66
+ }
67
+
68
+ /**
69
+ * Regions where `side` differs from `base`, as
70
+ * {baseStart, baseLength, sideStart, sideLength} in base order. Outside these
71
+ * hunks the two arrays match line for line.
72
+ */
73
+ function diffHunks(base, side) {
74
+ let prefix = 0;
75
+ const maxPrefix = Math.min(base.length, side.length);
76
+ while (prefix < maxPrefix && base[prefix] === side[prefix]) prefix += 1;
77
+ let baseEnd = base.length;
78
+ let sideEnd = side.length;
79
+ while (baseEnd > prefix && sideEnd > prefix && base[baseEnd - 1] === side[sideEnd - 1]) {
80
+ baseEnd -= 1;
81
+ sideEnd -= 1;
82
+ }
83
+ const n = baseEnd - prefix;
84
+ const m = sideEnd - prefix;
85
+ if (n === 0 && m === 0) return [];
86
+ if (n === 0 || m === 0 || n * m > MAX_LCS_CELLS) {
87
+ return [{ baseStart: prefix, baseLength: n, sideStart: prefix, sideLength: m }];
88
+ }
89
+
90
+ const hunks = [];
91
+ let b = prefix;
92
+ let s = prefix;
93
+ for (const [mb, ms] of lcsMatches(base, prefix, baseEnd, side, prefix, sideEnd)) {
94
+ if (mb > b || ms > s) {
95
+ hunks.push({ baseStart: b, baseLength: mb - b, sideStart: s, sideLength: ms - s });
96
+ }
97
+ b = mb + 1;
98
+ s = ms + 1;
99
+ }
100
+ if (baseEnd > b || sideEnd > s) {
101
+ hunks.push({ baseStart: b, baseLength: baseEnd - b, sideStart: s, sideLength: sideEnd - s });
102
+ }
103
+ return hunks;
104
+ }
105
+
106
+ /**
107
+ * The side's lines corresponding to the group's base range [start, end).
108
+ * Between its own hunks a side matches the base line for line, so the range
109
+ * extends from the first hunk (pulled back by the matched lead-in) to the
110
+ * last hunk (pushed forward by the matched tail).
111
+ */
112
+ function sideSlice(group, sideHunks, baseLines, sideLines) {
113
+ if (sideHunks.length === 0) return baseLines.slice(group.start, group.end);
114
+ const first = sideHunks[0];
115
+ const last = sideHunks[sideHunks.length - 1];
116
+ const lo = first.sideStart - (first.baseStart - group.start);
117
+ const hi = last.sideStart + last.sideLength + (group.end - (last.baseStart + last.baseLength));
118
+ return sideLines.slice(lo, hi);
119
+ }
120
+
121
+ /**
122
+ * Three-way merge of whole-file strings.
123
+ *
124
+ * Returns {text, conflicts}: `text` is the merged result; `conflicts` lists
125
+ * every overlapping region as {baseStart, baseEnd, ours, theirs} (line
126
+ * indices into BASE; `ours`/`theirs` are that region's text on each side).
127
+ * For conflicted regions `text` keeps OURS.
128
+ *
129
+ * Fast paths: either side identical to base (or to the other side) short-
130
+ * circuits without diffing.
131
+ */
132
+ function merge3(baseText, oursText, theirsText) {
133
+ if (oursText === theirsText) return { text: oursText, conflicts: [] };
134
+ if (oursText === baseText) return { text: theirsText, conflicts: [] };
135
+ if (theirsText === baseText) return { text: oursText, conflicts: [] };
136
+
137
+ const base = splitLines(baseText);
138
+ const ours = splitLines(oursText);
139
+ const theirs = splitLines(theirsText);
140
+ const hunks = [
141
+ ...diffHunks(base, ours).map((h) => ({ ...h, side: 'ours' })),
142
+ ...diffHunks(base, theirs).map((h) => ({ ...h, side: 'theirs' })),
143
+ ].sort((a, b) => (a.baseStart - b.baseStart)
144
+ || (a.side === b.side ? 0 : a.side === 'ours' ? -1 : 1));
145
+
146
+ // Group hunks whose base ranges overlap. Adjacent (touching) ranges stay
147
+ // separate — they merge cleanly — with one exception: insertions from both
148
+ // sides at the exact same base point have no defined order, so they group
149
+ // (and therefore conflict) too.
150
+ const groups = [];
151
+ for (const hunk of hunks) {
152
+ const group = groups[groups.length - 1];
153
+ const joins = group && (
154
+ hunk.baseStart < group.end
155
+ || (hunk.baseStart === group.end && hunk.baseLength === 0
156
+ && group.hunks.some((h) => h.side !== hunk.side
157
+ && h.baseLength === 0 && h.baseStart === hunk.baseStart))
158
+ );
159
+ if (joins) {
160
+ group.hunks.push(hunk);
161
+ group.end = Math.max(group.end, hunk.baseStart + hunk.baseLength);
162
+ } else {
163
+ groups.push({ start: hunk.baseStart, end: hunk.baseStart + hunk.baseLength, hunks: [hunk] });
164
+ }
165
+ }
166
+
167
+ const out = [];
168
+ const conflicts = [];
169
+ let cursor = 0;
170
+ for (const group of groups) {
171
+ out.push(base.slice(cursor, group.start).join(''));
172
+ const oursHunks = group.hunks.filter((h) => h.side === 'ours');
173
+ const theirsHunks = group.hunks.filter((h) => h.side === 'theirs');
174
+ const oursRegion = sideSlice(group, oursHunks, base, ours).join('');
175
+ const theirsRegion = sideSlice(group, theirsHunks, base, theirs).join('');
176
+ if (theirsHunks.length === 0 || oursRegion === theirsRegion) {
177
+ out.push(oursRegion);
178
+ } else if (oursHunks.length === 0) {
179
+ out.push(theirsRegion);
180
+ } else {
181
+ // Overlap with different content on each side: keep OURS in the text,
182
+ // surface the conflict to the caller.
183
+ conflicts.push({
184
+ baseStart: group.start,
185
+ baseEnd: group.end,
186
+ ours: oursRegion,
187
+ theirs: theirsRegion,
188
+ });
189
+ out.push(oursRegion);
190
+ }
191
+ cursor = group.end;
192
+ }
193
+ out.push(base.slice(cursor).join(''));
194
+ return { text: out.join(''), conflicts };
195
+ }
196
+
197
+ module.exports = { merge3 };
@@ -109,6 +109,21 @@ async function handleDocUpdate(body, sendJson) {
109
109
  }
110
110
  }
111
111
 
112
+ // Versioned whole-text write (the SDK write contract — semantics live in
113
+ // docs.writeDocText): body {path, text, baseHash?, baseSeq?}. `baseHash` is
114
+ // the sha256 returned by open/write for the exact text the writer read;
115
+ // with it, stale writes three-way merge instead of clobbering live edits.
116
+ async function handleDocWrite(body, sendJson) {
117
+ try {
118
+ sendJson(200, require('./docs').writeDocText(body?.path, body?.text, {
119
+ baseHash: body?.baseHash,
120
+ baseSeq: body?.baseSeq,
121
+ }));
122
+ } catch (error) {
123
+ sendHandlerError(error, sendJson);
124
+ }
125
+ }
126
+
112
127
  async function handleResourceList(sendJson) {
113
128
  try {
114
129
  sendJson(200, { resources: appResources.listAppResources() });
@@ -134,7 +149,11 @@ async function handleEvents(query, sendJson) {
134
149
 
135
150
  function handleStream(req, res, query) {
136
151
  appResources.ensureAppResourcesLoaded();
137
- const after = parsePositiveInt(query.after, 0);
152
+ // Ordered-bootstrap mode (docs/local-live-protocol.md §3): the snapshot
153
+ // travels on the stream itself as the first data frame, so `after` is
154
+ // ignored — the snapshot IS the resume point.
155
+ const bootstrap = String(query.bootstrap || '').trim() === '1';
156
+ const after = bootstrap ? 0 : parsePositiveInt(query.after, 0);
138
157
 
139
158
  res.writeHead(200, {
140
159
  'Content-Type': 'text/event-stream; charset=utf-8',
@@ -144,23 +163,26 @@ function handleStream(req, res, query) {
144
163
  });
145
164
  res.write(': local-live-store connected\n\n');
146
165
 
147
- const gap = replayGapAfter(after);
148
- if (gap) {
149
- sendSseControl(res, 'reset', { ...gap, reset: true });
150
- res.end();
151
- return;
152
- }
166
+ let backlog = [];
167
+ if (!bootstrap) {
168
+ const gap = replayGapAfter(after);
169
+ if (gap) {
170
+ sendSseControl(res, 'reset', { ...gap, reset: true });
171
+ res.end();
172
+ return;
173
+ }
153
174
 
154
- const backlog = listEventsAfter(after, { limit: STREAM_REPLAY_MAX_EVENTS + 1 });
155
- if (backlog.length > STREAM_REPLAY_MAX_EVENTS) {
156
- sendSseControl(res, 'reset', {
157
- code: 'state_replay_too_large',
158
- message: 'Too many missed events to replay; fetch a fresh snapshot.',
159
- after,
160
- reset: true,
161
- });
162
- res.end();
163
- return;
175
+ backlog = listEventsAfter(after, { limit: STREAM_REPLAY_MAX_EVENTS + 1 });
176
+ if (backlog.length > STREAM_REPLAY_MAX_EVENTS) {
177
+ sendSseControl(res, 'reset', {
178
+ code: 'state_replay_too_large',
179
+ message: 'Too many missed events to replay; fetch a fresh snapshot.',
180
+ after,
181
+ reset: true,
182
+ });
183
+ res.end();
184
+ return;
185
+ }
164
186
  }
165
187
 
166
188
  // One teardown for every way the stream can die. Idempotent because close
@@ -195,12 +217,48 @@ function handleStream(req, res, query) {
195
217
  }
196
218
  };
197
219
 
220
+ // The subscriber registers BEFORE the bootstrap snapshot is built so no
221
+ // event can slip between them: events landing mid-build are held here and
222
+ // flushed after the snapshot frame. `floorSeq` is the resume point — the
223
+ // client's `after` in legacy mode, the snapshot's seq once it is built.
224
+ let heldWhileBootstrapping = bootstrap ? [] : null;
225
+ let floorSeq = after;
198
226
  unsubscribe = subscribeStateEvents((event) => {
199
- if (event.seq > after) writeSafe(() => sendSseEvent(res, event));
227
+ if (heldWhileBootstrapping) {
228
+ heldWhileBootstrapping.push(event);
229
+ return;
230
+ }
231
+ if (event.seq > floorSeq) writeSafe(() => sendSseEvent(res, event));
200
232
  });
201
233
 
202
- for (const event of backlog) {
203
- writeSafe(() => sendSseEvent(res, event));
234
+ if (bootstrap) {
235
+ let snapshot;
236
+ try {
237
+ // Exactly the REST snapshot path (handleSnapshot), including its
238
+ // failure contract: a resource read throwing must surface as a clean
239
+ // stream error, not a response that never completes.
240
+ snapshot = buildSnapshot(query.resources);
241
+ } catch (error) {
242
+ writeSafe(() => sendSseControl(res, 'error', {
243
+ status: Number(error?.statusCode) || 500,
244
+ error: error?.message || 'internal error',
245
+ }));
246
+ teardown();
247
+ return;
248
+ }
249
+ floorSeq = snapshot.seq;
250
+ writeSafe(() => sendSseControl(res, 'snapshot', snapshot));
251
+ const held = heldWhileBootstrapping;
252
+ heldWhileBootstrapping = null;
253
+ // Events that landed while the snapshot was being built: anything at or
254
+ // below snapshot.seq is already inside it; the rest flush in seq order.
255
+ for (const event of held) {
256
+ if (event.seq > floorSeq) writeSafe(() => sendSseEvent(res, event));
257
+ }
258
+ } else {
259
+ for (const event of backlog) {
260
+ writeSafe(() => sendSseEvent(res, event));
261
+ }
204
262
  }
205
263
 
206
264
  // A named event (not an SSE comment) so EventSource clients can observe it
@@ -224,6 +282,7 @@ function handleStream(req, res, query) {
224
282
  module.exports = {
225
283
  handleDocOpen,
226
284
  handleDocUpdate,
285
+ handleDocWrite,
227
286
  handleEvents,
228
287
  handleResourceEmit,
229
288
  handleResourceList,
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
- const test = require('node:test');
4
+ const { after, test } = require('node:test');
5
5
 
6
6
  // Transport semantics: in attached mode a session's identity is established
7
7
  // once at attach (daemon tab selection + a stamp on the guest document) and
@@ -15,6 +15,16 @@ const test = require('node:test');
15
15
  const attach = require('../browser/attach');
16
16
  const { surfaces, knownSessions } = require('../browser/sessions');
17
17
 
18
+ // These transport tests assert their exact daemon command sequence. A real
19
+ // browser-auth bundle on the developer machine would prepend `state load`, so
20
+ // keep the fixture hermetic and restore the caller's environment afterward.
21
+ const savedCookieSource = process.env.AMALGM_BROWSER_COOKIE_SOURCE;
22
+ process.env.AMALGM_BROWSER_COOKIE_SOURCE = '0';
23
+ after(() => {
24
+ if (savedCookieSource === undefined) delete process.env.AMALGM_BROWSER_COOKIE_SOURCE;
25
+ else process.env.AMALGM_BROWSER_COOKIE_SOURCE = savedCookieSource;
26
+ });
27
+
18
28
  // Patch attach.run before engine destructures it (engine binds at require).
19
29
  const calls = [];
20
30
  let nextResults = [];
@@ -0,0 +1,251 @@
1
+ 'use strict';
2
+
3
+ // Three-way merge on external disk edits: a writer that read the file, then
4
+ // wrote a full text based on that stale read while a human typed via Yjs,
5
+ // must not delete the human's live edits (the audit's silent-loss finding).
6
+ // Unit tests cover state/merge3; integration tests cover the docs.js wiring:
7
+ // merged splices, conflict sidecars, the event's `conflict` field, and the
8
+ // watcher ignoring sidecars.
9
+
10
+ const test = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-docs-merge-test-'));
17
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
18
+
19
+ const Y = require('yjs');
20
+ const { closeLocalDb } = require('../state/db');
21
+ const docs = require('../state/docs');
22
+ const { merge3 } = require('../state/merge3');
23
+ const { listEventsAfter, currentSeq } = require('../state/events');
24
+
25
+ const docDir = path.join(tempRoot, 'workspace');
26
+ fs.mkdirSync(docDir, { recursive: true });
27
+
28
+ function writeDoc(name, text) {
29
+ const target = path.join(docDir, name);
30
+ fs.writeFileSync(target, text, 'utf8');
31
+ return target;
32
+ }
33
+
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ function clientFromState(stateBase64) {
39
+ const doc = new Y.Doc();
40
+ Y.applyUpdate(doc, new Uint8Array(Buffer.from(stateBase64, 'base64')));
41
+ return doc;
42
+ }
43
+
44
+ function encodeLocalEdit(doc, mutate) {
45
+ let captured = null;
46
+ const listener = (update) => { captured = update; };
47
+ doc.on('update', listener);
48
+ mutate(doc.getText('content'));
49
+ doc.off('update', listener);
50
+ assert.ok(captured, 'edit should produce an update');
51
+ return Buffer.from(captured).toString('base64');
52
+ }
53
+
54
+ function sidecarsFor(target) {
55
+ const ext = path.extname(target);
56
+ const stem = path.basename(target, ext);
57
+ const pattern = new RegExp(
58
+ `^${stem}\\.conflicted-\\d{8}-\\d{6}(?:-\\d+)?${ext.replace('.', '\\.')}$`,
59
+ );
60
+ return fs.readdirSync(path.dirname(target))
61
+ .filter((name) => pattern.test(name))
62
+ .map((name) => path.join(path.dirname(target), name));
63
+ }
64
+
65
+ test.after(() => {
66
+ docs.closeAllDocs();
67
+ closeLocalDb();
68
+ fs.rmSync(tempRoot, { recursive: true, force: true });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // merge3 unit tests
73
+ // ---------------------------------------------------------------------------
74
+
75
+ test('merge3: fast paths short-circuit when one side did not move', () => {
76
+ const base = 'a\nb\nc\n';
77
+ assert.deepEqual(merge3(base, base, 'a\nB\nc\n'), { text: 'a\nB\nc\n', conflicts: [] });
78
+ assert.deepEqual(merge3(base, 'a\nB\nc\n', base), { text: 'a\nB\nc\n', conflicts: [] });
79
+ assert.deepEqual(merge3(base, 'same\n', 'same\n'), { text: 'same\n', conflicts: [] });
80
+ });
81
+
82
+ test('merge3: disjoint edits both survive', () => {
83
+ const base = 'l1\nl2\nl3\nl4\nl5\n';
84
+ const ours = 'l1 mine\nl2\nl3\nl4\nl5\n';
85
+ const theirs = 'l1\nl2\nl3\nl4\nl5 theirs\n';
86
+ const merged = merge3(base, ours, theirs);
87
+ assert.equal(merged.text, 'l1 mine\nl2\nl3\nl4\nl5 theirs\n');
88
+ assert.equal(merged.conflicts.length, 0);
89
+ });
90
+
91
+ test('merge3: adjacent-but-not-overlapping lines merge cleanly', () => {
92
+ const base = 'l1\nl2\nl3\nl4\n';
93
+ const ours = 'l1\nl2 mine\nl3\nl4\n';
94
+ const theirs = 'l1\nl2\nl3 theirs\nl4\n';
95
+ const merged = merge3(base, ours, theirs);
96
+ assert.equal(merged.text, 'l1\nl2 mine\nl3 theirs\nl4\n');
97
+ assert.equal(merged.conflicts.length, 0);
98
+ });
99
+
100
+ test('merge3: overlapping edits conflict and keep OURS', () => {
101
+ const base = 'l1\nl2\nl3\n';
102
+ const ours = 'l1\nl2 mine\nl3\n';
103
+ const theirs = 'l1\nl2 theirs\nl3\n';
104
+ const merged = merge3(base, ours, theirs);
105
+ assert.equal(merged.text, 'l1\nl2 mine\nl3\n');
106
+ assert.equal(merged.conflicts.length, 1);
107
+ assert.equal(merged.conflicts[0].ours, 'l2 mine\n');
108
+ assert.equal(merged.conflicts[0].theirs, 'l2 theirs\n');
109
+ });
110
+
111
+ test('merge3: identical overlapping changes apply once, no conflict', () => {
112
+ const base = 'l1\nl2\nl3\nl4\n';
113
+ // Both sides made the SAME change to l2; ours also changed l4 on its own.
114
+ const ours = 'l1\nl2 same-change\nl3\nl4 mine\n';
115
+ const theirs = 'l1\nl2 same-change\nl3\nl4\n';
116
+ const merged = merge3(base, ours, theirs);
117
+ assert.equal(merged.text, 'l1\nl2 same-change\nl3\nl4 mine\n');
118
+ assert.equal(merged.conflicts.length, 0);
119
+ });
120
+
121
+ test('merge3: insertions from both sides at the same point conflict', () => {
122
+ const base = 'l1\nl2\n';
123
+ const ours = 'l1\nours inserted\nl2\n';
124
+ const theirs = 'l1\ntheirs inserted\nl2\n';
125
+ const merged = merge3(base, ours, theirs);
126
+ assert.equal(merged.conflicts.length, 1);
127
+ assert.equal(merged.text, ours, 'conflicted insertion keeps OURS');
128
+ });
129
+
130
+ test('merge3: whole-file rewrite vs one-line local edit conflicts, local line survives', () => {
131
+ const base = 'l1\nl2\nl3\n';
132
+ const ours = 'l1\nl2 human edit\nl3\n';
133
+ const theirs = 'completely\ndifferent\nfile\ncontents\n';
134
+ const merged = merge3(base, ours, theirs);
135
+ assert.equal(merged.conflicts.length, 1);
136
+ assert.ok(merged.text.includes('l2 human edit\n'), 'local edit must survive the rewrite');
137
+ assert.equal(merged.text, ours, 'a total-overlap rewrite keeps the live doc text');
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Integration: stale-based external writes against live Yjs edits.
142
+ // Timing mirrors state-docs.test.js: the watcher reconcile debounce is 200ms
143
+ // and the doc's own write-back 300ms, so the external write lands immediately
144
+ // after the (unflushed) local edit and reconcile sees both sides diverged.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ test('disjoint external write merges: human edit and agent edit both land', async () => {
148
+ const base = 'l1\nl2\nl3\nl4\nl5\n';
149
+ const target = writeDoc('merge-disjoint.md', base);
150
+ const opened = docs.openDoc(target);
151
+ const client = clientFromState(opened.state);
152
+ const before = currentSeq();
153
+
154
+ // Human types line 1 via Yjs (write-back still debounced: disk is stale)…
155
+ docs.applyDocUpdates(target, [
156
+ encodeLocalEdit(client, (ytext) => ytext.insert('l1'.length, ' human')),
157
+ ]);
158
+ // …while an agent that read the ORIGINAL file writes a full text with only
159
+ // line 5 changed — based on the old content, without the human's edit.
160
+ fs.writeFileSync(target, 'l1\nl2\nl3\nl4\nl5 agent\n', 'utf8');
161
+ await sleep(900);
162
+
163
+ assert.equal(
164
+ docs.getDocText(target),
165
+ 'l1 human\nl2\nl3\nl4\nl5 agent\n',
166
+ 'both the live edit and the disjoint external edit must survive',
167
+ );
168
+ assert.equal(fs.readFileSync(target, 'utf8'), 'l1 human\nl2\nl3\nl4\nl5 agent\n');
169
+ assert.equal(sidecarsFor(target).length, 0, 'clean merge writes no sidecar');
170
+ const conflictEvents = listEventsAfter(before)
171
+ .filter((e) => e.resource === opened.resource && e.patch?.conflict);
172
+ assert.equal(conflictEvents.length, 0, 'clean merge publishes no conflict');
173
+ });
174
+
175
+ test('overlapping external write conflicts: OURS kept, THEIRS preserved in sidecar, conflict published', async () => {
176
+ const base = 'alpha\nbeta\ngamma\n';
177
+ const target = writeDoc('merge-conflict.md', base);
178
+ const opened = docs.openDoc(target);
179
+ const client = clientFromState(opened.state);
180
+ const before = currentSeq();
181
+
182
+ docs.applyDocUpdates(target, [
183
+ encodeLocalEdit(client, (ytext) => ytext.insert('alpha\nbeta'.length, ' (human)')),
184
+ ]);
185
+ const theirs = 'alpha\nbeta (agent)\ngamma\n';
186
+ fs.writeFileSync(target, theirs, 'utf8');
187
+ await sleep(900);
188
+
189
+ // The live doc keeps the human's line — never silently replaced.
190
+ assert.equal(docs.getDocText(target), 'alpha\nbeta (human)\ngamma\n');
191
+ // The merged text overwrote THEIRS on disk (intended: THEIRS is in the sidecar).
192
+ assert.equal(fs.readFileSync(target, 'utf8'), 'alpha\nbeta (human)\ngamma\n');
193
+
194
+ const sidecars = sidecarsFor(target);
195
+ assert.equal(sidecars.length, 1, 'conflict must write exactly one sidecar');
196
+ assert.equal(fs.readFileSync(sidecars[0], 'utf8'), theirs, 'sidecar holds the full incoming disk text');
197
+
198
+ const conflictEvents = listEventsAfter(before)
199
+ .filter((e) => e.resource === opened.resource && e.patch?.conflict);
200
+ assert.equal(conflictEvents.length, 1);
201
+ const { conflict } = conflictEvents[0].patch;
202
+ assert.equal(conflict.sidecarPath, sidecars[0]);
203
+ assert.equal(conflict.hunks, 1);
204
+ assert.ok(typeof conflict.at === 'string' && !Number.isNaN(Date.parse(conflict.at)));
205
+
206
+ // Sidecars can never become live docs…
207
+ assert.throws(() => docs.openDoc(sidecars[0]), /sidecar/);
208
+ // …and the watcher ignores them: poking the sidecar emits nothing.
209
+ const settled = currentSeq();
210
+ fs.writeFileSync(sidecars[0], `${theirs}poke\n`, 'utf8');
211
+ await sleep(700);
212
+ const after = listEventsAfter(settled).filter((e) => e.resource === opened.resource);
213
+ assert.equal(after.length, 0, `sidecar writes must not re-ingest (got ${JSON.stringify(after)})`);
214
+ assert.equal(docs.getDocText(target), 'alpha\nbeta (human)\ngamma\n');
215
+ });
216
+
217
+ test('mixed external write: conflicting hunk keeps OURS, disjoint hunk still lands', async () => {
218
+ const base = 'h1\nh2\nh3\nh4\nh5\n';
219
+ const target = writeDoc('merge-mixed.md', base);
220
+ const opened = docs.openDoc(target);
221
+ const client = clientFromState(opened.state);
222
+ const before = currentSeq();
223
+
224
+ docs.applyDocUpdates(target, [
225
+ encodeLocalEdit(client, (ytext) => ytext.insert('h1'.length, ' human')),
226
+ ]);
227
+ // The agent's stale-based write touches line 1 (conflicts with the human)
228
+ // AND line 5 (disjoint — must merge in).
229
+ const theirs = 'h1 agent\nh2\nh3\nh4\nh5 agent\n';
230
+ fs.writeFileSync(target, theirs, 'utf8');
231
+ await sleep(900);
232
+
233
+ assert.equal(
234
+ docs.getDocText(target),
235
+ 'h1 human\nh2\nh3\nh4\nh5 agent\n',
236
+ 'human line kept on conflict, disjoint agent line merged in',
237
+ );
238
+ assert.equal(fs.readFileSync(target, 'utf8'), 'h1 human\nh2\nh3\nh4\nh5 agent\n');
239
+
240
+ const sidecars = sidecarsFor(target);
241
+ assert.equal(sidecars.length, 1);
242
+ assert.equal(fs.readFileSync(sidecars[0], 'utf8'), theirs);
243
+
244
+ // The merged splice and the conflict ride the same doc event.
245
+ const conflictEvents = listEventsAfter(before)
246
+ .filter((e) => e.resource === opened.resource && e.patch?.conflict);
247
+ assert.equal(conflictEvents.length, 1);
248
+ assert.ok(conflictEvents[0].patch.yjs, 'merged splice and conflict share one event patch');
249
+ assert.equal(conflictEvents[0].patch.conflict.hunks, 1);
250
+ assert.equal(conflictEvents[0].source, 'doc:disk');
251
+ });