diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index 33960a3f..53d38895 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -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({}); diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 91ed2efd..8444aebc 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -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 }; diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 63625fc3..7b5a9a4e 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -177,6 +177,7 @@ export interface DocmostClientLike { type?: 'page' | 'inline', selection?: string, parentCommentId?: string, + suggestedText?: string, ): Promise<{ data: Record; success: boolean }>; resolveComment( commentId: string, diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index fd144690..d1cdc528 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -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; diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js index 1ba52bba..360745e5 100644 --- a/packages/mcp/build/index.js +++ b/packages/mcp/build/index.js @@ -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 diff --git a/packages/mcp/build/lib/comment-anchor.js b/packages/mcp/build/lib/comment-anchor.js index 50e113b2..03a50931 100644 --- a/packages/mcp/build/lib/comment-anchor.js +++ b/packages/mcp/build/lib/comment-anchor.js @@ -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 diff --git a/packages/mcp/build/lib/filters.js b/packages/mcp/build/lib/filters.js index 63a6a55e..eb056968 100644 --- a/packages/mcp/build/lib/filters.js +++ b/packages/mcp/build/lib/filters.js @@ -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) { diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 14fd45ae..9e9eb421 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -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", ); } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 456e851b..4d0a9375 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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); }, diff --git a/packages/mcp/src/lib/comment-anchor.ts b/packages/mcp/src/lib/comment-anchor.ts index 79dbb469..7deb6620 100644 --- a/packages/mcp/src/lib/comment-anchor.ts +++ b/packages/mcp/src/lib/comment-anchor.ts @@ -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 diff --git a/packages/mcp/src/lib/filters.ts b/packages/mcp/src/lib/filters.ts index f1104d50..c789ec3a 100644 --- a/packages/mcp/src/lib/filters.ts +++ b/packages/mcp/src/lib/filters.ts @@ -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, }; } diff --git a/packages/mcp/test/mock/create-comment.test.mjs b/packages/mcp/test/mock/create-comment.test.mjs index c0d6859e..d854bdb0 100644 --- a/packages/mcp/test/mock/create-comment.test.mjs +++ b/packages/mcp/test/mock/create-comment.test.mjs @@ -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"); +}); diff --git a/packages/mcp/test/unit/comment-anchor.test.mjs b/packages/mcp/test/unit/comment-anchor.test.mjs index 490cba96..c35d853b 100644 --- a/packages/mcp/test/unit/comment-anchor.test.mjs +++ b/packages/mcp/test/unit/comment-anchor.test.mjs @@ -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); +});