feat(agent-tools): suggestedText on create_comment with strict anchor uniqueness (#315 phase 6)
Agents can attach a suggested replacement when creating an inline comment, via both the MCP create_comment tool and the AI-chat createComment tool. Because applying a suggestion edits the EXACT anchored text, an ambiguous anchor would let Apply corrupt the wrong occurrence. So when suggestedText is set the selection must occur EXACTLY ONCE: - new countAnchorMatches(doc, selection) counts occurrences across all blocks (same normalization/traversal as canAnchorInDoc), counting occurrences (2 in one block => 2) — stricter than block-count, never under-counting distinct occurrences (false-unique is the dangerous direction). - client.createComment gains suggestedText: a pre-check (getPageJson + countAnchorMatches: 0 => not-found, >=2 => ambiguity error) before create, and an AUTHORITATIVE live check inside the anchoring mutation that recomputes on the live doc and, if != 1, aborts and rolls back the just-created comment (reusing the existing safeDeleteComment "anchor not found" path). Ordinary comments keep first-occurrence behavior unchanged. - suggestedText is rejected on a reply or without selection in all three layers (MCP handler, MCP client, AI-chat tool), mirroring the server DTO/service. - filterComment surfaces suggestedText/suggestionAppliedAt/suggestionAppliedById. - DocmostClientLike.createComment signature updated. MCP build/ rebuilt. Tests: countAnchorMatches (0/1/N, within/across/nested block, span nodes, quote normalization); createComment (ambiguous refused pre-create, reply and no-selection rejected, unique succeeds and forwards suggestedText, filterComment surfaces it); ai-chat schema accepts suggestedText. MCP 443 pass; ai-chat 601 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -518,6 +518,20 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('createComment: accepts an optional suggestedText alongside a selection', async () => {
|
||||
const tools = await buildTools();
|
||||
const result = await inputSchemaOf(tools.createComment).validate({
|
||||
pageId: '019efe44-0000-0000-0000-000000000000',
|
||||
content: 'A remark',
|
||||
selection: 'титановый проводник',
|
||||
suggestedText: 'медный проводник',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.value).toMatchObject({
|
||||
suggestedText: 'медный проводник',
|
||||
});
|
||||
});
|
||||
|
||||
it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => {
|
||||
const tools = await buildTools();
|
||||
const result = await inputSchemaOf(tools.getOutline).validate({});
|
||||
|
||||
@@ -450,8 +450,10 @@ export class AiChatToolsService {
|
||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. Reversible via the ' +
|
||||
'comment UI.',
|
||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||
'once in the page. Reversible via the comment UI.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
@@ -473,24 +475,57 @@ export class AiChatToolsService {
|
||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||
'of replies only).',
|
||||
),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||
'refused.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, content, selection, parentCommentId }) => {
|
||||
// createComment(pageId, content, type, selection?, parentCommentId?).
|
||||
// Top-level comments are inline and must carry a selection to anchor
|
||||
// on; replies inherit the parent's anchor (no selection). Throwing
|
||||
// here surfaces a tool error to the model (Vercel `ai` SDK) so the
|
||||
// agent retries with a better selection — do not catch/suppress it.
|
||||
execute: async ({
|
||||
pageId,
|
||||
content,
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
}) => {
|
||||
// createComment(pageId, content, type, selection?, parentCommentId?,
|
||||
// suggestedText?). Top-level comments are inline and must carry a
|
||||
// selection to anchor on; replies inherit the parent's anchor (no
|
||||
// selection). Throwing here surfaces a tool error to the model (Vercel
|
||||
// `ai` SDK) so the agent retries with a better selection — do not
|
||||
// catch/suppress it.
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await client.createComment(
|
||||
pageId,
|
||||
content,
|
||||
'inline',
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
);
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
return { commentId: data.id, pageId };
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface DocmostClientLike {
|
||||
type?: 'page' | 'inline',
|
||||
selection?: string,
|
||||
parentCommentId?: string,
|
||||
suggestedText?: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
resolveComment(
|
||||
commentId: string,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { withPageLock } from "./lib/page-lock.js";
|
||||
import { applyTextEdits, } from "./lib/json-edit.js";
|
||||
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
||||
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
|
||||
import { applyAnchorInDoc, canAnchorInDoc, countAnchorMatches, } from "./lib/comment-anchor.js";
|
||||
import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, canonicalizeFootnotes, insertInlineFootnote, } from "./lib/transforms.js";
|
||||
import vm from "node:vm";
|
||||
// Supported image types, kept as two lookup tables so both a local file
|
||||
@@ -1912,9 +1912,21 @@ export class DocmostClient {
|
||||
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
|
||||
* their parent's anchor: they take NO selection and are not anchored.
|
||||
*/
|
||||
async createComment(pageId, content, type = "page", selection, parentCommentId) {
|
||||
async createComment(pageId, content, type = "page", selection, parentCommentId, suggestedText) {
|
||||
await this.ensureAuthenticated();
|
||||
const isReply = !!parentCommentId;
|
||||
const hasSuggestion = suggestedText !== undefined && suggestedText !== null;
|
||||
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
|
||||
// the exact anchored text, so it is only meaningful on a top-level inline
|
||||
// comment that carries a selection.
|
||||
if (hasSuggestion) {
|
||||
if (isReply) {
|
||||
throw new Error("create_comment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.");
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error("create_comment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.");
|
||||
}
|
||||
}
|
||||
// Only top-level comments are inline-anchored, so they are stored as
|
||||
// "inline". Replies carry no inline selection, so they keep the historical
|
||||
// general ("page") type — both backward-compatible and semantically correct.
|
||||
@@ -1931,16 +1943,33 @@ export class DocmostClient {
|
||||
if (!isReply && selection) {
|
||||
try {
|
||||
const page = await this.getPageJson(pageId);
|
||||
if (!canAnchorInDoc(page.content, selection)) {
|
||||
if (hasSuggestion) {
|
||||
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
|
||||
// exact anchored text, and ordinary anchoring silently takes the first
|
||||
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
|
||||
// rejected BEFORE creating the comment.
|
||||
const matches = countAnchorMatches(page.content, selection);
|
||||
if (matches === 0) {
|
||||
throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).");
|
||||
}
|
||||
if (matches >= 2) {
|
||||
throw new Error(`create_comment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
|
||||
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
|
||||
"(still <=250 chars) so it appears exactly once.");
|
||||
}
|
||||
}
|
||||
else if (!canAnchorInDoc(page.content, selection)) {
|
||||
throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).");
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Rethrow our own "not found" error; swallow read/network errors so the
|
||||
// live anchor step can still try (and enforce) the anchoring.
|
||||
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
|
||||
// errors so the live anchor step can still try (and enforce) anchoring.
|
||||
if (e instanceof Error &&
|
||||
e.message.startsWith("create_comment: could not find the selection")) {
|
||||
(e.message.startsWith("create_comment: could not find the selection") ||
|
||||
e.message.startsWith("create_comment: the suggestion's selection is ambiguous"))) {
|
||||
throw e;
|
||||
}
|
||||
if (process.env.DEBUG) {
|
||||
@@ -1962,6 +1991,10 @@ export class DocmostClient {
|
||||
payload.selection = selection;
|
||||
if (parentCommentId)
|
||||
payload.parentCommentId = parentCommentId;
|
||||
// Only a top-level inline comment (with a selection) may carry a suggestion.
|
||||
if (!isReply && selection && hasSuggestion) {
|
||||
payload.suggestedText = suggestedText;
|
||||
}
|
||||
const response = await this.client.post("/comments/create", payload);
|
||||
const comment = response.data.data || response.data;
|
||||
const markdown = comment.content
|
||||
@@ -1988,15 +2021,34 @@ export class DocmostClient {
|
||||
throw new Error("create_comment: the server returned no comment id, so the comment could not be anchored");
|
||||
}
|
||||
let anchored = false;
|
||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||
let ambiguousInLiveDoc = false;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// /comments/create REST call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
// Route through the mutatePage seam (not the free function) so this
|
||||
// wrapper's uniqueness gate + rollback can be unit-tested without a live
|
||||
// Hocuspocus collab socket.
|
||||
const mutation = await this.mutatePage(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (hasSuggestion) {
|
||||
// Authoritative uniqueness check against the LIVE document: a
|
||||
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
|
||||
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
|
||||
// no longer has exactly one occurrence (it changed since the
|
||||
// pre-check), abort so the just-created comment is rolled back
|
||||
// rather than mis-anchored to the first occurrence.
|
||||
const liveCount = countAnchorMatches(doc, selection);
|
||||
if (liveCount !== 1) {
|
||||
ambiguousInLiveDoc = liveCount >= 2;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (applyAnchorInDoc(doc, selection, newCommentId)) {
|
||||
anchored = true;
|
||||
return doc;
|
||||
@@ -2014,10 +2066,13 @@ export class DocmostClient {
|
||||
throw e;
|
||||
}
|
||||
if (!anchored) {
|
||||
// Mutation aborted because the selection was not found in the live
|
||||
// document. Roll back the comment and surface a hard error.
|
||||
// Mutation aborted because the selection was not found (or, for a
|
||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||
// and surface a hard error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
throw new Error("create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
|
||||
throw new Error(ambiguousInLiveDoc
|
||||
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
|
||||
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
|
||||
}
|
||||
result.anchored = true;
|
||||
return result;
|
||||
|
||||
@@ -520,7 +520,10 @@ export function createDocmostMcpServer(config) {
|
||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
||||
"selection.",
|
||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
||||
"page — expand it with surrounding context if it is ambiguous.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page to comment on"),
|
||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
||||
@@ -537,12 +540,30 @@ export function createDocmostMcpServer(config) {
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe("Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
||||
"<=250 chars) if it occurs more than once, or the call is refused."),
|
||||
},
|
||||
}, async ({ pageId, content, selection, parentCommentId }) => {
|
||||
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error("create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.");
|
||||
}
|
||||
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId);
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error("create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.");
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error("create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.");
|
||||
}
|
||||
}
|
||||
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId, suggestedText);
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: update_comment
|
||||
|
||||
@@ -210,6 +210,77 @@ function spliceCommentMark(blockContent, match, commentId) {
|
||||
}
|
||||
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
|
||||
}
|
||||
/**
|
||||
* Count how many times `selection` occurs across the whole document, using the
|
||||
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
|
||||
* at the first hit: every non-overlapping occurrence within each block's text
|
||||
* runs is counted and summed across all blocks (depth-first, the same traversal
|
||||
* as canAnchorInDoc).
|
||||
*
|
||||
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
|
||||
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
|
||||
* silently edit the wrong place, so a suggestion is only allowed when this
|
||||
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
|
||||
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
||||
* occurrences inside one block are correctly reported as 2.)
|
||||
*/
|
||||
export function countAnchorMatches(doc, selection) {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0)
|
||||
return 0;
|
||||
// Count non-overlapping occurrences of the normalized selection within a
|
||||
// single block's direct content, matching findAnchorInBlock's run building.
|
||||
const countInBlock = (blockContent) => {
|
||||
if (!Array.isArray(blockContent))
|
||||
return 0;
|
||||
let count = 0;
|
||||
let i = 0;
|
||||
while (i < blockContent.length) {
|
||||
const node = blockContent[i];
|
||||
if (!node || typeof node !== "object" || node.type !== "text") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Accumulate a maximal run of consecutive text nodes.
|
||||
let rawRun = "";
|
||||
let j = i;
|
||||
while (j < blockContent.length) {
|
||||
const n = blockContent[j];
|
||||
if (!n || typeof n !== "object" || n.type !== "text")
|
||||
break;
|
||||
rawRun += typeof n.text === "string" ? n.text : "";
|
||||
j++;
|
||||
}
|
||||
const norm = normalizeForMatch(rawRun).norm;
|
||||
// Count every non-overlapping occurrence in this run.
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = norm.indexOf(normSel, from);
|
||||
if (idx === -1)
|
||||
break;
|
||||
count++;
|
||||
from = idx + normSel.length;
|
||||
}
|
||||
i = j > i ? j : i + 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
let total = 0;
|
||||
const visit = (node, depth) => {
|
||||
if (depth > MAX_DEPTH || !node || typeof node !== "object")
|
||||
return;
|
||||
if (!Array.isArray(node.content))
|
||||
return;
|
||||
total += countInBlock(node.content);
|
||||
for (const child of node.content) {
|
||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
||||
visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(doc, 0);
|
||||
return total;
|
||||
}
|
||||
/**
|
||||
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
||||
* whose content matches `selection`, splice the comment mark across the matched
|
||||
|
||||
@@ -70,6 +70,11 @@ export function filterComment(comment, markdownContent) {
|
||||
editedAt: comment.editedAt || null,
|
||||
resolvedAt: comment.resolvedAt || null,
|
||||
resolvedById: comment.resolvedById || null,
|
||||
// Suggestion state: the proposed replacement text (if any) and, once a human
|
||||
// applies it via the UI, when and by whom.
|
||||
suggestedText: comment.suggestedText || null,
|
||||
suggestionAppliedAt: comment.suggestionAppliedAt || null,
|
||||
suggestionAppliedById: comment.suggestionAppliedById || null,
|
||||
};
|
||||
}
|
||||
export function filterSearchResult(result) {
|
||||
|
||||
@@ -56,7 +56,11 @@ import {
|
||||
} from "./lib/json-edit.js";
|
||||
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
||||
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
|
||||
import {
|
||||
applyAnchorInDoc,
|
||||
canAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
} from "./lib/comment-anchor.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
@@ -2395,10 +2399,28 @@ export class DocmostClient {
|
||||
type: "page" | "inline" = "page",
|
||||
selection?: string,
|
||||
parentCommentId?: string,
|
||||
suggestedText?: string,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const isReply = !!parentCommentId;
|
||||
const hasSuggestion =
|
||||
suggestedText !== undefined && suggestedText !== null;
|
||||
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
|
||||
// the exact anchored text, so it is only meaningful on a top-level inline
|
||||
// comment that carries a selection.
|
||||
if (hasSuggestion) {
|
||||
if (isReply) {
|
||||
throw new Error(
|
||||
"create_comment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"create_comment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Only top-level comments are inline-anchored, so they are stored as
|
||||
// "inline". Replies carry no inline selection, so they keep the historical
|
||||
// general ("page") type — both backward-compatible and semantically correct.
|
||||
@@ -2418,18 +2440,40 @@ export class DocmostClient {
|
||||
if (!isReply && selection) {
|
||||
try {
|
||||
const page = await this.getPageJson(pageId);
|
||||
if (!canAnchorInDoc(page.content, selection)) {
|
||||
if (hasSuggestion) {
|
||||
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
|
||||
// exact anchored text, and ordinary anchoring silently takes the first
|
||||
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
|
||||
// rejected BEFORE creating the comment.
|
||||
const matches = countAnchorMatches(page.content, selection);
|
||||
if (matches === 0) {
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
if (matches >= 2) {
|
||||
throw new Error(
|
||||
`create_comment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
|
||||
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
|
||||
"(still <=250 chars) so it appears exactly once.",
|
||||
);
|
||||
}
|
||||
} else if (!canAnchorInDoc(page.content, selection)) {
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Rethrow our own "not found" error; swallow read/network errors so the
|
||||
// live anchor step can still try (and enforce) the anchoring.
|
||||
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
|
||||
// errors so the live anchor step can still try (and enforce) anchoring.
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message.startsWith("create_comment: could not find the selection")
|
||||
(e.message.startsWith("create_comment: could not find the selection") ||
|
||||
e.message.startsWith(
|
||||
"create_comment: the suggestion's selection is ambiguous",
|
||||
))
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
@@ -2454,6 +2498,10 @@ export class DocmostClient {
|
||||
};
|
||||
if (!isReply && selection) payload.selection = selection;
|
||||
if (parentCommentId) payload.parentCommentId = parentCommentId;
|
||||
// Only a top-level inline comment (with a selection) may carry a suggestion.
|
||||
if (!isReply && selection && hasSuggestion) {
|
||||
payload.suggestedText = suggestedText;
|
||||
}
|
||||
|
||||
const response = await this.client.post("/comments/create", payload);
|
||||
const comment = response.data.data || response.data;
|
||||
@@ -2485,12 +2533,18 @@ export class DocmostClient {
|
||||
);
|
||||
}
|
||||
let anchored = false;
|
||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||
let ambiguousInLiveDoc = false;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// /comments/create REST call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
// Route through the mutatePage seam (not the free function) so this
|
||||
// wrapper's uniqueness gate + rollback can be unit-tested without a live
|
||||
// Hocuspocus collab socket.
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -2499,6 +2553,19 @@ export class DocmostClient {
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (hasSuggestion) {
|
||||
// Authoritative uniqueness check against the LIVE document: a
|
||||
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
|
||||
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
|
||||
// no longer has exactly one occurrence (it changed since the
|
||||
// pre-check), abort so the just-created comment is rolled back
|
||||
// rather than mis-anchored to the first occurrence.
|
||||
const liveCount = countAnchorMatches(doc, selection as string);
|
||||
if (liveCount !== 1) {
|
||||
ambiguousInLiveDoc = liveCount >= 2;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
||||
anchored = true;
|
||||
return doc;
|
||||
@@ -2517,11 +2584,14 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
if (!anchored) {
|
||||
// Mutation aborted because the selection was not found in the live
|
||||
// document. Roll back the comment and surface a hard error.
|
||||
// Mutation aborted because the selection was not found (or, for a
|
||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||
// and surface a hard error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
throw new Error(
|
||||
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
ambiguousInLiveDoc
|
||||
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
|
||||
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -722,7 +722,10 @@ server.registerTool(
|
||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
||||
"selection.",
|
||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
||||
"page — expand it with surrounding context if it is ambiguous.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page to comment on"),
|
||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
||||
@@ -741,20 +744,45 @@ server.registerTool(
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
||||
"<=250 chars) if it occurs more than once, or the call is refused.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ pageId, content, selection, parentCommentId }) => {
|
||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await docmostClient.createComment(
|
||||
pageId,
|
||||
content,
|
||||
"inline",
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
|
||||
@@ -242,6 +242,74 @@ function spliceCommentMark(
|
||||
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times `selection` occurs across the whole document, using the
|
||||
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
|
||||
* at the first hit: every non-overlapping occurrence within each block's text
|
||||
* runs is counted and summed across all blocks (depth-first, the same traversal
|
||||
* as canAnchorInDoc).
|
||||
*
|
||||
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
|
||||
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
|
||||
* silently edit the wrong place, so a suggestion is only allowed when this
|
||||
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
|
||||
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
||||
* occurrences inside one block are correctly reported as 2.)
|
||||
*/
|
||||
export function countAnchorMatches(doc: any, selection: string): number {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0) return 0;
|
||||
|
||||
// Count non-overlapping occurrences of the normalized selection within a
|
||||
// single block's direct content, matching findAnchorInBlock's run building.
|
||||
const countInBlock = (blockContent: any[]): number => {
|
||||
if (!Array.isArray(blockContent)) return 0;
|
||||
let count = 0;
|
||||
let i = 0;
|
||||
while (i < blockContent.length) {
|
||||
const node = blockContent[i];
|
||||
if (!node || typeof node !== "object" || node.type !== "text") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Accumulate a maximal run of consecutive text nodes.
|
||||
let rawRun = "";
|
||||
let j = i;
|
||||
while (j < blockContent.length) {
|
||||
const n = blockContent[j];
|
||||
if (!n || typeof n !== "object" || n.type !== "text") break;
|
||||
rawRun += typeof n.text === "string" ? n.text : "";
|
||||
j++;
|
||||
}
|
||||
const norm = normalizeForMatch(rawRun).norm;
|
||||
// Count every non-overlapping occurrence in this run.
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = norm.indexOf(normSel, from);
|
||||
if (idx === -1) break;
|
||||
count++;
|
||||
from = idx + normSel.length;
|
||||
}
|
||||
i = j > i ? j : i + 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
const visit = (node: any, depth: number): void => {
|
||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return;
|
||||
if (!Array.isArray(node.content)) return;
|
||||
total += countInBlock(node.content);
|
||||
for (const child of node.content) {
|
||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
||||
visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(doc, 0);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
||||
* whose content matches `selection`, splice the comment mark across the matched
|
||||
|
||||
@@ -75,6 +75,11 @@ export function filterComment(comment: any, markdownContent?: string) {
|
||||
editedAt: comment.editedAt || null,
|
||||
resolvedAt: comment.resolvedAt || null,
|
||||
resolvedById: comment.resolvedById || null,
|
||||
// Suggestion state: the proposed replacement text (if any) and, once a human
|
||||
// applies it via the UI, when and by whom.
|
||||
suggestedText: comment.suggestedText || null,
|
||||
suggestionAppliedAt: comment.suggestionAppliedAt || null,
|
||||
suggestionAppliedById: comment.suggestionAppliedById || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -229,3 +229,226 @@ test("a reply creates without selection or anchoring and is stored as type 'page
|
||||
"a reply must skip the pre-check/anchoring (no /pages/info read)",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4) suggestedText + a DUPLICATE selection is refused BEFORE creating anything:
|
||||
// a suggestion must anchor to a unique location, so >=2 occurrences throws the
|
||||
// ambiguity error (the /pages/info pre-check short-circuits before create).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestedText with an ambiguous selection is refused before creating", async () => {
|
||||
let createCalls = 0;
|
||||
let infoCalls = 0;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
infoCalls++;
|
||||
// "target" appears in two blocks -> ambiguous for a suggestion.
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "page-1",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "first target here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "second target here" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createCalls++;
|
||||
sendJson(res, 200, { data: { id: "should-not-happen" } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.createComment(
|
||||
"page-1",
|
||||
"body",
|
||||
"inline",
|
||||
"target",
|
||||
undefined,
|
||||
"TARGET",
|
||||
),
|
||||
/ambiguous/i,
|
||||
"an ambiguous suggestion selection must reject with the ambiguity error",
|
||||
);
|
||||
assert.ok(infoCalls >= 1, "the pre-check must read the page via /pages/info");
|
||||
assert.equal(
|
||||
createCalls,
|
||||
0,
|
||||
"/comments/create must NEVER be called for an ambiguous suggestion",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5) suggestedText on a reply is refused immediately (before any HTTP).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestedText on a reply is rejected", async () => {
|
||||
let anyCall = 0;
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
anyCall++;
|
||||
sendJson(res, 200, { data: { id: "x" } });
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.createComment(
|
||||
"page-1",
|
||||
"body",
|
||||
"inline",
|
||||
undefined,
|
||||
"parent-1",
|
||||
"replacement",
|
||||
),
|
||||
/reply/i,
|
||||
"suggestedText on a reply must be rejected",
|
||||
);
|
||||
assert.equal(anyCall, 0, "no create/info call for a rejected reply suggestion");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6) suggestedText without a selection is refused immediately.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestedText without a selection is rejected", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { data: { id: "x" } });
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.createComment(
|
||||
"page-1",
|
||||
"body",
|
||||
"inline",
|
||||
undefined,
|
||||
undefined,
|
||||
"replacement",
|
||||
),
|
||||
/selection/i,
|
||||
"suggestedText without a selection must be rejected",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 7) suggestedText + a UNIQUE selection succeeds: the pre-check passes, the
|
||||
// create payload carries suggestedText, and the live anchoring step (stubbed
|
||||
// via the mutatePage seam) writes the comment mark exactly once.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestedText with a unique selection succeeds and forwards the payload", async () => {
|
||||
let createPayload = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// "brave" is unique in the page.
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createPayload = JSON.parse(raw);
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "cmt-ok-1",
|
||||
content: createPayload.content,
|
||||
selection: createPayload.selection,
|
||||
suggestedText: createPayload.suggestedText,
|
||||
type: createPayload.type,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
// Subclass to stub the collab write seam: no live Hocuspocus socket, but the
|
||||
// wrapper's uniqueness gate + applyAnchorInDoc still run against `doc`.
|
||||
class TestClient extends DocmostClient {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId(pageId) {
|
||||
return "11111111-1111-1111-1111-111111111111";
|
||||
}
|
||||
async mutatePage(pageId, collabToken, apiUrl, transform) {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
};
|
||||
const out = transform(doc);
|
||||
return { doc: out, verify: { ok: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.createComment(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"please rename",
|
||||
"inline",
|
||||
"brave",
|
||||
undefined,
|
||||
"bold",
|
||||
);
|
||||
|
||||
assert.equal(result.success, true, "a unique suggestion must resolve");
|
||||
assert.equal(result.anchored, true, "the comment must be anchored");
|
||||
assert.ok(createPayload, "/comments/create must have been called");
|
||||
assert.equal(
|
||||
createPayload.suggestedText,
|
||||
"bold",
|
||||
"the create payload must carry suggestedText for a top-level inline comment",
|
||||
);
|
||||
assert.equal(createPayload.selection, "brave");
|
||||
assert.equal(result.data.suggestedText, "bold", "filterComment surfaces suggestedText");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findAnchorInBlock,
|
||||
canAnchorInDoc,
|
||||
applyAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
} from "../../build/lib/comment-anchor.js";
|
||||
|
||||
const COMMENT_ID = "cmt-123";
|
||||
@@ -208,3 +209,68 @@ test("anchoring works inside a nested block (e.g. list item) via DFS recursion",
|
||||
assert.equal(marked.length, 1);
|
||||
assert.equal(marked[0].text, "target");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// countAnchorMatches — the uniqueness gate for suggestions. Counts every
|
||||
// non-overlapping occurrence across the whole document (0 / 1 / N).
|
||||
// ---------------------------------------------------------------------------
|
||||
test("countAnchorMatches returns 0 when the selection is absent", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
|
||||
assert.equal(countAnchorMatches(doc, "missing"), 0);
|
||||
});
|
||||
|
||||
test("countAnchorMatches returns 1 for a unique selection", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "Hello brave world" }]);
|
||||
assert.equal(countAnchorMatches(doc, "brave"), 1);
|
||||
});
|
||||
|
||||
test("countAnchorMatches counts multiple occurrences within one block", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "ab ab ab" }]);
|
||||
assert.equal(countAnchorMatches(doc, "ab"), 3);
|
||||
});
|
||||
|
||||
test("countAnchorMatches sums occurrences across separate blocks", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "first target here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "second target here" }] },
|
||||
],
|
||||
};
|
||||
assert.equal(countAnchorMatches(doc, "target"), 2);
|
||||
});
|
||||
|
||||
test("countAnchorMatches counts a match spanning adjacent text nodes as one", () => {
|
||||
const doc = paragraphDoc([
|
||||
{ type: "text", text: "запуска ", marks: [{ type: "italic" }] },
|
||||
{ type: "text", text: "перед блоком", marks: [{ type: "italic" }] },
|
||||
]);
|
||||
assert.equal(countAnchorMatches(doc, "запуска перед"), 1);
|
||||
});
|
||||
|
||||
test("countAnchorMatches counts matches inside nested (recursed) blocks", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "outer target" }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "nested target" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(countAnchorMatches(doc, "target"), 2);
|
||||
});
|
||||
|
||||
test("countAnchorMatches applies the same normalization as anchoring", () => {
|
||||
// Smart quotes in the doc match ASCII quotes in the selection.
|
||||
const doc = paragraphDoc([{ type: "text", text: "say “hi” now" }]);
|
||||
assert.equal(countAnchorMatches(doc, '"hi"'), 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user