fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)

mutatePageContent wrote agent edits back by DELETING the whole Yjs fragment and
re-applying a fresh Y.Doc. Yjs is a CRDT — the editor anchors its selection to
node ids — so wiping every id made an open editor's cursor lose its anchor and
snap to the end of the document on every agent write. It was most visible on
comment anchoring (issue #152): a comment changes no text, yet the cursor jumped.
(Before commit 4201f0a3 the anchoring silently no-op'd, so the destructive write
never ran for comments — hence the regression.)

Fix: write via  (y-prosemirror) — the same routine the editor
uses to sync its own edits into Yjs. It structurally diffs the new doc against
the live fragment and touches only changed nodes, preserving the ids of unchanged
ones, so the cursor stays put. This improves ALL agent write tools (text edits,
node ops, comments, replace) — minimal diff instead of full replace: less collab
noise, stable block-ids, other users' cursors no longer disrupted.

- collaboration.ts: new  (sanitize -> PMNode.fromJSON against
  a memoized docmost schema -> updateYFragment in one transact), keeping the
  findUnstorableAttr encode diagnostic; swap the destructive write-back for it.
- package.json: y-prosemirror promoted to a direct dependency (was transitive).
- test: comment-cursor-stability.test.mjs — a Yjs RelativePosition (the cursor
  anchor) survives both a sibling edit and a comment-mark anchoring (the old
  full-replace tombstoned it -> null). 292 package tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 05:56:05 +03:00
parent acf6d85b07
commit fd92eff7d6
6 changed files with 173 additions and 20 deletions

View File

@@ -4,11 +4,18 @@ import * as Y from "yjs";
import WebSocket from "ws"; import WebSocket from "ws";
import { marked } from "marked"; import { marked } from "marked";
import { generateJSON } from "@tiptap/html"; import { generateJSON } from "@tiptap/html";
import { getSchema } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";
import { updateYFragment } from "y-prosemirror";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { docmostExtensions } from "./docmost-schema.js"; import { docmostExtensions } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js"; import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
import { summarizeChange } from "./diff.js"; import { summarizeChange } from "./diff.js";
// The ProseMirror schema for the docmost editor, built once (mirrors diff.ts).
// `updateYFragment` needs a real PM Node, so we re-hydrate the transformed JSON
// against this schema before diffing it into the live Yjs fragment.
const docmostSchema = getSchema(docmostExtensions);
// Setup DOM environment for Tiptap HTML parsing in Node.js // Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>"); const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
global.window = dom.window; global.window = dom.window;
@@ -450,6 +457,40 @@ export function buildYDoc(doc) {
throw new Error(`Failed to encode document to Yjs (toYdoc): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`); throw new Error(`Failed to encode document to Yjs (toYdoc): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
} }
} }
/**
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
* preserving the Yjs identity of unchanged nodes (issue #152).
*
* The previous approach deleted the whole fragment and re-applied a fresh Y.Doc,
* which discarded every Yjs node id. y-prosemirror anchors the editor selection
* to those ids, so an open editor's cursor lost its anchor and snapped to the
* end of the document on every agent write (most visibly on comment anchoring,
* which changes no text at all). `updateYFragment` is exactly the routine the
* editor itself uses to sync ProseMirror edits into Yjs: it diffs the new node
* against the current fragment and touches only the changed children, so
* unchanged nodes keep their ids and the live cursor stays put.
*
* Must run inside a single `transact` so the diff applies atomically (no remote
* update interleaves). Keeps `buildYDoc`'s `findUnstorableAttr` diagnostic for
* the opaque "Unexpected content type" encode failure.
*/
export function applyDocToFragment(ydoc, newDoc) {
const safe = sanitizeForYjs(newDoc);
const fragment = ydoc.getXmlFragment("default");
try {
const pmNode = PMNode.fromJSON(docmostSchema, safe);
ydoc.transact(() => {
updateYFragment(ydoc, fragment, pmNode, {
mapping: new Map(),
isOMark: new Map(),
});
});
}
catch (e) {
const bad = findUnstorableAttr(safe);
throw new Error(`Failed to encode document to Yjs (updateYFragment): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
}
}
/** /**
* Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc. * Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc.
* Throws the same descriptive error as the apply path when it is not. Used by * Throws the same descriptive error as the apply path when it is not. Used by
@@ -649,16 +690,10 @@ export async function mutatePageContent(pageId, collabToken, baseUrl, transform)
finish(null, mutationResult); finish(null, mutationResult);
return; return;
} }
const tempDoc = buildYDoc(newDoc); // Structural diff into the live fragment (issue #152): preserves
// Fetch the fragment immediately before the transact that mutates // the Yjs ids of unchanged nodes, so an open editor's cursor is not
// it, rather than reusing a handle grabbed across the transform. // yanked to the end of the document on every agent write.
const fragment = ydoc.getXmlFragment("default"); applyDocToFragment(ydoc, newDoc);
ydoc.transact(() => {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc));
});
} }
catch (e) { catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found", // Includes errors thrown by transform (e.g. "afterText not found",

1
packages/mcp/node_modules/y-prosemirror generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/y-prosemirror@1.3.7_prosemirror-model@1.25.1_prosemirror-state@1.4.3_prosemirror-view@1_0ad6648b7e1f6d6f3287a40e0e62139b/node_modules/y-prosemirror

View File

@@ -52,6 +52,7 @@
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"ws": "^8.19.0", "ws": "^8.19.0",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.29", "yjs": "^13.6.29",
"zod": "^3.22.0" "zod": "^3.22.0"
}, },

View File

@@ -4,12 +4,20 @@ import * as Y from "yjs";
import WebSocket from "ws"; import WebSocket from "ws";
import { marked } from "marked"; import { marked } from "marked";
import { generateJSON } from "@tiptap/html"; import { generateJSON } from "@tiptap/html";
import { getSchema } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";
import { updateYFragment } from "y-prosemirror";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { docmostExtensions } from "./docmost-schema.js"; import { docmostExtensions } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js"; import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
import { summarizeChange, VerifyReport } from "./diff.js"; import { summarizeChange, VerifyReport } from "./diff.js";
// The ProseMirror schema for the docmost editor, built once (mirrors diff.ts).
// `updateYFragment` needs a real PM Node, so we re-hydrate the transformed JSON
// against this schema before diffing it into the live Yjs fragment.
const docmostSchema = getSchema(docmostExtensions);
/** /**
* The resolved value of every content-mutating collab write: the document that * The resolved value of every content-mutating collab write: the document that
* was written (or the live doc when the transform aborted) plus a verifiable * was written (or the live doc when the transform aborted) plus a verifiable
@@ -506,6 +514,42 @@ export function buildYDoc(doc: any): Y.Doc {
} }
} }
/**
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
* preserving the Yjs identity of unchanged nodes (issue #152).
*
* The previous approach deleted the whole fragment and re-applied a fresh Y.Doc,
* which discarded every Yjs node id. y-prosemirror anchors the editor selection
* to those ids, so an open editor's cursor lost its anchor and snapped to the
* end of the document on every agent write (most visibly on comment anchoring,
* which changes no text at all). `updateYFragment` is exactly the routine the
* editor itself uses to sync ProseMirror edits into Yjs: it diffs the new node
* against the current fragment and touches only the changed children, so
* unchanged nodes keep their ids and the live cursor stays put.
*
* Must run inside a single `transact` so the diff applies atomically (no remote
* update interleaves). Keeps `buildYDoc`'s `findUnstorableAttr` diagnostic for
* the opaque "Unexpected content type" encode failure.
*/
export function applyDocToFragment(ydoc: Y.Doc, newDoc: any): void {
const safe = sanitizeForYjs(newDoc);
const fragment = ydoc.getXmlFragment("default");
try {
const pmNode = PMNode.fromJSON(docmostSchema, safe);
ydoc.transact(() => {
updateYFragment(ydoc, fragment, pmNode, {
mapping: new Map(),
isOMark: new Map(),
});
});
} catch (e) {
const bad = findUnstorableAttr(safe);
throw new Error(
`Failed to encode document to Yjs (updateYFragment): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`,
);
}
}
/** /**
* Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc. * Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc.
* Throws the same descriptive error as the apply path when it is not. Used by * Throws the same descriptive error as the apply path when it is not. Used by
@@ -727,16 +771,10 @@ export async function mutatePageContent(
return; return;
} }
const tempDoc = buildYDoc(newDoc); // Structural diff into the live fragment (issue #152): preserves
// Fetch the fragment immediately before the transact that mutates // the Yjs ids of unchanged nodes, so an open editor's cursor is not
// it, rather than reusing a handle grabbed across the transform. // yanked to the end of the document on every agent write.
const fragment = ydoc.getXmlFragment("default"); applyDocToFragment(ydoc, newDoc);
ydoc.transact(() => {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc));
});
} catch (e) { } catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found", // Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller. // "text not found"): propagate them verbatim to the caller.

View File

@@ -0,0 +1,75 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as Y from "yjs";
import { applyDocToFragment } from "../../build/lib/collaboration.js";
// Regression for issue #152: agent writes (comment anchoring especially) must
// NOT yank the open editor's cursor to the end of the document. The cursor is a
// Yjs RelativePosition anchored to node ids; the old write-back deleted the whole
// fragment and rebuilt it, destroying every id, so the position no longer
// resolved. `applyDocToFragment` uses `updateYFragment` (the editor's own diff),
// which keeps unchanged nodes' ids — so a RelativePosition still resolves.
const para = (text, marks) => ({
type: "paragraph",
content: [{ type: "text", text, ...(marks ? { marks } : {}) }],
});
const doc = (...paras) => ({ type: "doc", content: paras });
/** The XmlText of the Nth paragraph in the live fragment. */
function paragraphText(ydoc, n) {
const el = ydoc.getXmlFragment("default").get(n); // <paragraph> XmlElement
return el.get(0); // its XmlText child
}
test("an UNCHANGED node keeps its Yjs identity across an edit (cursor survives)", () => {
const ydoc = new Y.Doc();
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second")));
// Anchor a cursor at offset 5 inside the FIRST (soon-to-be-unchanged) paragraph.
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 5);
// Edit only the SECOND paragraph; the first is untouched.
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second edited")));
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
assert.notEqual(abs, null, "the cursor's relative position must still resolve");
assert.equal(abs.index, 5, "the cursor must stay at the same offset");
// And the edit actually landed.
assert.equal(paragraphText(ydoc, 1).toString(), "Second edited");
});
test("anchoring a comment mark keeps the cursor in the marked text (issue #152)", () => {
const ydoc = new Y.Doc();
applyDocToFragment(ydoc, doc(para("Hello world")));
// The user's cursor sits inside the text that is about to be commented.
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 3);
// Agent anchors a comment over "Hello" — text is identical, only a mark added.
applyDocToFragment(
ydoc,
doc({
type: "paragraph",
content: [
{
type: "text",
text: "Hello",
marks: [
{ type: "comment", attrs: { commentId: "c1", resolved: false } },
],
},
{ type: "text", text: " world" },
],
}),
);
// The text is intact (the mark splits "Hello" / " world" but reads the same).
const para0 = ydoc.getXmlFragment("default").get(0);
assert.equal(para0.toString().replace(/<[^>]*>/g, ""), "Hello world");
// ...and the cursor anchored before the write still resolves (did not jump to
// the document end as it did with the destructive full-replace).
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
assert.notEqual(abs, null, "comment anchoring must not destroy the cursor anchor");
});

3
pnpm-lock.yaml generated
View File

@@ -946,6 +946,9 @@ importers:
ws: ws:
specifier: 8.20.1 specifier: 8.20.1
version: 8.20.1 version: 8.20.1
y-prosemirror:
specifier: 1.3.7
version: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))
yjs: yjs:
specifier: ^13.6.29 specifier: ^13.6.29
version: 13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810) version: 13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)