From ce4bf0d6a98874091881625efbf1014fb5ff5997 Mon Sep 17 00:00:00 2001 From: bjohas Date: Mon, 11 May 2026 14:43:25 +0100 Subject: [PATCH 1/2] fix(doc-api): use textRanges[0] instead of highlightRange for comment anchoring highlightRange is snippet-relative (offset by up to SNIPPET_PADDING=30 chars from the start of the block). Telling LLMs to build target.range from it produces silently mis-anchored comments or TARGET_NOT_FOUND errors. The correct field is context.textRanges[0], which is a complete block-relative TextAddress ({kind:"text", blockId, range:{start,end}}) and can be passed straight through as the comment target. Update the superdoc_comment tool description accordingly. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 16fca2d306d12d34898bdf689e1652d686dac769) --- packages/document-api/src/contract/operation-definitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index c47287b509..6197f0d965 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -397,7 +397,7 @@ export const INTENT_GROUP_META: Record = { toolName: 'superdoc_comment', description: 'Manage document comment threads: create, read, update, and delete. ' + - 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target: {kind:"text", blockId:"", range:{start:, end:}} using the blockId and highlightRange from the search result. ' + + 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from the search result: use result.items[0].context.textRanges[0] directly as the target — it is already a complete {kind:"text", blockId, range:{start,end}} block-relative address. Do NOT use result.highlightRange — it is offset relative to the snippet preview (up to 30 chars off) and will mis-anchor the comment or trigger TARGET_NOT_FOUND. ' + 'For threaded replies, pass "parentId" with the parent comment ID. ' + 'Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). ' + 'Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. ' + From 56450666f57f452f659670e88ced6c48f5a4fd9f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 21 May 2026 18:07:32 -0300 Subject: [PATCH 2/2] fix(doc-api): retarget comment-anchor guidance to query.match wire shape @bjohas correctly identified that highlightRange is snippet-relative and unsafe for comment ranges. The original patch pointed at items[0].context.textRanges[0], which is doc.find output shape. superdoc_search dispatches to doc.query.match, whose items expose {target, snippet, highlightRange, blocks} with no `context` field. Retarget the description prose and the prompt template to build the comment target from items[0].blocks: - single block: {kind: "text", blockId: blocks[0].blockId, range: blocks[0].range} - cross block: {kind: "text", segments: blocks.map(b => ({blockId: b.blockId, range: b.range}))} Both shapes are accepted by comments.create (TextAddress | TextTarget). items[0].target is a SelectionTarget and is not accepted. Regenerate the shipped MCP catalog and prompt artifacts, and add a regression test that pins the wire shape so the broken path can't be reintroduced. A related bug at packages/superdoc/AGENTS.md (same misuse of items[0].target) is a follow-up; separate PR. (cherry picked from commit cc0ebe16bc3aff2c31c8aec3fe3008fa017faf15) --- apps/mcp/src/generated/catalog.ts | 2 +- apps/mcp/src/generated/mcp-prompt.ts | 2 +- .../src/contract/operation-definitions.ts | 2 +- .../sdk/langs/browser/src/system-prompt.ts | 2 +- .../prompt-templates/system-prompt-core.md | 2 +- packages/sdk/tools/system-prompt-mcp.md | 2 +- packages/sdk/tools/system-prompt.md | 2 +- .../plan-engine/query-match-adapter.test.ts | 51 +++++++++++++++++++ 8 files changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 1266b99ac2..4cd0a69ba6 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2286,7 +2286,7 @@ export const MCP_TOOL_CATALOG = { { toolName: 'superdoc_comment', description: - 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target: {kind:"text", blockId:"", range:{start:, end:}} using the blockId and highlightRange from the search result. For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', + 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', inputSchema: { type: 'object', properties: { diff --git a/apps/mcp/src/generated/mcp-prompt.ts b/apps/mcp/src/generated/mcp-prompt.ts index 25cb3b0fdf..46435c5288 100644 --- a/apps/mcp/src/generated/mcp-prompt.ts +++ b/apps/mcp/src/generated/mcp-prompt.ts @@ -416,7 +416,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) \`\`\` diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 6197f0d965..7920d9b8d0 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -397,7 +397,7 @@ export const INTENT_GROUP_META: Record = { toolName: 'superdoc_comment', description: 'Manage document comment threads: create, read, update, and delete. ' + - 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from the search result: use result.items[0].context.textRanges[0] directly as the target — it is already a complete {kind:"text", blockId, range:{start,end}} block-relative address. Do NOT use result.highlightRange — it is offset relative to the snippet preview (up to 30 chars off) and will mis-anchor the comment or trigger TARGET_NOT_FOUND. ' + + 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). ' + 'For threaded replies, pass "parentId" with the parent comment ID. ' + 'Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). ' + 'Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. ' + diff --git a/packages/sdk/langs/browser/src/system-prompt.ts b/packages/sdk/langs/browser/src/system-prompt.ts index c5405691d7..af0d99b5c4 100644 --- a/packages/sdk/langs/browser/src/system-prompt.ts +++ b/packages/sdk/langs/browser/src/system-prompt.ts @@ -371,7 +371,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) \`\`\` diff --git a/packages/sdk/tools/prompt-templates/system-prompt-core.md b/packages/sdk/tools/prompt-templates/system-prompt-core.md index f58224266c..7cba4ad117 100644 --- a/packages/sdk/tools/prompt-templates/system-prompt-core.md +++ b/packages/sdk/tools/prompt-templates/system-prompt-core.md @@ -365,7 +365,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/sdk/tools/system-prompt-mcp.md b/packages/sdk/tools/system-prompt-mcp.md index 4d3dbcfd7b..20f5518d31 100644 --- a/packages/sdk/tools/system-prompt-mcp.md +++ b/packages/sdk/tools/system-prompt-mcp.md @@ -414,7 +414,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/sdk/tools/system-prompt.md b/packages/sdk/tools/system-prompt.md index 35c44db92e..5f0fef82a0 100644 --- a/packages/sdk/tools/system-prompt.md +++ b/packages/sdk/tools/system-prompt.md @@ -369,7 +369,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts index ff4d266a0b..2033fe1100 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts @@ -871,3 +871,54 @@ describe('queryMatchAdapter — cardinality', () => { expect(result.total).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// Wire-shape regression guard +// +// Pins the public contract of items[] so callers (LLM tool prompts, agent +// guides) cannot drift back to fields that do not exist on the wire. The +// `superdoc_comment` MCP description previously told agents to use +// `items[0].context.textRanges[0]`, which is a field on `doc.find` output +// (the legacy sibling) and not on `doc.query.match` (where `superdoc_search` +// dispatches). Comments use `TextAddress | TextTarget` built from +// `items[0].blocks[*]`. +// --------------------------------------------------------------------------- + +describe('queryMatchAdapter — wire-shape contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('rev-1'); + }); + + it('emits items without a `context` field; range data lives on blocks[i]', () => { + const candidates = [{ nodeId: 'p1', pos: 0, end: 22, text: 'Title Body text here' }]; + const editor = makeEditorWithBlocks(candidates); + setupBlockIndex(candidates.map(({ nodeId, pos, end }) => ({ nodeId, pos, end }))); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [{ textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 5, end: 17 } }] }], + total: 1, + }); + mockedDeps.captureRunsInRange.mockReturnValue(captured([capturedRun(5, 17, [])])); + + const result = queryMatchAdapter(editor, { + select: { type: 'text', pattern: 'Body text he' }, + }); + + const item = result.items[0] as any; + + // The wire output of `query.match` has no `context` and no `textRanges`. + // Adding either would silently re-enable the previously-broken agent + // guidance that pointed at `items[0].context.textRanges[0]`. + expect(item.context).toBeUndefined(); + expect(item.textRanges).toBeUndefined(); + + // The block-relative range a comment target needs lives on blocks[i].range. + expect(item.blocks[0].blockId).toBe('p1'); + expect(item.blocks[0].range).toEqual({ start: 5, end: 17 }); + + // `target` is a SelectionTarget (kind:'selection'), which `comments.create` + // does NOT accept (it takes TextAddress | TextTarget, kind:'text'). + expect(item.target.kind).toBe('selection'); + }); +});