@agent-native/toolkit 0.19.3 → 0.19.4-nightly-20260903220410

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.
@@ -41,6 +41,197 @@ export interface TopLevelDiff {
41
41
  toPos: number;
42
42
  }
43
43
 
44
+ export interface TopLevelHunk {
45
+ baseFromIndex: number;
46
+ baseToIndex: number;
47
+ changedFromIndex: number;
48
+ changedToIndex: number;
49
+ }
50
+
51
+ export type BaseAwareReconcileResult =
52
+ | { status: "noop" }
53
+ | { status: "applied"; mergedDoc: ProseMirrorNode }
54
+ | { status: "conflict"; localDraft: ProseMirrorNode }
55
+ | { status: "failed"; reason: "schema" | "ambiguous" | "transaction" };
56
+
57
+ function nodesEqual(left: ProseMirrorNode, right: ProseMirrorNode): boolean {
58
+ return left.eq(right);
59
+ }
60
+
61
+ /**
62
+ * Return every changed top-level run in base coordinates. The bounded LCS is
63
+ * intentionally conservative: large or duplicate-heavy ambiguous documents
64
+ * fail closed instead of guessing which identical block an edit targeted.
65
+ */
66
+ export function diffTopLevelHunks(
67
+ baseDoc: ProseMirrorNode,
68
+ changedDoc: ProseMirrorNode,
69
+ ): TopLevelHunk[] | null {
70
+ const base = Array.from({ length: baseDoc.childCount }, (_, i) =>
71
+ baseDoc.child(i),
72
+ );
73
+ const changed = Array.from({ length: changedDoc.childCount }, (_, i) =>
74
+ changedDoc.child(i),
75
+ );
76
+ if (base.length * changed.length > 250_000) return null;
77
+
78
+ const width = changed.length + 1;
79
+ const lcs = new Uint32Array((base.length + 1) * width);
80
+ for (let i = base.length - 1; i >= 0; i--) {
81
+ for (let j = changed.length - 1; j >= 0; j--) {
82
+ lcs[i * width + j] = nodesEqual(base[i], changed[j])
83
+ ? 1 + lcs[(i + 1) * width + j + 1]
84
+ : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]);
85
+ }
86
+ }
87
+
88
+ const matches: Array<[number, number]> = [];
89
+ let i = 0;
90
+ let j = 0;
91
+ while (i < base.length && j < changed.length) {
92
+ if (nodesEqual(base[i], changed[j])) {
93
+ matches.push([i++, j++]);
94
+ continue;
95
+ }
96
+ const skipBase = lcs[(i + 1) * width + j];
97
+ const skipChanged = lcs[i * width + j + 1];
98
+ if (skipBase === skipChanged) {
99
+ const insertion =
100
+ j + 1 < changed.length && nodesEqual(base[i], changed[j + 1]);
101
+ const deletion =
102
+ i + 1 < base.length && nodesEqual(base[i + 1], changed[j]);
103
+ if (insertion !== deletion) {
104
+ if (insertion) j++;
105
+ else i++;
106
+ continue;
107
+ }
108
+ // A plain substitution leaves the same suffix LCS after advancing both
109
+ // sides. Treat that as one changed run; only competing cross-matches are
110
+ // ambiguous enough to fail closed.
111
+ const skipBoth = lcs[(i + 1) * width + j + 1];
112
+ if (skipBoth === skipBase) {
113
+ i++;
114
+ j++;
115
+ continue;
116
+ }
117
+ return null;
118
+ }
119
+ if (skipBase > skipChanged) i++;
120
+ else j++;
121
+ }
122
+
123
+ const hunks: TopLevelHunk[] = [];
124
+ let baseCursor = 0;
125
+ let changedCursor = 0;
126
+ for (const [baseMatch, changedMatch] of [
127
+ ...matches,
128
+ [base.length, changed.length] as [number, number],
129
+ ]) {
130
+ if (baseCursor !== baseMatch || changedCursor !== changedMatch) {
131
+ hunks.push({
132
+ baseFromIndex: baseCursor,
133
+ baseToIndex: baseMatch,
134
+ changedFromIndex: changedCursor,
135
+ changedToIndex: changedMatch,
136
+ });
137
+ }
138
+ baseCursor = baseMatch + 1;
139
+ changedCursor = changedMatch + 1;
140
+ }
141
+ return hunks;
142
+ }
143
+
144
+ function hunksOverlap(left: TopLevelHunk, right: TopLevelHunk): boolean {
145
+ const leftInsert = left.baseFromIndex === left.baseToIndex;
146
+ const rightInsert = right.baseFromIndex === right.baseToIndex;
147
+ if (leftInsert && rightInsert) {
148
+ return left.baseFromIndex === right.baseFromIndex;
149
+ }
150
+ if (leftInsert) {
151
+ return (
152
+ left.baseFromIndex >= right.baseFromIndex &&
153
+ left.baseFromIndex <= right.baseToIndex
154
+ );
155
+ }
156
+ if (rightInsert) {
157
+ return (
158
+ right.baseFromIndex >= left.baseFromIndex &&
159
+ right.baseFromIndex <= left.baseToIndex
160
+ );
161
+ }
162
+ return (
163
+ left.baseFromIndex < right.baseToIndex &&
164
+ right.baseFromIndex < left.baseToIndex
165
+ );
166
+ }
167
+
168
+ function mappedLocalIndex(
169
+ baseIndex: number,
170
+ localHunks: readonly TopLevelHunk[],
171
+ ): number {
172
+ let mapped = baseIndex;
173
+ for (const hunk of localHunks) {
174
+ if (hunk.baseToIndex > baseIndex) break;
175
+ mapped +=
176
+ hunk.changedToIndex -
177
+ hunk.changedFromIndex -
178
+ (hunk.baseToIndex - hunk.baseFromIndex);
179
+ }
180
+ return mapped;
181
+ }
182
+
183
+ /** Apply authoritative server changes onto a locally edited document. */
184
+ export function reconcileDocAgainstBase(
185
+ editor: Editor,
186
+ baseDoc: ProseMirrorNode,
187
+ serverDoc: ProseMirrorNode,
188
+ ): BaseAwareReconcileResult {
189
+ const liveDoc = editor.state.doc;
190
+ if (
191
+ baseDoc.type.schema !== editor.schema ||
192
+ serverDoc.type.schema !== editor.schema ||
193
+ baseDoc.type !== liveDoc.type ||
194
+ serverDoc.type !== liveDoc.type
195
+ ) {
196
+ return { status: "failed", reason: "schema" };
197
+ }
198
+ const localHunks = diffTopLevelHunks(baseDoc, liveDoc);
199
+ const serverHunks = diffTopLevelHunks(baseDoc, serverDoc);
200
+ if (!localHunks || !serverHunks) {
201
+ return { status: "failed", reason: "ambiguous" };
202
+ }
203
+ if (serverHunks.length === 0) return { status: "noop" };
204
+ if (
205
+ localHunks.some((local) =>
206
+ serverHunks.some((server) => hunksOverlap(local, server)),
207
+ )
208
+ ) {
209
+ return { status: "conflict", localDraft: liveDoc };
210
+ }
211
+
212
+ try {
213
+ const tr = editor.state.tr;
214
+ for (const hunk of [...serverHunks].reverse()) {
215
+ const fromIndex = mappedLocalIndex(hunk.baseFromIndex, localHunks);
216
+ const toIndex = mappedLocalIndex(hunk.baseToIndex, localHunks);
217
+ tr.replaceWith(
218
+ positionOfChild(tr.doc, fromIndex),
219
+ positionOfChild(tr.doc, toIndex),
220
+ serverDoc.content.cut(
221
+ positionOfChild(serverDoc, hunk.changedFromIndex),
222
+ positionOfChild(serverDoc, hunk.changedToIndex),
223
+ ),
224
+ );
225
+ }
226
+ tr.setMeta("addToHistory", false);
227
+ tr.setMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION, true);
228
+ editor.view.dispatch(tr);
229
+ return { status: "applied", mergedDoc: editor.state.doc };
230
+ } catch {
231
+ return { status: "failed", reason: "transaction" };
232
+ }
233
+ }
234
+
44
235
  /**
45
236
  * Diff two documents at top-level-node granularity: trim the common prefix and
46
237
  * suffix (node equality via ProseMirror's `Node.eq`, which is deep) and return
@@ -48,12 +48,16 @@ afterEach(() => {
48
48
  interface HarnessProps {
49
49
  value: string;
50
50
  contentUpdatedAt: string;
51
+ contentRevision?: string;
51
52
  editorOwnedFocus?: boolean;
52
53
  }
53
54
 
54
55
  interface CollabSeedHarnessProps {
55
56
  collabSynced: boolean;
56
57
  fragmentLength: number;
58
+ value?: string;
59
+ contentRevision?: string;
60
+ contentUpdatedAt?: string;
57
61
  }
58
62
 
59
63
  interface Captured {
@@ -61,6 +65,10 @@ interface Captured {
61
65
  emitted: string[];
62
66
  setContentCalls: number;
63
67
  registerEmitted?: (markdown: string) => boolean;
68
+ reconciled?: Array<{
69
+ status: "merged" | "conflict" | "failed";
70
+ content: string;
71
+ }>;
64
72
  }
65
73
 
66
74
  function makeHarness() {
@@ -69,6 +77,7 @@ function makeHarness() {
69
77
  function Harness({
70
78
  value,
71
79
  contentUpdatedAt,
80
+ contentRevision,
72
81
  editorOwnedFocus = false,
73
82
  }: HarnessProps) {
74
83
  const guardsRef = React.useRef<ReturnType<
@@ -92,6 +101,13 @@ function makeHarness() {
92
101
  editor,
93
102
  value,
94
103
  contentUpdatedAt,
104
+ contentRevision,
105
+ onBaseAwareReconcile: (result) => {
106
+ (captured.reconciled ??= []).push({
107
+ status: result.status,
108
+ content: result.content,
109
+ });
110
+ },
95
111
  editable: true,
96
112
  isEditorFocused: () => editorOwnedFocus,
97
113
  getMarkdown: getEditorMarkdown,
@@ -122,7 +138,13 @@ function makeHarness() {
122
138
  function makeCollabSeedHarness(initialContent = "") {
123
139
  const captured: Captured = { editor: null, emitted: [], setContentCalls: 0 };
124
140
 
125
- function Harness({ collabSynced, fragmentLength }: CollabSeedHarnessProps) {
141
+ function Harness({
142
+ collabSynced,
143
+ fragmentLength,
144
+ value = "seeded content",
145
+ contentRevision,
146
+ contentUpdatedAt = "2024-01-01T00:00:01.000Z",
147
+ }: CollabSeedHarnessProps) {
126
148
  const guardsRef = React.useRef<ReturnType<
127
149
  typeof useCollabReconcile
128
150
  > | null>(null);
@@ -153,8 +175,15 @@ function makeCollabSeedHarness(initialContent = "") {
153
175
  editor,
154
176
  ydoc: fakeYdoc as never,
155
177
  collabSynced,
156
- value: "seeded content",
157
- contentUpdatedAt: "2024-01-01T00:00:01.000Z",
178
+ value,
179
+ contentUpdatedAt,
180
+ contentRevision,
181
+ onBaseAwareReconcile: (result) => {
182
+ (captured.reconciled ??= []).push({
183
+ status: result.status,
184
+ content: result.content,
185
+ });
186
+ },
158
187
  editable: true,
159
188
  getMarkdown: getEditorMarkdown,
160
189
  setContent: (ed, v) => {
@@ -189,6 +218,62 @@ function render(
189
218
  }
190
219
 
191
220
  describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
221
+ it("merges a newer non-overlapping revision and reports the combined draft", async () => {
222
+ const { captured, Harness } = makeHarness();
223
+ render(root, Harness, {
224
+ value: "Alpha\n\nBravo\n\nCharlie",
225
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
226
+ contentRevision: "revision-1",
227
+ });
228
+ await flush();
229
+
230
+ act(() =>
231
+ captured.editor!.commands.setContent("Alpha local\n\nBravo\n\nCharlie"),
232
+ );
233
+ render(root, Harness, {
234
+ value: "Alpha\n\nBravo\n\nCharlie server",
235
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
236
+ contentRevision: "revision-2",
237
+ });
238
+ await flush();
239
+
240
+ expect(captured.reconciled).toEqual([
241
+ {
242
+ status: "merged",
243
+ content: "Alpha local\n\nBravo\n\nCharlie server",
244
+ },
245
+ ]);
246
+ expect(getEditorMarkdown(captured.editor!)).toBe(
247
+ "Alpha local\n\nBravo\n\nCharlie server",
248
+ );
249
+ });
250
+
251
+ it("reports an overlap once and preserves the local draft", async () => {
252
+ const { captured, Harness } = makeHarness();
253
+ render(root, Harness, {
254
+ value: "Alpha\n\nBravo",
255
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
256
+ contentRevision: "revision-1",
257
+ });
258
+ await flush();
259
+ act(() => captured.editor!.commands.setContent("Alpha local\n\nBravo"));
260
+
261
+ const newer = {
262
+ value: "Alpha server\n\nBravo",
263
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
264
+ contentRevision: "revision-2",
265
+ };
266
+ render(root, Harness, newer);
267
+ await flush();
268
+ render(root, Harness, newer);
269
+ await flush();
270
+
271
+ expect(getEditorMarkdown(captured.editor!)).toBe("Alpha local\n\nBravo");
272
+ expect(captured.reconciled).toEqual([
273
+ { status: "conflict", content: "Alpha local\n\nBravo" },
274
+ ]);
275
+ });
276
+
192
277
  it("persists the first local edit after a synced empty collaborative document", async () => {
193
278
  const captured: Captured = {
194
279
  editor: null,
@@ -296,6 +381,47 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
296
381
  expect(getEditorMarkdown(captured.editor!)).toBe("seeded content");
297
382
  });
298
383
 
384
+ it("uses the collaborative seed as the base for a later three-way merge", async () => {
385
+ const { captured, Harness } = makeCollabSeedHarness();
386
+ act(() => {
387
+ root.render(
388
+ React.createElement(Harness, {
389
+ collabSynced: true,
390
+ fragmentLength: 0,
391
+ value: "Alpha\n\nBravo\n\nCharlie",
392
+ contentRevision: "revision-1",
393
+ }),
394
+ );
395
+ });
396
+ await flush();
397
+
398
+ act(() =>
399
+ captured.editor!.commands.setContent("Alpha local\n\nBravo\n\nCharlie"),
400
+ );
401
+ act(() => {
402
+ root.render(
403
+ React.createElement(Harness, {
404
+ collabSynced: true,
405
+ fragmentLength: 1,
406
+ value: "Alpha\n\nBravo\n\nCharlie server",
407
+ contentRevision: "revision-2",
408
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
409
+ }),
410
+ );
411
+ });
412
+ await flush();
413
+
414
+ expect(captured.reconciled).toEqual([
415
+ {
416
+ status: "merged",
417
+ content: "Alpha local\n\nBravo\n\nCharlie server",
418
+ },
419
+ ]);
420
+ expect(getEditorMarkdown(captured.editor!)).toBe(
421
+ "Alpha local\n\nBravo\n\nCharlie server",
422
+ );
423
+ });
424
+
299
425
  it("applies a deliberate REVERT to a previously-applied value after a local edit (not swallowed as echo)", async () => {
300
426
  // Regression for the revert-safety carve-out: the doc-equivalence echo
301
427
  // guards (value === lastAppliedValueRef) only fire when the editor is
@@ -11,6 +11,7 @@ import {
11
11
  RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION,
12
12
  applyDocSurgically,
13
13
  defaultParseValue,
14
+ reconcileDocAgainstBase,
14
15
  } from "./surgical-apply.js";
15
16
 
16
17
  export { RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION };
@@ -60,6 +61,16 @@ export interface UseCollabReconcileOptions {
60
61
  value: string;
61
62
  /** Timestamp of the authoritative value; gates newer-than reconcile. */
62
63
  contentUpdatedAt?: string | null;
64
+ /** Opaque authoritative body revision. Enables base-aware reconciliation. */
65
+ contentRevision?: string | null;
66
+ /** Reports an automatic merge to persist, or a conflict whose local draft was preserved. */
67
+ onBaseAwareReconcile?: (result: {
68
+ status: "merged" | "conflict" | "failed";
69
+ content: string;
70
+ serverContent: string;
71
+ baseRevision: string;
72
+ serverRevision: string;
73
+ }) => void;
63
74
  /** Whether the editor accepts edits. Reconcile/seed only run for the live editor. */
64
75
  editable: boolean;
65
76
  /**
@@ -234,6 +245,8 @@ export function useCollabReconcile({
234
245
  awareness = null,
235
246
  value,
236
247
  contentUpdatedAt,
248
+ contentRevision,
249
+ onBaseAwareReconcile,
237
250
  editable,
238
251
  isEditorFocused = defaultIsEditorFocused,
239
252
  getMarkdown = getEditorMarkdown,
@@ -272,6 +285,11 @@ export function useCollabReconcile({
272
285
  ? initialAppliedUpdatedAt
273
286
  : (contentUpdatedAt ?? null),
274
287
  );
288
+ const authoritativeBaseRef = useRef<{
289
+ value: string;
290
+ revision: string;
291
+ } | null>(contentRevision ? { value, revision: contentRevision } : null);
292
+ const reportedConflictRevisionRef = useRef<string | null>(null);
275
293
 
276
294
  // Whether THIS client is the one that seeds the empty shared doc / applies an
277
295
  // authoritative external snapshot into it. Exactly one client does, so the
@@ -329,6 +347,10 @@ export function useCollabReconcile({
329
347
  if (!collab || !editor || editor.isDestroyed || !ydoc) return;
330
348
  if (seededRef.current) return;
331
349
  if (!collabSynced) return;
350
+ if (contentRevision) {
351
+ authoritativeBaseRef.current = { value, revision: contentRevision };
352
+ reportedConflictRevisionRef.current = null;
353
+ }
332
354
  // An empty SQL value has nothing to seed. Release the first real keystroke
333
355
  // immediately, but when a fragment already exists defer the ambiguous
334
356
  // reconcile decision for one task: active-peer or just-emitted local content
@@ -431,6 +453,7 @@ export function useCollabReconcile({
431
453
  value,
432
454
  isLeadClient,
433
455
  contentUpdatedAt,
456
+ contentRevision,
434
457
  getMarkdown,
435
458
  setContent,
436
459
  shouldSeed,
@@ -519,6 +542,10 @@ export function useCollabReconcile({
519
542
  (value === lastAppliedValueRef.current ||
520
543
  normalizedValue === lastAppliedSerializedRef.current))
521
544
  ) {
545
+ if (contentRevision) {
546
+ authoritativeBaseRef.current = { value, revision: contentRevision };
547
+ reportedConflictRevisionRef.current = null;
548
+ }
522
549
  if (contentUpdatedAt) {
523
550
  lastAppliedUpdatedAtRef.current = contentUpdatedAt;
524
551
  }
@@ -596,6 +623,74 @@ export function useCollabReconcile({
596
623
  return;
597
624
  }
598
625
  isSettingContentRef.current = true;
626
+ const authoritativeBase = authoritativeBaseRef.current;
627
+ if (
628
+ contentRevision &&
629
+ onBaseAwareReconcile &&
630
+ authoritativeBase &&
631
+ authoritativeBase.revision !== contentRevision
632
+ ) {
633
+ const parse =
634
+ parseValue === false ? null : (parseValue ?? defaultParseValue);
635
+ const baseDoc = parse?.(editor, authoritativeBase.value) ?? null;
636
+ const serverDoc = parse?.(editor, value) ?? null;
637
+ if (!baseDoc || !serverDoc) {
638
+ isSettingContentRef.current = false;
639
+ if (reportedConflictRevisionRef.current !== contentRevision) {
640
+ reportedConflictRevisionRef.current = contentRevision;
641
+ onBaseAwareReconcile({
642
+ status: "failed",
643
+ content: beforeMarkdown,
644
+ serverContent: value,
645
+ baseRevision: authoritativeBase.revision,
646
+ serverRevision: contentRevision,
647
+ });
648
+ }
649
+ return;
650
+ }
651
+ const reconciled = reconcileDocAgainstBase(
652
+ editor,
653
+ baseDoc,
654
+ serverDoc,
655
+ );
656
+ if (
657
+ reconciled.status === "conflict" ||
658
+ reconciled.status === "failed"
659
+ ) {
660
+ isSettingContentRef.current = false;
661
+ if (reportedConflictRevisionRef.current !== contentRevision) {
662
+ reportedConflictRevisionRef.current = contentRevision;
663
+ onBaseAwareReconcile({
664
+ status: reconciled.status,
665
+ content: beforeMarkdown,
666
+ serverContent: value,
667
+ baseRevision: authoritativeBase.revision,
668
+ serverRevision: contentRevision,
669
+ });
670
+ }
671
+ return;
672
+ }
673
+ const merged = getMarkdown(editor);
674
+ isSettingContentRef.current = false;
675
+ authoritativeBaseRef.current = { value, revision: contentRevision };
676
+ reportedConflictRevisionRef.current = null;
677
+ lastEmittedRef.current = merged;
678
+ pushEmittedRing(recentEmittedRef.current, merged);
679
+ lastAppliedValueRef.current = value;
680
+ lastAppliedSerializedRef.current = merged;
681
+ if (contentUpdatedAt)
682
+ lastAppliedUpdatedAtRef.current = contentUpdatedAt;
683
+ if (reconciled.status === "applied" && merged !== normalized) {
684
+ onBaseAwareReconcile({
685
+ status: "merged",
686
+ content: merged,
687
+ serverContent: value,
688
+ baseRevision: authoritativeBase.revision,
689
+ serverRevision: contentRevision,
690
+ });
691
+ }
692
+ return;
693
+ }
599
694
  // Surgical path first: replace only the changed top-level run so
600
695
  // unchanged block NodeViews are never torn down and (under
601
696
  // Collaboration) Yjs sees a minimal edit instead of a full-fragment
@@ -623,6 +718,10 @@ export function useCollabReconcile({
623
718
  pushEmittedRing(recentEmittedRef.current, serialized);
624
719
  lastAppliedValueRef.current = value;
625
720
  lastAppliedSerializedRef.current = serialized;
721
+ if (contentRevision) {
722
+ authoritativeBaseRef.current = { value, revision: contentRevision };
723
+ reportedConflictRevisionRef.current = null;
724
+ }
626
725
  if (contentUpdatedAt) {
627
726
  lastAppliedUpdatedAtRef.current = contentUpdatedAt;
628
727
  }
@@ -637,6 +736,7 @@ export function useCollabReconcile({
637
736
  };
638
737
  }, [
639
738
  contentUpdatedAt,
739
+ contentRevision,
640
740
  editor,
641
741
  value,
642
742
  collab,
@@ -647,6 +747,7 @@ export function useCollabReconcile({
647
747
  parseValue,
648
748
  normalizeValue,
649
749
  isEditorFocused,
750
+ onBaseAwareReconcile,
650
751
  ]);
651
752
 
652
753
  const shouldIgnoreUpdate = (transaction: Transaction): boolean => {