diff --git a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts index a7b015a55c..ed0272e051 100644 --- a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts +++ b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts @@ -72,6 +72,42 @@ describe('validateValueAgainstTypeSpec – oneOf with mixed schemas', () => { }); }); +describe('validateValueAgainstTypeSpec – repeated actionable oneOf errors', () => { + const repeatedUnknownKeySchema: CliTypeSpec = { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string' }, + op: { const: 'text.rewrite' }, + }, + required: ['id', 'op'], + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + op: { const: 'text.insert' }, + }, + required: ['id', 'op'], + }, + ], + }; + + test('surfaces the shared nested schema error instead of the generic oneOf message', () => { + try { + validateValueAgainstTypeSpec({ id: 'r1', op: 'text.rewrite', '},{': ':' }, repeatedUnknownKeySchema, 'steps[0]'); + throw new Error('Expected CliError to be thrown'); + } catch (error) { + const cliError = error as CliError; + expect(cliError.message).toBe('steps[0].},{ is not allowed by schema.'); + expect((cliError.details as { selectedError?: string }).selectedError).toBe( + 'steps[0].},{ is not allowed by schema.', + ); + } + }); +}); + describe('validateValueAgainstTypeSpec – enum branch', () => { const enumSchema: CliTypeSpec = { type: 'string', diff --git a/apps/cli/src/lib/operation-args.ts b/apps/cli/src/lib/operation-args.ts index fd8c5286da..7b70771731 100644 --- a/apps/cli/src/lib/operation-args.ts +++ b/apps/cli/src/lib/operation-args.ts @@ -115,6 +115,37 @@ function extractConstValues(variants: CliTypeSpec[]): string[] { return values; } +function isNestedValidationMessage(path: string, message: string): boolean { + return message.startsWith(`${path}.`) || message.startsWith(`${path}[`); +} + +function selectRepeatedActionableOneOfError(path: string, errors: string[]): string | null { + const counts = new Map(); + for (const error of errors) { + counts.set(error, (counts.get(error) ?? 0) + 1); + } + + let bestMessage: string | null = null; + let bestScore = 0; + + for (const [message, count] of counts.entries()) { + if (count < 2) continue; + + const nested = isNestedValidationMessage(path, message); + const isShapeError = message.includes(' is not allowed by schema.') || message.includes(' is required.'); + + if (!nested && !isShapeError) continue; + + const score = count * 10 + (nested ? 2 : 0) + (isShapeError ? 1 : 0); + if (score > bestScore) { + bestScore = score; + bestMessage = message; + } + } + + return bestMessage; +} + export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec, path: string): void { if ('const' in schema) { if (value !== schema.const) { @@ -136,11 +167,12 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec } const allowedValues = extractConstValues(variants); + const selectedError = selectRepeatedActionableOneOfError(path, errors); const message = allowedValues.length > 0 ? `${path} must be one of: ${allowedValues.join(', ')}.` - : `${path} must match one of the allowed schema variants.`; - throw new CliError('VALIDATION_ERROR', message, { errors }); + : (selectedError ?? `${path} must match one of the allowed schema variants.`); + throw new CliError('VALIDATION_ERROR', message, { errors, selectedError }); } if (schema.type === 'json') return; @@ -236,7 +268,11 @@ function validateResponseValueAgainstTypeSpec(value: unknown, schema: CliTypeSpe errors.push(error instanceof Error ? error.message : String(error)); } } - throw new CliError('VALIDATION_ERROR', `${path} must match one of the allowed schema variants.`, { errors }); + const selectedError = selectRepeatedActionableOneOfError(path, errors); + throw new CliError('VALIDATION_ERROR', selectedError ?? `${path} must match one of the allowed schema variants.`, { + errors, + selectedError, + }); } if (schema.type === 'json') return; diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 5f14fb2091..8887fe4a9e 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1016,5 +1016,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "ea6e458d5c1476621d84f5bde9f85df9c3d620d0e226f206c57456f5ed6d20b3" + "sourceHash": "b61fad6a3a330af8a57b78ded260c8d8918486c9829b50804227fbeb15e8bf53" } diff --git a/apps/docs/document-api/reference/blocks/list.mdx b/apps/docs/document-api/reference/blocks/list.mdx index 27c6346695..3b6f777e90 100644 --- a/apps/docs/document-api/reference/blocks/list.mdx +++ b/apps/docs/document-api/reference/blocks/list.mdx @@ -1,14 +1,14 @@ --- title: blocks.list sidebarTitle: blocks.list -description: List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering. +description: "List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering. +List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering. - Operation ID: `blocks.list` - API member path: `editor.doc.blocks.list(...)` @@ -20,12 +20,13 @@ List top-level blocks in document order with IDs, types, and text previews. Supp ## Expected result -Returns a BlocksListResult with total block count, an ordered array of block entries (ordinal, nodeId, nodeType, textPreview, isEmpty), and the current document revision. +Returns a BlocksListResult with total block count, an ordered array of block entries (ordinal, nodeId, nodeType, textPreview, optional text, isEmpty), and the current document revision. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | +| `includeText` | boolean | no | | | `limit` | number | no | | | `nodeTypes` | enum[] | no | | | `offset` | number | no | | @@ -53,10 +54,10 @@ Returns a BlocksListResult with total block count, an ordered array of block ent { "blocks": [ { - "isEmpty": true, "nodeId": "node-def456", "nodeType": "paragraph", "ordinal": 1, + "text": "Hello, world.", "textPreview": "example" } ], @@ -80,6 +81,10 @@ Returns a BlocksListResult with total block count, an ordered array of block ent { "additionalProperties": false, "properties": { + "includeText": { + "description": "When true, includes the full flattened block text in each block entry.", + "type": "boolean" + }, "limit": { "description": "Maximum blocks to return. Omit for all blocks.", "minimum": 1, @@ -184,6 +189,17 @@ Returns a BlocksListResult with total block count, an ordered array of block ent } ] }, + "text": { + "description": "Full flattened block text when requested with includeText.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "textPreview": { "oneOf": [ { diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 1c3e2574c8..ec6d5c293e 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -79,7 +79,7 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | -| blocks.list | editor.doc.blocks.list(...) | List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering. | +| blocks.list | editor.doc.blocks.list(...) | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering. | | blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | blocks.deleteRange | editor.doc.blocks.deleteRange(...) | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 649cc0d2c0..ac984a0aed 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -543,6 +543,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } @@ -654,108 +685,180 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "type": "string" }, "where": { - "additionalProperties": false, - "properties": { - "by": { - "const": "select", - "type": "string" - }, - "require": { - "enum": [ - "first", - "exactlyOne" - ] - }, - "select": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "caseSensitive": { - "description": "Case-sensitive matching. Default: false.", - "type": "boolean" - }, - "mode": { - "description": "Match mode: 'contains' (substring) or 'regex'.", - "enum": [ - "contains", - "regex" - ] - }, - "pattern": { - "description": "Text or regex pattern to match.", - "type": "string" - }, - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search." - } - }, - "required": [ - "type", - "pattern" - ], - "type": "object" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" }, - { - "additionalProperties": false, - "properties": { - "kind": { - "description": "Filter: 'block' or 'inline'.", - "enum": [ - "block", - "inline" - ] - }, - "nodeType": { - "description": "Block type to match (paragraph, heading, table, listItem, etc.).", - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" - ] + "require": { + "enum": [ + "first", + "exactlyOne" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "description": "Case-sensitive matching. Default: false.", + "type": "boolean" + }, + "mode": { + "description": "Match mode: 'contains' (substring) or 'regex'.", + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "description": "Text or regex pattern to match.", + "type": "string" + }, + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search." + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" }, - "type": { - "const": "node", - "description": "Must be 'node' for node type search." + { + "additionalProperties": false, + "properties": { + "kind": { + "description": "Filter: 'block' or 'inline'.", + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "description": "Block type to match (paragraph, heading, table, listItem, etc.).", + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node", + "description": "Must be 'node' for node type search." + } + }, + "required": [ + "type" + ], + "type": "object" } - }, - "required": [ - "type" - ], - "type": "object" + ] + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" } - ] + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } - }, - "required": [ - "by", - "select", - "require" - ], - "type": "object" + ] } }, "required": [ @@ -928,6 +1031,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } @@ -1927,6 +2061,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index 9ad6f47828..49f0687b69 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -529,6 +529,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } @@ -640,108 +671,180 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "type": "string" }, "where": { - "additionalProperties": false, - "properties": { - "by": { - "const": "select", - "type": "string" - }, - "require": { - "enum": [ - "first", - "exactlyOne" - ] - }, - "select": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "caseSensitive": { - "description": "Case-sensitive matching. Default: false.", - "type": "boolean" - }, - "mode": { - "description": "Match mode: 'contains' (substring) or 'regex'.", - "enum": [ - "contains", - "regex" - ] - }, - "pattern": { - "description": "Text or regex pattern to match.", - "type": "string" - }, - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search." - } - }, - "required": [ - "type", - "pattern" - ], - "type": "object" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" }, - { - "additionalProperties": false, - "properties": { - "kind": { - "description": "Filter: 'block' or 'inline'.", - "enum": [ - "block", - "inline" - ] - }, - "nodeType": { - "description": "Block type to match (paragraph, heading, table, listItem, etc.).", - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" - ] + "require": { + "enum": [ + "first", + "exactlyOne" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "description": "Case-sensitive matching. Default: false.", + "type": "boolean" + }, + "mode": { + "description": "Match mode: 'contains' (substring) or 'regex'.", + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "description": "Text or regex pattern to match.", + "type": "string" + }, + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search." + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" }, - "type": { - "const": "node", - "description": "Must be 'node' for node type search." + { + "additionalProperties": false, + "properties": { + "kind": { + "description": "Filter: 'block' or 'inline'.", + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "description": "Block type to match (paragraph, heading, table, listItem, etc.).", + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node", + "description": "Must be 'node' for node type search." + } + }, + "required": [ + "type" + ], + "type": "object" } - }, - "required": [ - "type" - ], - "type": "object" + ] + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" } - ] + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } - }, - "required": [ - "by", - "select", - "require" - ], - "type": "object" + ] } }, "required": [ @@ -914,6 +1017,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } @@ -1913,6 +2047,37 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "target" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "block", + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + } + }, + "required": [ + "by", + "nodeType", + "nodeId" + ], + "type": "object" } ] } diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 7f661dd5ea..f56f2817d8 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -530,7 +530,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.insert` | `insert` | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | | `doc.delete` | `delete` | Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. | -| `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering. | +| `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering. | | `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | `doc.blocks.deleteRange` | `blocks delete-range` | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | | `doc.query.match` | `query match` | Deterministic selector-based search returning mutation-grade addresses and text ranges. Use this to discover targets before any mutation. | @@ -990,7 +990,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.insert` | `insert` | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | | `doc.delete` | `delete` | Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. | -| `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering. | +| `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering. | | `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | `doc.blocks.delete_range` | `blocks delete-range` | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | | `doc.query.match` | `query match` | Deterministic selector-based search returning mutation-grade addresses and text ranges. Use this to discover targets before any mutation. | diff --git a/examples/collaboration/ai-node-sdk/Makefile b/examples/collaboration/ai-node-sdk/Makefile index 391570cf37..53a0d1c52f 100644 --- a/examples/collaboration/ai-node-sdk/Makefile +++ b/examples/collaboration/ai-node-sdk/Makefile @@ -111,19 +111,25 @@ restore-npm-sdk: ## Restore SDK from npm if currently symlinked link-local-sdk: ## Build and link local SuperDoc SDK + CLI @echo "Linking local SuperDoc SDK ($(CLI_TARGET))..." - @# 1. Always rebuild CLI binary to pick up latest source changes + @# 1. Refresh generated artifacts so SDK + CLI reflect latest contract/tool changes + @echo " Generating derived SDK/tool artifacts..." + @cd $(ROOT) && $(PNPM) run generate:all + @# 2. Rebuild the Node SDK dist that the symlinked package actually exports + @echo " Building Node SDK..." + @cd $(ROOT) && $(PNPM) --filter @superdoc-dev/sdk run build + @# 3. Always rebuild CLI binary to pick up latest source changes @echo " Building CLI binary..." @cd $(ROOT) && $(PNPM) --filter @superdoc-dev/cli run build:native:host - @# 2. Ensure server node_modules exists + @# 4. Ensure server node_modules exists @if [ ! -d server/node_modules ]; then \ echo " Installing server deps first..."; \ cd server && npm install; \ fi - @# 3. Replace npm SDK with symlink to workspace source + @# 5. Replace npm SDK with symlink to workspace source @rm -rf server/node_modules/@superdoc-dev/sdk @ln -sf $(SDK_SRC) server/node_modules/@superdoc-dev/sdk @echo " ✓ Linked SDK → $(SDK_SRC)" - @# 4. Place CLI binary where the workspace SDK can find it via require.resolve + @# 6. Place CLI binary where the workspace SDK can find it via require.resolve @# Node follows the symlink real path, so the binary must be in the @# workspace SDK's own node_modules, not the server's. @mkdir -p $(SDK_SRC)/node_modules/$(CLI_PKG)/bin @@ -133,12 +139,16 @@ link-local-sdk: ## Build and link local SuperDoc SDK + CLI @echo "✓ Local SDK ready" rebuild-local-sdk: ## Rebuild CLI binary and re-link + @echo "Refreshing generated artifacts..." + @cd $(ROOT) && $(PNPM) run generate:all + @echo "Rebuilding Node SDK..." + @cd $(ROOT) && $(PNPM) --filter @superdoc-dev/sdk run build @echo "Rebuilding CLI binary..." @cd $(ROOT) && $(PNPM) --filter @superdoc-dev/cli run build:native:host @mkdir -p $(SDK_SRC)/node_modules/$(CLI_PKG)/bin @cp $(CLI_ARTIFACT) $(SDK_SRC)/node_modules/$(CLI_PKG)/bin/$(CLI_BINARY) @chmod +x $(SDK_SRC)/node_modules/$(CLI_PKG)/bin/$(CLI_BINARY) - @echo "✓ CLI binary updated" + @echo "✓ Local SDK + CLI updated" # ─── Cleanup ────────────────────────────────────────────────────────────────── diff --git a/examples/collaboration/ai-node-sdk/server/src/agent/runner.ts b/examples/collaboration/ai-node-sdk/server/src/agent/runner.ts index f5cb4e43f7..b30cdb1a07 100644 --- a/examples/collaboration/ai-node-sdk/server/src/agent/runner.ts +++ b/examples/collaboration/ai-node-sdk/server/src/agent/runner.ts @@ -26,6 +26,51 @@ interface ToolCallAccumulator { argsChunks: string[]; } +type ParsedToolCall = { + id: string; + name: string; + args: Record; + parseError: { ok: false; error: string; details: { toolName: string; rawArguments: string; message: string } } | null; +}; + +function parseToolCallArgs( + toolName: string, + argsStr: string, +): Pick { + try { + const parsed = JSON.parse(argsStr || '{}'); + if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { args: parsed as Record, parseError: null }; + } + + return { + args: {}, + parseError: { + ok: false, + error: `Tool arguments for ${toolName} must decode to a JSON object.`, + details: { + toolName, + rawArguments: argsStr, + message: `Parsed ${Array.isArray(parsed) ? 'an array' : typeof parsed} instead of an object.`, + }, + }, + }; + } catch (error) { + return { + args: {}, + parseError: { + ok: false, + error: `Tool arguments for ${toolName} were not valid JSON.`, + details: { + toolName, + rawArguments: argsStr, + message: error instanceof Error ? error.message : String(error), + }, + }, + }; + } +} + export async function* executeRun(params: RunParams): AsyncGenerator { const { input, conversationHistory, documentHandle, model, signal } = params; @@ -103,18 +148,13 @@ export async function* executeRun(params: RunParams): AsyncGenerator { } // Build assistant message with tool_calls - const toolCalls: Array<{ id: string; name: string; args: Record }> = []; + const toolCalls: ParsedToolCall[] = []; const openaiToolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []; for (const [, acc] of accumulators) { const argsStr = acc.argsChunks.join(''); - let args: Record = {}; - try { - args = JSON.parse(argsStr || '{}'); - } catch { - // malformed JSON from LLM - } - toolCalls.push({ id: acc.id, name: acc.name, args }); + const parsedCall = parseToolCallArgs(acc.name, argsStr); + toolCalls.push({ id: acc.id, name: acc.name, ...parsedCall }); openaiToolCalls.push({ id: acc.id, type: 'function', @@ -134,10 +174,14 @@ export async function* executeRun(params: RunParams): AsyncGenerator { const start = Date.now(); let result: unknown; - try { - result = await dispatchSuperDocTool(documentHandle, tc.name, tc.args); - } catch (err: any) { - result = { ok: false, error: err?.message ?? String(err) }; + if (tc.parseError) { + result = tc.parseError; + } else { + try { + result = await dispatchSuperDocTool(documentHandle, tc.name, tc.args); + } catch (err: any) { + result = { ok: false, error: err?.message ?? String(err) }; + } } const durationMs = Date.now() - start; diff --git a/packages/document-api/src/blocks/blocks.test.ts b/packages/document-api/src/blocks/blocks.test.ts index 1649024b14..1b36884c54 100644 --- a/packages/document-api/src/blocks/blocks.test.ts +++ b/packages/document-api/src/blocks/blocks.test.ts @@ -216,5 +216,20 @@ describe('executeBlocksList', () => { executeBlocksList(adapter, input); expect(adapter.list).toHaveBeenCalledWith(input); }); + + it('passes through includeText unchanged', () => { + const adapter = makeListAdapter(); + const input: BlocksListInput = { includeText: true, offset: 1 }; + executeBlocksList(adapter, input); + expect(adapter.list).toHaveBeenCalledWith(input); + }); + }); + + describe('input validation', () => { + it('rejects non-boolean includeText', () => { + expect(() => executeBlocksList(makeListAdapter(), { includeText: 'yes' as any })).toThrow( + DocumentApiValidationError, + ); + }); }); }); diff --git a/packages/document-api/src/blocks/blocks.ts b/packages/document-api/src/blocks/blocks.ts index 6c1321d237..2e9b92af1c 100644 --- a/packages/document-api/src/blocks/blocks.ts +++ b/packages/document-api/src/blocks/blocks.ts @@ -89,6 +89,12 @@ function validateBlocksListInput(input?: BlocksListInput): void { } } } + + if (input.includeText != null && typeof input.includeText !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.list includeText must be a boolean.', { + fields: ['includeText'], + }); + } } // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 9c94cc82e0..939b3b2a20 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -128,7 +128,8 @@ export const INTENT_GROUP_META: Record = { toolName: 'superdoc_get_content', description: 'Read document content in various formats. Call this first in any workflow to understand document structure before making edits. ' + - 'Action "blocks" returns structured block data with nodeId, nodeType, textPreview, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. ' + + 'Action "blocks" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. ' + + 'When you need to evaluate or rewrite existing paragraphs or clauses, prefer action "blocks" with includeText:true so you can identify the correct block and then target it by nodeId. ' + 'Action "text" and "markdown" return the full document as plain text or Markdown. Action "html" returns HTML. ' + 'Action "info" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. ' + 'The "blocks" action supports pagination via "offset" and "limit", and filtering via "nodeTypes". Other actions ignore these parameters. ' + @@ -136,6 +137,7 @@ export const INTENT_GROUP_META: Record = { 'Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.', inputExamples: [ { action: 'blocks' }, + { action: 'blocks', includeText: true, offset: 0, limit: 20 }, { action: 'blocks', offset: 0, limit: 20, nodeTypes: ['heading', 'paragraph'] }, { action: 'text' }, { action: 'info' }, @@ -154,6 +156,7 @@ export const INTENT_GROUP_META: Record = { 'Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values — use what the blocks show. ' + 'Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. ' + 'A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. ' + + 'For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. ' + 'Refs expire after any mutation; always re-search before the next edit. ' + 'For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. ' + 'Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). ' + @@ -319,11 +322,15 @@ export const INTENT_GROUP_META: Record = { 'All steps succeed or all fail; no partial application. ' + 'Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. ' + 'Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. ' + - 'Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"|"last"} or {by:"ref", ref:"..."}), and "args" with operation-specific parameters. ' + + 'Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"} or {by:"ref", ref:"..."} or {by:"block", nodeType:"paragraph", nodeId:"..."}), and "args" with operation-specific parameters. ' + + 'Use {by:"block", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action "blocks" without relying on text matching. ' + + 'For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:"blocks" and includeText:true, then rewrite the matching block by nodeId. ' + + 'Use {by:"select"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. ' + 'For create steps, "where" targets an existing anchor block and args.position ("before" or "after") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. ' + 'For format.apply with require "all", use a node selector to format every heading or paragraph at once: {by:"select", select:{type:"node", nodeType:"heading"}, require:"all"}. ' + 'Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. ' + 'Action "preview" dry-runs the plan. Action "apply" executes it. ' + + 'If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. ' + 'Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.', inputExamples: [ { @@ -348,6 +355,12 @@ export const INTENT_GROUP_META: Record = { { action: 'apply', steps: [ + { + id: 'r1', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'paragraph', nodeId: '' }, + args: { replacement: { text: 'Updated clause text.' } }, + }, { id: 'f1', op: 'format.apply', @@ -817,9 +830,9 @@ export const OPERATION_DEFINITIONS = { 'blocks.list': { memberPath: 'blocks.list', description: - 'List top-level blocks in document order with IDs, types, and text previews. Supports pagination via offset/limit and optional nodeType filtering.', + 'List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit and optional nodeType filtering.', expectedResult: - 'Returns a BlocksListResult with total block count, an ordered array of block entries (ordinal, nodeId, nodeType, textPreview, isEmpty), and the current document revision.', + 'Returns a BlocksListResult with total block count, an ordered array of block entries (ordinal, nodeId, nodeType, textPreview, optional text, isEmpty), and the current document revision.', requiresDocumentContext: true, metadata: readOperation({ throws: ['INVALID_INPUT'], diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index f1a91981b5..b26ad463f4 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3054,6 +3054,10 @@ const operationSchemas: Record = { items: { enum: [...blockNodeTypeValues] }, description: "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types.", }, + includeText: { + type: 'boolean', + description: 'When true, includes the full flattened block text in each block entry.', + }, }), output: objectSchema( { @@ -3066,6 +3070,10 @@ const operationSchemas: Record = { nodeId: { type: 'string', description: 'Block ID for targeting with other tools.' }, nodeType: { enum: [...blockNodeTypeValues] }, textPreview: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + text: { + oneOf: [{ type: 'string' }, { type: 'null' }], + description: 'Full flattened block text when requested with includeText.', + }, isEmpty: { type: 'boolean' }, styleId: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Named paragraph style.' }, fontFamily: { type: 'string', description: 'Font family from first text run.' }, @@ -4769,19 +4777,37 @@ const operationSchemas: Record = { ['by', 'target'], ); - const stepWhereSchema: JsonSchema = { oneOf: [selectWhereSchema, refWhereSchema, targetWhereSchema] }; - - // Insert-only where (no 'all' require, no ref) - const insertWhereSchema = objectSchema( + const blockWhereSchema = objectSchema( { - by: { const: 'select', type: 'string' }, - select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, - within: blockNodeAddressSchema, - require: { enum: ['first', 'exactlyOne'] }, + by: { const: 'block', type: 'string' }, + nodeType: { enum: [...blockNodeTypeValues] }, + nodeId: { type: 'string' }, }, - ['by', 'select', 'require'], + ['by', 'nodeType', 'nodeId'], ); + const stepWhereSchema: JsonSchema = { + oneOf: [selectWhereSchema, refWhereSchema, targetWhereSchema, blockWhereSchema], + }; + + // Insert-only where (no 'all' require, no ref) + const insertWhereSchema: JsonSchema = { + oneOf: [ + objectSchema( + { + by: { const: 'select', type: 'string' }, + select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, + within: blockNodeAddressSchema, + require: { enum: ['first', 'exactlyOne'] }, + }, + ['by', 'select', 'require'], + ), + refWhereSchema, + targetWhereSchema, + blockWhereSchema, + ], + }; + // Assert where (select only, no require) const assertWhereSchema = objectSchema( { diff --git a/packages/document-api/src/types/blocks.types.ts b/packages/document-api/src/types/blocks.types.ts index 5e94b54bcc..29501832df 100644 --- a/packages/document-api/src/types/blocks.types.ts +++ b/packages/document-api/src/types/blocks.types.ts @@ -9,6 +9,8 @@ export interface BlockListEntry { nodeId: string; nodeType: BlockNodeType; textPreview: string | null; + /** Full flattened block text when requested via BlocksListInput.includeText. */ + text?: string | null; isEmpty: boolean; /** Named paragraph style ID (e.g. 'Normal', 'Heading1'). */ styleId?: string | null; @@ -34,6 +36,8 @@ export interface BlocksListInput { offset?: number; limit?: number; nodeTypes?: BlockNodeType[]; + /** Include full flattened text for each block. Omit to return textPreview only. */ + includeText?: boolean; } export interface BlocksListResult { diff --git a/packages/document-api/src/types/mutation-plan.types.ts b/packages/document-api/src/types/mutation-plan.types.ts index b6cbf9a2bc..5cace7bf45 100644 --- a/packages/document-api/src/types/mutation-plan.types.ts +++ b/packages/document-api/src/types/mutation-plan.types.ts @@ -5,7 +5,7 @@ * that changes document state is a step dispatched by the plan engine. */ -import type { BlockNodeAddress } from './base.js'; +import type { BlockNodeAddress, BlockNodeType } from './base.js'; import type { TextAddress, TrackedChangeAddress, SelectionTarget, DeleteBehavior } from './address.js'; import type { TextSelector, NodeSelector } from './query.js'; import type { InsertStylePolicy, StylePolicy } from './style-policy.types.js'; @@ -35,7 +35,13 @@ export type TargetWhere = { target: SelectionTarget; }; -export type StepWhere = SelectWhere | RefWhere | TargetWhere; +export type BlockWhere = { + by: 'block'; + nodeType: BlockNodeType; + nodeId: string; +}; + +export type StepWhere = SelectWhere | RefWhere | TargetWhere | BlockWhere; export type AssertWhere = { by: 'select'; diff --git a/packages/sdk/langs/browser/src/system-prompt.ts b/packages/sdk/langs/browser/src/system-prompt.ts index 10d0a4ec00..5cd5ae49b5 100644 --- a/packages/sdk/langs/browser/src/system-prompt.ts +++ b/packages/sdk/langs/browser/src/system-prompt.ts @@ -22,12 +22,14 @@ export const SYSTEM_PROMPT = `You are a document editing assistant. You have a D Every editing tool needs a **target** telling the API *where* to apply the change. There are three ways to get one: -- **From blocks data**: Each block has a \`ref\` (pass directly to superdoc_edit or superdoc_format) and a \`nodeId\` (for building \`at\` positions with superdoc_create). +- **From blocks data**: Each block has a \`ref\` (pass directly to superdoc_edit or superdoc_format), a \`nodeId\` (for building \`at\` positions with superdoc_create or \`where: {by: "block", ...}\` in superdoc_mutations), and optional full \`text\` when you call \`superdoc_get_content({action: "blocks", includeText: true})\`. - **From superdoc_search**: Returns \`handle.ref\` covering the matched text. Use search when you need to find text patterns, not when you already know which block to target. - **From superdoc_create**: Returns \`nodeId\` and \`ref\`. The ref is valid for one immediate format call. For subsequent operations, re-fetch blocks to get fresh refs. **Refs expire after any mutation** between separate tool calls. Within a superdoc_mutations batch, selectors resolve automatically — no manual re-searching between steps. +**Critical targeting rule:** when rewriting an entire paragraph, clause, or other known block, first read \`superdoc_get_content({action: "blocks", includeText: true})\`, identify the block's \`nodeId\`, then use \`where: {by: "block", nodeType, nodeId}\` in \`superdoc_mutations\`. Do NOT use a shortened text selector to rewrite a whole clause. + ## Common workflows ### Replace a word everywhere @@ -42,12 +44,42 @@ Use \`require: "all"\` with a single edit, not multiple steps targeting the same ### Rewrite a full paragraph \`\`\` -superdoc_get_content({action: "blocks"}) -// Find the paragraph in the response, use its block ref (covers full text) -superdoc_edit({action: "replace", ref: "", text: "Entirely new paragraph text."}) +superdoc_get_content({action: "blocks", includeText: true}) +// Find the paragraph/clause by its full text, then use its nodeId +superdoc_mutations({ + action: "apply", atomic: true, + steps: [ + { + id: "r1", + op: "text.rewrite", + where: {by: "block", nodeType: "paragraph", nodeId: ""}, + args: {replacement: {text: "Entirely new paragraph text."}} + } + ] +}) +\`\`\` + +Use \`includeText:true\` so you can identify the right block from one read call. A block ref from superdoc_get_content covers the entire block text, but for multi-step rewrites and contract redlines, prefer \`where: {by: "block", ...}\` in \`superdoc_mutations\` because it is stable and avoids brittle text matching. A search ref covers only the matched substring. Do NOT use a shortened search/text selector to replace an entire known block. + +### Redline a contract clause + +\`\`\` +superdoc_get_content({action: "blocks", includeText: true}) +// Identify the clause block using blocks[i].text and blocks[i].nodeId +superdoc_mutations({ + action: "apply", atomic: true, changeMode: "tracked", + steps: [ + { + id: "clause1", + op: "text.rewrite", + where: {by: "block", nodeType: "listItem", nodeId: ""}, + args: {replacement: {text: "Customer agrees to ..."}} + } + ] +}) \`\`\` -A block ref from superdoc_get_content covers the entire block text. A search ref covers only the matched substring. Use block refs when rewriting or shortening whole paragraphs. +If you only know a short anchor, use \`superdoc_search\` to locate the clause, then convert that result to the containing block \`nodeId\` before calling \`text.rewrite\`. Use \`by:"select"\` for discovery, not for whole-clause replacement. ### Add a new paragraph after a heading @@ -175,16 +207,19 @@ Total: 3 calls (read + insert + format-all-in-one-batch). Never more. Use superdoc_mutations for 2+ text changes, format changes, or a combination: \`\`\` +superdoc_get_content({action: "blocks", includeText: true}) superdoc_mutations({ action: "apply", atomic: true, changeMode: "direct", steps: [ - {id: "s1", op: "text.rewrite", where: {by: "select", select: {type: "text", pattern: "old term"}, require: "all"}, args: {replacement: {text: "new term"}}}, + {id: "s1", op: "text.rewrite", where: {by: "block", nodeType: "paragraph", nodeId: ""}, args: {replacement: {text: "Updated full paragraph text."}}}, {id: "s2", op: "text.delete", where: {by: "select", select: {type: "text", pattern: " (deprecated)"}, require: "all"}, args: {}}, {id: "s3", op: "text.insert", where: {by: "select", select: {type: "text", pattern: "Section Title"}, require: "first"}, args: {position: "after", content: {text: " (Updated)"}}} ] }) \`\`\` +Use \`by:"block"\` for whole-paragraph / whole-clause rewrites. Use \`by:"select"\` only for substring edits, discovery, or insertion relative to a sentence fragment. + Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by create steps in the same batch — the new content does not exist yet when selectors compile. Split creates and formatting into separate batches. Never create two steps targeting overlapping text in the same block. Combine them into a single text.rewrite instead. diff --git a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts index a6a3338587..e28058eb37 100644 --- a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts +++ b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts @@ -54,4 +54,49 @@ describe('dispatchSuperDocTool', () => { expect((error as SuperDocCliError).code).toBe('INVALID_ARGUMENT'); } }); + + test('strips obviously corrupted nested keys before dispatch', async () => { + const calls: unknown[] = []; + const documentHandle = { + mutations: { + apply: async (args: unknown) => { + calls.push(args); + return { ok: true }; + }, + }, + } as unknown as BoundDocApi; + + const args = { + action: 'apply', + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'r1', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'paragraph', nodeId: '6F228706' }, + args: { replacement: { text: 'Replacement clause' } }, + '},{': ':', + }, + ], + }; + + const result = await dispatchSuperDocTool(documentHandle, 'superdoc_mutations', args); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([ + { + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'r1', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'paragraph', nodeId: '6F228706' }, + args: { replacement: { text: 'Replacement clause' } }, + }, + ], + }, + ]); + }); }); diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index bbe86be0b7..d189a7fa19 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -39,10 +39,32 @@ type ToolCatalogEntry = { operations: OperationEntry[]; }; +const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value != null && !Array.isArray(value); } +function isObviouslyCorruptedToolArgKey(key: string): boolean { + const trimmed = key.trim(); + return trimmed.length === 0 || !/[\p{L}\p{N}]/u.test(trimmed); +} + +function stripCorruptedToolArgKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripCorruptedToolArgKeys(item)); + } + + if (!isRecord(value)) return value; + + const clean: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (isObviouslyCorruptedToolArgKey(key)) continue; + clean[key] = stripCorruptedToolArgKeys(entryValue); + } + return clean; +} + async function readJson(fileName: string): Promise { const filePath = path.join(toolsDir, fileName); let raw = ''; @@ -277,6 +299,14 @@ export async function dispatchSuperDocTool( }); } + const sanitizedArgs = stripCorruptedToolArgKeys(args); + if (!isRecord(sanitizedArgs)) { + throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { toolName }, + }); + } + // Validate against the tool schema before dispatch. const catalog = await getCachedCatalog(); const tool = catalog.tools.find((t) => t.toolName === toolName); @@ -286,14 +316,13 @@ export async function dispatchSuperDocTool( details: { toolName }, }); } - validateToolArgs(toolName, args, tool); + validateToolArgs(toolName, sanitizedArgs, tool); // Strip empty strings for known optional ID/enum params that LLMs fill with "" // instead of omitting. Only target params where "" is never a valid value. - const STRIP_EMPTY = new Set(['parentId', 'parentCommentId', 'id', 'status']); const cleanArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (value === '' && STRIP_EMPTY.has(key)) continue; + for (const [key, value] of Object.entries(sanitizedArgs)) { + if (value === '' && STRIP_EMPTY_OPTIONAL_ARGS.has(key)) continue; cleanArgs[key] = value; } diff --git a/packages/sdk/tools/prompt-templates/system-prompt-core.md b/packages/sdk/tools/prompt-templates/system-prompt-core.md index b9abf54373..87c6a2d171 100644 --- a/packages/sdk/tools/prompt-templates/system-prompt-core.md +++ b/packages/sdk/tools/prompt-templates/system-prompt-core.md @@ -16,12 +16,14 @@ Every editing tool needs a **target** telling the API *where* to apply the change. There are three ways to get one: -- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format) and a `nodeId` (for building `at` positions with superdoc_create). +- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format), a `nodeId` (for building `at` positions with superdoc_create or `where: {by: "block", ...}` in superdoc_mutations), and optional full `text` when you call `superdoc_get_content({action: "blocks", includeText: true})`. - **From superdoc_search**: Returns `handle.ref` covering the matched text. Use search when you need to find text patterns, not when you already know which block to target. - **From superdoc_create**: Returns `nodeId` and `ref`. The ref is valid for one immediate format call. For subsequent operations, re-fetch blocks to get fresh refs. **Refs expire after any mutation** between separate tool calls. Within a superdoc_mutations batch, selectors resolve automatically — no manual re-searching between steps. +**Critical targeting rule:** when rewriting an entire paragraph, clause, or other known block, first read `superdoc_get_content({action: "blocks", includeText: true})`, identify the block's `nodeId`, then use `where: {by: "block", nodeType, nodeId}` in `superdoc_mutations`. Do NOT use a shortened text selector to rewrite a whole clause. + ## Common workflows ### Replace a word everywhere @@ -36,12 +38,42 @@ Use `require: "all"` with a single edit, not multiple steps targeting the same p ### Rewrite a full paragraph ``` -superdoc_get_content({action: "blocks"}) -// Find the paragraph in the response, use its block ref (covers full text) -superdoc_edit({action: "replace", ref: "", text: "Entirely new paragraph text."}) +superdoc_get_content({action: "blocks", includeText: true}) +// Find the paragraph/clause by its full text, then use its nodeId +superdoc_mutations({ + action: "apply", atomic: true, + steps: [ + { + id: "r1", + op: "text.rewrite", + where: {by: "block", nodeType: "paragraph", nodeId: ""}, + args: {replacement: {text: "Entirely new paragraph text."}} + } + ] +}) +``` + +Use `includeText:true` so you can identify the right block from one read call. A block ref from superdoc_get_content covers the entire block text, but for multi-step rewrites and contract redlines, prefer `where: {by: "block", ...}` in `superdoc_mutations` because it is stable and avoids brittle text matching. A search ref covers only the matched substring. Do NOT use a shortened search/text selector to replace an entire known block. + +### Redline a contract clause + +``` +superdoc_get_content({action: "blocks", includeText: true}) +// Identify the clause block using blocks[i].text and blocks[i].nodeId +superdoc_mutations({ + action: "apply", atomic: true, changeMode: "tracked", + steps: [ + { + id: "clause1", + op: "text.rewrite", + where: {by: "block", nodeType: "listItem", nodeId: ""}, + args: {replacement: {text: "Customer agrees to ..."}} + } + ] +}) ``` -A block ref from superdoc_get_content covers the entire block text. A search ref covers only the matched substring. Use block refs when rewriting or shortening whole paragraphs. +If you only know a short anchor, use `superdoc_search` to locate the clause, then convert that result to the containing block `nodeId` before calling `text.rewrite`. Use `by:"select"` for discovery, not for whole-clause replacement. ### Add a new paragraph after a heading @@ -169,16 +201,19 @@ Total: 3 calls (read + insert + format-all-in-one-batch). Never more. Use superdoc_mutations for 2+ text changes, format changes, or a combination: ``` +superdoc_get_content({action: "blocks", includeText: true}) superdoc_mutations({ action: "apply", atomic: true, changeMode: "direct", steps: [ - {id: "s1", op: "text.rewrite", where: {by: "select", select: {type: "text", pattern: "old term"}, require: "all"}, args: {replacement: {text: "new term"}}}, + {id: "s1", op: "text.rewrite", where: {by: "block", nodeType: "paragraph", nodeId: ""}, args: {replacement: {text: "Updated full paragraph text."}}}, {id: "s2", op: "text.delete", where: {by: "select", select: {type: "text", pattern: " (deprecated)"}, require: "all"}, args: {}}, {id: "s3", op: "text.insert", where: {by: "select", select: {type: "text", pattern: "Section Title"}, require: "first"}, args: {position: "after", content: {text: " (Updated)"}}} ] }) ``` +Use `by:"block"` for whole-paragraph / whole-clause rewrites. Use `by:"select"` only for substring edits, discovery, or insertion relative to a sentence fragment. + Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by create steps in the same batch — the new content does not exist yet when selectors compile. Split creates and formatting into separate batches. Never create two steps targeting overlapping text in the same block. Combine them into a single text.rewrite instead. diff --git a/packages/sdk/tools/system-prompt-mcp.md b/packages/sdk/tools/system-prompt-mcp.md index 685c7c74be..8b16122809 100644 --- a/packages/sdk/tools/system-prompt-mcp.md +++ b/packages/sdk/tools/system-prompt-mcp.md @@ -65,12 +65,14 @@ One format.apply step per block. Combine `inline`, `alignment`, and `scope: "blo Every editing tool needs a **target** telling the API *where* to apply the change. There are three ways to get one: -- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format) and a `nodeId` (for building `at` positions with superdoc_create). +- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format), a `nodeId` (for building `at` positions with superdoc_create or `where: {by: "block", ...}` in superdoc_mutations), and optional full `text` when you call `superdoc_get_content({action: "blocks", includeText: true})`. - **From superdoc_search**: Returns `handle.ref` covering the matched text. Use search when you need to find text patterns, not when you already know which block to target. - **From superdoc_create**: Returns `nodeId` and `ref`. The ref is valid for one immediate format call. For subsequent operations, re-fetch blocks to get fresh refs. **Refs expire after any mutation** between separate tool calls. Within a superdoc_mutations batch, selectors resolve automatically — no manual re-searching between steps. +**Critical targeting rule:** when rewriting an entire paragraph, clause, or other known block, first read `superdoc_get_content({action: "blocks", includeText: true})`, identify the block's `nodeId`, then use `where: {by: "block", nodeType, nodeId}` in `superdoc_mutations`. Do NOT use a shortened text selector to rewrite a whole clause. + ## Common workflows ### Replace a word everywhere @@ -85,12 +87,42 @@ Use `require: "all"` with a single edit, not multiple steps targeting the same p ### Rewrite a full paragraph ``` -superdoc_get_content({action: "blocks"}) -// Find the paragraph in the response, use its block ref (covers full text) -superdoc_edit({action: "replace", ref: "", text: "Entirely new paragraph text."}) +superdoc_get_content({action: "blocks", includeText: true}) +// Find the paragraph/clause by its full text, then use its nodeId +superdoc_mutations({ + action: "apply", atomic: true, + steps: [ + { + id: "r1", + op: "text.rewrite", + where: {by: "block", nodeType: "paragraph", nodeId: ""}, + args: {replacement: {text: "Entirely new paragraph text."}} + } + ] +}) +``` + +Use `includeText:true` so you can identify the right block from one read call. A block ref from superdoc_get_content covers the entire block text, but for multi-step rewrites and contract redlines, prefer `where: {by: "block", ...}` in `superdoc_mutations` because it is stable and avoids brittle text matching. A search ref covers only the matched substring. Do NOT use a shortened search/text selector to replace an entire known block. + +### Redline a contract clause + +``` +superdoc_get_content({action: "blocks", includeText: true}) +// Identify the clause block using blocks[i].text and blocks[i].nodeId +superdoc_mutations({ + action: "apply", atomic: true, changeMode: "tracked", + steps: [ + { + id: "clause1", + op: "text.rewrite", + where: {by: "block", nodeType: "listItem", nodeId: ""}, + args: {replacement: {text: "Customer agrees to ..."}} + } + ] +}) ``` -A block ref from superdoc_get_content covers the entire block text. A search ref covers only the matched substring. Use block refs when rewriting or shortening whole paragraphs. +If you only know a short anchor, use `superdoc_search` to locate the clause, then convert that result to the containing block `nodeId` before calling `text.rewrite`. Use `by:"select"` for discovery, not for whole-clause replacement. ### Add a new paragraph after a heading @@ -218,16 +250,19 @@ Total: 3 calls (read + insert + format-all-in-one-batch). Never more. Use superdoc_mutations for 2+ text changes, format changes, or a combination: ``` +superdoc_get_content({action: "blocks", includeText: true}) superdoc_mutations({ action: "apply", atomic: true, changeMode: "direct", steps: [ - {id: "s1", op: "text.rewrite", where: {by: "select", select: {type: "text", pattern: "old term"}, require: "all"}, args: {replacement: {text: "new term"}}}, + {id: "s1", op: "text.rewrite", where: {by: "block", nodeType: "paragraph", nodeId: ""}, args: {replacement: {text: "Updated full paragraph text."}}}, {id: "s2", op: "text.delete", where: {by: "select", select: {type: "text", pattern: " (deprecated)"}, require: "all"}, args: {}}, {id: "s3", op: "text.insert", where: {by: "select", select: {type: "text", pattern: "Section Title"}, require: "first"}, args: {position: "after", content: {text: " (Updated)"}}} ] }) ``` +Use `by:"block"` for whole-paragraph / whole-clause rewrites. Use `by:"select"` only for substring edits, discovery, or insertion relative to a sentence fragment. + Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by create steps in the same batch — the new content does not exist yet when selectors compile. Split creates and formatting into separate batches. Never create two steps targeting overlapping text in the same block. Combine them into a single text.rewrite instead. diff --git a/packages/sdk/tools/system-prompt.md b/packages/sdk/tools/system-prompt.md index 72cdf05b1e..20b27de54f 100644 --- a/packages/sdk/tools/system-prompt.md +++ b/packages/sdk/tools/system-prompt.md @@ -20,12 +20,14 @@ You are a document editing assistant. You have a DOCX document open and a set of Every editing tool needs a **target** telling the API *where* to apply the change. There are three ways to get one: -- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format) and a `nodeId` (for building `at` positions with superdoc_create). +- **From blocks data**: Each block has a `ref` (pass directly to superdoc_edit or superdoc_format), a `nodeId` (for building `at` positions with superdoc_create or `where: {by: "block", ...}` in superdoc_mutations), and optional full `text` when you call `superdoc_get_content({action: "blocks", includeText: true})`. - **From superdoc_search**: Returns `handle.ref` covering the matched text. Use search when you need to find text patterns, not when you already know which block to target. - **From superdoc_create**: Returns `nodeId` and `ref`. The ref is valid for one immediate format call. For subsequent operations, re-fetch blocks to get fresh refs. **Refs expire after any mutation** between separate tool calls. Within a superdoc_mutations batch, selectors resolve automatically — no manual re-searching between steps. +**Critical targeting rule:** when rewriting an entire paragraph, clause, or other known block, first read `superdoc_get_content({action: "blocks", includeText: true})`, identify the block's `nodeId`, then use `where: {by: "block", nodeType, nodeId}` in `superdoc_mutations`. Do NOT use a shortened text selector to rewrite a whole clause. + ## Common workflows ### Replace a word everywhere @@ -40,12 +42,42 @@ Use `require: "all"` with a single edit, not multiple steps targeting the same p ### Rewrite a full paragraph ``` -superdoc_get_content({action: "blocks"}) -// Find the paragraph in the response, use its block ref (covers full text) -superdoc_edit({action: "replace", ref: "", text: "Entirely new paragraph text."}) +superdoc_get_content({action: "blocks", includeText: true}) +// Find the paragraph/clause by its full text, then use its nodeId +superdoc_mutations({ + action: "apply", atomic: true, + steps: [ + { + id: "r1", + op: "text.rewrite", + where: {by: "block", nodeType: "paragraph", nodeId: ""}, + args: {replacement: {text: "Entirely new paragraph text."}} + } + ] +}) +``` + +Use `includeText:true` so you can identify the right block from one read call. A block ref from superdoc_get_content covers the entire block text, but for multi-step rewrites and contract redlines, prefer `where: {by: "block", ...}` in `superdoc_mutations` because it is stable and avoids brittle text matching. A search ref covers only the matched substring. Do NOT use a shortened search/text selector to replace an entire known block. + +### Redline a contract clause + +``` +superdoc_get_content({action: "blocks", includeText: true}) +// Identify the clause block using blocks[i].text and blocks[i].nodeId +superdoc_mutations({ + action: "apply", atomic: true, changeMode: "tracked", + steps: [ + { + id: "clause1", + op: "text.rewrite", + where: {by: "block", nodeType: "listItem", nodeId: ""}, + args: {replacement: {text: "Customer agrees to ..."}} + } + ] +}) ``` -A block ref from superdoc_get_content covers the entire block text. A search ref covers only the matched substring. Use block refs when rewriting or shortening whole paragraphs. +If you only know a short anchor, use `superdoc_search` to locate the clause, then convert that result to the containing block `nodeId` before calling `text.rewrite`. Use `by:"select"` for discovery, not for whole-clause replacement. ### Add a new paragraph after a heading @@ -173,16 +205,19 @@ Total: 3 calls (read + insert + format-all-in-one-batch). Never more. Use superdoc_mutations for 2+ text changes, format changes, or a combination: ``` +superdoc_get_content({action: "blocks", includeText: true}) superdoc_mutations({ action: "apply", atomic: true, changeMode: "direct", steps: [ - {id: "s1", op: "text.rewrite", where: {by: "select", select: {type: "text", pattern: "old term"}, require: "all"}, args: {replacement: {text: "new term"}}}, + {id: "s1", op: "text.rewrite", where: {by: "block", nodeType: "paragraph", nodeId: ""}, args: {replacement: {text: "Updated full paragraph text."}}}, {id: "s2", op: "text.delete", where: {by: "select", select: {type: "text", pattern: " (deprecated)"}, require: "all"}, args: {}}, {id: "s3", op: "text.insert", where: {by: "select", select: {type: "text", pattern: "Section Title"}, require: "first"}, args: {position: "after", content: {text: " (Updated)"}}} ] }) ``` +Use `by:"block"` for whole-paragraph / whole-clause rewrites. Use `by:"select"` only for substring edits, discovery, or insertion relative to a sentence fragment. + Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by create steps in the same batch — the new content does not exist yet when selectors compile. Split creates and formatting into separate batches. Never create two steps targeting overlapping text in the same block. Combine them into a single text.rewrite instead. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts index d0d781adaf..ab73b282a4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts @@ -573,6 +573,47 @@ describe('blocksListWrapper', () => { expect(result.blocks[0]!.textPreview).toBe('Short text'); }); + it('returns full block text when includeText is true', () => { + const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Longer full text value' })], { + attrs: { paraId: 'p1', sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = { state: { doc } } as unknown as Editor; + + const result = blocksListWrapper(editor, { includeText: true }); + expect(result.blocks[0]!.text).toBe('Longer full text value'); + expect(result.blocks[0]!.textPreview).toBe('Longer full text value'); + }); + + it('omits full block text when includeText is false or omitted', () => { + const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Body text' })], { + attrs: { paraId: 'p1', sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = { state: { doc } } as unknown as Editor; + + expect(blocksListWrapper(editor).blocks[0]!.text).toBeUndefined(); + expect(blocksListWrapper(editor, { includeText: false }).blocks[0]!.text).toBeUndefined(); + }); + + it('returns null full text for non-text blocks when includeText is true', () => { + const table = createNode('table', [], { + attrs: { blockId: 't1', sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + const doc = createNode('doc', [table], { isBlock: false }); + const editor = { state: { doc } } as unknown as Editor; + + const result = blocksListWrapper(editor, { includeText: true }); + expect(result.blocks[0]!.text).toBeNull(); + expect(result.blocks[0]!.textPreview).toBeNull(); + }); + it('reads alignment from paragraphProperties.justification', () => { const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Centered' })], { attrs: { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts index e5eee9a4be..ab863ee946 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts @@ -65,6 +65,11 @@ function extractTextPreview(node: ProseMirrorNode): string | null { return text.slice(0, TEXT_PREVIEW_MAX_LENGTH); } +function extractBlockText(node: ProseMirrorNode): string | null { + if (!node.isTextblock) return null; + return node.textContent; +} + const HEADING_PATTERN = /^Heading(\d)$/; /** OOXML implicit default font size when neither styles nor docDefaults specifies one. */ @@ -309,6 +314,7 @@ export function blocksListWrapper(editor: Editor, input?: BlocksListInput): Bloc const blocks: BlockListEntry[] = paged.map((candidate, i) => { const textLength = computeTextContentLength(candidate.node); + const fullText = input?.includeText ? extractBlockText(candidate.node) : undefined; const ref = textLength > 0 ? encodeV4Ref({ @@ -327,6 +333,7 @@ export function blocksListWrapper(editor: Editor, input?: BlocksListInput): Bloc nodeId: candidate.nodeId, nodeType: candidate.nodeType, textPreview: extractTextPreview(candidate.node), + ...(fullText !== undefined ? { text: fullText } : {}), isEmpty: textLength === 0, ...extractBlockFormatting(candidate.node, styleCtx), ...(ref ? { ref } : {}), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts index 3e656459c3..29ab5532bb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts @@ -97,6 +97,148 @@ describe('compilePlan ref-targeting semantics', () => { }); }); +describe('compilePlan block-targeting semantics', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('0'); + mockedDeps.resolveTextRangeInBlock.mockImplementation( + (_node: unknown, pos: number, range: { start: number; end: number }) => ({ + from: pos + 1 + range.start, + to: pos + 1 + range.end, + }), + ); + }); + + it('resolves where.by="block" to the full text range of the addressed block', () => { + mockedDeps.getBlockIndex.mockReturnValue({ + candidates: [{ nodeId: 'p1', pos: 0, end: 12, node: { inlineContent: true, isTextblock: true } }], + byId: new Map([ + ['paragraph:p1', { nodeId: 'p1', pos: 0, end: 12, node: { inlineContent: true, isTextblock: true } }], + ]), + ambiguous: new Set(), + }); + + const editor = makeEditor(); + const steps: MutationStep[] = [ + { + id: 'rewrite-known-block', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + args: { replacement: { text: 'Updated' } }, + }, + ]; + + const plan = compilePlan(editor, steps); + const target = plan.mutationSteps[0].targets[0]; + expect(target.kind).toBe('range'); + if (target.kind === 'range') { + expect(target.blockId).toBe('p1'); + expect(target.from).toBe(0); + expect(target.to).toBe(10); + expect(target.text).toBe('abcdefghij'); + } + }); + + it('allows create steps to anchor against non-text blocks via where.by="block"', () => { + mockedDeps.getBlockIndex.mockReturnValue({ + candidates: [{ nodeId: 't1', pos: 20, end: 40, node: { inlineContent: false, isTextblock: false } }], + byId: new Map([ + ['table:t1', { nodeId: 't1', pos: 20, end: 40, node: { inlineContent: false, isTextblock: false } }], + ]), + ambiguous: new Set(), + }); + + const editor = makeEditor(); + const steps: MutationStep[] = [ + { + id: 'create-after-table', + op: 'create.heading', + where: { by: 'block', nodeType: 'table', nodeId: 't1' }, + args: { text: 'Summary', level: 2, position: 'after' }, + } as MutationStep, + ]; + + expect(() => compilePlan(editor, steps)).not.toThrow(); + }); + + it('rejects non-text blocks for text mutations addressed via where.by="block"', () => { + mockedDeps.getBlockIndex.mockReturnValue({ + candidates: [{ nodeId: 't1', pos: 20, end: 40, node: { inlineContent: false, isTextblock: false } }], + byId: new Map([ + ['table:t1', { nodeId: 't1', pos: 20, end: 40, node: { inlineContent: false, isTextblock: false } }], + ]), + ambiguous: new Set(), + }); + + const editor = makeEditor(); + const steps: MutationStep[] = [ + { + id: 'rewrite-table', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'table', nodeId: 't1' }, + args: { replacement: { text: 'Updated' } }, + }, + ]; + + try { + compilePlan(editor, steps); + } catch (error) { + expect(error).toBeInstanceOf(PlanError); + expect((error as PlanError).code).toBe('INVALID_TARGET'); + expect((error as PlanError).message).toContain('where.by "block"'); + return; + } + + throw new Error('expected compilePlan to reject non-text block target'); + }); +}); + +describe('compilePlan selector diagnostics', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('0'); + mockedDeps.getBlockIndex.mockReturnValue({ candidates: [], byId: new Map(), ambiguous: new Set() }); + mockedDeps.executeTextSelector.mockReturnValue({ matches: [], context: [], total: 0 }); + }); + + it('reports step and selector evidence on MATCH_NOT_FOUND for select clauses', () => { + const editor = makeEditor(); + const steps: MutationStep[] = [ + { + id: 'find-clause', + op: 'text.rewrite', + where: { + by: 'select', + select: { type: 'text', pattern: 'Confidential Information' }, + require: 'exactlyOne', + }, + args: { replacement: { text: 'Updated' } }, + }, + ]; + + try { + compilePlan(editor, steps); + } catch (error) { + expect(error).toBeInstanceOf(PlanError); + const planError = error as PlanError; + expect(planError.code).toBe('MATCH_NOT_FOUND'); + expect(planError.message).toContain('find-clause'); + expect(planError.message).toContain('Confidential Information'); + expect(planError.details).toMatchObject({ + stepId: 'find-clause', + stepOp: 'text.rewrite', + whereBy: 'select', + selectorType: 'text', + selectorPattern: 'Confidential Information', + candidateCount: 0, + }); + return; + } + + throw new Error('expected compilePlan to throw MATCH_NOT_FOUND'); + }); +}); + describe('compilePlan step-op allowlist', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts index 42e23cb2a1..ba6f2c8005 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts @@ -14,6 +14,7 @@ import type { SelectWhere, RefWhere, TargetWhere, + BlockWhere, TextAddress, } from '@superdoc/document-api'; import { MAX_PLAN_STEPS, MAX_PLAN_RESOLVED_TARGETS, isPublicMutationStepOp } from '@superdoc/document-api'; @@ -78,6 +79,10 @@ function isTargetWhere(where: MutationStep['where']): where is TargetWhere { return where.by === 'target'; } +function isBlockWhere(where: MutationStep['where']): where is BlockWhere { + return where.by === 'block'; +} + // --------------------------------------------------------------------------- // Create-step position validation // --------------------------------------------------------------------------- @@ -883,6 +888,82 @@ function resolveTargetWhereClause(editor: Editor, step: MutationStep, where: Tar }; } +function buildWholeBlockRangeTarget( + editor: Editor, + step: MutationStep, + candidate: BlockCandidate, +): CompiledRangeTarget { + if (isTextBlockCandidate(candidate)) { + const blockText = getBlockText(editor, candidate); + const addr: ResolvedAddress = { + blockId: candidate.nodeId, + from: 0, + to: blockText.length, + text: blockText, + marks: [], + blockPos: candidate.pos, + }; + return buildRangeTarget(editor, step, addr, candidate); + } + + return { + kind: 'range', + stepId: step.id, + op: step.op, + blockId: candidate.nodeId, + from: 0, + to: 0, + absFrom: candidate.pos, + absTo: candidate.end, + text: '', + marks: [], + capturedStyle: undefined, + }; +} + +function resolveBlockWhereClause( + editor: Editor, + index: BlockIndex, + step: MutationStep, + where: BlockWhere, +): CompiledRangeTarget { + const key = `${where.nodeType}:${where.nodeId}`; + if (index.ambiguous.has(key)) { + throw planError('AMBIGUOUS_TARGET', `block "${key}" matched multiple blocks`, step.id, { + stepId: step.id, + stepOp: step.op, + whereBy: 'block', + target: { nodeType: where.nodeType, nodeId: where.nodeId }, + }); + } + + const candidate = index.byId.get(key); + if (!candidate) { + throw planError('TARGET_NOT_FOUND', `block "${key}" was not found`, step.id, { + stepId: step.id, + stepOp: step.op, + whereBy: 'block', + target: { nodeType: where.nodeType, nodeId: where.nodeId }, + }); + } + + if (!isCreateOp(step.op) && !isTextBlockCandidate(candidate)) { + throw planError( + 'INVALID_TARGET', + `step "${step.op}" requires a text-bearing block for where.by "block", but "${where.nodeType}" is not text-bearing`, + step.id, + { + stepId: step.id, + stepOp: step.op, + whereBy: 'block', + target: { nodeType: where.nodeType, nodeId: where.nodeId }, + }, + ); + } + + return buildWholeBlockRangeTarget(editor, step, candidate); +} + /** * Captures inline style runs for an absolute PM position range. * @@ -934,12 +1015,15 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte const refWhere = isRefWhere(where) ? where : undefined; const selectWhere = isSelectWhere(where) ? where : undefined; const targetWhere = isTargetWhere(where) ? where : undefined; + const blockWhere = isBlockWhere(where) ? where : undefined; let targets: CompiledTarget[]; if (targetWhere) { const selectionTarget = resolveTargetWhereClause(editor, step, targetWhere); targets = [selectionTarget]; + } else if (blockWhere) { + targets = [resolveBlockWhereClause(editor, index, step, blockWhere)]; } else if (refWhere) { targets = resolveRefTargets(editor, index, step, refWhere); } else if (selectWhere) { @@ -998,7 +1082,7 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte }); // Target-where always produces exactly one target — return immediately. - if (targetWhere) { + if (targetWhere || blockWhere) { return targets; } @@ -1042,25 +1126,47 @@ function buildMatchNotFoundDetails(step: MutationStep, editor?: Editor): Record< const where = step.where; const select = 'select' in where ? (where as { select?: { type?: string; pattern?: string; mode?: string } }).select : undefined; - const within = 'within' in where ? (where as { within?: { blockId?: string } }).within : undefined; + const within = 'within' in where ? (where as { within?: { nodeType?: string; nodeId?: string } }).within : undefined; let textPreview: string | undefined; if (editor) { - const docSize = editor.state.doc.content.size; - const len = Math.min(docSize, 300); - if (len > 0) textPreview = editor.state.doc.textBetween(0, len, '\n', '\n'); + const rawDocSize = editor.state.doc.content?.size; + const len = typeof rawDocSize === 'number' ? Math.min(rawDocSize, 300) : 300; + if (len > 0 && typeof editor.state.doc.textBetween === 'function') { + textPreview = editor.state.doc.textBetween(0, len, '\n', '\n'); + } } return { + stepId: step.id, + stepOp: step.op, + whereBy: where.by, selectorType: select?.type ?? 'unknown', selectorPattern: select?.pattern ?? '', selectorMode: select?.mode ?? 'contains', - searchScope: within?.blockId ?? 'document', + searchScope: within?.nodeId ? `${within.nodeType ?? 'block'}:${within.nodeId}` : 'document', candidateCount: 0, ...(textPreview ? { textPreview } : {}), }; } +function buildMatchNotFoundMessage(step: MutationStep): string { + const where = step.where; + const select = + 'select' in where + ? (where as { select?: { type?: string; pattern?: string; nodeType?: string } }).select + : undefined; + const within = 'within' in where ? (where as { within?: { nodeType?: string; nodeId?: string } }).within : undefined; + + const selectorDescription = + select?.type === 'node' + ? `node selector for "${select.nodeType ?? 'unknown'}"` + : `text selector for "${select?.pattern ?? ''}"`; + const scopeDescription = within?.nodeId ? ` within ${within.nodeType ?? 'block'}:${within.nodeId}` : ''; + + return `step "${step.id}" (${step.op}) matched zero ranges for ${selectorDescription}${scopeDescription}`; +} + function applyCardinalityCheck(step: MutationStep, targets: CompiledTarget[], editor?: Editor): void { const where = step.where; if (!('require' in where) || where.require === undefined) return; @@ -1071,7 +1177,7 @@ function applyCardinalityCheck(step: MutationStep, targets: CompiledTarget[], ed if (targets.length === 0) { throw planError( 'MATCH_NOT_FOUND', - 'selector matched zero ranges', + buildMatchNotFoundMessage(step), step.id, buildMatchNotFoundDetails(step, editor), ); @@ -1080,7 +1186,7 @@ function applyCardinalityCheck(step: MutationStep, targets: CompiledTarget[], ed if (targets.length === 0) { throw planError( 'MATCH_NOT_FOUND', - 'selector matched zero ranges', + buildMatchNotFoundMessage(step), step.id, buildMatchNotFoundDetails(step, editor), ); @@ -1094,7 +1200,7 @@ function applyCardinalityCheck(step: MutationStep, targets: CompiledTarget[], ed if (targets.length === 0) { throw planError( 'MATCH_NOT_FOUND', - 'selector matched zero ranges', + buildMatchNotFoundMessage(step), step.id, buildMatchNotFoundDetails(step, editor), );