fix(mcp): resolved-якоря переживают полный markdown-write

Read-путь прячет resolved comment-анкоры (#337), поэтому markdown, который
агент шлёт в updatePageMarkdown, их уже не содержит — наивный full-write
стирал ВСЕ resolved comment-марки (потеря данных). Активные комментарии
переживают round-trip сами (read отдаёт их <span data-comment-id>), а
resolved — нет.

Правка: в write-пути (updatePageContentRealtime) пере-прививаем resolved-
марки из ЖИВОГО дока на совпадающие текстовые диапазоны свежеимпортированного
тела, механикой анкоринга comment-anchor. spliceCommentMark обобщён на
произвольную марку; добавлены applyCommentMarkInDoc (сохраняет resolved:true
+ attrs), collectResolvedCommentSpans и regraftResolvedComments (чистая, не
мутирует входы). Спан, чей текст агент изменил/удалил, просто не
переанкорится и отбрасывается (он и так resolved). first-occurrence-семантика
как у остального анкоринга.

Проверено: 6 новых наблюдаемых тестов + весь MCP unit-suite (691) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 18:19:10 +03:00
parent 047433595e
commit a09935aa29
3 changed files with 227 additions and 4 deletions
+8 -1
View File
@@ -23,6 +23,7 @@ import {
} from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import { regraftResolvedComments } from "./comment-anchor.js";
import { VerifyReport } from "./diff.js";
import { acquireCollabSession } from "./collab-session.js";
@@ -338,6 +339,12 @@ export async function updatePageContentRealtime(
pageId,
collabToken,
baseUrl,
() => tiptapJson,
// #493: an agent read HIDES resolved-comment anchors (#337), so the markdown
// it sends here no longer carries them — a naive full rewrite would erase
// every resolved comment mark. Re-graft the resolved marks from the LIVE doc
// onto the matching text in the freshly-imported body. Active comments are
// untouched (they ride through the markdown themselves); a resolved span whose
// text the agent changed simply does not re-anchor and is dropped.
(liveDoc) => regraftResolvedComments(liveDoc, tiptapJson),
);
}
+112 -3
View File
@@ -312,10 +312,9 @@ export function canAnchorInDoc(doc: any, selection: string): boolean {
function spliceCommentMark(
blockContent: any[],
match: AnchorMatch,
commentId: string,
commentMark: any,
): void {
const { startChild, startOffset, endChild, endOffset } = match;
const commentMark = makeCommentMark(commentId);
const fragments: any[] = [];
for (let k = startChild; k <= endChild; k++) {
@@ -451,6 +450,22 @@ export function applyAnchorInDoc(
doc: any,
selection: string,
commentId: string,
): boolean {
return applyCommentMarkInDoc(doc, selection, makeCommentMark(commentId));
}
/**
* Core of {@link applyAnchorInDoc}, but splices an ARBITRARY comment mark object
* (not just a fresh `{ commentId, resolved:false }`) across the first matching
* range. This lets a caller re-apply a mark that carries `resolved:true` and any
* other stored attrs. Depth-first (same order as canAnchorInDoc); mutates in
* place on the first matching block and returns true, else returns false without
* mutating.
*/
export function applyCommentMarkInDoc(
doc: any,
selection: string,
commentMark: any,
): boolean {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return false;
@@ -459,7 +474,7 @@ export function applyAnchorInDoc(
if (!Array.isArray(node.content)) return false;
const match = findAnchorInBlock(node.content, effective);
if (match) {
spliceCommentMark(node.content, match, commentId);
spliceCommentMark(node.content, match, commentMark);
return true;
}
for (const child of node.content) {
@@ -471,3 +486,97 @@ export function applyAnchorInDoc(
};
return visit(doc, 0);
}
/** A resolved inline-comment span lifted from a doc: its mark + anchored text. */
export interface ResolvedCommentSpan {
commentId: string;
/** The full comment mark (carrying `resolved:true` + any stored attrs). */
mark: any;
/** The concatenated raw text the mark spans — used as the re-anchor selection. */
text: string;
}
/** True when a text node carries a RESOLVED comment mark; returns that mark. */
function resolvedCommentMarkOf(node: any): any | null {
if (!node || node.type !== "text" || !Array.isArray(node.marks)) return null;
return (
node.marks.find(
(m: any) =>
m && m.type === "comment" && m.attrs?.resolved === true && m.attrs?.commentId,
) || null
);
}
/**
* Collect every RESOLVED inline-comment span in `doc`, in document order. Within
* each block's direct content, a maximal run of consecutive text nodes sharing
* the same resolved `commentId` is ONE span; its concatenated raw text is the
* selection used to re-anchor it elsewhere. Active (unresolved) comment marks are
* ignored — they survive a markdown round-trip on their own (a page read emits
* their `<span data-comment-id>` wrapper), whereas resolved anchors are hidden
* from agent reads (#337) and would be erased by a full-body markdown rewrite.
*/
export function collectResolvedCommentSpans(doc: any): ResolvedCommentSpan[] {
const spans: ResolvedCommentSpan[] = [];
const visit = (node: any, depth: number): void => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return;
if (!Array.isArray(node.content)) return;
const content = node.content;
let i = 0;
while (i < content.length) {
const mark = resolvedCommentMarkOf(content[i]);
if (mark) {
const commentId = mark.attrs.commentId;
let text = "";
let j = i;
while (j < content.length) {
const mj = resolvedCommentMarkOf(content[j]);
if (!mj || mj.attrs.commentId !== commentId) break;
text += typeof content[j].text === "string" ? content[j].text : "";
j++;
}
if (text.length > 0) spans.push({ commentId, mark, text });
i = j > i ? j : i + 1;
} else {
i++;
}
}
for (const child of content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
visit(child, depth + 1);
}
}
};
visit(doc, 0);
return spans;
}
/**
* Re-graft RESOLVED comment marks from `oldDoc` onto matching text ranges in
* `newDoc`, returning a NEW doc (never mutates the inputs).
*
* WHY (#493): an agent read hides resolved-comment anchors (#337), so the
* markdown it sends to a FULL-body rewrite (`updatePageMarkdown`) no longer
* carries them — a naive full write would erase every resolved comment mark.
* This restores them: each resolved span from the previous document is re-anchored
* onto the SAME text in the newly-imported body (first occurrence, using the
* shared anchoring / markdown-strip fallback), preserving `resolved:true` and the
* stored attrs. A span whose text the agent changed or deleted simply does not
* re-anchor and is dropped (its anchor is gone; it was already resolved). Active
* comments are untouched — they ride through the markdown themselves.
*/
export function regraftResolvedComments<T = any>(oldDoc: any, newDoc: T): T {
if (!newDoc || typeof newDoc !== "object") return newDoc;
const spans = collectResolvedCommentSpans(oldDoc);
if (spans.length === 0) return newDoc;
const out =
typeof structuredClone === "function"
? structuredClone(newDoc)
: (JSON.parse(JSON.stringify(newDoc)) as T);
for (const span of spans) {
// Clone the mark so the new document never shares a mark object with oldDoc.
const markClone = { type: "comment", attrs: { ...span.mark.attrs } };
applyCommentMarkInDoc(out, span.text, markClone);
}
return out;
}
@@ -0,0 +1,107 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectResolvedCommentSpans,
regraftResolvedComments,
applyCommentMarkInDoc,
} from "../../build/lib/comment-anchor.js";
/**
* #493 commit 6 — resolved-comment anchors must survive a full markdown rewrite
* (updatePageMarkdown). An agent read HIDES resolved anchors (#337), so its
* markdown drops them; a naive full write would erase the resolved comment marks.
* `regraftResolvedComments(oldDoc, newDoc)` re-anchors them onto the matching
* text. These exercise the real anchoring (no mock).
*/
const doc = (...content) => ({ type: "doc", content });
const para = (...content) => ({ type: "paragraph", content });
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
const resolvedComment = (commentId) => ({ type: "comment", attrs: { commentId, resolved: true } });
const activeComment = (commentId) => ({ type: "comment", attrs: { commentId, resolved: false } });
/** The comment mark on a text node, or null. */
function commentMarkOf(node) {
const marks = Array.isArray(node?.marks) ? node.marks : [];
return marks.find((m) => m && m.type === "comment") || null;
}
/** Flatten every text node in a doc (deep). */
function textNodes(node, out = []) {
if (!node || typeof node !== "object") return out;
if (node.type === "text") out.push(node);
if (Array.isArray(node.content)) for (const c of node.content) textNodes(c, out);
return out;
}
test("collectResolvedCommentSpans: only resolved marks, concatenated across a run", () => {
const old = doc(
para(
text("keep "),
text("resolved bit", [resolvedComment("r1")]),
text(" and "),
text("active bit", [activeComment("a1")]),
),
);
const spans = collectResolvedCommentSpans(old);
assert.equal(spans.length, 1);
assert.equal(spans[0].commentId, "r1");
assert.equal(spans[0].text, "resolved bit");
assert.equal(spans[0].mark.attrs.resolved, true);
});
test("regraft restores a resolved mark the agent's markdown dropped", () => {
// OLD doc has a resolved comment on "important note".
const old = doc(para(text("An "), text("important note", [resolvedComment("r1")]), text(" here.")));
// NEW doc (re-imported from the agent's markdown) has the SAME text but NO
// comment mark — the resolved anchor was hidden on read.
const fresh = doc(para(text("An important note here.")));
const out = regraftResolvedComments(old, fresh);
// Inputs are not mutated.
assert.equal(commentMarkOf(textNodes(fresh)[0]), null);
// The resolved mark is back on exactly "important note".
const marked = textNodes(out).filter((n) => commentMarkOf(n));
assert.equal(marked.length, 1);
assert.equal(marked[0].text, "important note");
assert.equal(commentMarkOf(marked[0]).attrs.commentId, "r1");
assert.equal(commentMarkOf(marked[0]).attrs.resolved, true);
});
test("a resolved span whose text the agent changed is dropped (no re-anchor)", () => {
const old = doc(para(text("stale text", [resolvedComment("r1")])));
const fresh = doc(para(text("completely rewritten body")));
const out = regraftResolvedComments(old, fresh);
assert.equal(textNodes(out).filter((n) => commentMarkOf(n)).length, 0);
});
test("regraft is a no-op when the old doc has no resolved comments", () => {
const old = doc(para(text("plain "), text("active", [activeComment("a1")])));
const fresh = doc(para(text("plain active")));
const out = regraftResolvedComments(old, fresh);
assert.equal(textNodes(out).filter((n) => commentMarkOf(n)).length, 0);
});
test("multiple distinct resolved comments are all restored", () => {
const old = doc(
para(text("first", [resolvedComment("r1")]), text(" middle "), text("second", [resolvedComment("r2")])),
);
const fresh = doc(para(text("first middle second")));
const out = regraftResolvedComments(old, fresh);
const byId = Object.fromEntries(
textNodes(out)
.filter((n) => commentMarkOf(n))
.map((n) => [commentMarkOf(n).attrs.commentId, n.text]),
);
assert.equal(byId["r1"], "first");
assert.equal(byId["r2"], "second");
});
test("applyCommentMarkInDoc preserves an arbitrary mark's attrs (resolved:true)", () => {
const d = doc(para(text("anchor me somewhere")));
const ok = applyCommentMarkInDoc(d, "anchor me", { type: "comment", attrs: { commentId: "x9", resolved: true } });
assert.equal(ok, true);
const marked = textNodes(d).filter((n) => commentMarkOf(n));
assert.equal(marked[0].text, "anchor me");
assert.equal(commentMarkOf(marked[0]).attrs.resolved, true);
});