fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152) #154
@@ -4,11 +4,25 @@ 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 { 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, docmostSchema } 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";
|
||||||
|
/**
|
||||||
|
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
|
||||||
|
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
|
||||||
|
* `applyDocToFragment` -> `updateYFragment`) so the message wording stays in one
|
||||||
|
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
|
||||||
|
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
|
||||||
|
* `findUnstorableAttr`.
|
||||||
|
*/
|
||||||
|
function unstorableYjsError(safe, label, e) {
|
||||||
|
const bad = findUnstorableAttr(safe);
|
||||||
|
return new Error(`Failed to encode document to Yjs (${label}): ${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)."}`);
|
||||||
|
}
|
||||||
// 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;
|
||||||
@@ -446,17 +460,74 @@ export function buildYDoc(doc) {
|
|||||||
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
|
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
const bad = findUnstorableAttr(safe);
|
throw unstorableYjsError(safe, "toYdoc", e);
|
||||||
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)."}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc.
|
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
|
||||||
* Throws the same descriptive error as the apply path when it is not. Used by
|
* preserving the Yjs identity of unchanged nodes (issue #152).
|
||||||
* the dry-run preview so it fails identically to apply.
|
*
|
||||||
|
* 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");
|
||||||
|
// Hydrate the ProseMirror node in its OWN try so a failure here (e.g. an
|
||||||
|
// unknown node type) is labelled "fromJSON" — the stage that actually threw —
|
||||||
|
// instead of being misattributed to the Yjs write stage (#154 review).
|
||||||
|
let pmNode;
|
||||||
|
try {
|
||||||
|
pmNode = PMNode.fromJSON(docmostSchema, safe);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "fromJSON", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ydoc.transact(() => {
|
||||||
|
updateYFragment(ydoc, fragment, pmNode, {
|
||||||
|
mapping: new Map(),
|
||||||
|
isOMark: new Map(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "updateYFragment", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Run an independent Yjs-encodability check (the same `sanitizeForYjs` + schema
|
||||||
|
* the apply path uses) and throw the same descriptive error when the doc cannot
|
||||||
|
* be stored. Used by the dry-run preview.
|
||||||
|
*
|
||||||
|
* Note: it does NOT run `updateYFragment` against the live fragment, so it is an
|
||||||
|
* encodability GATE, not a byte-for-byte rehearsal of apply — `buildYDoc`
|
||||||
|
* (`toYdoc`) and `applyDocToFragment` (`updateYFragment`) are two different
|
||||||
|
* encoders that nonetheless reject the same unstorable attributes. To narrow the
|
||||||
|
* preview/apply gap it ALSO rehearses the apply path's `PMNode.fromJSON`
|
||||||
|
* hydration, so a doc that would only fail there (e.g. an unknown node type) is
|
||||||
|
* rejected at preview time too (#154 review). Still cheap: no live fragment, no
|
||||||
|
* `updateYFragment`.
|
||||||
*/
|
*/
|
||||||
export function assertYjsEncodable(doc) {
|
export function assertYjsEncodable(doc) {
|
||||||
buildYDoc(doc);
|
buildYDoc(doc);
|
||||||
|
const safe = sanitizeForYjs(doc);
|
||||||
|
try {
|
||||||
|
PMNode.fromJSON(docmostSchema, safe);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "fromJSON", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/** Time we wait for the initial handshake/sync before giving up. */
|
/** Time we wait for the initial handshake/sync before giving up. */
|
||||||
const CONNECT_TIMEOUT_MS = 25000;
|
const CONNECT_TIMEOUT_MS = 25000;
|
||||||
@@ -649,16 +720,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",
|
||||||
|
|||||||
@@ -16,13 +16,10 @@
|
|||||||
* If recreateTransform / the changeset throws on a pathological document pair,
|
* If recreateTransform / the changeset throws on a pathological document pair,
|
||||||
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
||||||
*/
|
*/
|
||||||
import { getSchema } from "@tiptap/core";
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
|
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
|
||||||
import { docmostExtensions } from "./docmost-schema.js";
|
import { docmostSchema } from "./docmost-schema.js";
|
||||||
/** Build the schema once; it is pure and reused across calls. */
|
|
||||||
const schema = getSchema(docmostExtensions);
|
|
||||||
/** Recursively concatenate the plain text of a JSON node. */
|
/** Recursively concatenate the plain text of a JSON node. */
|
||||||
function plainText(node) {
|
function plainText(node) {
|
||||||
if (!node || typeof node !== "object")
|
if (!node || typeof node !== "object")
|
||||||
@@ -242,8 +239,8 @@ export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примеча
|
|||||||
let fellBack = false;
|
let fellBack = false;
|
||||||
const changedBlocks = new Set();
|
const changedBlocks = new Set();
|
||||||
try {
|
try {
|
||||||
const oldNode = Node.fromJSON(schema, oldDocJson);
|
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||||
const newNode = Node.fromJSON(schema, newDocJson);
|
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||||
const tr = recreateTransform(oldNode, newNode, {
|
const tr = recreateTransform(oldNode, newNode, {
|
||||||
complexSteps: false,
|
complexSteps: false,
|
||||||
wordDiffs: true,
|
wordDiffs: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import TaskItem from "@tiptap/extension-task-item";
|
|||||||
import Highlight from "@tiptap/extension-highlight";
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import Subscript from "@tiptap/extension-subscript";
|
import Subscript from "@tiptap/extension-subscript";
|
||||||
import Superscript from "@tiptap/extension-superscript";
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
import { Node, Extension, Mark } from "@tiptap/core";
|
import { Node, Extension, Mark, getSchema } from "@tiptap/core";
|
||||||
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this
|
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this
|
||||||
// package can stay on the same @tiptap/core version as the editor and avoid a
|
// package can stay on the same @tiptap/core version as the editor and avoid a
|
||||||
// duplicate-tiptap version split in the monorepo. Reads a single declaration
|
// duplicate-tiptap version split in the monorepo. Reads a single declaration
|
||||||
@@ -1126,3 +1126,10 @@ export const docmostExtensions = [
|
|||||||
PageBreak,
|
PageBreak,
|
||||||
DocmostAttributes,
|
DocmostAttributes,
|
||||||
];
|
];
|
||||||
|
/**
|
||||||
|
* The ProseMirror schema for the docmost editor, built ONCE from
|
||||||
|
* `docmostExtensions`. Pure and reused by every consumer (diff, collaboration
|
||||||
|
* write-back) so the schema can never drift between call sites — it lives next
|
||||||
|
* to the extension list it is derived from.
|
||||||
|
*/
|
||||||
|
export const docmostSchema = getSchema(docmostExtensions);
|
||||||
|
|||||||
1
packages/mcp/node_modules/y-prosemirror
generated
vendored
Symbolic link
1
packages/mcp/node_modules/y-prosemirror
generated
vendored
Symbolic link
@@ -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
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2995,9 +2995,9 @@ export class DocmostClient {
|
|||||||
const raw = await this.getPageRaw(pageId);
|
const raw = await this.getPageRaw(pageId);
|
||||||
const current = raw.content || { type: "doc", content: [] };
|
const current = raw.content || { type: "doc", content: [] };
|
||||||
runTransform(current);
|
runTransform(current);
|
||||||
// Exercise the same Yjs encoder the apply path uses, so the preview
|
// Run an independent Yjs-encodability check (same sanitize + schema as the
|
||||||
// fails with the SAME descriptive error when the doc is not encodable
|
// apply path), so the preview fails with the same descriptive error when
|
||||||
// instead of returning a misleadingly-green diff.
|
// the doc is not encodable instead of returning a misleadingly-green diff.
|
||||||
assertYjsEncodable(newDoc);
|
assertYjsEncodable(newDoc);
|
||||||
return {
|
return {
|
||||||
pushed: false,
|
pushed: false,
|
||||||
|
|||||||
@@ -4,12 +4,29 @@ 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 { 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, docmostSchema } 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
|
||||||
|
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
|
||||||
|
* `applyDocToFragment` -> `updateYFragment`) so the message wording stays in one
|
||||||
|
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
|
||||||
|
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
|
||||||
|
* `findUnstorableAttr`.
|
||||||
|
*/
|
||||||
|
function unstorableYjsError(safe: any, label: string, e: unknown): Error {
|
||||||
|
const bad = findUnstorableAttr(safe);
|
||||||
|
return new Error(
|
||||||
|
`Failed to encode document to Yjs (${label}): ${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)."}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -499,20 +516,73 @@ export function buildYDoc(doc: any): Y.Doc {
|
|||||||
try {
|
try {
|
||||||
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
|
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const bad = findUnstorableAttr(safe);
|
throw unstorableYjsError(safe, "toYdoc", e);
|
||||||
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)."}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc.
|
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
|
||||||
* Throws the same descriptive error as the apply path when it is not. Used by
|
* preserving the Yjs identity of unchanged nodes (issue #152).
|
||||||
* the dry-run preview so it fails identically to apply.
|
*
|
||||||
|
* 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");
|
||||||
|
// Hydrate the ProseMirror node in its OWN try so a failure here (e.g. an
|
||||||
|
// unknown node type) is labelled "fromJSON" — the stage that actually threw —
|
||||||
|
// instead of being misattributed to the Yjs write stage (#154 review).
|
||||||
|
let pmNode: PMNode;
|
||||||
|
try {
|
||||||
|
pmNode = PMNode.fromJSON(docmostSchema, safe);
|
||||||
|
} catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "fromJSON", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ydoc.transact(() => {
|
||||||
|
updateYFragment(ydoc, fragment, pmNode, {
|
||||||
|
mapping: new Map(),
|
||||||
|
isOMark: new Map(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "updateYFragment", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an independent Yjs-encodability check (the same `sanitizeForYjs` + schema
|
||||||
|
* the apply path uses) and throw the same descriptive error when the doc cannot
|
||||||
|
* be stored. Used by the dry-run preview.
|
||||||
|
*
|
||||||
|
* Note: it does NOT run `updateYFragment` against the live fragment, so it is an
|
||||||
|
* encodability GATE, not a byte-for-byte rehearsal of apply — `buildYDoc`
|
||||||
|
* (`toYdoc`) and `applyDocToFragment` (`updateYFragment`) are two different
|
||||||
|
* encoders that nonetheless reject the same unstorable attributes. To narrow the
|
||||||
|
* preview/apply gap it ALSO rehearses the apply path's `PMNode.fromJSON`
|
||||||
|
* hydration, so a doc that would only fail there (e.g. an unknown node type) is
|
||||||
|
* rejected at preview time too (#154 review). Still cheap: no live fragment, no
|
||||||
|
* `updateYFragment`.
|
||||||
*/
|
*/
|
||||||
export function assertYjsEncodable(doc: any): void {
|
export function assertYjsEncodable(doc: any): void {
|
||||||
buildYDoc(doc);
|
buildYDoc(doc);
|
||||||
|
const safe = sanitizeForYjs(doc);
|
||||||
|
try {
|
||||||
|
PMNode.fromJSON(docmostSchema, safe);
|
||||||
|
} catch (e) {
|
||||||
|
throw unstorableYjsError(safe, "fromJSON", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Time we wait for the initial handshake/sync before giving up. */
|
/** Time we wait for the initial handshake/sync before giving up. */
|
||||||
@@ -727,16 +797,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.
|
||||||
|
|||||||
@@ -17,11 +17,10 @@
|
|||||||
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSchema } from "@tiptap/core";
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
|
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
|
||||||
import { docmostExtensions } from "./docmost-schema.js";
|
import { docmostSchema } from "./docmost-schema.js";
|
||||||
|
|
||||||
/** A single inserted/deleted change with its containing-block context. */
|
/** A single inserted/deleted change with its containing-block context. */
|
||||||
export interface DiffChange {
|
export interface DiffChange {
|
||||||
@@ -49,8 +48,6 @@ export interface DiffResult {
|
|||||||
markdown: string;
|
markdown: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the schema once; it is pure and reused across calls. */
|
|
||||||
const schema = getSchema(docmostExtensions);
|
|
||||||
|
|
||||||
/** Recursively concatenate the plain text of a JSON node. */
|
/** Recursively concatenate the plain text of a JSON node. */
|
||||||
function plainText(node: any): string {
|
function plainText(node: any): string {
|
||||||
@@ -288,8 +285,8 @@ export function diffDocs(
|
|||||||
const changedBlocks = new Set<string>();
|
const changedBlocks = new Set<string>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oldNode = Node.fromJSON(schema, oldDocJson);
|
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||||
const newNode = Node.fromJSON(schema, newDocJson);
|
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||||
const tr = recreateTransform(oldNode, newNode, {
|
const tr = recreateTransform(oldNode, newNode, {
|
||||||
complexSteps: false,
|
complexSteps: false,
|
||||||
wordDiffs: true,
|
wordDiffs: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import TaskItem from "@tiptap/extension-task-item";
|
|||||||
import Highlight from "@tiptap/extension-highlight";
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import Subscript from "@tiptap/extension-subscript";
|
import Subscript from "@tiptap/extension-subscript";
|
||||||
import Superscript from "@tiptap/extension-superscript";
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
import { Node, Extension, Mark } from "@tiptap/core";
|
import { Node, Extension, Mark, getSchema } from "@tiptap/core";
|
||||||
|
|
||||||
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this
|
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this
|
||||||
// package can stay on the same @tiptap/core version as the editor and avoid a
|
// package can stay on the same @tiptap/core version as the editor and avoid a
|
||||||
@@ -1223,3 +1223,11 @@ export const docmostExtensions = [
|
|||||||
PageBreak,
|
PageBreak,
|
||||||
DocmostAttributes,
|
DocmostAttributes,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ProseMirror schema for the docmost editor, built ONCE from
|
||||||
|
* `docmostExtensions`. Pure and reused by every consumer (diff, collaboration
|
||||||
|
* write-back) so the schema can never drift between call sites — it lives next
|
||||||
|
* to the extension list it is derived from.
|
||||||
|
*/
|
||||||
|
export const docmostSchema = getSchema(docmostExtensions);
|
||||||
|
|||||||
164
packages/mcp/test/unit/comment-cursor-stability.test.mjs
Normal file
164
packages/mcp/test/unit/comment-cursor-stability.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import {
|
||||||
|
applyDocToFragment,
|
||||||
|
assertYjsEncodable,
|
||||||
|
} 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The diagnostic catch branch of applyDocToFragment (#154 review): a doc that
|
||||||
|
// cannot be hydrated/encoded must be re-thrown wrapped with the stage label, not
|
||||||
|
// leak the raw ProseMirror/Yjs error. An unknown node type makes
|
||||||
|
// PMNode.fromJSON (against the docmost schema) throw — a reliable trigger
|
||||||
|
// (sanitizeForYjs only strips `undefined`, so an undefined attr would be removed
|
||||||
|
// before it could fail). The hydration now has its OWN try, so the label is the
|
||||||
|
// accurate stage `fromJSON` (the earlier `updateYFragment` label was misleading).
|
||||||
|
test("applyDocToFragment wraps a hydration failure with the (fromJSON) diagnostic", () => {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const bad = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "totally_unknown_node_xyz_12345" }],
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => applyDocToFragment(ydoc, bad),
|
||||||
|
/Failed to encode document to Yjs \(fromJSON\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #154 review (suggestion 2): structural-diff edge cases the cursor-survival
|
||||||
|
// path must handle without losing the unchanged node's id or throwing.
|
||||||
|
|
||||||
|
test("deleting a NEIGHBOUR keeps the unchanged node's cursor anchor (diff path)", () => {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
applyDocToFragment(ydoc, doc(para("Keep me"), para("Delete me")));
|
||||||
|
|
||||||
|
// Anchor inside the first paragraph, which survives the deletion unchanged.
|
||||||
|
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 4);
|
||||||
|
|
||||||
|
// Remove the second paragraph entirely; the first must keep its Yjs identity.
|
||||||
|
applyDocToFragment(ydoc, doc(para("Keep me")));
|
||||||
|
|
||||||
|
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
||||||
|
assert.notEqual(abs, null, "the surviving node's cursor anchor must still resolve");
|
||||||
|
assert.equal(abs.index, 4, "the cursor must stay at the same offset");
|
||||||
|
assert.equal(ydoc.getXmlFragment("default").length, 1, "neighbour was deleted");
|
||||||
|
assert.equal(paragraphText(ydoc, 0).toString(), "Keep me");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writing an EMPTY document clears the fragment without throwing", () => {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
applyDocToFragment(ydoc, doc(para("Something"), para("Else")));
|
||||||
|
assert.equal(ydoc.getXmlFragment("default").length, 2);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
applyDocToFragment(ydoc, { type: "doc", content: [] }),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ydoc.getXmlFragment("default").length,
|
||||||
|
0,
|
||||||
|
"the fragment is emptied (doc -> empty)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing a top-level node TYPE diffs in place (paragraph -> heading)", () => {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
applyDocToFragment(ydoc, doc(para("Title text"), para("Body")));
|
||||||
|
|
||||||
|
// Replace the first paragraph with a heading carrying the same text.
|
||||||
|
applyDocToFragment(
|
||||||
|
ydoc,
|
||||||
|
doc(
|
||||||
|
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Title text" }] },
|
||||||
|
para("Body"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = ydoc.getXmlFragment("default").get(0);
|
||||||
|
assert.equal(first.nodeName, "heading", "the top-level node type changed");
|
||||||
|
assert.equal(first.toString().replace(/<[^>]*>/g, ""), "Title text");
|
||||||
|
});
|
||||||
|
|
||||||
|
// #154 review (suggestion B / architecture B): the dry-run gate now also
|
||||||
|
// rehearses PMNode.fromJSON, so a doc that fails ONLY in hydration (not in
|
||||||
|
// toYdoc) is rejected at preview time, with the accurate `fromJSON` label.
|
||||||
|
test("assertYjsEncodable rejects an un-hydratable doc at preview time (fromJSON gate)", () => {
|
||||||
|
const bad = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "totally_unknown_node_xyz_67890" }],
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => assertYjsEncodable(bad),
|
||||||
|
/Failed to encode document to Yjs/,
|
||||||
|
);
|
||||||
|
});
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user