Skip to content

Commit a906137

Browse files
andrii-harboursuperdoc-oss-port[bot]
authored andcommitted
LLM tools core preset (#264)
* feat(document-api): tracked-change actions, comment export, list/numbering read model Engine + Document API work backing the LLM-tools core preset: - tracked-change side-targeted reject (decide `side` selector), tracked w:pPrChange apply, tracked lists.attach - comment + comment-reply export; comments on tracked changes persist on export - blocks.list read model: paragraph indent projection and computed numbering (marker/path/kind) so agents can see legal clause numbers - list-item / list-sequence resolver hardening; table cell shading background Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(document-api): tighten trackChanges.decide id-target side selector Addresses review findings on the side-targeted reject surface: - id-target `side` runtime accepted `insert`/`delete` aliases the published schema forbids (strict `inserted`/`deleted`). Drop the aliases so runtime matches the contract. Range targets are unchanged. - narrow the id-target `side` type to a new `ReplacementSide` (`'inserted'|'deleted'`); it no longer advertises move-only `source`/ `destination` that always throw for id targets. - decision-engine: a stale `side` selector on a change whose targeted half was already resolved (only the other side survives as a standalone insertion/ deletion) fell through and silently resolved the surviving side. Fail closed unless the standalone side matches the requested side. Only id targets set selection.side, so range/all decisions are unaffected. Adds 5 regression tests (3 decide-validation, 2 decision-engine). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): drop unused destructure var in tracked-numbering path `_existingChange` was flagged by @typescript-eslint/no-unused-vars as an ERROR (the file's TS lint config does not honor the /^_/ ignore pattern for rest-destructure siblings), failing CI lint. Snapshot the former paragraph properties with an explicit delete instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(super-editor): surface tracked paragraph-property changes in the review API Tracked numbering-attach / alignment changes (w:pPrChange) were export-only: they round-tripped to Word but never appeared in trackChanges.list and could not be accepted/rejected via trackChanges.decide, because they live on node attrs (paragraphProperties.change), not marks, and both enumeration sites (the review graph and the doc-api resolver) scan marks + structural rows only. To a customer that reads as "tracked numbering silently failed". Mirror the existing tableRow structural precedent for attr-based changes: - pprChanges.ts enumerator walks blocks for a valid paragraphProperties.change - review-graph projects each into a Formatting logical change (synthetic whole-block segment + non-enumerable change.pprChange payload) - decision-engine routes change.pprChange to planPprDecision before the type-based branches (it is typed Formatting but has no mark): accept drops the change record (numbering stays), reject restores the former properties; applied via setNodeMarkup like clearRowTrackChange - the doc-api resolver (groupTrackedChanges) appends them as formatting changes so they surface in trackChanges.list; the node-stored record id doubles as the public + command id, routing decide to the same review-graph change New integration test proves list + accept + reject end-to-end; 1476 tracked- change tests pass. Also drops an unused `retired` param (lint). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(super-editor): prettier-format the 3551 integration tests These integration tests were committed without prettier formatting, failing the CI format:check gate. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): address pPrChange review — type, reject sync, schema - review-graph: declare the optional `pprChange` flag on TrackedSegment so the synthetic segment literal type-checks (fixes CI TS2353; mirrors `structural`) - decision-engine: rejecting a tracked pPrChange now also syncs the TOP-LEVEL numberingProperties + listRendering to the restored former state, not just paragraphProperties — otherwise a rejected block still reads/renders as a numbered list item in any path that doesn't re-run the numbering plugin - contract: publish the `numbering` and `indent` fields on the blocks.list output schema (they were returned + typed on BlockListEntry but omitted from the closed JSON schema, so schema-driven clients dropped them); regenerated reference docs + manifest check:types 0 errors, lint 0 errors, 1234 tracked-change tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(painter-dom): align thick-border test with SD-3028 authored-width rule border-utils.test.ts expected thick borders at max(width*2, 3), but SD-3028 deliberately paints `thick` at the authored w:sz width (no 2x, min 1px) — see getBorderBandWidthPx + border-band.test.ts ("thick paints at the authored width"). The painter test and the applyBorder comment were stale leftovers from before that decision and failed on origin/main too; they only surfaced here because doc-api/sdk changes trigger CI's `--project=!*super-editor*` vitest job. Helper is unchanged. 91 border tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(document-api): clear cell `background` on cell-scoped clearShading Shading a cell writes the `background` attr (the render/export source of truth), but tablesClearShadingAdapter only deleted `background` on the table-scoped path. A cell-scoped clear (incl. tables.setShading({ color: null })) removed tableCellProperties.shading but left `background`, so the cell stayed shaded on screen and on export while the receipt reported success. Delete `background` on the cell clear path too (mirrors the set path + the table-scoped per-cell clear). Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): export tracked pPrChange with a Word-safe decimal w:id A tracked pPrChange (tracked numbering/alignment) exported its w:id as the internal change.id, which for API-created changes is a uuidv4 — but OOXML w:id must be a decimal integer, so Word repairs/drops it and re-import can't match. Imported pPrChanges already carry a decimal id (kept as-is); API-created UUIDs are converted to a stable decimal in a high, allocator-clear range. Adds decode-path tests (imported-decimal preserved; UUID → deterministic decimal in range) + a decimal-id assertion on the numbering export test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): make tracked pPr changes non-positional in the review graph The synthetic pPr segment spans the whole block, so treating it positionally caused three collateral bugs when a paragraph also had other content: a text-range decide captured it (partial Formatting → CAPABILITY_UNAVAILABLE), accepting/rejecting it detached unrelated comments in the block, and a pPr change inside a tracked table was routed through the mark-based contained-child planner (null-mark deref). Fix, one idea — pPr changes are resolved only by id/all via planPprDecision, never positionally: - keep the pPr segment off graph.segments / bySegmentId (review-graph) - skip the resolvedRanges push for pPr decisions (no comment detach) - skip pPr changes in the staying-table cascade 1435 tracked-change tests pass (list + accept + reject flow intact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): address SD-3551 review — tracked-change edge cases + test rigor Review round 2 (PR #262, @caio-pizzol). Each item reproduced, fixed where broken, and covered with a test: 1. One-sided replacement survivor: buildLogicalChange now downgrades a change labeled "replacement" with only one side present to the plain insertion/ deletion it is — so the survivor of a side-targeted accept/reject resolves normally instead of failing "replacement missing inserted or deleted side". 2. pPrChange inside a KEPT tracked table: the main staying-table cascade now excludes pPr changes (matching the side-effect sweep), so they resolve via planPprDecision by id instead of no-opping through the mark-based child planner (pPr is attr-based, not an inline mark). 3. Tracked lists.attach with no user: guarded with ensureTrackedCapability so a tracked pPrChange can no longer be stamped with a blank author (mirrors the ins/del and lists.insert tracked paths). 4. Comment export: assertions tightened to exact count + zero empty comments so a sidebar-only tracked-change row leaking as an empty comment is caught (the hasCommentBody filter itself was already correct — verified). 5. pPrChange w:id: routed through the shared Word revision-id allocator (threaded into the pPr export path) for doc-wide uniqueness; the FNV-1a hash is now only a no-allocator fallback. 194 review-model tests + touched integration tests + check:types + lint green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(super-editor): flatten pPrChange on final export + cascade it when a table is accepted Follow-ups from the automated review of the previous commit, plus test fixes: - Final-doc export now flattens a tracked paragraph-property revision: when isFinalDoc, drop the .change record so the "final" DOCX carries only the accepted numbering/alignment (no pending w:pPrChange) — matching how the ins/del translators strip their wrappers on isFinalDoc. - Accepting a tracked table BY ID now resolves contained pPr changes too: the staying-table side-effect sweep routes them through planPprDecision instead of skipping them, so a reviewed table has no leftover numbering revisions. (accept-all already handled this via the main loop.) Also: - Fix two unit tests broken by the earlier w:id-allocator threading: the generate-paragraph-properties decode assertion uses objectContaining (the call also carries the allocator + part path), and the lists.attach conformance throwCase drives its mode-independent TARGET_NOT_FOUND in direct mode (the tracked path's user guard would otherwise fire first on a no-user editor). - Drop ticket/review-process tags from code comments and test names. Full review-model + document-api-adapters suites, generate-paragraph-properties, and the pPrChange translator all green; check:types + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(super-editor): drop synthetic tracked-change rows from comment export comments.list() returns one projection row per tracked change so the sidebar can render revisions beside real comments. Feeding those rows into exportDocx({ comments }) turned every tracked change into a spurious <w:comment>: the row carries the change excerpt as `text`, so the body-presence filter let it through. Identify synthetic rows by identity — commentId/id equals trackedChangeLink.trackedChangeId — and exclude them. A genuine comment anchored on a tracked change keeps its own distinct id, so it still exports. Add a regression test for the comments.list() -> exportDocx({ comments }) path, drop leftover console.log calls from the comment integration tests, and assert the threaded reply bumps the document revision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(super-editor): large-document performance — headless linked-styles + visible read model - skip whole-doc linked-style inline-CSS decorations when the editor runs headless (view-only work); ~50s -> ~16s on a 38-page redline - blocks.list returns the VISIBLE text model (skips tracked-deleted runs) so a second edit to an already-edited block resolves offsets against the same text the plan engine applies against — no more "Offset N out of range" drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(super-editor): blocks.list length/ref/preview use the visible text model The visible read-model fix switched blocks.list `text` to visible but left `textLength`, `isEmpty`, and the encoded block `ref` (segments[].end) on the raw model via computeTextContentLength(node). For a redlined block that handed out a whole-block ref ending past the visible text, so re-editing it through the ref threw "text offset out of range" (the plan compiler resolves refs as visible) — the very drift the visible read model set out to remove, only half-applied. Compute length + preview on the visible model too. Adds two regression tests (partial-delete ref end == visible length; fully-deleted block reports isEmpty + no ref). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sdk): LLM-tools core preset — 3-tool surface, actions, windowed inspect Ships the `core` preset (DEFAULT_PRESET stays `legacy`) exposing three model-facing tools: superdoc_inspect, superdoc_perform_action (with an `action` parameter), superdoc_execute_code. - actions layer (formerly "recipes"): renamed throughout the SDK/CLI/MCP surface - move_text reimplemented as tracked delete-then-insert (source deleted first so the text search can't match the inserted copy), inheriting destination style — no dedicated engine move op - windowed + lean agent_inspect (blockOffset/blockLimit/omitEmptyBlocks/ dropTextPreview) so large documents fit the context budget - Python parity (core preset + smoke), operation catalog, truthful receipts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(ai): core-preset + llm-tools reference, execute-code + core-chat examples AI-tools documentation and runnable examples for the core preset. Kept as a separate commit so it can move to its own PR (docs/examples) if desired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): clear lint errors in the core-preset surface - actions.ts: `applied` is only property-mutated, never reassigned → const (prefer-const error) - product-action-smoke.mjs: the `fail` counter was incremented but never read (no-unused-vars error); report it in the summary line, which also surfaces the failure count the smoke was silently dropping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(cli): prettier-format core-preset CLI files Committed without prettier formatting, failing the CI format:check gate. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sdk): batch add_comments, move_table/delete_table, fix tracked changeMode threading - add_comments (renamed from add_comment): comment many targets in ONE call via selectors[]; avoids the model fanning out N concurrent add_comment tool calls. - move_table: relocate a whole table in one call (wraps doc.tables.move). - delete_table: remove an entire table in one call (wraps doc.blocks.delete). - fix: attach_numbering / add_list_items / insert_list_items passed changeMode in the input object, but lists.attach/insert read it from the second options arg — so changeMode:"tracked" was silently dropped and no w:pPrChange was recorded. Now passed in the options arg; tracked numbering is reviewable end-to-end. - prompt + tool-hint updates for the new/renamed actions and the MOVE rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(examples): actions-only core-chat demo — filter execute_code, fix bridge multi-arg, add .docx export - server: advertise reads + actions only (filter superdoc_execute_code) and load a demo-local actions-only system prompt (no code-execution guidance). - doc-bridge: forward the FULL argument list to the browser — it was sending only argList[0], silently dropping the 2nd options arg (e.g. { changeMode:'tracked' }). - add an "Export .docx" button to round-trip the live document. - system-prompt-actions.md: the core prompt with execute_code/scripting removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(sdk): expand + fix core-preset action surface Consolidate and extend the LLM-tools core preset actions: - Fold insert_list_items into add_list_items; support anchorText|listOrdinal, entries|items, RELATIVE levels incl. NEGATIVE (dedent toward the top level), placement after the anchor's whole sub-tree (path-prefix, skips interleaved non-numbered paragraphs so a new item can't steal siblings), and neighbour style-match (fontFamily/fontSize/bold/color from the anchor). - Add reply_to_comment (threads via comments.create + parentCommentId — the document-API contract exposes no reply op), set_font_family, redo_changes, and split_list (wraps doc.lists.split). - Replace move_section (ordinal-only, real-headings-only) with move_range: text-addressed range / visual section (auto-extends to the next heading-like block), section-aware afterText/beforeText destination. - delete_text: optional selector to scope deletions to one block; refuse an unscoped whitespace-only find (the 500-target footgun). - move_text: honour changeMode (direct by default, tracked on request) instead of forcing tracked; direct requires afterText. - Regenerate the advertised tool schema/hints/groups from the registry; update the SDK + demo system prompts and the core-preset reference for the renamed/added actions. - Unit tests: 70 passing, covering the new and changed actions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sdk): sync the system prompt with the 40-action registry The shipped prompt's ACTIONS section had drifted badly from the action registry: it documented 2 PHANTOM actions (insert_image_with_caption, set_table_shading — hallucination bait: the model calls them and gets "unknown action") and omitted 8 real ones (resolve_comments, redo_changes, format_paragraph, move_text, style_table, set_paragraph_spacing, insert_page_break, add_hyperlink). delete_text's note also predated its selector scoping. Sync both the SDK prompt and the demo actions-only prompt to the full 40-action registry, and add a drift-guard unit test that fails on any missing or phantom per-action entry so the prompt can't silently drift again. Validated with an actions-only revision-fidelity eval run: 74/84 correctness (88%) vs the 73/84 baseline — no regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(sdk): tool-surface configuration + provider-native formats Two customer-facing controls on the core preset's advertised surface: - excludeActions: remove actions from superdoc_perform_action — the enum, the grouped description, and any argument properties only those actions use all shrink together. Unknown names throw (typo protection); excluding every action drops the tool entirely. - excludeTools: remove whole tools (e.g. superdoc_execute_code). Available on chooseTools/getTools in the Node SDK, threaded through the CLI preset op (comma-separated flags), and exposed in the Python SDK (choose_tools excludeActions/excludeTools, core preset kwargs) — verified end-to-end Python -> CLI -> Node. Dispatch accepts the same lists as defense-in-depth and refuses excluded calls. The legacy preset ignores the options unchanged (covered by tests). Provider formats: 'vercel' now emits the AI SDK's flat {name, description, inputSchema} dialect (was openai-nested), matching tool()/jsonSchema(); 'openai' stays Chat-Completions-nested, 'anthropic' {name, description, input_schema} with cache_control on the last tool. Format-shape tests lock all four providers; the demo/eval consumers read inputSchema with a parameters fallback for older builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): repair pre-existing branch breaks caught by review-prep gates - execute-code (and preset dispatch, in the previous commit) narrowed the runtime-neutral OpenedRuntimeDocument to the v1 editor-backed handle — openSessionDocument's return type lost `editor` in the main merge and CLI typecheck failed (same guard legacy-compat's assertV1Opened uses). - manual-command-allowlist test: the branch added `execute code` + the six `preset *` commands/operations/files to the runtime allowlist without updating the test's expected lists — CLI suite failed 3 tests. - Python core-preset smoke: dispatched `insert_paragraph` (singular), an action that never existed in the core set; fixed to insert_paragraphs. CLI suite: 1373 pass / 0 fail. Python: 128 unit tests + live smoke pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sdk): honor tracked mode on every host + Codex review fixes Codex pre-merge review findings, verified and fixed: - P1 tracked inserts silently ran DIRECT on in-process hosts: the create helpers (paragraph/heading/table/list-create/list-insert/toc/attach) passed changeMode only in the input, which the in-process DocumentApi (browser bridge, CLI preset dispatch, Python core) ignores — it reads MutationOptions from the second arg, while the CLI-transport client reads the input flag. Pass BOTH (each dialect ignores the other's copy) so changeMode:"tracked" produces real tracked changes everywhere. - P1 set_paragraph_spacing / insert_page_break / add_hyperlink advertised changeMode but their v1 adapters reject tracked mode — removed the arg from types/registry/hints/prompts and marked them "Direct edit". - P1 move_text could delete the source and THEN fail on a bad destination anchor (data loss reported as failure) — pre-flight now verifies both spans exist before any mutation. - P2 Python dispatch exclusion parity: doc.preset.dispatch accepts excludeActions/excludeTools (CSV), threaded from the Python core preset into the Node dispatch guard — verified end-to-end (excluded action and tool refused, allowed action unaffected). - P3 add_hyperlink receipt named doc.hyperlinks.insert; it calls wrap. Tests: dual-dialect options capture, move_text pre-flight, no-changeMode hints; 229 SDK + 1373 CLI tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(sdk): cap per-item receipt lists (token hygiene) Receipts live in the conversation and are re-billed as prompt tokens on every subsequent model turn (measured: 96% of eval spend is prompt-side). Per-item actions (whole-body set_font_family, format_text on every occurrence, batch add_comments) emitted one executedOperations / selectedTargets entry per item — a 200-paragraph font change produced a receipt costing thousands of tokens, forever. Cap both lists at 8 entries in the compacted receipt and carry the true totals in executedOperationCount / selectedTargetCount. The work itself is unchanged — only the receipt shrinks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(sdk): exclusion-aware system prompt (narrows WITH the tool surface) excludeActions/excludeTools narrowed the advertised tools but getSystemPrompt still returned the full prompt — teaching the model actions it could not call (wasted tokens + guaranteed schema rejections). getSystemPrompt(preset, { excludeActions, excludeTools }) now narrows the prompt with the SAME options as getTools: - excluding superdoc_execute_code returns the hand-tuned ACTIONS-ONLY variant, promoted from the demo into a bundled SDK asset (system-prompt-actions-only.md — the exact prompt the actions-only evals validated at 75/84). ~1.8K tokens/turn smaller than the full prompt before caching. - excludeActions drops the per-action documentation lines (single-line entries enforced by the drift guard, which now covers BOTH prompt variants); a paired line survives while either action remains callable. - unknown names throw — same typo protection as getTools. Threaded through the CLI preset op (CSV flags) and the Python SDK (get_system_prompt kwargs) — verified end-to-end. The demo now consumes the SDK asset via getSystemPrompt('core', {excludeTools}) instead of carrying a local copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(sdk): core preset is actions-only by default; drop excludeTools Product decision: code execution (superdoc_execute_code) is WIP and ships behind a future safety flag — - the core preset now advertises TWO tools (superdoc_inspect, superdoc_perform_action); execute_code is no longer in getTools output or the catalog, but remains dispatchable for SDK callers. - getSystemPrompt('core') serves the actions-only prompt (the variant the evals validate; ~1.8K tokens/turn smaller). The code-inclusive prompt stays bundled, unserved, for the future opt-in. The MCP prompt no longer mentions execute_code. - the excludeTools option is removed everywhere it was threaded (getTools, getSystemPrompt, chooseTools, dispatch guard, CLI flags, Python kwargs) — not needed yet; excludeActions stays. Demo simplified to the preset defaults (no local tool filtering, no prompt options). Tests updated: 235 Node + 1373 CLI + 128 Python + live smoke. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(sdk): simplification-review cleanup From the Codex + internal simplification review: - Dead code: unused _systemPromptCache; 6 orphan ACTION_ARG_SCHEMA entries left by the removed insert_image_with_caption phantom. - Stale docs/comments: core preset headers and descriptions said "3-tool / 29 verbs" (it is 2 advertised tools / 40 actions), presets.ts said v1 ships only legacy, Python descriptor/docstrings matched. - Coherence: getSystemPromptForProvider now accepts excludeActions so the provider-shaped prompt narrows with the tool surface on every path; CLI help mentions --excludeActions. - Integrator types exported from the package root: BoundDocApi (the doc-handle contract dispatch expects), ActionName, AgentReceipt, and the preset option/result types. - matchOneBlock builds its inline payload via inlineLookFromRow (single source for "inline look"), applying only the delta vs the created block. Larger refactors from the review (receipt harness, format-range consolidation, prompt base+addendum, table-family helper, replace_text path fold, CLI session plumbing, Python proxy dedupe) are logged as follow-ups — each is eval-gated or too broad for pre-review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): silence no-relative-packages in the contract export script export-sdk-contract.ts deliberately imports document-api SOURCE (it runs pre-build, and the package's exports map exposes neither ./src/* nor ./scripts/*, so the alias form the rule suggests would not resolve). Justified inline disables; CLI lint is now 0 errors and the script still produces the 426-operation contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sdk): unbreak CI lint — dynamic import in the product-action smoke CI lints pre-build, so the smoke script's static import of ../dist/index.js could not resolve there (import-x/no-unresolved error) while passing locally where dist exists. An inline disable would flip to an "unused directive" warning locally and get stripped by --fix, re-breaking CI — so use a top-level-await dynamic import instead: env-independent lint, identical runtime behavior (verified: script imports and runs against the built dist). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lint): ignore built dist/ paths in import-x/no-unresolved The previous attempt (dynamic import) did not survive CI: the rule checks dynamic import() specifiers too. Root cause stands — CI lints pre-build, so scripts that exercise the BUILT package (../dist/index.js) cannot resolve there while resolving fine locally. Fix it where the config already fixes the same class: the rule's ignore list has '^\..*/generated/' for codegen artifacts not in git; add '^\..*/dist/' for built output on the same reasoning. The smoke script goes back to a plain static import. Verified by linting with dist/ removed (the CI condition): clean both with and without the build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sdk): exempt the branch's CLI-only ops in contract-integrity tests The contract-integrity suite exempts CLI-only operations from the doc-backed success/failure-schema invariant, but its exemption set predated this branch's CLI-only additions: doc.executeCode and the six doc.preset.* proxy ops. The two mutating ones (executeCode, preset dispatch) tripped the invariant and failed CI SDK / validate. Same drift pattern as the manual-command-allowlist test fixed earlier — the runtime lists gained the ops, the test's mirror list did not. Codegen suite: 58 pass locally after a full artifact regen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: strip docs and example apps from PR — shipping separately - Revert apps/docs changes to main (docs land in a dedicated PR; current drafts still said 'three tools' and covered the WIP execute_code surface) - Remove examples/ai/core-chat-demo and examples/ai/execute-code-agent (kept as local dev harnesses, not part of this PR) - Prune the corresponding pnpm-lock importers; remaining lockfile delta vs main is only the CLI's @superdoc-dev/sdk workspace link - Repoint the Python core-preset smoke at a tracked super-editor fixture instead of the demo app fixture (smoke re-run: PASSED) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(superdoc): build Node SDK before CLI tests apps/cli/src/lib/preset-ops.ts imports @superdoc-dev/sdk (the core preset proxy), so the cli-tests job needs the SDK's dist built after install — same invocation ci-sdk.yml uses. Reproduced locally: host tests fail with 'Cannot find module @superdoc-dev/sdk' without dist, pass with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): distinguish unreadable prompt assets from missing ones readPromptFile swallowed every readFile error and reported TOOLS_ASSET_NOT_FOUND, misclassifying permission/IO failures (EACCES, EISDIR, transient IO) as a missing asset. Now only ENOENT falls through to the next layout candidate; any other failure throws TOOLS_ASSET_UNREADABLE with the underlying cause in details. Addresses Qodo review finding #2 on PR #264. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(superdoc): generate SDK sources before building it in cli-tests The Node SDK's src/generated/ client is gitignored (produced by generate:all), so the SDK build added for cli-tests failed on a fresh checkout with TS2307 on ../generated/client.js. Run generate:all first, mirroring ci-sdk.yml's install → generate → build sequence. Verified locally from a cleaned generated dir: generate:all → SDK build → SDK 238/238 + CLI host 7/7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): thread exclude_actions through Python dispatch; complete ActionArgs surface Codex round-3 review fixes: - Python dispatch_superdoc_tool/_async now expose exclude_actions and forward it to the preset dispatch guard (parity with Node); legacy dispatch accepts-and-ignores it like its other core-only kwargs, and the PresetDescriptor protocol declares the kwarg - ActionArgs union gains the 9 newer action-args types (convert_list, attach_numbering, split_list, format_text, format_paragraph, apply_style, move_text, undo/redo_changes); agent barrel re-exports the full public set - Scrub stale text: catalog comment said 3 public tools + excludeTools, test header/property referenced removed excludeTools, CLI example used nonexistent insert_paragraph, smoke docstring named the unserved system-prompt.md Gates: node typecheck + 238 SDK tests + build, 131 python tests, python smoke E2E, 1753 CLI tests — all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sdk): gate CI on the full core-preset test surface Review follow-up (PR #264): the new SDK/python suites and the product smoke were never wired into CI, and the smoke scored 0/84 because it dispatched without preset:'core' (default legacy has no superdoc_perform_action). - product-action-smoke: dispatch through the core preset; refresh stale mock actions (insert_paragraphs, color_text→format_text) and replace the removed insert_image_with_caption tasks with TOC coverage → 84/84 - sdk-validate: run the full Node SDK unit tree (238 tests), python pytest suite (131, uv fallback on dev machines), and the 84-task smoke - ci-sdk.yml (+ subtree mirror): install pytest for the validate job Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): blockOrdinal targets the right block on the workflow path Review follow-up (PR #264): blockOrdinal requests are 1-based (parseOrdinal rejects < 1) but the workflow doc-index keys blocks on the raw doc-api block.ordinal, which is 0-based — so blockOrdinal:1 silently edited the SECOND block via the list/text/structure workflow tools. Add the missing -1 at the lookup (mirrors the agent-selector resolver). Also normalize the model-facing snapshot block ordinal to 1-based (both ingestion points) so superdoc_inspect displays the same convention the selectors accept — it previously echoed the doc-api's 0-based value while every sibling ordinal kind (paragraph/heading/table) was 1-based. New workflow-resolve tests pin 1..N coverage, first-block resolution, and no-wrap on out-of-range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): reply_to_comment sends a contract-valid create payload Review follow-up (PR #264): two defects made the verb effectively non-functional — - the single-segment anchor omitted the 'text' kind discriminator; every oneOf branch of comments.create's target requires one, so the common case hard-failed with VALIDATION_ERROR - the threading key was parentCommentId, but the contract param is parentId; the SDK→CLI transport silently drops unknown keys, so even successful replies landed as unthreaded top-level comments The mock's create capture had the same destructure blind spot ({text, parentCommentId} only) which is how the wrong key passed tests; it now records raw payloads and the test asserts the exact contract shape (parentId present, parentCommentId absent, kind/blockId/range). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): honor tracked changeMode on the append_list placement path Review follow-up (PR #264): appendListAtPlacement created the item paragraphs with no changeMode in either dialect, so changeMode:'tracked' with a positional placement produced untracked direct edits while the receipt reported ok. The fromParagraphs list conversion (createListFromParagraphRange) also only set the input-dialect key, so in-process hosts (browser bridge, CLI preset dispatch, Python core) ran it untracked; same for the add_list_items ghost-normalization path. All three sites now pass changeMode in both dialects (input key + 2nd MutationOptions arg — see executeCreateParagraph). Regression test pins both channels on the placement path; mock lists.create now supports the {from,to} range form and captures its options arg. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): rewrite_block never fabricates replacement text Review follow-up (PR #264): preserveShortTitleMeaning silently replaced a tracked-mode rewrite of any short title-like block with canned boilerplate ('This <Title> states the same thing in plainer English...') whenever the requested text reused fewer than two of the original's keywords — invented redline content on the flagship tracked-changes path. Removed the wrapper and its whole helper cluster; the caller's text now lands verbatim. Kept normalizeTitleLikeRewriteText (actions.ts), which is content-preserving: it only re-cases the quoted ALL-CAPS original inside the rewrite. Both behaviors pinned by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): move_range refuses ranges it would flatten Review follow-up (PR #264): move_range recreates blocks as plain paragraph/heading text and force-deletes the originals — a table (or list/image) inside the range collapsed to its text preview with the original destroyed, silently. The execute step now refuses any range containing a non-paragraph/heading block BEFORE mutating, with a teaching error naming the offending blocks and suggesting move_table / a narrower range. Prompt lines updated (both files) so the model knows the constraint up front. True structural relocation (preserving node subtrees and inline marks) is follow-up work; this closes the silent-data-loss hole. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(document-engine): regenerate sdks.mdx operations table Review follow-up (PR #264): generate:all rewrites this generated table from the contract, which this branch extends (doc.executeCode + doc.preset.* CLI ops). Committing the regenerated artifact so a clean generate:all leaves the tree unchanged. (Prose documentation for the core preset ships in the SD-3553 docs PR.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): embed prompts so native CLI binaries can serve them Review follow-up (PR #264): bun-compiled binaries resolve import.meta.url inside bun's virtual filesystem, so readPromptFile's on-disk candidates never exist there — preset get-system-prompt failed with TOOLS_ASSET_NOT_FOUND in every published native CLI package and in the Python wheels' embedded companion CLI (which Python's core preset proxies through). src/prompts/*.md are now also compiled in via a generated TS module (scripts/embed-prompts.mjs, committed + regenerated on every build); readPromptFile stays filesystem-first and falls back to the embedded copy when all candidates fail. Drift test pins the module to the .md sources; fallback test covers the no-candidates path. Verified against a real bun --compile binary: preset get-system-prompt --preset core now returns the prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(sdk): exempt embedded-prompts.generated.ts from prettier The commit hook reformatted the generated module, which the next embed-prompts.mjs run would revert — permanent churn. Generated file, generator-owned formatting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): preset-dispatched execute_code gets the same crash rollback Review follow-up (PR #264): the 'execute code' command snapshot-and- restores when a script mutates and then throws, but the preset-dispatch shim (doc.preset.dispatch → superdoc_execute_code — the path Python's core preset and SDK preset.dispatch use) called the raw runner, so crash debris persisted into the session and the next save. The envelope now lives in lib/execute-code-rollback.ts and both paths share it: crash → document restored to its pre-script state, receipt marked rolledBack, nothing persists (preset dispatch also ignores the restore-transaction's revision bump). Success/read-only behavior is byte-identical to before. Tests cover the envelope directly plus the reviewer's repro end-to-end through runPresetDispatch('core', 'superdoc_execute_code', ...). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sdk): drop unused list-create helpers executeListCreateFromParagraph / executeListInsert were never called — the placement paths thread changeMode inline now. Their unused-var lint warnings were exactly the noise that masked the append_list tracked-mode gap during review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sdk): keep e2e host tests out of the validate sweep; surface check output CI SDK validate failed on the new full-tree sweep for two reasons this fixes/exposes: - request-timeout-ms.e2e.test.ts spawns a live CLI host; in CI that resolves to the published platform binary rather than the branch build. e2e files are dev-local now (the cli-tests job covers host behavior against the branch build); all plain unit files still run. - check failures printed only 'Command failed: <cmd>' — the pytest failure detail was swallowed. Failing checks now print the command's stdout/stderr tail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sdk): deselect Linux-CI-fragile mock-host pipe tests from validate The surfaced pytest output pinpointed the CI SDK failures: 4 pre-existing test_transport.py async large-response/overflow tests whose mock-host child dies mid-response on Linux runners ('Host process disconnected'). They pass locally and predate this branch — deselected in the validate gate, kept for local runs. Follow-up: harden the mock host on CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): serve the core preset over MCP (MCP_PRESET=core) The MCP server only supported the legacy intent tools; MCP_PRESET=core exited with 'unknown preset'. It now registers the SDK core preset's two advertised tools (superdoc_inspect, superdoc_perform_action) straight from the preset catalog — schemas can't drift from the SDK surface — and dispatches through the SDK preset dispatcher against the session's in-process DocumentApi (same host dialect as CLI preset dispatch). Instructions switch to the SDK's MCP-flavored core prompt. superdoc_execute_code stays unreachable over MCP. Legacy remains the default and is untouched. Verified: protocol integration test (list → open → inspect → perform_action receipt → text present) plus a live run of the built dist bundle. 42/42 MCP tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): reply_to_comment threads for real — no target, dual parent keys The eval surfaced the actual engine contract: comments.create REJECTS a reply that carries any target ('Cannot combine parentCommentId with target') — the thread inherits the parent's anchor. Both prior attempts passed the parent's segments as a target, so every reply failed. Now the create sends only {text, parentId, parentCommentId}: parentId is the contract/transport param (the CLI reverses it after parsing); parentCommentId is what in-process hosts (MCP server, browser bridge) read. Verified end-to-end against the eval's own fixture: receipt ok, reply present as a second w:comment in the saved docx. Also: convert_list's lists.setType now passes tracked mode in both dialects (same gap class as append_list). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(document-api): comments.create accepts the contract param parentId Live-demo trace: in-process hosts (browser bridge, MCP, CLI preset dispatch) strict-validate comments.create input and rejected parentId with 'Unknown field' — while parentId IS the operation's public contract param (the CLI already renames it to parentCommentId after parsing, and the transport drops the engine-side name). Replies therefore worked over the SDK transport but failed on every in-process host. validateCreateCommentInput now normalizes the alias (parentId → parentCommentId, erroring if both are present and disagree), so the SDK's dual-key reply payload works on every host. Covered by document-api unit tests and an MCP protocol test replaying the demo flow (add comment → reply → thread visible). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): attach_numbering tracked mode survives the CLI transport Deterministic repro from the numbering-001 eval probe: the attach call carried changeMode only in the 2nd MutationOptions arg, which the CLI transport does not encode — over the wire the attach ran direct and no w:pPrChange was recorded (receipt still said ok because the numbering itself landed). Raw lists.attach with input.changeMode produced pPrChange=2, isolating the gap to this call site. Now dual-dialect like every other mutation call; regression test pins both channels via the mock's new attach capture. Verified end-to-end: the agent-path repro now saves pPrChange=2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit findings on MCP annotations + comments input type - superdoc_perform_action no longer advertises destructiveHint:false over MCP — the action surface includes destructive verbs (delete_table, delete_text, replace_text), so clients must not treat it as additive-only and skip confirmations - CommentsCreateInput now declares the parentId contract alias so typed callers don't need a cast to use the public param name Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sdk): single core system prompt; real MCP instructions - Drop the unserved code-inclusive prompt variant (Code-Act guidance returns with the execute_code safety-flag work; keeping a 28K shadow prompt in sync bought nothing today) - system-prompt-actions-only.md becomes system-prompt.md — it IS the core system prompt; served bytes unchanged (rename only, no eval needed), drift guard now covers the single file - mcp-prompt.md was a one-line stub; it is now a real MCP instructions document (session lifecycle, inspect-first workflow, receipts, tracked-changes guidance) mirroring the legacy MCP prompt's structure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sdk): createAgentToolkit — tools, prompt, and dispatch coherent by construction excludeActions previously relied on the caller passing the SAME list to chooseTools, getSystemPrompt, and dispatchSuperDocTool — forget one and an excluded action lingers in the prompt (or executes on dispatch). createAgentToolkit takes one options object and returns {tools, meta, systemPrompt, dispatch} with the preset + exclusions applied to all three; the dispatcher is pre-bound with the exclusion guard. Python parity: create_agent_toolkit returns the same surface with dispatch/dispatch_async closures. The legacy preset ignores exclusion options everywhere, matching the standalone functions — legacy callers see identical tools and prompt through the toolkit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sdk): align the MOVE prompt rule with move_range's content guard CodeRabbit caught the contradiction: the MOVE overview still promised move_range handles whole visual sections 'with ALL content', while the guard refuses ranges containing tables/lists/images — inviting refused attempts on mixed-content sections (the refused-retry thrash the eval measured). The rule now states the piecewise strategy up front. Also 'nodeIds' → 'node IDs' in the MCP prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): close PR 264 review gaps (#346) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Note: this ports only the public subtree changes from a mixed source commit (81 public paths, 2 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: e86a7bbd9d1c9defb6b014927d9a02f92deceff4 Ported-Public-Prefix: superdoc/public
1 parent 7e794ab commit a906137

81 files changed

Lines changed: 22369 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-sdk.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
with:
4040
python-version: '3.12'
4141

42+
- name: Install Python test dependencies
43+
run: python3 -m pip install pytest pytest-asyncio
44+
4245
- name: Install dependencies
4346
run: pnpm install --frozen-lockfile
4447

.github/workflows/ci-superdoc.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ jobs:
247247
- name: Build superdoc (CLI runtime dependency)
248248
run: pnpm run build:superdoc
249249

250+
- name: Generate SDK sources (gitignored generated client)
251+
run: pnpm run generate:all
252+
253+
- name: Build Node SDK (CLI preset ops dependency)
254+
run: pnpm --prefix packages/sdk/langs/node run build
255+
250256
- name: Run CLI tests
251257
run: pnpm run test:cli
252258

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ tests/consumer-typecheck/tsconfig.matrix.json
3636

3737
# Markdown files
3838
*.md
39+
packages/sdk/langs/node/src/embedded-prompts.generated.ts

apps/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"predev": "node scripts/ensure-superdoc-build.js",
1414
"dev": "bun run src/index.ts",
1515
"prebuild": "node scripts/ensure-superdoc-build.js",
16-
"build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js",
16+
"build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js && rm -rf dist/prompts && mkdir -p dist/prompts && cp ../../packages/sdk/langs/node/src/prompts/*.md dist/prompts/",
1717
"prebuild:native": "node scripts/ensure-superdoc-build.js",
1818
"build:native": "bun build src/index.ts --compile --outfile dist/superdoc && node scripts/copy-runtime-assets.js",
1919
"build:native:all": "node scripts/build-native-cli.js --all",
@@ -45,6 +45,7 @@
4545
"yjs": "catalog:"
4646
},
4747
"devDependencies": {
48+
"@superdoc-dev/sdk": "workspace:*",
4849
"@superdoc/document-api": "workspace:*",
4950
"@superdoc/super-editor": "workspace:*",
5051
"@types/bun": "catalog:",

apps/cli/scripts/export-sdk-contract.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
1515
import { resolve, dirname } from 'node:path';
1616
import { tmpdir } from 'node:os';
1717

18+
// This build script deliberately reaches into document-api SOURCE (it runs
19+
// pre-build, and the package's `exports` map exposes neither ./src/* nor
20+
// ./scripts/*, so the alias form the lint rule suggests would not resolve).
21+
// eslint-disable-next-line import-x/no-relative-packages
1822
import { COMMAND_CATALOG } from '../../../packages/document-api/src/contract/command-catalog.ts';
23+
// eslint-disable-next-line import-x/no-relative-packages
1924
import { INTENT_GROUP_META } from '../../../packages/document-api/src/contract/operation-definitions.ts';
25+
// eslint-disable-next-line import-x/no-relative-packages
2026
import { buildContractSnapshot } from '../../../packages/document-api/scripts/lib/contract-snapshot.ts';
2127
import { ensureDocumentApiBuild } from './ensure-superdoc-build.js';
2228

apps/cli/src/__tests__/cli.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2352,6 +2352,59 @@ describe('superdoc CLI', () => {
23522352
expect(closeResult.code).toBe(0);
23532353
});
23542354

2355+
test('expected revision protects execute code and preset dispatch', async () => {
2356+
await runCli(['open', SAMPLE_DOC, '--session', 'llm-guard']);
2357+
2358+
const advance = await runCli([
2359+
'execute',
2360+
'code',
2361+
'--session',
2362+
'llm-guard',
2363+
'--code',
2364+
"doc.create.paragraph({ text: 'REVISION_GUARD_BASELINE' }); return 'ok';",
2365+
]);
2366+
expect(advance.code).toBe(0);
2367+
2368+
const staleExecute = await runCli([
2369+
'execute',
2370+
'code',
2371+
'--session',
2372+
'llm-guard',
2373+
'--expected-revision',
2374+
'0',
2375+
'--code',
2376+
"doc.create.paragraph({ text: 'STALE_EXECUTE_CODE' }); return 'ok';",
2377+
]);
2378+
expect(staleExecute.code).toBe(1);
2379+
expect(parseJsonOutput<ErrorEnvelope>(staleExecute).error.code).toBe('REVISION_MISMATCH');
2380+
2381+
const stalePreset = await runCli([
2382+
'preset',
2383+
'dispatch',
2384+
'--session',
2385+
'llm-guard',
2386+
'--preset',
2387+
'core',
2388+
'--tool-name',
2389+
'superdoc_perform_action',
2390+
'--args-json',
2391+
JSON.stringify({ action: 'insert_paragraphs', text: 'STALE_PRESET_DISPATCH' }),
2392+
'--expected-revision',
2393+
'0',
2394+
]);
2395+
expect(stalePreset.code).toBe(1);
2396+
expect(parseJsonOutput<ErrorEnvelope>(stalePreset).error.code).toBe('REVISION_MISMATCH');
2397+
2398+
const textResult = await runCli(['get-text', '--session', 'llm-guard']);
2399+
expect(textResult.code).toBe(0);
2400+
expect(textResult.stdout).toContain('REVISION_GUARD_BASELINE');
2401+
expect(textResult.stdout).not.toContain('STALE_EXECUTE_CODE');
2402+
expect(textResult.stdout).not.toContain('STALE_PRESET_DISPATCH');
2403+
2404+
const closeResult = await runCli(['close', '--discard', '--session', 'llm-guard']);
2405+
expect(closeResult.code).toBe(0);
2406+
});
2407+
23552408
test('session use switches default session', async () => {
23562409
const alphaOpen = await runCli(['open', SAMPLE_DOC, '--session', 'alpha']);
23572410
expect(alphaOpen.code).toBe(0);

apps/cli/src/__tests__/host.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,64 @@ describe('CLI host mode', () => {
563563
HOST_TEST_TIMEOUT_MS,
564564
);
565565

566+
test(
567+
'host request timeout blocks delayed execute-code mutations',
568+
async () => {
569+
const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-'));
570+
cleanup.push(stateDir);
571+
await mkdir(stateDir, { recursive: true });
572+
573+
const docCopy = path.join(stateDir, 'doc.docx');
574+
await copyFile(await resolveSourceDocFixture(), docCopy);
575+
576+
const host = launchHost(stateDir, ['--request-timeout-ms', '1000']);
577+
const sessionId = 'timeout-guard';
578+
const marker = 'HOST_TIMEOUT_LATE_MUTATION';
579+
580+
const open = await host.request('cli.invoke', {
581+
argv: ['open', docCopy, '--session', sessionId],
582+
stdinBase64: '',
583+
});
584+
expect(open.error).toBeUndefined();
585+
586+
const delayedMutation = await host.request('cli.invoke', {
587+
argv: [
588+
'preset',
589+
'dispatch',
590+
'--session',
591+
sessionId,
592+
'--preset',
593+
'core',
594+
'--tool-name',
595+
'superdoc_execute_code',
596+
'--args-json',
597+
JSON.stringify({
598+
code: `await new Promise((resolve) => setTimeout(resolve, 1200)); doc.create.paragraph({ text: '${marker}' }); return 'late';`,
599+
}),
600+
],
601+
stdinBase64: '',
602+
});
603+
if (delayedMutation.error) {
604+
expect(delayedMutation.error.code).toBe(-32011);
605+
} else {
606+
const payload = delayedMutation.result as { data?: { ok?: boolean; error?: { message?: string } } };
607+
expect(payload.data?.ok).toBe(false);
608+
}
609+
610+
await new Promise((resolve) => setTimeout(resolve, 500));
611+
612+
const text = await host.request('cli.invoke', {
613+
argv: ['get-text', '--session', sessionId],
614+
stdinBase64: '',
615+
});
616+
expect(text.error).toBeUndefined();
617+
expect(JSON.stringify(text.result)).not.toContain(marker);
618+
619+
await host.shutdown();
620+
},
621+
HOST_TEST_TIMEOUT_MS,
622+
);
623+
566624
test(
567625
'rejects --request-timeout-ms with a non-numeric value',
568626
async () => {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { executeCodeWithRollback } from '../../lib/execute-code-rollback';
3+
import { runPresetDispatch } from '../../lib/preset-ops';
4+
import type { EditorWithDoc } from '../../lib/document';
5+
6+
/**
7+
* Fake v1 editor: a doc with a real revision counter plus the ProseMirror-ish
8+
* state/dispatch surface the rollback envelope uses (state.doc snapshot,
9+
* state.tr.replaceWith, dispatch). Restoring bumps the revision like a real
10+
* editor transaction would — the envelope must normalize that away.
11+
*/
12+
function makeFakeEditor() {
13+
let revision = 0;
14+
let content = 'ORIGINAL';
15+
const doc = {
16+
info: () => ({ revision: String(revision) }),
17+
mutate: (next: string) => {
18+
content = next;
19+
revision += 1;
20+
return { ok: true };
21+
},
22+
};
23+
const snapshot = { content: { size: 10, restoreTo: 'ORIGINAL' } };
24+
const editor = {
25+
doc,
26+
state: {
27+
doc: snapshot,
28+
get tr() {
29+
return {
30+
replaceWith: (_from: number, _to: number, restored: { restoreTo: string }) => ({ restored }),
31+
};
32+
},
33+
},
34+
dispatch: (tr: { restored: { restoreTo: string } }) => {
35+
content = tr.restored.restoreTo;
36+
revision += 1; // a restore transaction still advances the editor revision
37+
},
38+
};
39+
return {
40+
editor: editor as unknown as EditorWithDoc,
41+
getContent: () => content,
42+
};
43+
}
44+
45+
describe('executeCodeWithRollback', () => {
46+
test('crash after mutation restores the document and does not count as mutated', async () => {
47+
const { editor, getContent } = makeFakeEditor();
48+
const outcome = await executeCodeWithRollback(editor, "doc.mutate('BROKEN'); throw new Error('boom');");
49+
expect(outcome.result.ok).toBe(false);
50+
expect(outcome.result.rolledBack).toBe(true);
51+
expect(outcome.mutated).toBe(false);
52+
expect(outcome.revisionAfter).toBe(outcome.revisionBefore);
53+
expect(getContent()).toBe('ORIGINAL');
54+
});
55+
56+
test('successful script keeps its mutations and reports mutated', async () => {
57+
const { editor, getContent } = makeFakeEditor();
58+
const outcome = await executeCodeWithRollback(editor, "doc.mutate('CHANGED'); return 'done';");
59+
expect(outcome.result.ok).toBe(true);
60+
expect(outcome.result.rolledBack).toBeUndefined();
61+
expect(outcome.mutated).toBe(true);
62+
expect(getContent()).toBe('CHANGED');
63+
});
64+
65+
test('read-only script mutates nothing', async () => {
66+
const { editor, getContent } = makeFakeEditor();
67+
const outcome = await executeCodeWithRollback(editor, 'return doc.info().revision;');
68+
expect(outcome.result.ok).toBe(true);
69+
expect(outcome.mutated).toBe(false);
70+
expect(getContent()).toBe('ORIGINAL');
71+
});
72+
73+
test('timeout blocks late document calls from mutating the session', async () => {
74+
const { editor, getContent } = makeFakeEditor();
75+
const outcome = await executeCodeWithRollback(
76+
editor,
77+
"await new Promise((resolve) => setTimeout(resolve, 25)); doc.mutate('LATE'); return 'late';",
78+
{ timeoutMs: 5 },
79+
);
80+
81+
expect(outcome.result.ok).toBe(false);
82+
expect(outcome.mutated).toBe(false);
83+
await new Promise((resolve) => setTimeout(resolve, 40));
84+
expect(getContent()).toBe('ORIGINAL');
85+
});
86+
87+
test('delayed work after a successful return cannot mutate the session', async () => {
88+
const { editor, getContent } = makeFakeEditor();
89+
const outcome = await executeCodeWithRollback(
90+
editor,
91+
"setTimeout(() => doc.mutate('LATE'), 25); return 'scheduled';",
92+
{ timeoutMs: 100 },
93+
);
94+
95+
expect(outcome.result.ok).toBe(true);
96+
expect(outcome.mutated).toBe(false);
97+
await new Promise((resolve) => setTimeout(resolve, 40));
98+
expect(getContent()).toBe('ORIGINAL');
99+
});
100+
});
101+
102+
describe('preset dispatch superdoc_execute_code (shim parity)', () => {
103+
test('a crashing script dispatched through the core preset is rolled back', async () => {
104+
const { editor, getContent } = makeFakeEditor();
105+
const result = (await runPresetDispatch(
106+
'core',
107+
'superdoc_execute_code',
108+
{ code: "doc.mutate('BROKEN'); throw new Error('boom');" },
109+
editor,
110+
)) as { ok?: boolean; rolledBack?: boolean };
111+
expect(result.ok).toBe(false);
112+
expect(result.rolledBack).toBe(true);
113+
// Reviewer repro (PR #264): the paragraph used to survive the crash.
114+
expect(getContent()).toBe('ORIGINAL');
115+
});
116+
117+
test('a successful script dispatched through the core preset keeps its edits', async () => {
118+
const { editor, getContent } = makeFakeEditor();
119+
const result = (await runPresetDispatch(
120+
'core',
121+
'superdoc_execute_code',
122+
{ code: "doc.mutate('CHANGED'); return 'ok';" },
123+
editor,
124+
)) as { ok?: boolean };
125+
expect(result.ok).toBe(true);
126+
expect(getContent()).toBe('CHANGED');
127+
});
128+
});

apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ describe('manual command allowlist', () => {
1313
'close',
1414
'insert tab',
1515
'insert line-break',
16+
// CLI/SDK-only session op: model-authored JS against the live editor.doc.
17+
'execute code',
1618
'session list',
1719
'session save',
1820
'session close',
1921
'session set-default',
2022
'session use',
23+
// LLM-tools preset proxies (cross-language surface for the Python SDK).
24+
'preset list',
25+
'preset get-catalog',
26+
'preset get-tools',
27+
'preset get-system-prompt',
28+
'preset get-mcp-prompt',
29+
'preset dispatch',
2130
]);
2231
});
2332

@@ -28,10 +37,17 @@ describe('manual command allowlist', () => {
2837
'doc.close',
2938
'doc.insertTab',
3039
'doc.insertLineBreak',
40+
'doc.executeCode',
3141
'doc.session.list',
3242
'doc.session.save',
3343
'doc.session.close',
3444
'doc.session.setDefault',
45+
'doc.preset.list',
46+
'doc.preset.getCatalog',
47+
'doc.preset.getTools',
48+
'doc.preset.getSystemPrompt',
49+
'doc.preset.getMcpPrompt',
50+
'doc.preset.dispatch',
3551
]);
3652
});
3753

@@ -44,10 +60,12 @@ describe('manual command allowlist', () => {
4460
expect(actual).toEqual([
4561
'call.ts',
4662
'close.ts',
63+
'execute-code.ts',
4764
'insert-inline-special.ts',
4865
'install.ts',
4966
'legacy-compat.ts',
5067
'open.ts',
68+
'preset.ts',
5169
'save.ts',
5270
'session-close.ts',
5371
'session-list.ts',

apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('operation runtime metadata', () => {
112112
expect(openOptions.map((o) => o.name)).not.toContain('runtime');
113113
});
114114

115-
test('final recipe-provider parity operations expose their promoted CLI params', () => {
115+
test('final action-provider parity operations expose their promoted CLI params', () => {
116116
const blocksListMeta = CLI_OPERATION_METADATA['doc.blocks.list'];
117117
expect(blocksListMeta.params.find((p) => p.name === 'in')?.flag).toBe('in-json');
118118

0 commit comments

Comments
 (0)