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,
|
||||
|
||||
Reference in New Issue
Block a user