diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml index 12c4a0deee..e6fbfeb564 100644 --- a/.github/workflows/promote-stable.yml +++ b/.github/workflows/promote-stable.yml @@ -84,8 +84,8 @@ jobs: git show ":3:${f}" | normalize_json_without_keys version > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } ;; pyproject.toml|*/pyproject.toml) - git show ":2:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } - git show ":3:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } + git show ":2:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } + git show ":3:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } ;; version.json|*/version.json) git show ":2:${f}" | normalize_json_without_keys version sdkVersion > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml index 5abf46d8e7..68c8544825 100644 --- a/.github/workflows/release-vscode-ext.yml +++ b/.github/workflows/release-vscode-ext.yml @@ -2,7 +2,7 @@ # Stable releases are orchestrated centrally by release-stable.yml so that # every stable release shares one concurrency slot and one git push lane. # Note: VS Code Marketplace doesn't support semver prerelease versions, so -# main pushes only build a .vsix and attach it to the GitHub release. +# main pushes create prerelease tags without publishing a .vsix. name: ๐Ÿ“ฆ Release vscode-ext on: diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 980eb49041..d463d59416 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -1,12 +1,11 @@ name: ๐Ÿ”„ Sync stable โ†’ main -# Stable pushes first run semantic-release, which can append version/tag -# commits back to stable. Sync only after that release lane finishes so main -# never gets a PR from an intermediate stable head. +# Stable releases can append version and tag commits. Wait for that lane to +# drain, then pin its settled head before merging it into main. on: workflow_run: workflows: - - "๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" + - '๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)' types: - completed workflow_dispatch: @@ -14,7 +13,6 @@ on: permissions: actions: read contents: write - pull-requests: write concurrency: group: sync-stable-to-main @@ -27,15 +25,13 @@ jobs: github.event_name == 'workflow_dispatch' || ( github.event.workflow_run.head_branch == 'stable' && - ( - github.event.workflow_run.conclusion == 'success' || - github.event.workflow_run.conclusion == 'failure' - ) + github.event.workflow_run.conclusion == 'success' ) steps: + # Manual dispatch is the recovery path after an investigated failure, + # but it still waits for active stable releases before pinning stable. - name: Wait for stable release lane to drain - if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -52,7 +48,7 @@ jobs: --jq '[.[] | select((.name == "๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder") and .status != "completed")] | length') if [ "$active_runs" -eq 0 ]; then - echo "Stable release lane is idle." + echo "Stable release lane is drained." break fi @@ -85,7 +81,8 @@ jobs: - name: Sync stable into main env: - GH_TOKEN: ${{ steps.generate_token.outputs.token }} + EVENT_NAME: ${{ github.event_name }} + TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail @@ -93,18 +90,59 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git fetch origin main stable --tags --prune + stable_sha=$(git rev-parse origin/stable) - SYNC_BRANCH="sync/stable-to-main-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$SYNC_BRANCH" origin/main + if [ "$EVENT_NAME" = "workflow_run" ]; then + if ! git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"; then + echo "Pinned stable SHA does not descend from the successful tooling release." + exit 1 + fi + + release_subject_pattern='^chore(\([^)]+\))?: [^[:space:]]+ \[skip ci\]$' + unexpected_commit=false + while read -r sha subject; do + [ -z "$sha" ] && continue + + author_email=$(git show -s --format='%ae' "$sha") + committer_email=$(git show -s --format='%ce' "$sha") + if [ "$author_email" != "semantic-release-bot@martynus.net" ] || + [ "$committer_email" != "semantic-release-bot@martynus.net" ] || + [[ ! "$subject" =~ $release_subject_pattern ]]; then + echo "Pinned stable includes a non-release commit: $sha $subject" + unexpected_commit=true + continue + fi + + changed_files=$(git diff-tree --no-commit-id --name-only -r "$sha") + if [ -z "$changed_files" ]; then + echo "Release writeback has no version files: $sha" + unexpected_commit=true + continue + fi + + while read -r f; do + case "$f" in + package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) ;; + *) + echo "Release writeback changes an unexpected file: $sha $f" + unexpected_commit=true + ;; + esac + done <<< "$changed_files" + done < <(git log --format='%H %s' "$TRIGGER_STABLE_SHA..$stable_sha") + + if [ "$unexpected_commit" = true ]; then + exit 1 + fi + fi - if git merge-base --is-ancestor origin/stable origin/main; then + git checkout --detach origin/main + + if git merge-base --is-ancestor "$stable_sha" origin/main; then echo "No changes to sync - stable is already in main's ancestry." exit 0 fi - PR_TITLE="๐Ÿ”„ Sync stable โ†’ main" - MERGE_STATUS="clean" - normalize_json_without_keys() { python3 -c 'import json, sys; data = json.load(sys.stdin); [data.pop(key, None) for key in sys.argv[1:]]; sys.stdout.write(json.dumps(data, sort_keys=True, separators=(",", ":")))' "$@" } @@ -121,8 +159,8 @@ jobs: git show ":3:${f}" | normalize_json_without_keys version > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } ;; pyproject.toml|*/pyproject.toml) - git show ":2:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } - git show ":3:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } + git show ":2:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } + git show ":3:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; } ;; version.json|*/version.json) git show ":2:${f}" | normalize_json_without_keys version sdkVersion > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; } @@ -140,17 +178,13 @@ jobs: return "${status}" } - # Use a real merge, not a squash, so stable release tags become - # reachable from main. semantic-release uses reachable tags as the - # version floor for @next prereleases. - if git merge --no-ff --no-edit origin/stable; then - MERGE_STATUS="clean" - else + # A real merge makes stable release tags reachable from main, which + # semantic-release needs as the version floor for @next. + if ! git merge --no-ff --no-edit "$stable_sha"; then echo "Merge conflict โ€” attempting auto-resolution of release artifacts..." - # Auto-resolve release artifact conflicts only when the conflict is - # version-only: keep stable's already-published version, but never - # drop dependency, script, export, or package metadata changes. + # Only published-version fields may take stable's side. Other + # package metadata conflicts remain manual. while read -r f; do case "$f" in package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) @@ -166,7 +200,6 @@ jobs: done < <(git diff --name-only --diff-filter=U) if [ -z "$(git diff --name-only --diff-filter=U)" ]; then - MERGE_STATUS="auto_resolved" git commit -m "chore: merge stable into main (release conflicts auto-resolved)" else echo "Unresolved conflicts remain after version-only release artifact auto-resolution." @@ -176,45 +209,10 @@ jobs: fi fi - if ! git merge-base --is-ancestor origin/stable HEAD; then + if ! git merge-base --is-ancestor "$stable_sha" HEAD; then echo "Stable is still not in the sync branch ancestry after merge." exit 1 fi - git push origin "$SYNC_BRANCH" - - # Check for existing open sync PR - EXISTING=$(gh pr list --base main --head "$SYNC_BRANCH" --state open --json number -q '.[0].number' 2>/dev/null || true) - if [ -n "$EXISTING" ]; then - echo "Sync PR #$EXISTING already exists." - exit 0 - fi - - gh pr create \ - --base main \ - --head "$SYNC_BRANCH" \ - --title "$PR_TITLE" \ - --body "$(cat < The document engine for DOCX files.
Renders, edits, and automates .docx files in the browser, headless on the server, and within AI agent workflows.
- Self-hosted. Open source. Works with React, Vue, and vanilla JS. + Self-hosted. Open source. Works with React, Vue, Angular, Svelte, and vanilla JS.

diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 067e8e7596..08525d89be 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -12,7 +12,7 @@ const { * * Keep in sync with .github/workflows/release-cli.yml paths: trigger. */ -require('../../scripts/semantic-release/patch-commit-filter.cjs')([ +const RELEASE_PATHS = [ 'apps/cli', 'packages/document-api', 'packages/superdoc', @@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/preset-geometry', 'shared', 'pnpm-workspace.yaml', -]); +]; + +require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS); const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; @@ -33,16 +35,28 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases -const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; +const notesPlugin = isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc CLI', + paths: RELEASE_PATHS, + audience: 'Developers using the SuperDoc CLI to convert and process documents from the command line', + instructions: + 'This CLI wraps the document engine. Only mention engine changes when they change CLI commands, output, or supported document operations.', + }, + }, + ]; const config = { branches, @@ -89,13 +103,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-cli** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-cli** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', + }, + ]); +} module.exports = config; diff --git a/apps/create/.releaserc.cjs b/apps/create/.releaserc.cjs index d9078f8021..67afe4662a 100644 --- a/apps/create/.releaserc.cjs +++ b/apps/create/.releaserc.cjs @@ -13,12 +13,10 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -49,13 +47,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/create** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/create** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', + }, + ]); +} module.exports = config; diff --git a/apps/docs/ai/agents/core-preset.mdx b/apps/docs/ai/agents/core-preset.mdx index d695396ed4..67ec932ef8 100644 --- a/apps/docs/ai/agents/core-preset.mdx +++ b/apps/docs/ai/agents/core-preset.mdx @@ -350,9 +350,7 @@ Unknown action names in the list throw immediately (typo protection). The legacy ## Creating custom actions - -**Coming soon.** A first-class authoring kit (`defineAction`) is in the works: define your own action with a typed schema and handler, register it alongside the built-in forty, and it appears in the tool enum, the system prompt's action list, and the dispatch surface automatically โ€” same receipts, same exclusion support. Until it ships, custom capabilities can be added as [custom tools](/ai/agents/legacy-preset#creating-custom-tools) registered next to the preset's own tools in your loop. - +Use `defineAction` to add a named action with built-in `steps` or a native `run` function. `createAgentToolkit` keeps the resulting tool schema, system prompt, and dispatcher aligned. See [Custom actions](/ai/agents/custom-actions) for the SDK workflow and the optional coding-agent skill. ## Over MCP diff --git a/apps/docs/ai/agents/custom-actions.mdx b/apps/docs/ai/agents/custom-actions.mdx new file mode 100644 index 0000000000..a8aa783fd1 --- /dev/null +++ b/apps/docs/ai/agents/custom-actions.mdx @@ -0,0 +1,248 @@ +--- +title: Custom actions +sidebarTitle: Custom actions +description: "Add custom document actions to the core preset with defineAction and createAgentToolkit." +keywords: "superdoc custom actions, defineAction, createAgentToolkit, extendPreset, composePreset, superdoc_perform_action, llm document actions, authoring skill" +--- + +The [`core` preset](/ai/agents/core-preset) ships 40 built-in actions. Add a custom action when your agent needs another named document operation. Custom actions run through `superdoc_perform_action` alongside the built-ins. + +You define custom actions in your application with the installed SDK. The toolkit adds them to the tool schema, system prompt, and dispatcher together. + +## Quick start + +Define an action, pass it to `createAgentToolkit`, then call the returned dispatcher. These examples assume your application already has an open document handle named `doc`. + + + + ```typescript + import { createAgentToolkit, defineAction } from '@superdoc-dev/sdk'; + + const stampBanner = defineAction({ + name: 'superdoc.stamp_banner', + description: 'Insert a banner at the top of the document.', + input: { + type: 'object', + properties: { + label: { type: 'string', default: 'CONFIDENTIAL' }, + }, + }, + steps: [ + { + action: 'insert_paragraphs', + args: { + texts: ['{{label}}'], + placement: { at: 'document_start' }, + }, + }, + ], + }); + + const toolkit = await createAgentToolkit({ + provider: 'openai', + actions: [stampBanner], + }); + + const receipt = await toolkit.dispatch(doc, 'superdoc_perform_action', { + action: 'superdoc.stamp_banner', + label: 'CONFIDENTIAL', + }); + ``` + + + ```python + from superdoc import create_agent_toolkit, define_action + + stamp_banner = define_action( + name="superdoc.stamp_banner", + description="Insert a banner at the top of the document.", + input_schema={ + "type": "object", + "properties": { + "label": {"type": "string", "default": "CONFIDENTIAL"}, + }, + }, + steps=[ + { + "action": "insert_paragraphs", + "args": { + "texts": ["{{label}}"], + "placement": {"at": "document_start"}, + }, + }, + ], + ) + + toolkit = create_agent_toolkit({ + "provider": "openai", + "actions": [stamp_banner], + }) + + receipt = toolkit["dispatch"](doc, "superdoc_perform_action", { + "action": "superdoc.stamp_banner", + "label": "CONFIDENTIAL", + }) + ``` + + + +Custom arguments are flat beside `action`. Do not nest them under an `args` property. + +`createAgentToolkit` builds an ephemeral preset over `core`. The returned tools, system prompt, and dispatcher use the same action surface. Provider shaping for Anthropic, OpenAI, Vercel, and generic tools is automatic. + +## Choose an execution tier + +`defineAction` returns an `ActionSpec`. Every action spec uses exactly one execution tier: + +| Tier | What it does | Use it when | +| --- | --- | --- | +| **`steps`** | Runs a sequence of built-in `core` actions | The built-ins already cover the operation. This keeps their targeting and verification behavior. | +| **`run`** | Runs your function against the document handle | You need Document API operations or application logic that the built-ins do not provide. | + +Prefer `steps` when possible. In a step argument, a value that is exactly `{{label}}` passes the original argument value. Text around the placeholder interpolates it into a string. + +Use `run` for operations such as footnotes, headers, bookmarks, or table borders. The function runs in your application process. + + + + ```typescript + import { defineAction } from '@superdoc-dev/sdk'; + + const listFootnotes = defineAction({ + name: 'superdoc.list_footnotes', + description: 'List all footnotes in the document.', + input: { type: 'object', properties: {} }, + run: (doc) => doc.footnotes.list({}), + }); + ``` + + + ```python + from superdoc import define_action + + def _list_footnotes(doc, _args): + return doc.footnotes.list({}) + + list_footnotes = define_action( + name="superdoc.list_footnotes", + description="List all footnotes in the document.", + input_schema={"type": "object", "properties": {}}, + run=_list_footnotes, + ) + ``` + + + +## Limit the built-in actions + +Pass `includeCoreActions` to keep only selected built-ins. Custom actions passed through `actions` remain available alongside that subset. + +```typescript +import { createAgentToolkit } from '@superdoc-dev/sdk'; + +const toolkit = await createAgentToolkit({ + provider: 'openai', + includeCoreActions: ['insert_paragraphs', 'add_comments'], +}); +``` + +Python accepts the same option in the input dictionary: `"includeCoreActions": ["insert_paragraphs", "add_comments"]`. + +## Generate an action with a coding agent + +The `superdoc-custom-actions` skill guides Claude Code and Codex through choosing a tier, defining the action, wiring it into the toolkit, and testing it against a document. + +Install it from your application's root directory: + +```bash +npx skills add superdoc-dev/superdoc/packages/sdk/skills/superdoc-custom-actions --copy +``` + +Then ask your coding agent for the operation you need: + +> Add a custom action that inserts a footnote after a piece of text. + +The skill instructs the agent to verify the action against a real document when possible. If the environment cannot provide a suitable document, the agent should label the action as unverified and include a test for the first real run. + +`--copy` vendors the skill into your project. Add `-a ` if you want to install it for one supported coding agent only. + +## Python async clients + +If your application uses `AsyncSuperDocClient` or `dispatch_async`, a `run` action must use `async def` and await every `doc.*` call. A synchronous function receives coroutine values from the async document handle. `steps` actions do not require changes. + +## Reuse a named preset + + + Register a preset when several toolkit instances or standalone SDK functions need to resolve the same action set by ID. + + + + ```typescript + import { createAgentToolkit, defineAction, extendPreset, registerPreset } from '@superdoc-dev/sdk'; + + const listFootnotes = defineAction({ + name: 'superdoc.list_footnotes', + description: 'List all footnotes in the document.', + input: { type: 'object', properties: {} }, + run: (doc) => doc.footnotes.list({}), + }); + + registerPreset(extendPreset('core', { + id: 'custom_superdoc_preset', + actions: [listFootnotes], + })); + + const toolkit = await createAgentToolkit({ + provider: 'openai', + preset: 'custom_superdoc_preset', + }); + ``` + + + ```python + from superdoc import create_agent_toolkit, define_action, extend_preset, register_preset + + def _list_footnotes(doc, _args): + return doc.footnotes.list({}) + + list_footnotes = define_action( + name="superdoc.list_footnotes", + description="List all footnotes in the document.", + input_schema={"type": "object", "properties": {}}, + run=_list_footnotes, + ) + + register_preset(extend_preset( + "core", + id="custom_superdoc_preset", + actions=[list_footnotes], + )) + + toolkit = create_agent_toolkit({ + "provider": "openai", + "preset": "custom_superdoc_preset", + }) + ``` + + + + Registration is process-local. Run it during application startup before resolving the preset. Pass the preset ID to every preset-scoped standalone function. Common examples include: + + - Node.js: `chooseTools`, `getToolCatalog`, `listTools`, `getSystemPrompt`, and `dispatchSuperDocTool` + - Python: `choose_tools`, `get_tool_catalog`, `list_tools`, `get_system_prompt`, `dispatch_superdoc_tool`, and `dispatch_superdoc_tool_async` + + The dispatchers returned by `createAgentToolkit` and `create_agent_toolkit` are already bound to the preset. + + Use `composePreset` or `compose_preset` instead when a named preset should keep only selected built-in actions. + + +## Related + + + + The 40 built-in actions your custom actions extend. + + + The toolkit, dispatch, and agent loop. + + diff --git a/apps/docs/ai/agents/llm-tools.mdx b/apps/docs/ai/agents/llm-tools.mdx index 444b0968cd..9d2e79a160 100644 --- a/apps/docs/ai/agents/llm-tools.mdx +++ b/apps/docs/ai/agents/llm-tools.mdx @@ -465,7 +465,7 @@ Core-preset receipts make the events meaningful for users: `action` names read l Custom capabilities are documented per preset: -- **Core preset** โ€” [Creating custom actions](/ai/agents/core-preset#creating-custom-actions): the `defineAction` authoring kit (coming soon). +- **Core preset:** [Custom actions](/ai/agents/custom-actions) add named actions with `defineAction` and `createAgentToolkit`. - **Legacy preset** โ€” [Creating custom tools](/ai/agents/legacy-preset#creating-custom-tools): define provider tools that call `doc.*` operations and merge them with the SDK's. ## SDK functions diff --git a/apps/docs/ai/agents/skills.mdx b/apps/docs/ai/agents/skills.mdx index 50d7b62583..fcee99c829 100644 --- a/apps/docs/ai/agents/skills.mdx +++ b/apps/docs/ai/agents/skills.mdx @@ -1,15 +1,21 @@ --- title: Skills sidebarTitle: Skills -description: Reusable prompt templates that teach LLMs how to edit documents with SuperDoc tools -keywords: "llm skills, prompt templates, ai document editing, superdoc skills" +description: Install reusable instructions that help coding agents work with SuperDoc +keywords: "coding agent skills, custom actions, claude code, codex, superdoc skills" --- -Skills are reusable prompt files that teach LLMs how to use SuperDoc tools effectively. A skill contains editing instructions, tool usage patterns, and best practices: so your LLM agent knows how to query, mutate, and format documents without trial and error. +SuperDoc skills are reusable instruction bundles for coding agents. They provide task-specific guidance, examples, and verification steps for working with the SDK. - -We haven't shipped skills yet. Follow the repo for updates. - +## Custom actions + +The `superdoc-custom-actions` skill helps Claude Code and Codex define, wire, and test custom LLM actions. + +```bash +npx skills add superdoc-dev/superdoc/packages/sdk/skills/superdoc-custom-actions --copy +``` + +See [Custom actions](/ai/agents/custom-actions) for the SDK workflow, execution tiers, and installation details. ## Related diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 2b61a86d70..8b9252f7e9 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -201,6 +201,7 @@ "group": "Presets", "pages": ["ai/agents/core-preset", "ai/agents/legacy-preset"] }, + "ai/agents/custom-actions", "ai/agents/integrations", "ai/agents/best-practices", "ai/agents/debugging", diff --git a/apps/docs/scripts/validate-ai-snippets.mjs b/apps/docs/scripts/validate-ai-snippets.mjs index 4ee87a0142..71bb951e55 100644 --- a/apps/docs/scripts/validate-ai-snippets.mjs +++ b/apps/docs/scripts/validate-ai-snippets.mjs @@ -41,6 +41,7 @@ const PAGES = [ 'ai/agents/integrations.mdx', 'ai/agents/best-practices.mdx', 'ai/agents/debugging.mdx', + 'ai/agents/custom-actions.mdx', ]; if (!existsSync(SDK_DIST)) { diff --git a/apps/mcp/.releaserc.cjs b/apps/mcp/.releaserc.cjs index 6eff113f91..71fc1ff905 100644 --- a/apps/mcp/.releaserc.cjs +++ b/apps/mcp/.releaserc.cjs @@ -14,7 +14,7 @@ const { * Keep in sync with .github/workflows/release-mcp.yml paths and * .github/package-impact-map.md. */ -require('../../scripts/semantic-release/patch-commit-filter.cjs')([ +const RELEASE_PATHS = [ 'apps/mcp', 'packages/sdk', 'apps/cli', @@ -26,7 +26,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/preset-geometry', 'shared', 'pnpm-workspace.yaml', -]); +]; + +require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS); const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; @@ -37,16 +39,28 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases -const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; +const notesPlugin = isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc MCP', + paths: RELEASE_PATHS, + audience: 'Developers and AI agents using the SuperDoc MCP server tools', + instructions: + 'This server wraps the SDK and document engine as MCP tools. Only mention SDK or engine changes when they change tool behavior, schemas, or supported operations.', + }, + }, + ]; const config = { branches, @@ -92,13 +106,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/mcp** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/mcp** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', + }, + ]); +} module.exports = config; diff --git a/apps/vscode-ext/.releaserc.cjs b/apps/vscode-ext/.releaserc.cjs index 503eb1649c..f1a0ae0cee 100644 --- a/apps/vscode-ext/.releaserc.cjs +++ b/apps/vscode-ext/.releaserc.cjs @@ -13,7 +13,7 @@ const { * * Keep in sync with .github/workflows/release-vscode-ext.yml paths: trigger. */ -require('../../scripts/semantic-release/patch-commit-filter.cjs')([ +const RELEASE_PATHS = [ 'apps/vscode-ext', 'packages/superdoc', 'packages/super-editor', @@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/preset-geometry', 'shared', 'pnpm-workspace.yaml', -]); +]; + +require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS); const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; @@ -33,16 +35,28 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags still proceed on main. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases -const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; +const notesPlugin = isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc for VS Code', + paths: RELEASE_PATHS, + audience: 'VS Code users editing documents with the SuperDoc extension', + instructions: + 'This extension bundles the SuperDoc editor. Only mention editor/engine changes when they change what a VS Code user sees or can do inside the editor.', + }, + }, + ]; const config = { branches, @@ -65,16 +79,9 @@ const config = { ], }; -// VS Code Marketplace doesn't support semver prerelease versions (e.g., 0.0.1-next.1) -// Only publish stable releases to marketplace; prereleases get GitHub release with .vsix attached -if (isPrerelease) { - config.plugins.push([ - '@semantic-release/exec', - { - prepareCmd: 'pnpm run package', // Creates .vsix file only - }, - ]); -} else { +// VS Code Marketplace doesn't support semver prerelease versions, so only +// stable releases build and publish a .vsix. +if (!isPrerelease) { config.plugins.push([ '@semantic-release/exec', { @@ -103,14 +110,15 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - assets: [{ path: '*.vsix', label: 'VS Code Extension' }], - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **vscode-ext** v${nextRelease.version}', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + assets: [{ path: '*.vsix', label: 'VS Code Extension' }], + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **vscode-ext** v${nextRelease.version}', + }, + ]); +} module.exports = config; diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs index 99641a0d19..2ab6f02d36 100644 --- a/packages/esign/.releaserc.cjs +++ b/packages/esign/.releaserc.cjs @@ -21,12 +21,10 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases @@ -64,13 +62,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **esign** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **esign** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', + }, + ]); +} module.exports = config; diff --git a/packages/fonts/.releaserc.cjs b/packages/fonts/.releaserc.cjs index 88ec66b9b3..1d0d5b9fba 100644 --- a/packages/fonts/.releaserc.cjs +++ b/packages/fonts/.releaserc.cjs @@ -14,7 +14,8 @@ const branches = [ ]; const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -const shouldCommentOnRelease = !isPrerelease; +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; const shouldCommentOnLinearRelease = true; const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -49,13 +50,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/fonts** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/fonts** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', + }, + ]); +} module.exports = config; diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs index 0914f533f1..a6e95457fc 100644 --- a/packages/react/.releaserc.cjs +++ b/packages/react/.releaserc.cjs @@ -13,7 +13,7 @@ const { * When react migrates `superdoc` to peerDependencies, narrow this to * packages/react only. See .github/package-impact-map.md. */ -require('../../scripts/semantic-release/patch-commit-filter.cjs')([ +const RELEASE_PATHS = [ 'packages/react', 'packages/superdoc', 'packages/super-editor', @@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/preset-geometry', 'shared', 'pnpm-workspace.yaml', -]); +]; + +require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS); const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; @@ -33,16 +35,28 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases -const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; +const notesPlugin = isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc React', + paths: RELEASE_PATHS, + audience: 'React developers embedding the @superdoc-dev/react component', + instructions: + "This package wraps the SuperDoc editor for React. Only mention editor changes when they affect the embedded editor's behavior or the component's props and API.", + }, + }, + ]; const config = { branches, @@ -82,13 +96,14 @@ config.plugins.push([ { teamKeys: ['SD'], addComment: shouldCommentOnLinearRelease, packageName: 'react' }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/react** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/react** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', + }, + ]); +} module.exports = config; diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs index d051242492..968c28446a 100644 --- a/packages/sdk/.releaserc.cjs +++ b/packages/sdk/.releaserc.cjs @@ -10,7 +10,7 @@ const { * This shared helper patches git-log-parser to expand commit analysis to * dependency paths. It REPLACES semantic-release-commit-filter. */ -require('../../scripts/semantic-release/patch-commit-filter.cjs')([ +const RELEASE_PATHS = [ 'packages/sdk', 'apps/cli', 'packages/document-api', @@ -21,7 +21,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/preset-geometry', 'shared', 'pnpm-workspace.yaml', -]); +]; + +require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS); const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; const isCiRelease = Boolean(process.env.CI); @@ -33,16 +35,29 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases -const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; +const notesPlugin = isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc SDK', + paths: RELEASE_PATHS, + audience: + 'Developers integrating the SuperDoc SDK (Node and Python bindings) into their own backend or CLI tooling', + instructions: + "The SDK wraps the CLI and document engine. Only mention CLI or engine changes when they change the SDK's API, output, or supported operations.", + }, + }, + ]; const config = { branches, @@ -116,13 +131,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-sdk** v${nextRelease.version}', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-sdk** v${nextRelease.version}', + }, + ]); +} module.exports = config; diff --git a/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts b/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts new file mode 100644 index 0000000000..2aed62564f --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts @@ -0,0 +1,138 @@ +/** + * Custom-action END-TO-END test against the real CLI host. + * + * Registers an `extendPreset('core', { id:'acme', actions: footnoteActions })` + * preset, opens a real .docx, and dispatches `footnotes.add` then + * `footnotes.list` as run-tier custom actions. Proves the whole path โ€” + * dispatch โ†’ run(doc, args) โ†’ doc.footnotes.* over JSON-RPC โ†’ receipt โ€” works + * with ZERO CLI host changes. + * + * Requires the spawned host (SUPERDOC_CLI_BIN โ†’ apps/cli/src/index.ts, run via + * bun). Skips nothing โ€” if the host cannot start, the test fails loudly. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { createSuperDocClient } from '../index.ts'; +import { registerPreset, unregisterPreset } from '../tools.ts'; +import { dispatchSuperDocTool, chooseTools, createAgentToolkit } from '../tools.ts'; +import { extendPreset } from '../actions/define.ts'; +import { footnoteActions } from './fixtures/footnotes.ts'; + +// packages/sdk/langs/node/src/__tests__ โ†’ repo root +const REPO_ROOT = path.resolve(import.meta.dir, '../../../../../..'); +const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); +const FIXTURE_DOC = path.join(REPO_ROOT, 'packages/super-editor/src/editors/v1/tests/data/advanced-text.docx'); + +const E2E_TIMEOUT_MS = 60_000; + +describe('custom footnote actions (e2e)', () => { + beforeAll(() => { + registerPreset(extendPreset('core', { id: 'acme', actions: footnoteActions })); + }); + afterAll(() => { + try { + unregisterPreset('acme'); + } catch { + // ignore + } + }); + + test('footnote action names appear in the acme superdoc_perform_action enum', async () => { + const { tools } = await chooseTools({ provider: 'anthropic', preset: 'acme' }); + const actionTool = tools.find((t) => (t as { name?: string }).name === 'superdoc_perform_action') as + | { input_schema?: { properties?: { action?: { enum?: string[] } } } } + | undefined; + const names = actionTool?.input_schema?.properties?.action?.enum ?? []; + for (const r of footnoteActions) expect(names).toContain(r.name); + }); + + test( + 'dispatch footnotes.add then footnotes.list shows the inserted note', + async () => { + const client = createSuperDocClient({ + env: { SUPERDOC_CLI_BIN: CLI_BIN }, + }); + try { + await client.connect(); + const doc = await client.open({ doc: FIXTURE_DOC }); + + // Find a body paragraph block to anchor the footnote on. + const blocks = (await doc.blocks.list({ limit: 50, includeText: true })) as { + blocks: Array<{ nodeId: string; nodeType: string; text?: string }>; + }; + const para = blocks.blocks.find((b) => b.nodeType === 'paragraph' && (b.text?.length ?? 0) > 0); + expect(para).toBeDefined(); + const at = { + kind: 'text', + segments: [{ blockId: para!.nodeId, range: { start: 0, end: 0 } }], + }; + + const addReceipt = (await dispatchSuperDocTool( + doc, + 'superdoc_perform_action', + { action: 'footnotes.add', at, content: 'Inserted by the footnotes.add custom action.' }, + { preset: 'acme' }, + )) as { status: string; action: string; result?: unknown }; + + expect(addReceipt.action).toBe('footnotes.add'); + expect(addReceipt.status).toBe('succeeded'); + + const listReceipt = (await dispatchSuperDocTool( + doc, + 'superdoc_perform_action', + { action: 'footnotes.list' }, + { preset: 'acme' }, + )) as { status: string; result?: { items?: unknown[] } }; + + expect(listReceipt.status).toBe('succeeded'); + const items = listReceipt.result?.items ?? []; + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + + await doc.close({ discard: true }); + } finally { + await client.dispose(); + } + }, + E2E_TIMEOUT_MS, + ); + + test( + 'a custom action executes through the one-call createAgentToolkit dispatch', + async () => { + // The whole point of the review item: build the toolkit from `actions` + // and run a custom action through the RETURNED dispatch (no preset id). + const kit = await createAgentToolkit({ provider: 'anthropic', actions: footnoteActions }); + const client = createSuperDocClient({ env: { SUPERDOC_CLI_BIN: CLI_BIN } }); + try { + await client.connect(); + const doc = await client.open({ doc: FIXTURE_DOC }); + const blocks = (await doc.blocks.list({ limit: 50, includeText: true })) as { + blocks: Array<{ nodeId: string; nodeType: string; text?: string }>; + }; + const para = blocks.blocks.find((b) => b.nodeType === 'paragraph' && (b.text?.length ?? 0) > 0); + expect(para).toBeDefined(); + const at = { kind: 'text', segments: [{ blockId: para!.nodeId, range: { start: 0, end: 0 } }] }; + + const addReceipt = (await kit.dispatch(doc, 'superdoc_perform_action', { + action: 'footnotes.add', + at, + content: 'Inserted via the one-call toolkit dispatch.', + })) as { status: string; action: string }; + expect(addReceipt.action).toBe('footnotes.add'); + expect(addReceipt.status).toBe('succeeded'); + + const listReceipt = (await kit.dispatch(doc, 'superdoc_perform_action', { + action: 'footnotes.list', + })) as { status: string; result?: { items?: unknown[] } }; + expect(listReceipt.status).toBe('succeeded'); + expect((listReceipt.result?.items ?? []).length).toBeGreaterThanOrEqual(1); + + await doc.close({ discard: true }); + } finally { + await client.dispose(); + } + }, + E2E_TIMEOUT_MS, + ); +}); diff --git a/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts b/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts new file mode 100644 index 0000000000..b814171de4 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts @@ -0,0 +1,1130 @@ +/** + * Custom-action tests. + * + * Most run WITHOUT the CLI host binary: they use a fake base preset to assert + * the merged tool list / catalog / prompt, exclusion coherence, tier routing, + * and receipt shapes. The run-tier fixture (footnotes) exercises the same + * surfaces the e2e test drives against the real host. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { BoundDocApi } from '../generated/client.ts'; +import { composePreset, defineAction, executionKindOf, extendPreset } from '../actions/define.ts'; +import { footnoteActions } from './fixtures/footnotes.ts'; +import { ACTION_NAMES_LIST } from '../agent/actions.ts'; +import { chooseTools, createAgentToolkit, dispatchSuperDocTool, getToolCatalog, getSystemPrompt } from '../tools.ts'; +import { getPreset, listPresets, registerPreset, unregisterPreset } from '../presets.ts'; +import { SuperDocCliError } from '../runtime/errors.js'; + +const REGISTERED: string[] = []; +afterEach(() => { + for (const id of REGISTERED.splice(0)) { + try { + unregisterPreset(id); + } catch { + // ignore + } + } +}); + +function actionProp(tool: unknown): { enum?: string[] } | undefined { + const t = tool as { + parameters?: { properties?: Record }; + input_schema?: { properties?: Record }; + function?: { parameters?: { properties?: Record } }; + }; + const props = t.function?.parameters?.properties ?? t.parameters?.properties ?? t.input_schema?.properties ?? {}; + return props.action as { enum?: string[] } | undefined; +} + +function schemaOf(tool: unknown): { properties?: Record } | undefined { + const t = tool as { + parameters?: { properties?: Record }; + input_schema?: { properties?: Record }; + function?: { parameters?: { properties?: Record } }; + }; + return t.function?.parameters ?? t.parameters ?? t.input_schema; +} + +function toolName(tool: unknown): string { + const t = tool as { name?: string; function?: { name?: string } }; + return t.function?.name ?? t.name ?? ''; +} + +describe('defineAction โ€” canonical type, two tiers', () => { + test('run tier keeps the native function (NOT serialized)', () => { + const run = (_doc: unknown, args: Record) => args; + const spec = defineAction({ + name: 'demo.echo', + description: 'echo', + input: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] }, + run, + }); + expect(spec.name).toBe('demo.echo'); + expect(spec.run).toBe(run); + expect(spec.inputSchema.required).toEqual(['x']); + }); + + test('steps tier normalizes steps and validates against built-ins', () => { + const spec = defineAction({ + name: 'demo.steps', + description: 'd', + input: { type: 'object', properties: { label: { type: 'string', default: 'X' } } }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }], + }); + expect(spec.steps).toEqual([{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }]); + expect(executionKindOf(spec)).toBe('steps'); + }); + + test('steps referencing a non-built-in action are rejected at define time', () => { + expect(() => + defineAction({ + name: 'demo.badstep', + description: 'd', + steps: [{ action: 'not_a_real_action', args: {} }], + }), + ).toThrow(SuperDocCliError); + }); + + test('requires exactly one execution tier', () => { + expect(() => defineAction({ name: 'demo.none', description: 'd' } as never)).toThrow(SuperDocCliError); + expect(() => + defineAction({ + name: 'demo.two', + description: 'd', + run: () => null, + steps: [{ action: 'insert_paragraphs' }], + } as never), + ).toThrow(SuperDocCliError); + }); +}); + +describe('extendPreset โ€” tool merging', () => { + test('footnote action names appear in superdoc_perform_action enum (anthropic)', async () => { + const acme = extendPreset('core', { id: 'acme', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme'); + const { tools, meta } = await chooseTools({ provider: 'anthropic', preset: 'acme' }); + expect(meta.preset).toBe('acme'); + const actionTool = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + expect(actionTool).toBeDefined(); + const names = actionProp(actionTool)?.enum ?? []; + for (const r of footnoteActions) expect(names).toContain(r.name); + // built-in actions are still present + expect(names).toContain('insert_paragraphs'); + // union'd properties: noteId/content come from footnote actions + const props = + (actionTool as { input_schema?: { properties?: Record } }).input_schema?.properties ?? {}; + expect(props.noteId).toBeDefined(); + expect(props.content).toBeDefined(); + }); + + test('open object schema without `properties` merges without throwing', async () => { + // A legal open schema that omits `properties` (e.g. `{ type: 'object', + // additionalProperties: true }`) must not crash getTools for the WHOLE + // preset. coerceInputSchema normalizes the missing bag to {}, and the merge + // loop guards with `?? {}` to match its twin (synthesizePerformAction) and + // the Python mirror. + const open = defineAction({ + name: 'demo.open', + description: 'open schema, no properties', + input: { type: 'object', additionalProperties: true }, + run: (_doc, args) => args, + }); + expect(open.inputSchema.properties).toEqual({}); + const acme = extendPreset('core', { id: 'acme-open', actions: [open] }); + registerPreset(acme); + REGISTERED.push('acme-open'); + const { tools } = await chooseTools({ provider: 'anthropic', preset: 'acme-open' }); + const actionTool = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + expect(actionTool).toBeDefined(); + const names = actionProp(actionTool)?.enum ?? []; + expect(names).toContain('demo.open'); + }); + + test('standalone:true exposes each action as its own provider tool', async () => { + // Standalone tool names become provider tool names, so they must be + // provider-safe (no dots). Use underscore-named variants of the footnotes. + const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') })); + const acme = extendPreset('core', { id: 'acme-standalone', actions: safe, standalone: true }); + registerPreset(acme); + REGISTERED.push('acme-standalone'); + const { tools } = await chooseTools({ provider: 'openai', preset: 'acme-standalone' }); + const names = tools.map(toolName); + for (const r of safe) expect(names).toContain(r.name); + const fnAdd = tools.find((t) => toolName(t) === 'footnotes_add') as { + type?: string; + function?: { parameters?: unknown }; + }; + expect(fnAdd.type).toBe('function'); + expect(fnAdd.function?.parameters).toBeDefined(); + }); + + test('standalone vercel tools use the FLAT core agent dialect', async () => { + const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') })); + const acme = extendPreset('core', { id: 'acme-vercel', actions: safe, standalone: true }); + registerPreset(acme); + REGISTERED.push('acme-vercel'); + const { tools } = await chooseTools({ provider: 'vercel', preset: 'acme-vercel' }); + const fnAdd = tools.find((t) => toolName(t) === 'footnotes_add') as { + type?: string; + inputSchema?: unknown; + function?: unknown; + }; + // Core's vercel dialect is flat {name, description, inputSchema} โ€” the + // nested OpenAI shape here would be invisible to Vercel AI SDK callers. + expect(fnAdd.function).toBeUndefined(); + expect(fnAdd.type).toBeUndefined(); + expect(fnAdd.inputSchema).toBeDefined(); + }); + + test('merged vercel advertises custom actions in the flat-dialect enum', async () => { + const acme = extendPreset('core', { id: 'acme-vercel-merged', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme-vercel-merged'); + const { tools } = await chooseTools({ provider: 'vercel', preset: 'acme-vercel-merged' }); + const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action') as { + inputSchema?: { properties?: { action?: { enum?: string[] } } }; + }; + expect(perform.inputSchema?.properties?.action?.enum).toContain('footnotes.add'); + }); + + test('excludeActions narrows custom AND builtin actions coherently', async () => { + const acme = extendPreset('core', { id: 'acme-excl', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme-excl'); + const excludeActions = ['footnotes.add', 'create_table']; + const { tools } = await chooseTools({ provider: 'openai', preset: 'acme-excl', excludeActions }); + const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action') as { + function?: { parameters?: { properties?: { action?: { enum?: string[] } } } }; + }; + const names = perform.function?.parameters?.properties?.action?.enum ?? []; + expect(names).not.toContain('footnotes.add'); // custom excluded by the wrapper + expect(names).not.toContain('create_table'); // builtin excluded by the base + expect(names).toContain('footnotes.list'); // other customs survive + + const prompt = await getSystemPrompt('acme-excl', { excludeActions }); + expect(prompt).not.toContain('- footnotes.add โ€”'); + expect(prompt).toContain('footnotes.list'); + + // Defense-in-depth: dispatching the excluded CUSTOM action is refused. + await expect( + dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'footnotes.add', at: {}, content: 'x' }, + { preset: 'acme-excl', excludeActions } as never, + ), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT', details: { excluded: true } }); + }); + + test('standalone mode does NOT route customs through perform_action (unadvertised path)', async () => { + const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') })); + const acme = extendPreset('core', { id: 'acme-standalone-gate', actions: safe, standalone: true }); + registerPreset(acme); + REGISTERED.push('acme-standalone-gate'); + // The standalone surface advertises footnotes_add as its OWN tool, so the + // perform_action route must fall through to the base, which rejects it. + await expect( + dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'footnotes_add', at: {}, content: 'x' }, + { preset: 'acme-standalone-gate' }, + ), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + }); + + test('standalone action keeps an `action`-named argument (not treated as discriminator)', async () => { + let received: Record | undefined; + const spec = defineAction({ + name: 'wf_trigger', + description: 'run a workflow step', + input: { + type: 'object', + properties: { action: { type: 'string', enum: ['approve', 'reject'] } }, + required: ['action'], + }, + run: (_doc, args) => { + received = args; + return { ok: true }; + }, + }); + const acme = extendPreset('core', { id: 'acme-standalone-arg', actions: [spec], standalone: true }); + registerPreset(acme); + REGISTERED.push('acme-standalone-arg'); + // Dispatched as its OWN tool: `action` is a real arg, must survive. + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'wf_trigger', + { action: 'approve' }, + { preset: 'acme-standalone-arg' }, + )) as { status: string }; + expect(receipt.status).toBe('succeeded'); + expect(received).toEqual({ action: 'approve' }); + }); + + test('standalone rejects dotted action names; merged accepts them', () => { + // Dotted name is invalid as a provider tool name (standalone), valid as an + // enum value (merged). + expect(() => extendPreset('core', { id: 'bad-standalone', actions: footnoteActions, standalone: true })).toThrow( + SuperDocCliError, + ); + expect(() => extendPreset('core', { id: 'ok-merged', actions: footnoteActions })).not.toThrow(); + }); + + test('anthropic cache:true marks exactly the LAST tool after standalone append', async () => { + const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') })); + const acme = extendPreset('core', { id: 'acme-cache', actions: safe, standalone: true }); + registerPreset(acme); + REGISTERED.push('acme-cache'); + const { tools, meta } = await chooseTools({ provider: 'anthropic', preset: 'acme-cache', cache: true }); + expect(meta.cacheStrategy).toBe('explicit'); + const withMarker = tools.filter((t) => (t as Record).cache_control != null); + expect(withMarker.length).toBe(1); + // and it must be the final tool in the list + expect((tools[tools.length - 1] as Record).cache_control).toEqual({ type: 'ephemeral' }); + }); + + test('catalog and system prompt include custom actions', async () => { + const acme = extendPreset('core', { id: 'acme-cat', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme-cat'); + const catalog = await getToolCatalog('acme-cat'); + const row = catalog.tools.find((t) => t.toolName === 'footnotes.add'); + expect(row).toBeDefined(); + expect(row?.mutates).toBe(true); + expect(row?.operations).toEqual([]); + const prompt = await getSystemPrompt('acme-cat'); + expect(prompt).toContain('## Custom actions'); + expect(prompt).toContain('footnotes.add'); + }); +}); + +describe('collision / duplicate validation', () => { + test('rejects a custom action named after a reserved tool', () => { + const bad = defineAction({ name: 'superdoc_execute_code', description: 'x', run: () => null }); + expect(() => extendPreset('core', { id: 'bad-reserved', actions: [bad], standalone: true })).toThrow( + SuperDocCliError, + ); + }); + + test('explicit null for an optional enum arg is rejected (a value, not an absence)', async () => { + const spec = defineAction({ + name: 'acme.enumnull', + description: 'd', + input: { type: 'object', properties: { mode: { type: 'string', enum: ['a', 'b'], default: 'a' } } }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }], + }); + const acme = extendPreset('core', { id: 'acme-enumnull', actions: [spec] }); + registerPreset(acme); + REGISTERED.push('acme-enumnull'); + await expect( + dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.enumnull', mode: null }, + { preset: 'acme-enumnull' }, + ), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + }); + + test('required arg with a declared default is satisfied by the default', async () => { + const calls: Array> = []; + registerPreset({ + id: 'req-default-base', + description: 'f', + supportsCacheControl: true, + getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }), + getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }), + getSystemPrompt: async () => 'b', + getMcpPrompt: async () => 'b', + dispatch: async (_h: BoundDocApi, _t: string, args: Record) => { + calls.push(args); + return { status: 'ok' }; + }, + }); + REGISTERED.push('req-default-base'); + const spec = defineAction({ + name: 'acme.reqdef', + description: 'd', + input: { type: 'object', properties: { label: { type: 'string', default: 'D' } }, required: ['label'] }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }], + }); + const acme = extendPreset('req-default-base', { id: 'acme-reqdef', actions: [spec] }); + registerPreset(acme); + REGISTERED.push('acme-reqdef'); + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.reqdef' }, // label omitted โ€” default must satisfy `required` + { preset: 'acme-reqdef' }, + )) as { status: string }; + expect(receipt.status).toBe('succeeded'); + expect(calls[0]!.texts).toEqual(['D']); + }); + + test('rejects a custom action colliding with a built-in name', () => { + const builtin = ACTION_NAMES_LIST[0]!; + const bad = defineAction({ name: builtin, description: 'x', run: () => null }); + expect(() => extendPreset('core', { id: 'bad', actions: [bad] })).toThrow(SuperDocCliError); + }); + + test('rejects duplicate custom names', () => { + const a = defineAction({ name: 'dup.one', description: 'a', run: () => null }); + const b = defineAction({ name: 'dup.one', description: 'b', run: () => null }); + expect(() => extendPreset('core', { id: 'dup', actions: [a, b] })).toThrow(SuperDocCliError); + }); +}); + +describe('registerPreset / unregisterPreset', () => { + test('cannot overwrite a built-in preset id', () => { + const fake = extendPreset('core', { id: 'will-rename', actions: [] }); + const asCore = { ...fake, id: 'core' }; + expect(() => registerPreset(asCore)).toThrow(SuperDocCliError); + }); + + test('cannot unregister a built-in', () => { + expect(() => unregisterPreset('core')).toThrow(SuperDocCliError); + }); + + test('register then resolve then unregister', () => { + const p = extendPreset('core', { id: 'tmp-preset', actions: footnoteActions }); + registerPreset(p); + expect(getPreset('tmp-preset').id).toBe('tmp-preset'); + unregisterPreset('tmp-preset'); + expect(() => getPreset('tmp-preset')).toThrow(SuperDocCliError); + }); +}); + +describe('dispatch โ€” validation + delegation (tier-agnostic)', () => { + test('missing required arg throws INVALID_ARGUMENT before dispatch', async () => { + const acme = extendPreset('core', { id: 'acme-validate', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme-validate'); + await expect( + dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'footnotes.add', content: 'x' }, + { preset: 'acme-validate' }, + ), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + }); + + test('a non-custom action delegates to the base dispatch', async () => { + let delegated = false; + registerPreset({ + id: 'fake-delegate', + description: 'f', + supportsCacheControl: true, + getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }), + getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }), + getSystemPrompt: async () => 'b', + getMcpPrompt: async () => 'b', + dispatch: async (_h: BoundDocApi, tool: string, args: Record) => { + delegated = true; + return { delegatedTool: tool, action: args.action }; + }, + }); + REGISTERED.push('fake-delegate'); + const acme = extendPreset('fake-delegate', { id: 'acme-delegate', actions: footnoteActions }); + registerPreset(acme); + REGISTERED.push('acme-delegate'); + const result = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'insert_paragraphs', text: 'hi' }, + { preset: 'acme-delegate' }, + )) as { delegatedTool: string }; + expect(delegated).toBe(true); + expect(result.delegatedTool).toBe('superdoc_perform_action'); + }); +}); + +describe('dispatch โ€” steps tier', () => { + /** Fake base that records perform_action step dispatches and returns scripted receipts. */ + function stepsBase(id: string, receipts: Array | Error>) { + const calls: Array> = []; + let cursor = 0; + registerPreset({ + id, + description: 'fake steps base', + supportsCacheControl: true, + getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }), + getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }), + getSystemPrompt: async () => 'base', + getMcpPrompt: async () => 'base', + dispatch: async (_h: BoundDocApi, tool: string, args: Record) => { + expect(tool).toBe('superdoc_perform_action'); + calls.push(args); + const next = receipts[Math.min(cursor, receipts.length - 1)]!; + cursor += 1; + if (next instanceof Error) throw next; + return next; + }, + }); + REGISTERED.push(id); + return calls; + } + + const stamp = defineAction({ + name: 'acme.stamp', + description: 'banner + comment', + input: { + type: 'object', + properties: { label: { type: 'string', default: 'CONFIDENTIAL' } }, + }, + steps: [ + { action: 'insert_paragraphs', args: { texts: ['{{label}}'], placement: { at: 'document_start' } } }, + { + action: 'add_comments', + args: { + selectors: [{ kind: 'textSearch', terms: ['{{label}}'] }], + commentText: 'Stamped: {{label}} โ€” verify.', + }, + }, + ], + }); + + test('dispatches each step through the base with templating + defaults applied', async () => { + const calls = stepsBase('steps-ok', [{ status: 'ok', verificationPassed: true }]); + const acme = extendPreset('steps-ok', { id: 'acme-steps', actions: [stamp] }); + registerPreset(acme); + REGISTERED.push('acme-steps'); + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.stamp' }, // label omitted โ†’ default applies + { preset: 'acme-steps' }, + )) as { status: string; steps: Array> }; + + expect(receipt.status).toBe('succeeded'); + expect(receipt.steps).toHaveLength(2); + // Whole-string template preserved the raw array value; default filled in. + expect(calls[0]).toEqual({ + action: 'insert_paragraphs', + texts: ['CONFIDENTIAL'], + placement: { at: 'document_start' }, + }); + // Partial template interpolated as text. + expect(calls[1]!.commentText).toBe('Stamped: CONFIDENTIAL โ€” verify.'); + expect(calls[1]!.selectors).toEqual([{ kind: 'textSearch', terms: ['CONFIDENTIAL'] }]); + }); + + test('caller-level changeMode reaches steps that do not pin their own', async () => { + const calls = stepsBase('steps-cm', [{ status: 'ok' }]); + const acme = extendPreset('steps-cm', { id: 'acme-steps-cm', actions: [stamp] }); + registerPreset(acme); + REGISTERED.push('acme-steps-cm'); + await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.stamp', label: 'X', changeMode: 'tracked' }, + { preset: 'acme-steps-cm' }, + ); + expect(calls[0]!.changeMode).toBe('tracked'); + expect(calls[1]!.changeMode).toBe('tracked'); + }); + + test('surface excludeActions are NOT forwarded into internal step dispatch', async () => { + // Base that simulates core's defense-in-depth: refuse any call whose action + // is in the invoke-time excludeActions. If a surface exclusion leaked into + // the steps, the insert_paragraphs step would be refused and acme.stamp + // (advertised!) would fail โ€” the coherence bug this guards against. + const seenOptions: Array<{ excludeActions?: unknown } | undefined> = []; + registerPreset({ + id: 'steps-excl-base', + description: 'fake base enforcing invoke-time exclusions', + supportsCacheControl: true, + getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }), + getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }), + getSystemPrompt: async () => 'base', + getMcpPrompt: async () => 'base', + dispatch: async (_h: BoundDocApi, _tool: string, args: Record, invokeOptions?: unknown) => { + const opts = invokeOptions as { excludeActions?: string[] } | undefined; + seenOptions.push(opts); + if (opts?.excludeActions?.includes(args.action as string)) { + throw new SuperDocCliError(`Action ${String(args.action)} is excluded by configuration.`, { + code: 'INVALID_ARGUMENT', + details: { excluded: true }, + }); + } + return { status: 'ok', verificationPassed: true }; + }, + }); + REGISTERED.push('steps-excl-base'); + const acme = extendPreset('steps-excl-base', { id: 'acme-steps-excl', actions: [stamp] }); + registerPreset(acme); + REGISTERED.push('acme-steps-excl'); + + // Hide insert_paragraphs from the model, but acme.stamp composes it. + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.stamp' }, + { preset: 'acme-steps-excl', excludeActions: ['insert_paragraphs'] } as never, + )) as { status: string; steps: unknown[] }; + expect(receipt.status).toBe('succeeded'); // step NOT refused + expect(receipt.steps).toHaveLength(2); + // Every internal step dispatch saw options WITHOUT excludeActions. + expect(seenOptions.every((o) => o?.excludeActions === undefined)).toBe(true); + + // Guard preserved: a DIRECT model call to the hidden built-in is still refused. + await expect( + dispatchSuperDocTool({} as BoundDocApi, 'superdoc_perform_action', { action: 'insert_paragraphs', text: 'x' }, { + preset: 'acme-steps-excl', + excludeActions: ['insert_paragraphs'], + } as never), + ).rejects.toThrow(/excluded/); + }); + + test('second-step failure aggregates to partial with failedStep evidence', async () => { + stepsBase('steps-fail2', [{ status: 'ok' }, { status: 'failed', errors: [{ message: 'no match' }] }]); + const acme = extendPreset('steps-fail2', { id: 'acme-steps-f2', actions: [stamp] }); + registerPreset(acme); + REGISTERED.push('acme-steps-f2'); + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.stamp' }, + { preset: 'acme-steps-f2' }, + )) as { status: string; failedStep?: { index: number } }; + expect(receipt.status).toBe('partial'); + expect(receipt.failedStep?.index).toBe(1); + }); + + test('first-step thrown validation error aggregates to failed', async () => { + stepsBase('steps-throw', [new SuperDocCliError('bad args', { code: 'INVALID_ARGUMENT' })]); + const acme = extendPreset('steps-throw', { id: 'acme-steps-throw', actions: [stamp] }); + registerPreset(acme); + REGISTERED.push('acme-steps-throw'); + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.stamp' }, + { preset: 'acme-steps-throw' }, + )) as { status: string; steps: Array<{ status: string }> }; + expect(receipt.status).toBe('failed'); + expect(receipt.steps[0]!.status).toBe('failed'); + }); +}); + +describe('dispatch โ€” steps tier extras', () => { + const parity = defineAction({ + name: 'acme.parity', + description: 'd', + steps: [{ action: 'insert_paragraphs', args: { texts: ['flag={{flag}} items={{items}} obj={{obj}}'] } }], + }); + const arrmiss = defineAction({ + name: 'acme.arrmiss', + description: 'd', + steps: [{ action: 'insert_paragraphs', args: { texts: ['{{present}}', '{{absent}}'] } }], + }); + + function stepsBase2(id: string, receipts: Array>) { + const calls: Array> = []; + let cursor = 0; + registerPreset({ + id, + description: 'fake', + supportsCacheControl: true, + getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }), + getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }), + getSystemPrompt: async () => 'base', + getMcpPrompt: async () => 'base', + dispatch: async (_h: BoundDocApi, _tool: string, args: Record) => { + calls.push(args); + const next = receipts[Math.min(cursor, receipts.length - 1)]!; + cursor += 1; + return next; + }, + }); + REGISTERED.push(id); + return calls; + } + + test('a PARTIAL step never rolls up into succeeded', async () => { + stepsBase2('steps-partial', [{ status: 'ok' }, { status: 'partial' }]); + const spec = defineAction({ + name: 'acme.twostep', + description: 'd', + steps: [ + { action: 'insert_paragraphs', args: { texts: ['a'] } }, + { action: 'add_comments', args: { selectors: [], commentText: 'c' } }, + ], + }); + const acme = extendPreset('steps-partial', { id: 'acme-partial', actions: [spec] }); + registerPreset(acme); + REGISTERED.push('acme-partial'); + const receipt = (await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.twostep' }, + { preset: 'acme-partial' }, + )) as { status: string; failedStep?: { index: number } }; + expect(receipt.status).toBe('partial'); + expect(receipt.failedStep?.index).toBe(1); + }); + + test('partial-template text forms are the cross-language JSON forms', async () => { + const calls = stepsBase2('steps-parity', [{ status: 'ok' }]); + const acme = extendPreset('steps-parity', { id: 'acme-parity', actions: [parity] }); + registerPreset(acme); + REGISTERED.push('acme-parity'); + await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.parity', flag: true, items: [1, 2], obj: { a: 1 } }, + { preset: 'acme-parity' }, + ); + expect(calls[0]!.texts).toEqual(['flag=true items=[1,2] obj={"a":1}']); + }); + + test('whole-string templates for absent args are dropped from arrays', async () => { + const calls = stepsBase2('steps-arrmiss', [{ status: 'ok' }]); + const acme = extendPreset('steps-arrmiss', { id: 'acme-arrmiss', actions: [arrmiss] }); + registerPreset(acme); + REGISTERED.push('acme-arrmiss'); + await dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'acme.arrmiss', present: 'AAA' }, + { preset: 'acme-arrmiss' }, + ); + expect(calls[0]!.texts).toEqual(['AAA']); + }); + + test('a raw spec with empty steps is rejected at extend time', () => { + const raw = { + name: 'acme.empty', + description: 'd', + inputSchema: { type: 'object', properties: {} }, + steps: [], + } as never; + expect(() => extendPreset('core', { id: 'acme-empty', actions: [raw] })).toThrow(SuperDocCliError); + }); +}); + +describe('dispatch โ€” run tier (native)', () => { + function handleWithRevisions(revisions: string[]) { + let i = 0; + return { + info: async () => ({ revision: revisions[Math.min(i++, revisions.length - 1)] }), + } as unknown as BoundDocApi; + } + + test('success receipt carries pre/post revision and the result', async () => { + const native = defineAction({ + name: 'acme.native', + description: 'd', + input: { type: 'object', properties: { x: { type: 'number', default: 7 } } }, + run: async (_doc, args) => ({ got: args.x }), + }); + const acme = extendPreset('core', { id: 'acme-native', actions: [native] }); + registerPreset(acme); + REGISTERED.push('acme-native'); + const receipt = (await dispatchSuperDocTool( + handleWithRevisions(['0', '1']), + 'superdoc_perform_action', + { action: 'acme.native' }, + { preset: 'acme-native' }, + )) as Record; + expect(receipt.status).toBe('succeeded'); + expect(receipt.result).toEqual({ got: 7 }); + expect(receipt.preRevision).toBe('0'); + expect(receipt.postRevision).toBe('1'); + }); + + test('failure after mutation reports partialMutation + revert recovery', async () => { + const native = defineAction({ + name: 'acme.native-fail', + description: 'd', + run: async () => { + throw new Error('boom between ops'); + }, + }); + const acme = extendPreset('core', { id: 'acme-native-fail', actions: [native] }); + registerPreset(acme); + REGISTERED.push('acme-native-fail'); + const receipt = (await dispatchSuperDocTool( + handleWithRevisions(['3', '4']), + 'superdoc_perform_action', + { action: 'acme.native-fail' }, + { preset: 'acme-native-fail' }, + )) as Record; + expect(receipt.status).toBe('failed'); + expect(receipt.partialMutation).toBe(true); + expect((receipt.recovery as { kind: string }).kind).toBe('revert'); + }); + + test('failure without mutation reports retry recovery', async () => { + const native = defineAction({ + name: 'acme.native-clean-fail', + description: 'd', + run: async () => { + throw new Error('failed before touching the doc'); + }, + }); + const acme = extendPreset('core', { id: 'acme-native-cf', actions: [native] }); + registerPreset(acme); + REGISTERED.push('acme-native-cf'); + const receipt = (await dispatchSuperDocTool( + handleWithRevisions(['5', '5']), + 'superdoc_perform_action', + { action: 'acme.native-clean-fail' }, + { preset: 'acme-native-cf' }, + )) as Record; + expect(receipt.status).toBe('failed'); + expect(receipt.partialMutation).toBe(false); + expect((receipt.recovery as { kind: string }).kind).toBe('retry'); + }); +}); + +describe('composePreset', () => { + test('custom-only preset (includeCoreActions: []) still advertises perform_action', async () => { + const composed = composePreset({ + id: 'composed-custom-only', + baseId: 'core', + includeCoreActions: [], + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-custom-only'); + const { tools } = await chooseTools({ provider: 'openai', preset: 'composed-custom-only' }); + const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + expect(perform).toBeDefined(); // synthesized โ€” the base dropped it + const names = actionProp(perform)?.enum ?? []; + for (const r of footnoteActions) expect(names).toContain(r.name); + expect(names).not.toContain('insert_paragraphs'); + }); + + test('includeCoreActions + excludeActions(custom) does not leak the excluded name into the enum', async () => { + const composed = composePreset({ + id: 'composed-leak', + baseId: 'core', + includeCoreActions: ['insert_paragraphs'], + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-leak'); + const { tools } = await chooseTools({ + provider: 'generic', + preset: 'composed-leak', + excludeActions: ['footnotes.add'], + }); + const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + const names = actionProp(perform)?.enum ?? []; + expect(names).not.toContain('footnotes.add'); // must not leak back via the allowlist rebuild + expect(names).toContain('footnotes.list'); + expect(names).toContain('insert_paragraphs'); + // The DESCRIPTION narrows with the allowlist too โ€” the base rebuilds it + // from the derived exclusion, not a hand-rolled filter. + const description = (perform as { description?: string }).description ?? ''; + expect(description).not.toContain('create_table'); + }); + + test('getToolCatalog narrows the composed perform_action enum, args, AND description with the allowlist', async () => { + const composed = composePreset({ + id: 'composed-cat', + baseId: 'core', + includeCoreActions: ['insert_paragraphs'], + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-cat'); + const catalog = await getToolCatalog('composed-cat'); + const perform = catalog.tools.find((t) => t.toolName === 'superdoc_perform_action'); + const schema = perform?.inputSchema as { properties?: Record } | undefined; + const names = schema?.properties?.action?.enum ?? []; + expect(names).toContain('insert_paragraphs'); + expect(names).not.toContain('create_table'); // outside the allowlist โ€” must not appear in the catalog + // The row narrows BEYOND the enum: create_table-only args are gone, so a + // catalog-driven UI/validator can't offer inputs for an action the preset + // refuses (the coherence gap this test guards against). + const props = Object.keys(schema?.properties ?? {}); + expect(props).not.toContain('rows'); + expect(props).not.toContain('columns'); + expect(props).toContain('text'); // insert_paragraphs' own arg survives + expect(perform?.description ?? '').not.toContain('create_table'); // description narrows too + // custom actions appear as their own catalog rows (the catalog keeps custom + // as separate rows rather than merging them into perform_action's enum the + // way getTools does โ€” so the built-in row here carries built-ins only). + expect(catalog.tools.some((t) => t.toolName === 'footnotes.add')).toBe(true); + }); + + test('getToolCatalog perform_action row matches getTools when there are no custom actions', async () => { + const composed = composePreset({ + id: 'composed-cat-nocustom', + baseId: 'core', + includeCoreActions: ['insert_paragraphs'], + }); + registerPreset(composed); + REGISTERED.push('composed-cat-nocustom'); + const catalog = await getToolCatalog('composed-cat-nocustom'); + const row = catalog.tools.find((t) => t.toolName === 'superdoc_perform_action'); + const rowProps = Object.keys((row?.inputSchema as { properties?: Record })?.properties ?? {}); + // With no custom actions the two representations coincide, so the row must + // equal the advertised tool exactly โ€” one narrowing, no drift. + const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-cat-nocustom' }); + const advertised = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + expect( + (row?.inputSchema as { properties?: { action?: { enum?: string[] } } })?.properties?.action?.enum ?? [], + ).toEqual(actionProp(advertised)?.enum ?? []); + expect(rowProps.sort()).toEqual(Object.keys(schemaOf(advertised)?.properties ?? {}).sort()); + }); + + test('getToolCatalog drops perform_action when the allowlist excludes every built-in', async () => { + const empty = composePreset({ + id: 'composed-cat-empty', + baseId: 'core', + includeCoreActions: [], + }); + registerPreset(empty); + REGISTERED.push('composed-cat-empty'); + const emptyCatalog = await getToolCatalog('composed-cat-empty'); + expect(emptyCatalog.tools.some((t) => t.toolName === 'superdoc_perform_action')).toBe(false); + + const customOnly = composePreset({ + id: 'composed-cat-custom-only', + baseId: 'core', + includeCoreActions: [], + actions: footnoteActions, + }); + registerPreset(customOnly); + REGISTERED.push('composed-cat-custom-only'); + const customOnlyCatalog = await getToolCatalog('composed-cat-custom-only'); + expect(customOnlyCatalog.tools.some((t) => t.toolName === 'superdoc_perform_action')).toBe(false); + for (const action of footnoteActions) { + expect(customOnlyCatalog.tools.some((t) => t.toolName === action.name)).toBe(true); + } + }); + + test('includeCoreActions is enforced at DISPATCH, not only in the enum', async () => { + const composed = composePreset({ + id: 'composed-allowlist', + baseId: 'core', + includeCoreActions: ['insert_paragraphs'], + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-allowlist'); + await expect( + dispatchSuperDocTool( + {} as BoundDocApi, + 'superdoc_perform_action', + { action: 'create_table', rows: 1, columns: 1 }, + { preset: 'composed-allowlist' }, + ), + ).rejects.toMatchObject({ + code: 'INVALID_ARGUMENT', + details: { excluded: true, excludedBy: 'includeCoreActions' }, + }); + }); + + test('excludeActions with a CUSTOM name neither throws nor advertises it', async () => { + const composed = composePreset({ id: 'composed-excl', baseId: 'core', actions: footnoteActions }); + registerPreset(composed); + REGISTERED.push('composed-excl'); + const excludeActions = ['footnotes.add', 'create_table']; + const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-excl', excludeActions }); + const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []; + expect(names).not.toContain('footnotes.add'); + expect(names).not.toContain('create_table'); + expect(names).toContain('footnotes.list'); + const prompt = await getSystemPrompt('composed-excl', { excludeActions }); + expect(prompt).not.toContain('- footnotes.add โ€”'); + }); + + test('drops superdoc_execute_code from advertised tools but keeps it dispatchable', async () => { + const composed = composePreset({ + id: 'composed-1', + baseId: 'core', + includeExecuteCode: false, + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-1'); + const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-1' }); + expect(tools.map(toolName)).not.toContain('superdoc_execute_code'); + // custom actions still merged into superdoc_perform_action + const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []; + expect(names).toContain('footnotes.add'); + }); + + test('includeCoreActions filters the superdoc_perform_action enum to the subset โˆช custom names', async () => { + const composed = composePreset({ + id: 'composed-2', + baseId: 'core', + includeCoreActions: ['insert_paragraphs', 'replace_text'], + actions: footnoteActions, + }); + registerPreset(composed); + REGISTERED.push('composed-2'); + const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-2' }); + const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []; + expect(names).toContain('insert_paragraphs'); + expect(names).toContain('replace_text'); + expect(names).toContain('footnotes.add'); + expect(names).not.toContain('create_table'); + }); +}); + +describe('createAgentToolkit โ€” one-call custom actions', () => { + test('actions build the surface directly โ€” no registerPreset, no preset id to manage', async () => { + const stamp = defineAction({ + name: 'superdoc.demo_stamp', + description: 'Insert a demo banner.', + input: { type: 'object', properties: { label: { type: 'string' } } }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }], + }); + const { tools, systemPrompt, dispatch, meta } = await createAgentToolkit({ + provider: 'openai', + actions: [stamp], + }); + const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action'); + expect(actionProp(perform)?.enum ?? []).toContain('superdoc.demo_stamp'); // custom action advertised + expect(systemPrompt).toContain('superdoc.demo_stamp'); // prompt narrows WITH it + expect(typeof dispatch).toBe('function'); + expect(meta.preset).toBe('custom_superdoc_preset'); + expect(listPresets().includes('custom_superdoc_preset')).toBe(false); // ephemeral โ€” no global leak + }); + + test('includeCoreActions keeps only the named built-ins alongside customs', async () => { + const stamp = defineAction({ + name: 'superdoc.demo_stamp', + description: 'Insert a demo banner.', + input: { type: 'object', properties: {} }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }], + }); + const { tools } = await createAgentToolkit({ + provider: 'generic', + actions: [stamp], + includeCoreActions: ['insert_paragraphs'], + }); + const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []; + expect(names).toContain('insert_paragraphs'); + expect(names).toContain('superdoc.demo_stamp'); + expect(names).not.toContain('create_table'); + }); +}); + +describe('defineAction / merge โ€” schema guards', () => { + test('two actions declaring the same arg with incompatible types are rejected', async () => { + const a = defineAction({ + name: 'x.a', + description: 'a', + input: { type: 'object', properties: { foo: { type: 'string' } } }, + run: () => null, + }); + const b = defineAction({ + name: 'x.b', + description: 'b', + input: { type: 'object', properties: { foo: { type: 'number' } } }, + run: () => null, + }); + await expect(createAgentToolkit({ provider: 'openai', actions: [a, b] })).rejects.toThrow( + /argument "foo".*conflicts/, + ); + }); + + test('a shared arg name with a compatible type + extra DESCRIPTION only is allowed', async () => { + // Reusing a built-in arg name (e.g. anchorText) with your own description + // is fine โ€” only documentation differs, so it is NOT a conflict. + const a = defineAction({ + name: 'x.a', + description: 'a', + input: { type: 'object', properties: { foo: { type: 'string' } } }, + run: () => null, + }); + const b = defineAction({ + name: 'x.b', + description: 'b', + input: { type: 'object', properties: { foo: { type: 'string', description: 'documented' } } }, + run: () => null, + }); + const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] }); + expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.b'); + }); + + test('a shared arg name whose schemas differ beyond description (one-sided enum) is rejected', async () => { + const a = defineAction({ + name: 'x.a', + description: 'a', + input: { type: 'object', properties: { mode: { type: 'string' } } }, + run: () => null, + }); + const b = defineAction({ + name: 'x.b', + description: 'b', + input: { type: 'object', properties: { mode: { type: 'string', enum: ['fast', 'slow'] } } }, + run: () => null, + }); + await expect(createAgentToolkit({ provider: 'generic', actions: [a, b] })).rejects.toThrow( + /argument "mode".*conflicts/, + ); + }); + + test('the same arg with an IDENTICAL schema across actions is allowed', async () => { + const a = defineAction({ + name: 'x.a', + description: 'a', + input: { type: 'object', properties: { foo: { type: 'string' } } }, + run: () => null, + }); + const b = defineAction({ + name: 'x.b', + description: 'b', + input: { type: 'object', properties: { foo: { type: 'string' } } }, + run: () => null, + }); + const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] }); + expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.a'); + }); + + test('the same arg with a REORDERED but identical schema is allowed (order-insensitive compare)', async () => { + const a = defineAction({ + name: 'x.a', + description: 'a', + input: { type: 'object', properties: { foo: { type: 'string', description: 'the foo' } } }, + run: () => null, + }); + const b = defineAction({ + name: 'x.b', + description: 'b', + input: { type: 'object', properties: { foo: { description: 'the foo', type: 'string' } } }, + run: () => null, + }); + const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] }); + expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.b'); + }); + + test('a Zod-like schema is rejected with a clear error (convert to JSON Schema)', () => { + const zodish = { _def: { typeName: 'ZodObject' }, parse: () => ({}) }; + expect(() => defineAction({ name: 'x.zod', description: 'z', input: zodish, run: () => null } as never)).toThrow( + /JSON Schema/, + ); + }); + + test('preset surface is immutable โ€” mutating the caller actions array after build has no effect', async () => { + const mk = (name: string) => + defineAction({ + name, + description: 'd', + input: { type: 'object', properties: {} }, + steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }], + }); + const arr = [mk('x.snapshotted')]; + const preset = extendPreset('core', { id: 'snap-preset', actions: arr }); + registerPreset(preset); + REGISTERED.push('snap-preset'); + + // Caller mutates its array AFTER the preset was built โ€” must NOT leak in. + arr.length = 0; + arr.push(mk('x.injected')); + + const { tools } = await chooseTools({ provider: 'generic', preset: 'snap-preset' }); + const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []; + expect(names).toContain('x.snapshotted'); // original still advertised + expect(names).not.toContain('x.injected'); // post-build push did not leak into the surface + }); +}); diff --git a/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts b/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts new file mode 100644 index 0000000000..479ffa0258 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts @@ -0,0 +1,137 @@ +/** + * Footnote actions โ€” the shared test fixture for custom actions. + * + * Footnotes are NOT built-in core actions; these define five namespaced + * `run`-tier custom actions (`footnotes.add` / `.list` / `.edit` / `.remove` / + * `.renumber`) over the typed `doc.footnotes.*` Document API. They run in the + * caller's process against the session-bound handle โ€” the same tier a customer + * reaches for when the built-in actions don't cover their domain. + * + * This is a test fixture only: it is not exported from the package and lives + * under __tests__ so it never ships in dist. Both the unit tests (against a + * fake base) and the e2e test (against the real CLI host) import it. + * + * Shapes (from packages/document-api/src/footnotes/footnotes.types.ts): + * - insert `at` is a TextTarget: { kind:'text', segments:[{ blockId, range:{start,end} }] } + * - update/remove target a FootnoteAddress: { kind:'entity', entityType:'footnote', noteId } + * - configure takes { type, scope:{kind:'document'}, numbering:{ format?, start? } } + */ + +import { defineAction, type ActionSpec } from '../../actions/define.js'; + +type FootnotesApi = { + footnotes: { + insert: (input: Record) => Promise; + list: (input: Record) => Promise; + update: (input: Record) => Promise; + remove: (input: Record) => Promise; + configure: (input: Record) => Promise; + }; +}; + +export const footnoteAdd: ActionSpec = defineAction({ + name: 'footnotes.add', + description: + "Insert a footnote (or endnote) at a text target. args: { at: TextTarget {kind:'text',segments:[{blockId,range:{start,end}}]}, content: string, type?: 'footnote'|'endnote' }.", + input: { + type: 'object', + additionalProperties: false, + required: ['at', 'content'], + properties: { + at: { type: 'object', description: "TextTarget: {kind:'text',segments:[{blockId,range:{start,end}}]}" }, + content: { type: 'string' }, + type: { type: 'string', enum: ['footnote', 'endnote'] }, + }, + }, + run: (doc, args) => + (doc as FootnotesApi).footnotes.insert({ + at: args.at, + type: (args.type as string) || 'footnote', + content: args.content, + }), +}); + +export const footnoteList: ActionSpec = defineAction({ + name: 'footnotes.list', + description: "List footnotes (or endnotes). args: { type?: 'footnote'|'endnote' }.", + input: { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['footnote', 'endnote'] }, + }, + }, + run: (doc, args) => (doc as FootnotesApi).footnotes.list(args.type ? { type: args.type } : {}), +}); + +export const footnoteEdit: ActionSpec = defineAction({ + name: 'footnotes.edit', + description: "Edit a footnote's content by noteId. args: { noteId: string, content: string }.", + input: { + type: 'object', + additionalProperties: false, + required: ['noteId', 'content'], + properties: { + noteId: { type: 'string' }, + content: { type: 'string' }, + }, + }, + run: (doc, args) => + (doc as FootnotesApi).footnotes.update({ + target: { kind: 'entity', entityType: 'footnote', noteId: args.noteId }, + patch: { content: args.content }, + }), +}); + +export const footnoteRemove: ActionSpec = defineAction({ + name: 'footnotes.remove', + description: 'Remove a footnote by noteId. args: { noteId: string }.', + input: { + type: 'object', + additionalProperties: false, + required: ['noteId'], + properties: { + noteId: { type: 'string' }, + }, + }, + run: (doc, args) => + (doc as FootnotesApi).footnotes.remove({ + target: { kind: 'entity', entityType: 'footnote', noteId: args.noteId }, + }), +}); + +export const footnoteRenumber: ActionSpec = defineAction({ + name: 'footnotes.renumber', + description: + "Reconfigure footnote numbering for the whole document. args: { type?: 'footnote'|'endnote', format?: string, start?: number }.", + input: { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['footnote', 'endnote'] }, + format: { + type: 'string', + enum: ['decimal', 'lowerRoman', 'upperRoman', 'lowerLetter', 'upperLetter', 'symbol'], + }, + start: { type: 'number' }, + }, + }, + run: (doc, args) => + (doc as FootnotesApi).footnotes.configure({ + type: (args.type as string) || 'footnote', + scope: { kind: 'document' }, + numbering: { + ...(args.format ? { format: args.format } : {}), + ...(args.start != null ? { start: args.start } : {}), + }, + }), +}); + +/** All five footnote actions, in a stable order. */ +export const footnoteActions: readonly ActionSpec[] = [ + footnoteAdd, + footnoteList, + footnoteEdit, + footnoteRemove, + footnoteRenumber, +]; diff --git a/packages/sdk/langs/node/src/actions/define.ts b/packages/sdk/langs/node/src/actions/define.ts new file mode 100644 index 0000000000..71735c13b0 --- /dev/null +++ b/packages/sdk/langs/node/src/actions/define.ts @@ -0,0 +1,1115 @@ +/** + * Customer-extensible **custom actions** for the SuperDoc LLM-tools SDK. The + * canonical ActionSpec has exactly ONE execution tier: + * + * - `steps` โ€” declarative composition of built-in core actions with + * {{arg}} templating; dispatches through the base preset and + * inherits its target resolution, receipts, and verification. + * - `run` โ€” a native function executed in the CALLER'S process against + * the typed session-bound doc handle, with synthesized + * truth-telling receipts (pre/post revision, partialMutation). + * + * NOTE: a third, IN-HOST tier โ€” a JS function-expression run inside the + * document host via `superdoc_execute_code` โ€” is intentionally not part of the + * kit yet. It lands together with the code-act execution path (and its safety + * envelope); until then the kit exposes only `steps` and `run`. + * + * `extendPreset`/`composePreset` merge custom actions into the + * superdoc_perform_action enum, tool description, system prompt, and dispatch + * COHERENTLY โ€” including excludeActions, which may name built-in actions + * (forwarded to the base) or custom ones (handled by the wrapper). No CLI + * host changes are required by either tier. + * + * Cross-runtime contract: templating semantics, input-schema defaults, and + * receipt shapes are identical to the Python mirror + * (`langs/python/superdoc/presets/custom.py`) for any JSON-serializable tool + * input. (NaN/Infinity/lone-surrogates are out of scope: they cannot appear in + * a JSON tool call.) + * + * @module + */ + +import type { BoundDocApi } from '../generated/client.js'; +import type { InvokeOptions } from '../runtime/process.js'; +import { SuperDocCliError } from '../runtime/errors.js'; +import { + getPreset, + type GetSystemPromptOptions, + type GetToolsOptions, + type GetToolsResult, + type PresetDescriptor, + type ToolCatalog, + type ToolCatalogEntry, + type ToolProvider, +} from '../presets.js'; +import { ACTION_NAMES_LIST } from '../agent/actions.js'; +import { AGENT_TOOL_NAMES, buildPerformActionDefinition } from '../agent/catalog.js'; + +// --------------------------------------------------------------------------- +// ActionSpec โ€” the shared contract (identical fields in Node & Python) +// --------------------------------------------------------------------------- + +/** Minimal JSON Schema object describing a action's flat args. */ +export type JSONSchemaObject = { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + [key: string]: unknown; +}; + +/** + * One step of a declarative (`steps`-tier) custom action: an existing built-in + * core action plus its args. String values may reference the custom action's + * own args with `{{name}}` templates โ€” a whole-string `"{{x}}"` substitutes the + * raw value (preserving arrays/objects/numbers), a partial `"...{{x}}..."` + * interpolates as text. + */ +export type ActionStep = { + action: string; + args?: Record; +}; + +/** + * The shared, language-neutral custom-action contract โ€” the canonical type. + * + * - `name` โ€” namespaced "." (e.g. "footnotes.add"). MUST NOT collide + * with a built-in core action name. + * - `description` โ€” shown to the model. + * - `inputSchema` โ€” JSON Schema for the FLAT args (NOT the `action` + * discriminator key). `properties.*.default` values are applied before + * execution on every tier. + * + * Exactly ONE execution tier is set: + * + * - `steps` โ€” DECLARATIVE (recommended): a sequence of built-in core actions + * with `{{arg}}` templating. Dispatches through the base preset, so it + * inherits target resolution, placement handling, receipts, and + * verification. Pure data โ€” serializable, cross-language by construction. + * - `run` โ€” NATIVE escape hatch: an async function executed in the caller's + * process against the typed session-bound doc handle. Receipts are + * synthesized (pre/post revision, partialMutation, recovery). + */ +export interface ActionSpec { + name: string; + description: string; + inputSchema: JSONSchemaObject; + steps?: readonly ActionStep[]; + run?: (doc: BoundDocApi, args: Record) => unknown; +} + +/** Which execution tier a spec uses. */ +export function executionKindOf(spec: ActionSpec): 'steps' | 'run' { + return Array.isArray(spec.steps) ? 'steps' : 'run'; +} + +const BUILTIN_ACTION_NAMES: ReadonlySet = new Set(ACTION_NAMES_LIST); + +// --------------------------------------------------------------------------- +// defineAction โ€” author a ActionSpec +// --------------------------------------------------------------------------- + +type DefineActionInputBase = { + name: string; + description: string; + /** + * JSON Schema object describing the action's flat args. Pass a plain JSON + * Schema (`{ type: 'object', properties: {...} }`). Zod (and other schema + * libraries) are NOT accepted directly โ€” convert first, e.g. with + * `zod-to-json-schema` โ€” otherwise `defineAction` throws a clear error rather + * than silently dropping your types. + */ + input?: JSONSchemaObject | Record; +}; + +type DefineActionWithSteps = DefineActionInputBase & { + /** Declarative tier: built-in core actions with {{arg}} templating. */ + steps: readonly ActionStep[]; + run?: never; +}; + +type DefineActionWithRun = DefineActionInputBase & { + /** + * Native tier: runs IN THE CALLER'S PROCESS against the typed session-bound + * doc handle (async `doc.*` client API). + */ + run: (doc: BoundDocApi, args: Record) => unknown; + steps?: never; +}; + +export type DefineActionInput = DefineActionWithSteps | DefineActionWithRun; + +function isJsonSchemaObject(value: unknown): value is JSONSchemaObject { + return ( + value != null && + typeof value === 'object' && + !Array.isArray(value) && + (value as Record).type === 'object' + ); +} + +/** + * Normalize the `input` field to a JSON Schema object. We take NO schema-library + * dependency: a Zod (or Zod-like) schema is rejected with a clear, actionable + * error instead of being silently accepted with its types dropped โ€” the caller + * converts it first (e.g. `zod-to-json-schema`). A plain JSON Schema object is + * the primary path; a bare properties bag is wrapped for convenience. + */ +function coerceInputSchema(input: DefineActionInput['input']): JSONSchemaObject { + if (input == null) { + return { type: 'object', properties: {}, additionalProperties: true }; + } + // Zod / Zod-like schemas expose `_def` (and usually `.parse`/`.safeParse`). + // Reject clearly rather than half-supporting them (dropping constraints). + // Checked BEFORE isJsonSchemaObject so a library schema can never slip through. + const duck = input as { _def?: unknown; parse?: unknown; safeParse?: unknown }; + if (duck._def != null || typeof duck.parse === 'function' || typeof duck.safeParse === 'function') { + throw new SuperDocCliError( + 'defineAction `input` must be a JSON Schema object, not a Zod (or other library) schema. ' + + 'Convert it first, e.g. `import { zodToJsonSchema } from "zod-to-json-schema"; ' + + 'defineAction({ input: zodToJsonSchema(mySchema), ... })`.', + { code: 'INVALID_ARGUMENT' }, + ); + } + if (isJsonSchemaObject(input)) { + // A valid object schema may legally omit `properties` (an open schema like + // `{ type: 'object', additionalProperties: true }`). The declared type makes + // `properties` required and every consumer (and the Python mirror) assumes + // it exists, so normalize a missing/non-object bag to {}. + return isRecord((input as { properties?: unknown }).properties) ? input : { ...input, properties: {} }; + } + // A bare properties bag โ€” wrap it. + return { type: 'object', properties: input as Record, additionalProperties: true }; +} + +/** + * Author a {@link ActionSpec}. Pass exactly one execution tier: `steps` + * (declarative โ€” recommended) or `run` (native function in your process). + */ +export function defineAction(input: DefineActionInput): ActionSpec { + if (typeof input.name !== 'string' || input.name.length === 0) { + throw new SuperDocCliError('defineAction requires a non-empty `name`.', { + code: 'INVALID_ARGUMENT', + details: { input: 'name' }, + }); + } + if (typeof input.description !== 'string') { + throw new SuperDocCliError(`defineAction "${input.name}" requires a string \`description\`.`, { + code: 'INVALID_ARGUMENT', + details: { name: input.name }, + }); + } + const tiers: Array<'steps' | 'run'> = []; + if (Array.isArray(input.steps)) tiers.push('steps'); + if (typeof input.run === 'function') tiers.push('run'); + if (tiers.length !== 1) { + throw new SuperDocCliError( + `defineAction "${input.name}" requires exactly one of \`steps\` or \`run\` (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`, + { code: 'INVALID_ARGUMENT', details: { name: input.name, tiers } }, + ); + } + const spec: ActionSpec = { + name: input.name, + description: input.description, + inputSchema: coerceInputSchema(input.input), + }; + if (tiers[0] === 'steps') { + const steps = input.steps as readonly ActionStep[]; + if (steps.length === 0) { + throw new SuperDocCliError(`defineAction "${input.name}": \`steps\` must be a non-empty array.`, { + code: 'INVALID_ARGUMENT', + details: { name: input.name }, + }); + } + steps.forEach((step, index) => { + if (step == null || typeof step.action !== 'string' || step.action.length === 0) { + throw new SuperDocCliError( + `defineAction "${input.name}": steps[${index}] needs a non-empty \`action\` string.`, + { code: 'INVALID_ARGUMENT', details: { name: input.name, index } }, + ); + } + if (!BUILTIN_ACTION_NAMES.has(step.action)) { + throw new SuperDocCliError( + `defineAction "${input.name}": steps[${index}].action "${step.action}" is not a built-in core action. Steps compose built-in actions only; use the \`run\` tier for anything else.`, + { code: 'INVALID_ARGUMENT', details: { name: input.name, index, action: step.action } }, + ); + } + if (step.args != null && !isRecord(step.args)) { + throw new SuperDocCliError(`defineAction "${input.name}": steps[${index}].args must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { name: input.name, index }, + }); + } + }); + spec.steps = steps.map((step) => ({ action: step.action, args: { ...(step.args ?? {}) } })); + } else { + spec.run = (input as DefineActionWithRun).run; + } + return spec; +} + +// --------------------------------------------------------------------------- +// Codegen โ€” identical to the Python mirror for JSON-serializable tool inputs +// (the only values a tool call can carry; NaN/Infinity/lone-surrogates are out +// of scope and serialize differently across Node and Python). + +// --------------------------------------------------------------------------- +// Collision / duplicate validation +// --------------------------------------------------------------------------- + +/** + * Provider tool-name rule. OpenAI/Anthropic require tool names to match + * `^[A-Za-z0-9_-]{1,64}$` โ€” dots are invalid. This only matters in STANDALONE + * mode, where the action name becomes a tool name; in MERGED mode the name is + * an enum VALUE (dots are fine). + */ +const PROVIDER_SAFE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/; + +/** Tool names of the agent surface itself โ€” a custom action must never shadow one. */ +const RESERVED_TOOL_NAMES: ReadonlySet = new Set(AGENT_TOOL_NAMES); + +/** + * Shared by extendPreset and composePreset: excludeActions may name BUILT-IN + * actions (forwarded to the base, which validates them) or CUSTOM actions + * (handled by the wrapper โ€” the base would reject names it doesn't know). + * Both halves narrow tools, prompt, and dispatch together, preserving the + * kit's coherence guarantee. + */ +function splitCustomExclusions( + byName: ReadonlyMap, + list: readonly string[] | undefined, +): { customExcluded: Set; builtinExcluded: readonly string[] | undefined } { + if (!list || list.length === 0) return { customExcluded: new Set(), builtinExcluded: undefined }; + const customExcluded = new Set(); + const builtin: string[] = []; + for (const name of list) { + if (byName.has(name)) customExcluded.add(name); + else builtin.push(name); + } + return { customExcluded, builtinExcluded: builtin.length > 0 ? builtin : undefined }; +} + +/** Defense-in-depth refusal shared by both wrappers' dispatchers. */ +function throwExcludedAction(toolName: string, actionName: string, extra: Record = {}): never { + throw new SuperDocCliError(`Action ${actionName} is excluded by configuration.`, { + code: 'INVALID_ARGUMENT', + details: { toolName, action: actionName, excluded: true, ...extra }, + }); +} + +/** + * Drop surface `excludeActions` from invoke options before INTERNAL step + * dispatch. Those exclusions govern what the MODEL may call directly (the + * top-level dispatch already enforced them); a steps-tier custom action is an + * authored composition whose steps must run even when a built-in they compose + * is hidden from the model. Everything else in invokeOptions passes through. + */ +function stripSurfaceExclusions(invokeOptions?: InvokeOptions): InvokeOptions | undefined { + if (!invokeOptions || !('excludeActions' in (invokeOptions as Record))) return invokeOptions; + const { excludeActions: _dropped, ...rest } = invokeOptions as InvokeOptions & { excludeActions?: unknown }; + return rest as InvokeOptions; +} + +function assertActionsValid(actions: readonly ActionSpec[], presetId: string, standalone = false): void { + const seen = new Set(); + for (const action of actions) { + // Raw spec objects can bypass defineAction โ€” re-validate the tier shape + // here so a hand-rolled {steps: []} can't fabricate succeeded receipts. + const tiers = [ + Array.isArray(action.steps) ? 'steps' : null, + typeof action.run === 'function' ? 'run' : null, + ].filter((tier): tier is string => tier != null); + if (tiers.length !== 1) { + throw new SuperDocCliError( + `Custom action "${action.name}" must have exactly one of steps/run (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`, + { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, tiers } }, + ); + } + if (Array.isArray(action.steps)) { + if (action.steps.length === 0) { + throw new SuperDocCliError(`Custom action "${action.name}": steps must be non-empty.`, { + code: 'INVALID_ARGUMENT', + details: { presetId, name: action.name }, + }); + } + for (const [index, step] of action.steps.entries()) { + if (!step || typeof step.action !== 'string' || !BUILTIN_ACTION_NAMES.has(step.action)) { + throw new SuperDocCliError( + `Custom action "${action.name}": steps[${index}].action must be a built-in core action.`, + { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, index } }, + ); + } + } + } + if (BUILTIN_ACTION_NAMES.has(action.name)) { + throw new SuperDocCliError( + `Custom action "${action.name}" collides with a built-in core action name. Use a namespaced name like "superdoc.${action.name}".`, + { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } }, + ); + } + if (RESERVED_TOOL_NAMES.has(action.name)) { + throw new SuperDocCliError( + `Custom action "${action.name}" collides with a reserved tool name โ€” it would shadow the agent surface itself.`, + { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } }, + ); + } + if (seen.has(action.name)) { + throw new SuperDocCliError(`Duplicate custom action name "${action.name}" in preset "${presetId}".`, { + code: 'INVALID_ARGUMENT', + details: { presetId, name: action.name }, + }); + } + // In standalone mode the action name becomes a provider tool name, which + // OpenAI/Anthropic reject unless it matches ^[A-Za-z0-9_-]{1,64}$ (dotted + // namespaced names are only valid as merged enum VALUES). + if (standalone && !PROVIDER_SAFE_TOOL_NAME.test(action.name)) { + throw new SuperDocCliError( + `standalone action names must match ^[A-Za-z0-9_-]{1,64}$; "${action.name}" has invalid characters โ€” use merged mode for dotted names.`, + { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } }, + ); + } + seen.add(action.name); + } +} + +// --------------------------------------------------------------------------- +// runCustomAction โ€” validate, codegen, dispatch via superdoc_execute_code, map receipt +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +/** Kit-level args every custom action accepts without declaring them. */ +const IMPLICIT_ACTION_ARGS = new Set(['changeMode', 'rationale']); + +function validateAgainstSchema(action: ActionSpec, args: Record): void { + const required = Array.isArray(action.inputSchema.required) ? action.inputSchema.required : []; + const missing = required.filter((key) => args[key] == null); + if (missing.length > 0) { + throw new SuperDocCliError(`Missing required argument(s) for ${action.name}: ${missing.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { action: action.name, missingKeys: missing }, + }); + } + const properties = isRecord(action.inputSchema.properties) ? action.inputSchema.properties : {}; + if (action.inputSchema.additionalProperties === false) { + const unknown = Object.keys(args).filter((key) => !(key in properties) && !IMPLICIT_ACTION_ARGS.has(key)); + if (unknown.length > 0) { + throw new SuperDocCliError(`Unknown argument(s) for ${action.name}: ${unknown.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { action: action.name, unknownKeys: unknown, knownKeys: Object.keys(properties) }, + }); + } + } + for (const [key, prop] of Object.entries(properties)) { + const value = args[key]; + if (value !== undefined && isRecord(prop) && Array.isArray(prop.enum) && !prop.enum.includes(value)) { + throw new SuperDocCliError( + `Invalid value for ${action.name}.${key}: ${JSON.stringify(value)} (allowed: ${prop.enum.map((entry) => JSON.stringify(entry)).join(', ')})`, + { code: 'INVALID_ARGUMENT', details: { action: action.name, key, allowed: prop.enum } }, + ); + } + } +} + +/** Fill in `inputSchema.properties.*.default` values for absent args. */ +function applyInputDefaults(action: ActionSpec, args: Record): Record { + const out = { ...args }; + for (const [key, prop] of Object.entries(action.inputSchema.properties ?? {})) { + if (out[key] === undefined && isRecord(prop) && 'default' in prop) out[key] = prop.default; + } + return out; +} + +const WHOLE_TEMPLATE = /^\{\{(\w+)\}\}$/; + +/** + * Substitute `{{arg}}` templates in a step's args. A whole-string `"{{x}}"` + * yields the RAW value (arrays/objects/numbers survive); partial templates + * interpolate as text. Keys whose whole-string template resolves to undefined + * are dropped, so optional args don't inject `undefined` into step args. + */ +function substituteTemplates(node: unknown, vars: Record): unknown { + if (typeof node === 'string') { + const whole = WHOLE_TEMPLATE.exec(node); + if (whole) return vars[whole[1]!]; + return node.replace(/\{\{(\w+)\}\}/g, (_, name: string) => { + const value = vars[name]; + // Text interpolation must be byte-identical across Node and Python: + // strings verbatim, null/undefined โ†’ '', everything else compact JSON + // (true/[1,2]/{"a":1} โ€” NOT String(), whose array/object/boolean forms + // differ from Python's str()). + if (value == null) return ''; + return typeof value === 'string' ? value : JSON.stringify(value); + }); + } + if (Array.isArray(node)) { + // Whole-string templates for ABSENT args are dropped from arrays too โ€” + // leaving undefined behind would serialize as null (and Python's sentinel + // would crash the transport). + return node.map((item) => substituteTemplates(item, vars)).filter((item) => item !== undefined); + } + if (isRecord(node)) { + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + const substituted = substituteTemplates(value, vars); + if (substituted !== undefined) out[key] = substituted; + } + return out; + } + return node; +} + +type StepReceiptRow = { + step: number; + action: string; + status: unknown; + verificationPassed?: unknown; +}; + +/** + * `steps` tier โ€” dispatch each built-in step through the base preset and + * aggregate per-step receipts. Stops at the first failed step; the aggregate + * status is `partial` when earlier steps landed, `failed` otherwise. + */ +async function runStepsAction( + base: PresetDescriptor, + action: ActionSpec, + documentHandle: BoundDocApi, + args: Record, + invokeOptions?: InvokeOptions, +): Promise> { + const vars = args; // defaults already applied by the router + const rows: StepReceiptRow[] = []; + // Steps are the author's curated composition, not model calls โ€” surface + // exclusions must not refuse a built-in the action deliberately composes. + const stepOptions = stripSurfaceExclusions(invokeOptions); + for (const [index, step] of (action.steps ?? []).entries()) { + let stepArgs = substituteTemplates(step.args ?? {}, vars) as Record; + // changeMode pass-through: a caller-level changeMode reaches every step + // that doesn't pin its own. + if (typeof vars.changeMode === 'string' && stepArgs.changeMode === undefined) { + stepArgs = { ...stepArgs, changeMode: vars.changeMode }; + } + let receipt: Record; + try { + const dispatched = await base.dispatch( + documentHandle, + 'superdoc_perform_action', + { action: step.action, ...stepArgs }, + stepOptions, + ); + receipt = isRecord(dispatched) ? dispatched : { status: 'ok', result: dispatched }; + } catch (error) { + // Validation errors THROW from dispatch; runtime failures come back as + // failed receipts. Normalize both into the per-step row. + const err = error as { code?: string; message?: string }; + receipt = { status: 'failed', errors: [{ code: err.code ?? null, message: err.message ?? String(error) }] }; + } + rows.push({ + step: index, + action: step.action, + status: receipt.status, + ...(receipt.verificationPassed !== undefined ? { verificationPassed: receipt.verificationPassed } : {}), + }); + if (receipt.status === 'failed') { + const anyLanded = rows.some((row) => row.status !== 'failed'); + return { + status: anyLanded ? 'partial' : 'failed', + action: action.name, + steps: rows, + failedStep: { index, receipt }, + }; + } + // Truthfulness: a step that only PARTIALLY landed (or whose verification + // disagreed) must not roll up into a clean `succeeded`. Stop and report + // partial with the evidence โ€” later steps may depend on the missing part. + if (receipt.status === 'partial' || receipt.verificationPassed === false) { + return { + status: 'partial', + action: action.name, + steps: rows, + failedStep: { index, receipt }, + }; + } + } + return { status: 'succeeded', action: action.name, steps: rows }; +} + +/** Read the session revision off a client-side doc handle, if it can. */ +async function readRevision(documentHandle: BoundDocApi): Promise { + const info = (documentHandle as { info?: (params: Record) => Promise }).info; + if (typeof info !== 'function') return null; + try { + const result = (await info.call(documentHandle, {})) as { revision?: unknown }; + return result?.revision == null ? null : String(result.revision); + } catch { + return null; + } +} + +/** + * `run` tier โ€” execute the native function in the caller's process against the + * typed doc handle, synthesizing a truth-telling receipt: pre/post revision, + * and on failure whether a partial mutation was left behind + a recovery hint. + */ +async function runNativeAction( + action: ActionSpec, + documentHandle: BoundDocApi, + args: Record, +): Promise> { + const vars = args; // defaults already applied by the router + const preRevision = await readRevision(documentHandle); + try { + const result = await action.run!(documentHandle, vars); + const postRevision = await readRevision(documentHandle); + return { status: 'succeeded', action: action.name, result, preRevision, postRevision }; + } catch (error) { + const err = error as { code?: string; message?: string }; + const postRevision = await readRevision(documentHandle); + const partialMutation = preRevision != null && postRevision != null && preRevision !== postRevision; + return { + status: 'failed', + action: action.name, + errors: [{ code: err.code ?? null, message: err.message ?? String(error) }], + preRevision, + postRevision, + partialMutation, + recovery: partialMutation + ? { kind: 'revert', call: 'superdoc_perform_action {action:"undo_changes"}' } + : { kind: 'retry' }, + }; + } +} + +/** Validate args, then route the custom action to its execution tier. */ +async function runCustomAction( + base: PresetDescriptor, + action: ActionSpec, + documentHandle: BoundDocApi, + rawArgs: Record, + invokeOptions?: InvokeOptions, + fromPerformAction = true, +): Promise> { + // `action` is the superdoc_perform_action discriminator ONLY on that route; + // in standalone mode the action is its own tool, so `action` may be a real + // declared argument โ€” strip it only when it came in as the discriminator. + const rawRest: Record = { ...rawArgs }; + if (fromPerformAction) delete rawRest.action; + // Apply schema defaults, THEN validate โ€” a required arg with a declared + // default is satisfiable by the default. + const args = applyInputDefaults(action, rawRest); + validateAgainstSchema(action, args); + return executionKindOf(action) === 'steps' + ? runStepsAction(base, action, documentHandle, args, invokeOptions) + : runNativeAction(action, documentHandle, args); +} + +// --------------------------------------------------------------------------- +// Tool-list merging โ€” mirror the provider shapes from agent/catalog.ts +// --------------------------------------------------------------------------- + +function toolNameOf(tool: unknown): string { + const t = tool as { name?: string; function?: { name?: string } }; + return t?.function?.name ?? t?.name ?? ''; +} + +/** + * Re-apply the Anthropic prompt-cache marker after the tool list was mutated. + * + * The base preset places `cache_control: { type: 'ephemeral' }` on its LAST + * tool. Appending (standalone) or removing tools (composePreset) can leave the + * marker mid-list or drop it entirely. When the marker is meaningful โ€” provider + * is anthropic and cache was requested โ€” strip any existing `cache_control` and + * put it back on the final last tool so the cached prefix stays correct. No-op + * for other providers / when cache was not requested / on an empty list. + */ +function renormalizeAnthropicCacheMarker(tools: unknown[], provider: ToolProvider, cacheRequested: boolean): unknown[] { + if (provider !== 'anthropic' || !cacheRequested || tools.length === 0) return tools; + const stripped = tools.map((tool) => { + if (!isRecord(tool) || !('cache_control' in tool)) return tool; + const { cache_control: _drop, ...rest } = tool as Record; + return rest; + }); + const last = stripped[stripped.length - 1]; + stripped[stripped.length - 1] = isRecord(last) ? { ...last, cache_control: { type: 'ephemeral' } } : last; + return stripped; +} + +function customActionsDescription(actions: readonly ActionSpec[]): string { + return ` Custom actions: ${actions.map((r) => `${r.name} (${r.description})`).join('; ')}.`; +} + +/** + * Merge custom actions into the existing `superdoc_perform_action` tool: append names to + * the `action` enum, union inputSchema.properties into the tool's properties, + * and extend the description. + */ +/** + * Reject a custom arg whose name collides with an existing arg (built-in or an + * earlier custom action) of a DIFFERENT shape. Merging into one flat + * `superdoc_perform_action` schema means a single name โ†’ one schema; silently + * keeping the first would advertise one shape for two meanings. Identical + * re-declarations (same JSON Schema) are allowed โ€” actions may share an arg. + */ +/** Order-insensitive JSON serialization: object keys sorted recursively (array + * order preserved โ€” it is semantic for `enum`/`required`). So two schemas that + * differ ONLY in key order compare equal. */ +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + const obj = value as Record; + const body = Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`) + .join(','); + return `{${body}}`; +} + +/** Documentation-only JSON Schema keywords โ€” differences here never conflict. */ +const METADATA_SCHEMA_KEYS: ReadonlySet = new Set(['description', 'title', 'examples', '$comment']); + +/** Drop doc-only keys (recursively) so comparison sees just the structural shape. */ +function structuralSchema(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(structuralSchema); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (METADATA_SCHEMA_KEYS.has(k)) continue; + out[k] = structuralSchema(v); + } + return out; +} + +/** Two arg schemas CONFLICT when they differ in any way EXCEPT documentation + * (description/title/examples/$comment): reusing a built-in arg name with your + * own description is fine, but a different type, enum (incl. one-sided), + * default, limit, pattern, or nested shape is a real conflict โ€” the merged + * surface advertises ONE schema per name. */ +function argSchemasConflict(a: unknown, b: unknown): boolean { + return canonicalJson(structuralSchema(a)) !== canonicalJson(structuralSchema(b)); +} + +function assertNoArgConflict( + properties: Record, + key: string, + value: unknown, + actionName: string, +): void { + if (key in properties && argSchemasConflict(properties[key], value)) { + throw new SuperDocCliError( + `Custom action "${actionName}" declares argument "${key}" with a schema that conflicts with an existing ` + + `argument of the same name on the superdoc_perform_action surface (they differ beyond description). Rename the argument, or match the existing schema exactly.`, + { code: 'INVALID_ARGUMENT', details: { action: actionName, arg: key } }, + ); + } +} + +function mergeIntoAgentAction(tools: unknown[], actions: readonly ActionSpec[]): unknown[] { + return tools.map((tool) => { + if (toolNameOf(tool) !== 'superdoc_perform_action') return tool; + const t = tool as Record; + // Provider dialects: openai nests under `function` (`parameters`); + // anthropic is flat with `input_schema`; the core agent dialect for + // vercel is flat with `inputSchema`; generic is flat with `parameters`. + const fn = isRecord(t.function) ? (t.function as Record) : null; + const schemaContainer = fn ?? t; + const schemaKey = + 'input_schema' in schemaContainer + ? 'input_schema' + : 'inputSchema' in schemaContainer + ? 'inputSchema' + : 'parameters'; + const schema = isRecord(schemaContainer[schemaKey]) ? (schemaContainer[schemaKey] as Record) : {}; + const properties = isRecord(schema.properties) ? { ...(schema.properties as Record) } : {}; + const actionProp = isRecord(properties.action) ? { ...(properties.action as Record) } : {}; + const enumValues = Array.isArray(actionProp.enum) ? [...(actionProp.enum as unknown[])] : []; + + for (const action of actions) { + if (!enumValues.includes(action.name)) enumValues.push(action.name); + for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) { + assertNoArgConflict(properties, key, value, action.name); + if (!(key in properties)) properties[key] = value; + } + } + const nextActionProp = { ...actionProp, enum: enumValues }; + const nextSchema = { ...schema, properties: { ...properties, action: nextActionProp } }; + const baseDescription = + typeof schemaContainer.description === 'string' ? (schemaContainer.description as string) : ''; + const nextDescription = baseDescription + customActionsDescription(actions); + const nextContainer = { ...schemaContainer, description: nextDescription, [schemaKey]: nextSchema }; + return fn ? { ...t, function: nextContainer } : nextContainer; + }); +} + +/** + * When the base dropped `superdoc_perform_action` entirely (all built-ins + * excluded / an empty allowlist), active custom actions would be advertised in + * the prompt and dispatchable โ€” but carried by NO tool. Synthesize a + * custom-only definition so a curated custom-only preset stays callable. + */ +function synthesizePerformAction(provider: ToolProvider, actions: readonly ActionSpec[]): unknown { + const properties: Record = { + action: { type: 'string', enum: actions.map((action) => action.name) }, + }; + for (const action of actions) { + for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) { + assertNoArgConflict(properties, key, value, action.name); + if (!(key in properties)) properties[key] = value; + } + } + const schema = { type: 'object', additionalProperties: true, required: ['action'], properties }; + const description = + "Perform one of this preset's custom document actions. Pick an action and pass its flat arguments." + + customActionsDescription(actions); + if (provider === 'anthropic') { + return { name: 'superdoc_perform_action', description, input_schema: schema }; + } + if (provider === 'vercel') { + return { name: 'superdoc_perform_action', description, inputSchema: schema }; + } + if (provider === 'openai') { + return { type: 'function', function: { name: 'superdoc_perform_action', description, parameters: schema } }; + } + return { name: 'superdoc_perform_action', description, parameters: schema }; +} + +/** Merge into an existing perform_action tool, or synthesize one if the base dropped it. */ +function mergeOrSynthesizePerformAction( + tools: unknown[], + actions: readonly ActionSpec[], + provider: ToolProvider, +): unknown[] { + const hasPerformAction = tools.some((tool) => toolNameOf(tool) === 'superdoc_perform_action'); + if (hasPerformAction) return mergeIntoAgentAction(tools, actions); + return [...tools, synthesizePerformAction(provider, actions)]; +} + +/** Build a single provider-shaped standalone tool for a custom action. */ +function standaloneTool(provider: ToolProvider, action: ActionSpec): unknown { + if (provider === 'anthropic') { + return { name: action.name, description: action.description, input_schema: action.inputSchema }; + } + // The core agent dialect for vercel is FLAT {name, description, inputSchema} + // (agent/catalog.ts toVercelTool) โ€” not the OpenAI nested function shape. + if (provider === 'vercel') { + return { name: action.name, description: action.description, inputSchema: action.inputSchema }; + } + if (provider === 'openai') { + return { + type: 'function', + function: { name: action.name, description: action.description, parameters: action.inputSchema }, + }; + } + // generic + return { name: action.name, description: action.description, parameters: action.inputSchema }; +} + +// --------------------------------------------------------------------------- +// extendPreset โ€” wrap a base preset with custom actions +// --------------------------------------------------------------------------- + +export interface ExtendPresetOptions { + /** New preset id. */ + id: string; + /** Optional description override. */ + description?: string; + /** Custom actions to add. */ + actions: readonly ActionSpec[]; + /** Wholesale system-prompt addendum; defaults to an auto-generated bullet list. */ + systemPromptExtra?: string; + /** When true, expose each action as its own tool instead of merging into superdoc_perform_action. */ + standalone?: boolean; +} + +function autoSystemPromptSection(actions: readonly ActionSpec[]): string { + if (actions.length === 0) return ''; + const bullets = actions.map((r) => `- ${r.name} โ€” ${r.description}`).join('\n'); + return `\n\n## Custom actions\n${bullets}`; +} + +/** + * Wrap `getPreset(baseId)` with custom actions. Returns a new + * {@link PresetDescriptor} that advertises and dispatches the custom actions + * while delegating everything else to the base preset. + */ +export function extendPreset(baseId: string, options: ExtendPresetOptions): PresetDescriptor { + const base = getPreset(baseId); + // Snapshot the caller's array โ€” the preset surface must stay immutable even + // if discovery/hot-reload code mutates the original list after construction + // (else getTools/prompt would drift from the byName dispatch map). + const actions = options.actions ? [...options.actions] : []; + const standalone = options.standalone === true; + assertActionsValid(actions, options.id, standalone); + const byName = new Map(actions.map((r) => [r.name, r] as const)); + + const splitExclusions = (list: readonly string[] | undefined) => splitCustomExclusions(byName, list); + + async function getTools(provider: ToolProvider, toolOptions?: GetToolsOptions): Promise { + const { customExcluded, builtinExcluded } = splitExclusions(toolOptions?.excludeActions); + const baseOptions = toolOptions ? { ...toolOptions, excludeActions: builtinExcluded } : undefined; + const result = await base.getTools(provider, baseOptions); + const activeActions = actions.filter((action) => !customExcluded.has(action.name)); + if (activeActions.length === 0) return result; + // Only re-place the anthropic marker when the base actually applied one โ€” + // marking tools for a base that reported 'disabled' would make the marker + // and the cacheStrategy metadata disagree. + const cacheRequested = toolOptions?.cache === true && result.cacheStrategy !== 'disabled'; + if (standalone) { + const extra = activeActions.map((action) => standaloneTool(provider, action)); + const tools = renormalizeAnthropicCacheMarker([...result.tools, ...extra], provider, cacheRequested); + return { ...result, tools }; + } + // Merge keeps the same tool count/order, so the marker is unaffected โ€” but + // re-normalize anyway to stay correct if a base ever reorders. + const merged = renormalizeAnthropicCacheMarker( + mergeOrSynthesizePerformAction(result.tools, activeActions, provider), + provider, + cacheRequested, + ); + return { ...result, tools: merged }; + } + + async function getCatalog(): Promise { + const catalog = await base.getCatalog(); + const extraRows: ToolCatalogEntry[] = actions.map((action) => ({ + toolName: action.name, + description: action.description, + inputSchema: action.inputSchema as unknown as Record, + mutates: true, + operations: [], + })); + const tools = [...catalog.tools, ...extraRows]; + return { ...catalog, toolCount: tools.length, tools }; + } + + async function getSystemPrompt(promptOptions?: GetSystemPromptOptions): Promise { + const { customExcluded, builtinExcluded } = splitExclusions(promptOptions?.excludeActions); + const basePrompt = await base.getSystemPrompt( + builtinExcluded ? { ...promptOptions, excludeActions: builtinExcluded } : undefined, + ); + const activeActions = actions.filter((action) => !customExcluded.has(action.name)); + const extra = options.systemPromptExtra ?? autoSystemPromptSection(activeActions); + return basePrompt + extra; + } + + async function getMcpPrompt(): Promise { + const basePrompt = await base.getMcpPrompt(); + const extra = options.systemPromptExtra ?? autoSystemPromptSection(actions); + return basePrompt + extra; + } + + async function dispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, + ): Promise { + // Defense-in-depth parity with core: a host that narrowed the advertised + // surface passes the same exclusions here, and an excluded CUSTOM action + // must be refused before it runs (the base can only refuse built-ins). + const exclusionOptions = invokeOptions as (InvokeOptions & { excludeActions?: readonly string[] }) | undefined; + const excluded = new Set(exclusionOptions?.excludeActions ?? []); + // Advertised surface == dispatchable surface: in standalone mode custom + // actions are advertised as their own tools, so the (unadvertised) + // perform_action route must not execute them โ€” it falls through to the + // base, which rejects the unknown action name. + if (!standalone && toolName === 'superdoc_perform_action' && isRecord(args) && typeof args.action === 'string') { + const action = byName.get(args.action); + if (action) { + if (excluded.has(action.name)) throwExcludedAction(toolName, action.name); + return runCustomAction(base, action, documentHandle, args, invokeOptions); + } + } + if (standalone && byName.has(toolName)) { + if (excluded.has(toolName)) throwExcludedAction(toolName, toolName); + const action = byName.get(toolName)!; + return runCustomAction(base, action, documentHandle, args, invokeOptions, /* fromPerformAction */ false); + } + return base.dispatch(documentHandle, toolName, args, invokeOptions); + } + + return { + id: options.id, + description: options.description ?? `${base.description} + ${actions.length} custom action(s).`, + supportsCacheControl: base.supportsCacheControl, + getTools, + getCatalog, + getSystemPrompt, + getMcpPrompt, + dispatch, + }; +} + +// --------------------------------------------------------------------------- +// composePreset โ€” build a preset over core, filtering its surface +// --------------------------------------------------------------------------- + +export interface ComposePresetOptions { + id: string; + baseId?: string; + /** When given, restrict the superdoc_perform_action enum to these built-in names โˆช custom names. */ + includeCoreActions?: readonly string[]; + /** When explicitly false, drop the superdoc_execute_code tool from the advertised tools. */ + includeExecuteCode?: boolean; + actions?: readonly ActionSpec[]; + /** Wholesale system prompt; when omitted, base + custom is used. */ + systemPrompt?: string; + description?: string; +} + +/** + * Compose a preset over `core` (or another base): optionally filter the + * `superdoc_perform_action` enum to a subset of built-in actions (โˆช custom names), and/or + * drop `superdoc_execute_code` from the advertised surface. Custom actions still + * dispatch via the superdoc_execute_code rewrite. + */ +export function composePreset(options: ComposePresetOptions): PresetDescriptor { + const baseId = options.baseId ?? 'core'; + // Snapshot the caller's array โ€” the preset surface must stay immutable even + // if discovery/hot-reload code mutates the original list after construction + // (else getTools/prompt would drift from the byName dispatch map). + const actions = options.actions ? [...options.actions] : []; + assertActionsValid(actions, options.id); + const base = getPreset(baseId); + const byName = new Map(actions.map((r) => [r.name, r] as const)); + const includeCore = options.includeCoreActions != null ? new Set(options.includeCoreActions) : null; + if (includeCore) { + for (const name of includeCore) { + if (!BUILTIN_ACTION_NAMES.has(name)) { + throw new SuperDocCliError(`includeCoreActions: unknown action "${name}".`, { + code: 'INVALID_ARGUMENT', + details: { presetId: options.id, unknownAction: name }, + }); + } + } + } + const dropExecuteCode = options.includeExecuteCode === false; + + const splitExclusions = (list: readonly string[] | undefined) => splitCustomExclusions(byName, list); + + async function getTools(provider: ToolProvider, toolOptions?: GetToolsOptions): Promise { + const { customExcluded, builtinExcluded } = splitExclusions(toolOptions?.excludeActions); + // The allowlist is implemented as a DERIVED exclusion forwarded to the + // base, so core narrows the enum, the grouped description, AND the + // advertised argument properties natively โ€” no hand-rebuilt schemas here + // (a second implementation of that narrowing is exactly what drifts). + const derivedExcludes = includeCore ? ACTION_NAMES_LIST.filter((name) => !includeCore.has(name)) : []; + const mergedExcludes = [...new Set([...derivedExcludes, ...(builtinExcluded ?? [])])]; + const baseOptions = { + ...(toolOptions ?? {}), + excludeActions: mergedExcludes.length > 0 ? mergedExcludes : undefined, + }; + const result = await base.getTools(provider, baseOptions); + let tools = result.tools; + if (dropExecuteCode) tools = tools.filter((t) => toolNameOf(t) !== 'superdoc_execute_code'); + const activeActions = actions.filter((action) => !customExcluded.has(action.name)); + if (activeActions.length > 0) tools = mergeOrSynthesizePerformAction(tools, activeActions, provider); + // Dropping superdoc_execute_code can strip the marker off the (former) last tool; + // re-normalize so the anthropic cached prefix is still correct. + tools = renormalizeAnthropicCacheMarker( + tools, + provider, + toolOptions?.cache === true && result.cacheStrategy !== 'disabled', + ); + return { ...result, tools }; + } + + async function getCatalog(): Promise { + const catalog = await base.getCatalog(); + let rows = catalog.tools; + if (dropExecuteCode) rows = rows.filter((row) => row.toolName !== 'superdoc_execute_code'); + // Advertised == dispatchable, catalog included: when includeCoreActions + // narrows the surface, the catalog's superdoc_perform_action row must narrow + // WITH it โ€” enum, description, AND argument properties โ€” or getToolCatalog() + // still advertises inputs for actions the preset refuses. Rebuild the row + // from the SAME builder getTools drives through the base (buildPerform- + // ActionDefinition), so there is one narrowing implementation, not a second + // that drifts. Canonical ACTION_NAMES_LIST order matches the getTools row. + if (includeCore != null) { + const includedBuiltins = ACTION_NAMES_LIST.filter((name) => includeCore.has(name)); + if (includedBuiltins.length === 0) { + rows = rows.filter((row) => row.toolName !== 'superdoc_perform_action'); + } else { + const def = buildPerformActionDefinition(includedBuiltins); + rows = rows.map((row) => + row.toolName === 'superdoc_perform_action' + ? { + ...row, + description: def.description, + inputSchema: def.inputSchema as unknown as Record, + } + : row, + ); + } + } + const extraRows: ToolCatalogEntry[] = actions.map((action) => ({ + toolName: action.name, + description: action.description, + inputSchema: action.inputSchema as unknown as Record, + mutates: true, + operations: [], + })); + const tools = [...rows, ...extraRows]; + return { ...catalog, toolCount: tools.length, tools }; + } + + async function getSystemPrompt(promptOptions?: GetSystemPromptOptions): Promise { + if (typeof options.systemPrompt === 'string') return options.systemPrompt; + // includeCoreActions narrows the ENUM; the prompt must narrow WITH it โ€” + // a per-action manual for an uncallable action teaches the model to call + // it. Derive the excluded built-ins and let the base strip their lines; + // requested exclusions may also name CUSTOM actions (handled here). + const { customExcluded, builtinExcluded } = splitExclusions(promptOptions?.excludeActions); + const derivedExcludes = includeCore ? ACTION_NAMES_LIST.filter((name) => !includeCore.has(name)) : []; + const merged = [...new Set([...derivedExcludes, ...(builtinExcluded ?? [])])]; + const basePrompt = await base.getSystemPrompt(merged.length > 0 ? { excludeActions: merged } : undefined); + const activeActions = actions.filter((action) => !customExcluded.has(action.name)); + return basePrompt + autoSystemPromptSection(activeActions); + } + + async function getMcpPrompt(): Promise { + if (typeof options.systemPrompt === 'string') return options.systemPrompt; + const basePrompt = await base.getMcpPrompt(); + return basePrompt + autoSystemPromptSection(actions); + } + + async function dispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, + ): Promise { + if (toolName === 'superdoc_perform_action' && isRecord(args) && typeof args.action === 'string') { + const action = byName.get(args.action); + if (action) { + const exclusionOptions = invokeOptions as (InvokeOptions & { excludeActions?: readonly string[] }) | undefined; + if (exclusionOptions?.excludeActions?.includes(action.name)) { + throwExcludedAction(toolName, action.name); + } + return runCustomAction(base, action, documentHandle, args, invokeOptions); + } + // Composition allowlist is a dispatch boundary too: a built-in outside + // includeCoreActions is not advertised and must not execute on a + // guessed/stale call (defense-in-depth parity with excludeActions). + if (includeCore && BUILTIN_ACTION_NAMES.has(args.action) && !includeCore.has(args.action)) { + throwExcludedAction(toolName, args.action, { excludedBy: 'includeCoreActions' }); + } + } + // superdoc_execute_code stays dispatchable internally even when not advertised + // (mirrors core-actions.ts): the base dispatch still routes it. + return base.dispatch(documentHandle, toolName, args, invokeOptions); + } + + return { + id: options.id, + description: options.description ?? `Composed over ${baseId} with ${actions.length} custom action(s).`, + supportsCacheControl: base.supportsCacheControl, + getTools, + getCatalog, + getSystemPrompt, + getMcpPrompt, + dispatch, + }; +} diff --git a/packages/sdk/langs/node/src/agent/catalog.ts b/packages/sdk/langs/node/src/agent/catalog.ts index ac3c1b3fa3..3b94c6aec2 100644 --- a/packages/sdk/langs/node/src/agent/catalog.ts +++ b/packages/sdk/langs/node/src/agent/catalog.ts @@ -293,7 +293,7 @@ function buildActionDescription(included?: ReadonlySet): string { * description, AND the advertised argument properties all shrink together * (an arg no remaining action declares is not advertised). */ -function buildPerformActionDefinition(includedActions: readonly string[]): AgentToolDefinition { +export function buildPerformActionDefinition(includedActions: readonly string[]): AgentToolDefinition { const included = new Set(includedActions); return { name: 'superdoc_perform_action', diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index cee1c01ae8..e2d5ec0e08 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -260,6 +260,17 @@ export type { AgentReceipt } from './agent/runtime.js'; export type { GetSystemPromptOptions, GetToolsOptions, GetToolsResult, PresetDescriptor } from './presets.js'; export { createAgentToolkit } from './tools.js'; export type { AgentToolkit, CreateAgentToolkitInput } from './tools.js'; +// Custom-action authoring kit: define actions in two execution tiers +// (steps / run) and extend or compose presets with them. +export { defineAction, extendPreset, composePreset, executionKindOf } from './actions/define.js'; +export type { + ActionSpec, + ActionStep, + DefineActionInput, + ExtendPresetOptions, + ComposePresetOptions, + JSONSchemaObject, +} from './actions/define.js'; export { SuperDocCliError } from './runtime/errors.js'; export type { InvokeOptions, diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index a2ae9e0f79..6a7d5a9ba1 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -23,6 +23,7 @@ import { type ToolCatalogOperation, type ToolProvider, } from './presets.js'; +import { extendPreset, composePreset, type ActionSpec } from './actions/define.js'; export { DEFAULT_PRESET, getPreset, listPresets, registerPreset, unregisterPreset }; export type { @@ -185,7 +186,26 @@ function resolvePromptPresetArg(preset?: string | { preset?: string }): string { * suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). For MCP * server instructions, use {@link getMcpPrompt} instead. */ -export type CreateAgentToolkitInput = ToolChooserInput; +export type CreateAgentToolkitInput = ToolChooserInput & { + /** + * Custom actions to expose alongside the base preset's built-ins. When set, + * the toolkit builds the extended preset FOR YOU โ€” no `registerPreset`, no + * preset id to invent or thread through later `dispatch` calls. This is the + * simple path: define your actions, hand them here, use the returned toolkit. + * Advanced callers can still `extendPreset`/`composePreset` + `preset`. + */ + actions?: readonly ActionSpec[]; + /** Base preset to extend when `actions` is set (default `'core'`). Ignored otherwise. */ + base?: string; + /** + * With `actions`, keep only these built-in action names (โˆช your custom + * actions) โ€” builds via `composePreset` instead of `extendPreset`. + */ + includeCoreActions?: readonly string[]; +}; + +/** Label for the ephemeral preset built by the one-call `actions` path. */ +const CUSTOM_TOOLKIT_PRESET_ID = 'custom_superdoc_preset'; export type AgentToolkit = { /** Provider-shaped tool definitions (see {@link chooseTools}). */ @@ -222,8 +242,45 @@ export type AgentToolkit = { * matching the standalone functions. */ export async function createAgentToolkit(input: CreateAgentToolkitInput): Promise { - const presetId = input.preset ?? DEFAULT_PRESET; const excludeActions = input.excludeActions ? [...input.excludeActions] : undefined; + + // One-call custom-actions path: define your actions, hand them here, use the + // returned toolkit. We build an ephemeral extended/composed preset over + // `base` (default 'core') and drive it directly โ€” no global registerPreset, + // and nothing for the caller to remember at dispatch time. + if ((input.actions && input.actions.length > 0) || input.includeCoreActions != null) { + // `preset` doubles as the base to extend here (so `{ preset: 'core', actions }` + // reads naturally); `base` wins if both are given. Default base is 'core'. + const baseId = input.base ?? input.preset ?? 'core'; + const actions = input.actions ?? []; + const descriptor = + input.includeCoreActions != null + ? composePreset({ + id: CUSTOM_TOOLKIT_PRESET_ID, + baseId, + includeCoreActions: input.includeCoreActions, + actions, + }) + : extendPreset(baseId, { id: CUSTOM_TOOLKIT_PRESET_ID, actions }); + const { tools, cacheStrategy } = await descriptor.getTools(input.provider, { + cache: input.cache === true, + excludeActions, + }); + const systemPrompt = await descriptor.getSystemPrompt(excludeActions ? { excludeActions } : undefined); + const dispatch: AgentToolkit['dispatch'] = (documentHandle, toolName, args = {}, invokeOptions) => + descriptor.dispatch(documentHandle, toolName, args, { + ...invokeOptions, + ...(excludeActions ? { excludeActions } : {}), + }); + return { + tools, + meta: { provider: input.provider, preset: descriptor.id, toolCount: tools.length, cacheStrategy }, + systemPrompt, + dispatch, + }; + } + + const presetId = input.preset ?? DEFAULT_PRESET; const { tools, meta } = await chooseTools({ ...input, preset: presetId, excludeActions }); const systemPrompt = await getSystemPrompt(presetId, excludeActions ? { excludeActions } : undefined); const dispatch: AgentToolkit['dispatch'] = (documentHandle, toolName, args = {}, invokeOptions) => diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index 9dc84d08f7..e0567d2859 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -1,4 +1,5 @@ from .presets import DEFAULT_PRESET, get_preset, list_presets, register_preset, unregister_preset +from .presets.custom import compose_preset, define_action, extend_preset from .client import AsyncSuperDocClient, AsyncSuperDocDocument, SuperDocClient, SuperDocDocument from .errors import SuperDocError from .skill_api import get_skill, install_skill, list_skills @@ -37,4 +38,7 @@ "list_presets", "register_preset", "unregister_preset", + "define_action", + "extend_preset", + "compose_preset", ] diff --git a/packages/sdk/langs/python/superdoc/presets/__init__.py b/packages/sdk/langs/python/superdoc/presets/__init__.py index b089cfcb4e..7a5ed27daa 100644 --- a/packages/sdk/langs/python/superdoc/presets/__init__.py +++ b/packages/sdk/langs/python/superdoc/presets/__init__.py @@ -33,9 +33,10 @@ class PresetDescriptor(Protocol): description: str supports_cache_control: bool - def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: ... + def get_tools(self, provider: ToolProvider, *, cache: bool = False, + exclude_actions: Optional[list] = None) -> Dict[str, Any]: ... def get_catalog(self) -> Dict[str, Any]: ... - def get_system_prompt(self) -> str: ... + def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str: ... def get_mcp_prompt(self) -> str: ... def dispatch( self, diff --git a/packages/sdk/langs/python/superdoc/presets/custom.py b/packages/sdk/langs/python/superdoc/presets/custom.py new file mode 100644 index 0000000000..24fbdf79b6 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/presets/custom.py @@ -0,0 +1,1183 @@ +"""Customer-extensible **custom actions** (Python mirror of the Node SDK kit). + +The canonical ActionSpec has exactly ONE execution tier: + +- ``steps`` โ€” declarative composition of built-in core actions with ``{{arg}}`` + templating; dispatches through the base preset and inherits its target + resolution, receipts, and verification. +- ``run`` โ€” a native Python callable executed in YOUR process against the + typed doc handle, with synthesized truth-telling receipts (pre/post + revision, partialMutation). Async callables require the async dispatcher. + +(A third, in-host tier lands with the code-act execution path; see the Node +``define.ts`` module header for the rationale.) + +``extend_preset``/``compose_preset`` merge custom actions into the +superdoc_perform_action enum, tool description, system prompt, and dispatch +COHERENTLY โ€” including ``exclude_actions``, which may name built-in actions +(forwarded to the base) or custom ones (handled by the wrapper). + +Cross-runtime contract: templating semantics, input-schema defaults, and +receipt shapes are identical to the Node kit +(``langs/node/src/actions/define.ts``) for any JSON-serializable tool input. + +Author flow:: + + add = define_action( + name='superdoc.add_footnote', + description='Insert a footnote right after the anchor text.', + input_schema={'type': 'object', 'properties': {...}, 'required': ['anchorText', 'content']}, + run=_add_footnote, + ) + acme = extend_preset('core', id='acme', actions=[add]) + register_preset(acme) + choose_tools({'provider': 'openai', 'preset': 'acme'}) + dispatch_superdoc_tool(handle, 'superdoc_perform_action', {'action': 'superdoc.add_footnote', ...}, preset='acme') +""" + +from __future__ import annotations + +import inspect +import json +import re +from dataclasses import dataclass +from typing import Any, Awaitable, Dict, List, Optional, Sequence + +from ..errors import SuperDocError +from . import ToolProvider, get_preset + +# --------------------------------------------------------------------------- +# Built-in core action names. MUST stay in sync with ACTION_NAMES_LIST in +# node/src/agent/actions.ts (source of truth). Collision checks compare against +# this set. (core's getCatalog lists the 3 tools, not the 35 action names, so a +# shared constant is the safest cross-runtime source (40 names). A unit test +# asserts the exact set so any drift from Node fails loudly. +# --------------------------------------------------------------------------- + +BUILTIN_ACTION_NAMES: frozenset = frozenset( + { + 'insert_paragraphs', + 'insert_heading', + 'replace_text', + 'delete_text', + 'append_list', + 'create_table', + 'comment_paragraphs', + 'add_comments', + 'resolve_comments', + 'reply_to_comment', + 'rewrite_block', + 'accept_tracked_changes', + 'reject_tracked_changes', + 'normalize_body_font_size', + 'set_font_family', + 'apply_letter_spacing', + 'fill_placeholders', + 'move_range', + 'insert_toc', + 'insert_table_row', + 'insert_table_column', + 'delete_table_row', + 'delete_table_column', + 'split_table', + 'convert_list', + 'split_list', + 'undo_changes', + 'redo_changes', + 'attach_numbering', + 'add_list_items', + 'format_text', + 'apply_style', + 'format_paragraph', + 'move_text', + 'style_table', + 'move_table', + 'delete_table', + 'set_paragraph_spacing', + 'insert_page_break', + 'add_hyperlink', + } +) + + +# --------------------------------------------------------------------------- +# define_action โ€” author a ActionSpec (a plain dict) +# --------------------------------------------------------------------------- + + +def define_action( + name: str, + description: str, + input_schema: Optional[Dict[str, Any]] = None, + steps: Optional[Sequence[Dict[str, Any]]] = None, + run: Optional[Any] = None, +) -> Dict[str, Any]: + """Author an ActionSpec dict โ€” the canonical custom-action type. + + ``input_schema`` is a JSON Schema object describing the action's flat args + (NOT the ``action`` discriminator). Exactly ONE execution tier is given: + + - ``steps`` โ€” DECLARATIVE (recommended): a list of built-in core actions + ``{'action': , 'args': {...}}`` with ``{{arg}}`` templating. Runs + through the base preset, inheriting target resolution, placement, + receipts, and verification. Pure data โ€” identical semantics from Node. + - ``run`` โ€” NATIVE escape hatch: a Python callable ``run(doc, args)`` + executed in YOUR process against the typed doc handle. + """ + if not isinstance(name, str) or not name: + raise SuperDocError('define_action requires a non-empty name.', code='INVALID_ARGUMENT') + if not isinstance(description, str): + raise SuperDocError( + f'define_action "{name}" requires a string description.', + code='INVALID_ARGUMENT', + details={'name': name}, + ) + tiers = [] + if steps is not None: + tiers.append('steps') + if run is not None: + tiers.append('run') + if len(tiers) != 1: + raise SuperDocError( + f'define_action "{name}" requires exactly one of steps or run ' + f"(got {'none' if not tiers else ' + '.join(tiers)}).", + code='INVALID_ARGUMENT', + details={'name': name, 'tiers': tiers}, + ) + if input_schema is None: + schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'additionalProperties': True} + elif isinstance(input_schema, dict): + # A proper JSON Schema object passes through; a bare properties bag is + # wrapped (matches Node's coerceInputSchema). + schema = ( + input_schema + if input_schema.get('type') == 'object' + else {'type': 'object', 'properties': input_schema, 'additionalProperties': True} + ) + else: + # Reject Zod/Pydantic/other schema objects clearly rather than silently + # dropping them for an empty schema. + raise SuperDocError( + f'define_action "{name}": input_schema must be a JSON Schema dict, not a ' + f'{type(input_schema).__name__}. Convert a Zod/Pydantic/other schema to a JSON ' + 'Schema dict first (e.g. Model.model_json_schema()).', + code='INVALID_ARGUMENT', + details={'name': name}, + ) + spec: Dict[str, Any] = { + 'name': name, + 'description': description, + 'inputSchema': schema, + } + if tiers[0] == 'steps': + if not isinstance(steps, (list, tuple)) or len(steps) == 0: + raise SuperDocError( + f'define_action "{name}": steps must be a non-empty list.', + code='INVALID_ARGUMENT', + details={'name': name}, + ) + normalized = [] + for index, step in enumerate(steps): + action_name = step.get('action') if isinstance(step, dict) else None + if not isinstance(action_name, str) or not action_name: + raise SuperDocError( + f'define_action "{name}": steps[{index}] needs a non-empty "action" string.', + code='INVALID_ARGUMENT', + details={'name': name, 'index': index}, + ) + if action_name not in BUILTIN_ACTION_NAMES: + raise SuperDocError( + f'define_action "{name}": steps[{index}].action "{action_name}" is not a built-in ' + 'core action. Steps compose built-in actions only; use run for anything else.', + code='INVALID_ARGUMENT', + details={'name': name, 'index': index, 'action': action_name}, + ) + step_args = step.get('args') + if step_args is not None and not isinstance(step_args, dict): + raise SuperDocError( + f'define_action "{name}": steps[{index}].args must be an object.', + code='INVALID_ARGUMENT', + details={'name': name, 'index': index}, + ) + normalized.append({'action': action_name, 'args': dict(step_args or {})}) + spec['steps'] = normalized + elif tiers[0] == 'run': + if not callable(run): + raise SuperDocError( + f'define_action "{name}": run must be callable.', + code='INVALID_ARGUMENT', + details={'name': name}, + ) + spec['run'] = run + return spec + + +def execution_kind_of(spec: Dict[str, Any]) -> str: + """Which execution tier a spec uses: 'steps' | 'run'.""" + return 'steps' if isinstance(spec.get('steps'), (list, tuple)) else 'run' + + +# --------------------------------------------------------------------------- +# Codegen โ€” identical to the Node template for JSON-serializable tool inputs +# (the only values a tool call can carry; NaN/Infinity/lone-surrogates are out +# of scope and serialize differently across Node and Python). +# --------------------------------------------------------------------------- + + + + +# --------------------------------------------------------------------------- +# Collision / duplicate validation +# --------------------------------------------------------------------------- + + +# Provider tool-name rule. OpenAI/Anthropic require tool names to match +# ^[A-Za-z0-9_-]{1,64}$ โ€” dots are invalid. This only matters in STANDALONE +# mode, where the action name becomes a tool name; in MERGED mode the name is +# an enum VALUE (dots are fine). +_PROVIDER_SAFE_TOOL_NAME = re.compile(r'^[A-Za-z0-9_-]{1,64}$') + +# Tool names of the agent surface itself โ€” a custom action must never shadow +# one (MUST stay in sync with AGENT_TOOL_NAMES in node/src/agent/catalog.ts). +_RESERVED_TOOL_NAMES = frozenset({ + 'superdoc_inspect', + 'superdoc_perform_action', + 'superdoc_execute_code', + 'agent_apply', + 'agent_verify', + 'agent_operation', +}) + + +def _assert_actions_valid( + actions: Sequence[Dict[str, Any]], preset_id: str, standalone: bool = False +) -> None: + seen = set() + for action in actions: + name = action.get('name') + # Raw spec dicts can bypass define_action โ€” re-validate the tier shape + # here so a hand-rolled {'steps': []} can't fabricate succeeded receipts. + tiers = [t for t, present in ( + ('steps', isinstance(action.get('steps'), (list, tuple))), + ('run', callable(action.get('run'))), + ) if present] + if len(tiers) != 1: + raise SuperDocError( + f'Custom action "{name}" must have exactly one of steps/run ' + f"(got {'none' if not tiers else ' + '.join(tiers)}).", + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name, 'tiers': tiers}, + ) + if isinstance(action.get('steps'), (list, tuple)): + steps = action['steps'] + if len(steps) == 0: + raise SuperDocError( + f'Custom action "{name}": steps must be non-empty.', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name}, + ) + for index, step in enumerate(steps): + step_action = step.get('action') if isinstance(step, dict) else None + if not isinstance(step_action, str) or step_action not in BUILTIN_ACTION_NAMES: + raise SuperDocError( + f'Custom action "{name}": steps[{index}].action must be a built-in core action.', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name, 'index': index}, + ) + if name in BUILTIN_ACTION_NAMES: + raise SuperDocError( + f'Custom action "{name}" collides with a built-in core action name. ' + f'Use a namespaced name like ".{name}".', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name}, + ) + if name in _RESERVED_TOOL_NAMES: + raise SuperDocError( + f'Custom action "{name}" collides with a reserved tool name โ€” it would shadow the agent surface itself.', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name}, + ) + if name in seen: + raise SuperDocError( + f'Duplicate custom action name "{name}" in preset "{preset_id}".', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name}, + ) + # In standalone mode the action name becomes a provider tool name, which + # OpenAI/Anthropic reject unless it matches ^[A-Za-z0-9_-]{1,64}$ (dotted + # namespaced names are only valid as merged enum VALUES). + if standalone and not _PROVIDER_SAFE_TOOL_NAME.match(name or ''): + raise SuperDocError( + f'standalone action names must match ^[A-Za-z0-9_-]{{1,64}}$; ' + f'"{name}" has invalid characters โ€” use merged mode for dotted names.', + code='INVALID_ARGUMENT', + details={'presetId': preset_id, 'name': name}, + ) + seen.add(name) + + +# --------------------------------------------------------------------------- +# Tool-list merging โ€” mirror provider shapes from agent/catalog.ts +# --------------------------------------------------------------------------- + + +def _tool_name(tool: Any) -> str: + if not isinstance(tool, dict): + return '' + fn = tool.get('function') + if isinstance(fn, dict) and isinstance(fn.get('name'), str): + return fn['name'] + return tool.get('name', '') if isinstance(tool.get('name'), str) else '' + + +def _custom_actions_description(actions: Sequence[Dict[str, Any]]) -> str: + body = '; '.join(f"{r['name']} ({r['description']})" for r in actions) + return f' Custom actions: {body}.' + + +def _perform_action_shape(tools: Sequence[Any]) -> tuple: + """Read the (description, JSON-schema) of the superdoc_perform_action tool + from a get_tools result, across provider shapes (function-wrapped or flat, + input_schema/inputSchema/parameters). Returns (None, None) when the tool is + absent โ€” e.g. every built-in excluded, where the host drops it.""" + for tool in tools: + if _tool_name(tool) != 'superdoc_perform_action' or not isinstance(tool, dict): + continue + fn = tool.get('function') if isinstance(tool.get('function'), dict) else None + container = fn if fn is not None else tool + schema_key = ('input_schema' if 'input_schema' in container + else 'inputSchema' if 'inputSchema' in container else 'parameters') + schema = container.get(schema_key) + desc = container.get('description') if isinstance(container.get('description'), str) else None + return desc, (dict(schema) if isinstance(schema, dict) else None) + return None, None + + +_METADATA_SCHEMA_KEYS = {'description', 'title', 'examples', '$comment'} + + +def _structural_schema(value: Any) -> Any: + """Drop doc-only keys (recursively) so only the structural shape is compared.""" + if isinstance(value, dict): + return {k: _structural_schema(v) for k, v in value.items() if k not in _METADATA_SCHEMA_KEYS} + if isinstance(value, list): + return [_structural_schema(v) for v in value] + return value + + +def _arg_schemas_conflict(a: Any, b: Any) -> bool: + """Two arg schemas CONFLICT when they differ in any way EXCEPT documentation + (description/title/examples/$comment). Reusing a built-in arg name with your + own description is fine, but a different type, enum (incl. one-sided), + default, limit, pattern, or nested shape is a real conflict.""" + return json.dumps(_structural_schema(a), sort_keys=True) != json.dumps(_structural_schema(b), sort_keys=True) + + +def _assert_no_arg_conflict(properties: Dict[str, Any], key: str, value: Any, action_name: str) -> None: + """Reject a custom arg whose name collides with an existing arg (built-in or + an earlier custom action) of an INCOMPATIBLE type/enum. A shared name with a + compatible type but extra metadata (description/default) is allowed.""" + if key in properties and _arg_schemas_conflict(properties[key], value): + raise SuperDocError( + f'Custom action "{action_name}" declares argument "{key}" with a schema that conflicts with an ' + 'existing argument of the same name on the superdoc_perform_action surface. Rename the argument, ' + 'or align its type.', + code='INVALID_ARGUMENT', + details={'action': action_name, 'arg': key}, + ) + + +def _merge_into_superdoc_perform_action(tools: List[Any], actions: Sequence[Dict[str, Any]]) -> List[Any]: + out: List[Any] = [] + for tool in tools: + if _tool_name(tool) != 'superdoc_perform_action' or not isinstance(tool, dict): + out.append(tool) + continue + t = dict(tool) + fn = t.get('function') if isinstance(t.get('function'), dict) else None + container = dict(fn) if fn is not None else t + schema_key = ('input_schema' if 'input_schema' in container + else 'inputSchema' if 'inputSchema' in container else 'parameters') + schema = dict(container.get(schema_key) or {}) + properties = dict(schema.get('properties') or {}) + action_prop = dict(properties.get('action') or {}) + enum_values = list(action_prop.get('enum') or []) + for action in actions: + if action['name'] not in enum_values: + enum_values.append(action['name']) + for key, value in (action.get('inputSchema', {}).get('properties') or {}).items(): + _assert_no_arg_conflict(properties, key, value, action['name']) + if key not in properties: + properties[key] = value + action_prop['enum'] = enum_values + properties['action'] = action_prop + schema['properties'] = properties + base_desc = container.get('description') if isinstance(container.get('description'), str) else '' + container['description'] = base_desc + _custom_actions_description(actions) + container[schema_key] = schema + if fn is not None: + t['function'] = container + out.append(t) + else: + out.append(container) + return out + + +def _synthesize_perform_action(provider: ToolProvider, actions: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + """When the base dropped superdoc_perform_action entirely (all built-ins + excluded / an empty allowlist), active custom actions would be advertised + in the prompt and dispatchable โ€” but carried by NO tool. Synthesize a + custom-only definition so a curated custom-only preset stays callable.""" + properties: Dict[str, Any] = { + 'action': {'type': 'string', 'enum': [r['name'] for r in actions]}, + } + for action in actions: + for key, value in (action.get('inputSchema', {}).get('properties') or {}).items(): + _assert_no_arg_conflict(properties, key, value, action['name']) + properties.setdefault(key, value) + schema = {'type': 'object', 'additionalProperties': True, 'required': ['action'], 'properties': properties} + description = ( + "Perform one of this preset's custom document actions. Pick an action and pass its flat arguments." + + _custom_actions_description(actions) + ) + if provider == 'anthropic': + return {'name': 'superdoc_perform_action', 'description': description, 'input_schema': schema} + if provider == 'vercel': + return {'name': 'superdoc_perform_action', 'description': description, 'inputSchema': schema} + if provider == 'openai': + return {'type': 'function', + 'function': {'name': 'superdoc_perform_action', 'description': description, 'parameters': schema}} + return {'name': 'superdoc_perform_action', 'description': description, 'parameters': schema} + + +def _merge_or_synthesize_perform_action(tools: List[Any], actions: Sequence[Dict[str, Any]], + provider: ToolProvider) -> List[Any]: + if any(_tool_name(t) == 'superdoc_perform_action' for t in tools): + return _merge_into_superdoc_perform_action(tools, actions) + return tools + [_synthesize_perform_action(provider, actions)] + + +def _standalone_tool(provider: ToolProvider, action: Dict[str, Any]) -> Dict[str, Any]: + if provider == 'anthropic': + return {'name': action['name'], 'description': action['description'], 'input_schema': action['inputSchema']} + # The core agent dialect for vercel is FLAT {name, description, inputSchema} + # (agent/catalog.ts toVercelTool) โ€” not the OpenAI nested function shape. + if provider == 'vercel': + return {'name': action['name'], 'description': action['description'], 'inputSchema': action['inputSchema']} + if provider == 'openai': + return { + 'type': 'function', + 'function': { + 'name': action['name'], + 'description': action['description'], + 'parameters': action['inputSchema'], + }, + } + # generic + return {'name': action['name'], 'description': action['description'], 'parameters': action['inputSchema']} + + +def _renormalize_anthropic_cache_marker( + tools: List[Any], provider: ToolProvider, cache_requested: bool +) -> List[Any]: + """Re-apply the Anthropic prompt-cache marker after the tool list was + mutated. Mirrors Node's ``renormalizeAnthropicCacheMarker``: the base places + ``cache_control`` on the LAST tool; appending/removing tools can leave it + mid-list or drop it, so strip any existing markers and re-apply to the final + last tool. No-op for other providers / when not requested / empty list. + """ + if provider != 'anthropic' or not cache_requested or not tools: + return tools + stripped: List[Any] = [] + for tool in tools: + if isinstance(tool, dict) and 'cache_control' in tool: + stripped.append({k: v for k, v in tool.items() if k != 'cache_control'}) + else: + stripped.append(tool) + last = stripped[-1] + if isinstance(last, dict): + stripped[-1] = {**last, 'cache_control': {'type': 'ephemeral'}} + return stripped + + +# --------------------------------------------------------------------------- +# runCustomAction โ€” validate, codegen, dispatch via superdoc_execute_code, map receipt +# --------------------------------------------------------------------------- + + +# Kit-level args every custom action accepts without declaring them. +_IMPLICIT_ACTION_ARGS = frozenset({'changeMode', 'rationale'}) + + +def _validate_against_schema(action: Dict[str, Any], args: Dict[str, Any]) -> None: + schema = action.get('inputSchema', {}) + required = schema.get('required') or [] + missing = [key for key in required if args.get(key) is None] + if missing: + raise SuperDocError( + f"Missing required argument(s) for {action['name']}: {', '.join(missing)}", + code='INVALID_ARGUMENT', + details={'action': action['name'], 'missingKeys': missing}, + ) + properties = schema.get('properties') if isinstance(schema.get('properties'), dict) else {} + if schema.get('additionalProperties') is False: + unknown = [k for k in args if k not in properties and k not in _IMPLICIT_ACTION_ARGS] + if unknown: + raise SuperDocError( + f"Unknown argument(s) for {action['name']}: {', '.join(unknown)}", + code='INVALID_ARGUMENT', + details={'action': action['name'], 'unknownKeys': unknown, 'knownKeys': list(properties)}, + ) + for key, prop in properties.items(): + if key not in args: + continue + value = args[key] + # Validate whenever the key is PRESENT โ€” an explicit None is a value, + # not an absence (Node parity: null !== undefined). Letting None + # through would also bypass schema defaults, which only fill missing + # keys, so the action would receive a live None. + if isinstance(prop, dict) and isinstance(prop.get('enum'), list) and value not in prop['enum']: + raise SuperDocError( + f"Invalid value for {action['name']}.{key}: {value!r} (allowed: {prop['enum']})", + code='INVALID_ARGUMENT', + details={'action': action['name'], 'key': key, 'allowed': prop['enum']}, + ) + + +# --------------------------------------------------------------------------- +# steps tier โ€” templating + interpreter (identical semantics to Node) +# --------------------------------------------------------------------------- + +_WHOLE_TEMPLATE = re.compile(r'^\{\{(\w+)\}\}$') + + +def _apply_input_defaults(action: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: + out = dict(args) + for key, prop in (action.get('inputSchema', {}).get('properties') or {}).items(): + if key not in out and isinstance(prop, dict) and 'default' in prop: + out[key] = prop['default'] + return out + + +def _substitute_templates(node: Any, arg_values: Dict[str, Any]) -> Any: + """Whole-string ``"{{x}}"`` yields the RAW value; partial templates + interpolate as text; keys resolving to None-from-missing are dropped by the + dict branch (so optional args don't inject nulls into step args).""" + if isinstance(node, str): + whole = _WHOLE_TEMPLATE.match(node) + if whole: + return arg_values.get(whole.group(1), _MISSING) + def _text(value: Any) -> str: + # Byte-identical with Node: strings verbatim, None -> '', everything + # else compact JSON (true/[1,2]/{"a":1}) โ€” NOT str(), whose bool/ + # list/dict forms differ from JS String(). + if value is None: + return '' + if isinstance(value, str): + return value + return json.dumps(value, separators=(',', ':'), ensure_ascii=False) + return re.sub(r'\{\{(\w+)\}\}', lambda m: _text(arg_values.get(m.group(1))), node) + if isinstance(node, list): + # Whole-string templates for ABSENT args are dropped from arrays too โ€” + # the sentinel must never leak into a transport payload. + return [item for item in (_substitute_templates(entry, arg_values) for entry in node) if item is not _MISSING] + if isinstance(node, dict): + out = {} + for key, value in node.items(): + substituted = _substitute_templates(value, arg_values) + if substituted is not _MISSING: + out[key] = substituted + return out + return node + + +class _MissingType: + def __repr__(self) -> str: # pragma: no cover + return '' + + +_MISSING = _MissingType() + + +def _step_args_for(action: Dict[str, Any], step: Dict[str, Any], arg_values: Dict[str, Any]) -> Dict[str, Any]: + step_args = _substitute_templates(step.get('args') or {}, arg_values) + if step_args is _MISSING or not isinstance(step_args, dict): + step_args = {} + # changeMode pass-through: a caller-level changeMode reaches every step + # that doesn't pin its own. + if isinstance(arg_values.get('changeMode'), str) and 'changeMode' not in step_args: + step_args = {**step_args, 'changeMode': arg_values['changeMode']} + return step_args + + +def _steps_row(index: int, step: Dict[str, Any], receipt: Dict[str, Any]) -> Dict[str, Any]: + row = {'step': index, 'action': step['action'], 'status': receipt.get('status')} + if 'verificationPassed' in receipt: + row['verificationPassed'] = receipt['verificationPassed'] + return row + + +def _steps_aggregate(action: Dict[str, Any], rows: List[Dict[str, Any]], failed_at: Optional[int] = None, + failed_receipt: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if failed_at is None: + return {'status': 'succeeded', 'action': action['name'], 'steps': rows} + any_landed = any(row['status'] != 'failed' for row in rows) + return { + 'status': 'partial' if any_landed else 'failed', + 'action': action['name'], + 'steps': rows, + 'failedStep': {'index': failed_at, 'receipt': failed_receipt}, + } + + +def _run_steps_action(base: Any, action: Dict[str, Any], document_handle: Any, + args: Dict[str, Any], invoke_options: Optional[Dict[str, Any]]) -> Dict[str, Any]: + arg_values = args # defaults already applied by the router + rows: List[Dict[str, Any]] = [] + for index, step in enumerate(action['steps']): + step_args = _step_args_for(action, step, arg_values) + try: + # Steps are the author's curated composition, not model calls โ€” so + # surface exclude_actions (already enforced at the top-level + # dispatch) must not refuse a built-in this action composes. + dispatched = base.dispatch( + document_handle, 'superdoc_perform_action', {'action': step['action'], **step_args}, + invoke_options + ) + receipt = dispatched if isinstance(dispatched, dict) else {'status': 'ok', 'result': dispatched} + except Exception as error: # noqa: BLE001 โ€” validation errors throw; normalize + receipt = {'status': 'failed', 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}]} + rows.append(_steps_row(index, step, receipt)) + if receipt.get('status') == 'failed': + return _steps_aggregate(action, rows, index, receipt) + # Truthfulness: a partially-landed step (or failed verification) must + # not roll up into a clean succeeded โ€” stop and report partial. + if receipt.get('status') == 'partial' or receipt.get('verificationPassed') is False: + return { + 'status': 'partial', + 'action': action['name'], + 'steps': rows, + 'failedStep': {'index': index, 'receipt': receipt}, + } + return _steps_aggregate(action, rows) + + +async def _run_steps_action_async(base: Any, action: Dict[str, Any], document_handle: Any, + args: Dict[str, Any], invoke_options: Optional[Dict[str, Any]]) -> Dict[str, Any]: + arg_values = args # defaults already applied by the router + rows: List[Dict[str, Any]] = [] + for index, step in enumerate(action['steps']): + step_args = _step_args_for(action, step, arg_values) + try: + # Steps are the author's curated composition, not model calls โ€” so + # surface exclude_actions (already enforced at the top-level + # dispatch) must not refuse a built-in this action composes. + dispatched = await base.dispatch_async( + document_handle, 'superdoc_perform_action', {'action': step['action'], **step_args}, + invoke_options + ) + receipt = dispatched if isinstance(dispatched, dict) else {'status': 'ok', 'result': dispatched} + except Exception as error: # noqa: BLE001 + receipt = {'status': 'failed', 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}]} + rows.append(_steps_row(index, step, receipt)) + if receipt.get('status') == 'failed': + return _steps_aggregate(action, rows, index, receipt) + # Truthfulness: a partially-landed step (or failed verification) must + # not roll up into a clean succeeded โ€” stop and report partial. + if receipt.get('status') == 'partial' or receipt.get('verificationPassed') is False: + return { + 'status': 'partial', + 'action': action['name'], + 'steps': rows, + 'failedStep': {'index': index, 'receipt': receipt}, + } + return _steps_aggregate(action, rows) + + +# --------------------------------------------------------------------------- +# run tier โ€” native Python callable with a synthesized truth-telling receipt +# --------------------------------------------------------------------------- + + +def _read_revision(document_handle: Any) -> Optional[str]: + info = getattr(document_handle, 'info', None) + if not callable(info): + return None + try: + result = info({}) + revision = result.get('revision') if isinstance(result, dict) else None + return None if revision is None else str(revision) + except Exception: # noqa: BLE001 โ€” revision evidence is best-effort + return None + + +def _native_receipt(action: Dict[str, Any], pre: Optional[str], post: Optional[str], + result: Any = None, error: Optional[Exception] = None) -> Dict[str, Any]: + if error is None: + return {'status': 'succeeded', 'action': action['name'], 'result': result, + 'preRevision': pre, 'postRevision': post} + partial = pre is not None and post is not None and pre != post + return { + 'status': 'failed', + 'action': action['name'], + 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}], + 'preRevision': pre, + 'postRevision': post, + 'partialMutation': partial, + 'recovery': {'kind': 'revert', 'call': 'superdoc_perform_action {action:"undo_changes"}'} + if partial else {'kind': 'retry'}, + } + + +def _run_native_action(action: Dict[str, Any], document_handle: Any, args: Dict[str, Any]) -> Dict[str, Any]: + arg_values = args # defaults already applied by the router + pre = _read_revision(document_handle) + try: + result = action['run'](document_handle, arg_values) + if inspect.isawaitable(result): + # An async run function through the SYNC dispatcher would return an + # un-awaited coroutine as a "successful" result โ€” refuse loudly. + result.close() + raise SuperDocError( + f"{action['name']}: run returned an awaitable from the sync dispatcher โ€” " + 'use dispatch_superdoc_tool_async / dispatch_async for async run functions.', + code='INVALID_ARGUMENT', + details={'action': action['name']}, + ) + return _native_receipt(action, pre, _read_revision(document_handle), result=result) + except Exception as error: # noqa: BLE001 โ€” becomes a failed receipt + return _native_receipt(action, pre, _read_revision(document_handle), error=error) + + +async def _read_revision_async(document_handle: Any) -> Optional[str]: + info = getattr(document_handle, 'info', None) + if not callable(info): + return None + try: + result = info({}) + if inspect.isawaitable(result): + result = await result + revision = result.get('revision') if isinstance(result, dict) else None + return None if revision is None else str(revision) + except Exception: # noqa: BLE001 โ€” revision evidence is best-effort + return None + + +async def _run_native_action_async(action: Dict[str, Any], document_handle: Any, args: Dict[str, Any]) -> Dict[str, Any]: + arg_values = args # defaults already applied by the router + pre = await _read_revision_async(document_handle) + try: + result = action['run'](document_handle, arg_values) + if inspect.isawaitable(result): + result = await result + return _native_receipt(action, pre, await _read_revision_async(document_handle), result=result) + except Exception as error: # noqa: BLE001 + return _native_receipt(action, pre, await _read_revision_async(document_handle), error=error) + + +# --------------------------------------------------------------------------- +# tier router โ€” one entry point for both preset wrappers +# --------------------------------------------------------------------------- + + +def _run_custom_action(base: Any, action: Dict[str, Any], document_handle: Any, + raw_args: Optional[Dict[str, Any]], invoke_options: Optional[Dict[str, Any]], + from_perform_action: bool = True) -> Dict[str, Any]: + args = dict(raw_args or {}) + # `action` is the superdoc_perform_action discriminator ONLY on that route; + # in standalone mode it may be a real declared argument โ€” strip only when + # it came in as the discriminator. + if from_perform_action: + args.pop('action', None) + # Apply schema defaults BEFORE validation โ€” a required arg with a declared + # default is satisfiable by the default. + args = _apply_input_defaults(action, args) + _validate_against_schema(action, args) + kind = execution_kind_of(action) + if kind == 'steps': + return _run_steps_action(base, action, document_handle, args, invoke_options) + return _run_native_action(action, document_handle, args) + + +async def _run_custom_action_async(base: Any, action: Dict[str, Any], document_handle: Any, + raw_args: Optional[Dict[str, Any]], + invoke_options: Optional[Dict[str, Any]], + from_perform_action: bool = True) -> Dict[str, Any]: + args = dict(raw_args or {}) + if from_perform_action: + args.pop('action', None) + args = _apply_input_defaults(action, args) + _validate_against_schema(action, args) + kind = execution_kind_of(action) + if kind == 'steps': + return await _run_steps_action_async(base, action, document_handle, args, invoke_options) + return await _run_native_action_async(action, document_handle, args) + + +def _split_exclusions(by_name: dict, exclude_actions: Optional[list]) -> tuple: + """excludeActions may name BUILT-IN actions (forwarded to the base, which + validates them) or CUSTOM actions (handled by the wrapper โ€” the base would + reject names it doesn't know). Returns (custom_excluded_set, builtin_list_or_None).""" + if not exclude_actions: + return frozenset(), None + custom = {name for name in exclude_actions if name in by_name} + builtin = [name for name in exclude_actions if name not in by_name] + return frozenset(custom), (builtin or None) + + +# --------------------------------------------------------------------------- +# extend_preset โ€” wrap a base preset with custom actions +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _ExtendedPreset: + id: str + description: str + supports_cache_control: bool + _base: Any + _actions: tuple + _by_name: dict + _standalone: bool + _system_prompt_extra: Optional[str] = None + + # --- tool surface --- + def get_tools(self, provider: ToolProvider, *, cache: bool = False, + exclude_actions: Optional[list] = None) -> Dict[str, Any]: + custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions) + base_kwargs = {'exclude_actions': builtin_excluded} if builtin_excluded else {} + result = self._base.get_tools(provider, cache=cache, **base_kwargs) + active = [r for r in self._actions if r['name'] not in custom_excluded] + if not active: + return result + tools = list(result.get('tools') or []) + if self._standalone: + tools = tools + [_standalone_tool(provider, r) for r in active] + else: + tools = _merge_or_synthesize_perform_action(tools, active, provider) + tools = _renormalize_anthropic_cache_marker( + tools, provider, bool(cache) and result.get('cacheStrategy') != 'disabled') + return {**result, 'tools': tools} + + def get_catalog(self) -> Dict[str, Any]: + catalog = self._base.get_catalog() + rows = list(catalog.get('tools') or []) + rows = rows + [ + { + 'toolName': r['name'], + 'description': r['description'], + 'inputSchema': r['inputSchema'], + 'mutates': True, + 'operations': [], + } + for r in self._actions + ] + return {**catalog, 'toolCount': len(rows), 'tools': rows} + + def _prompt_extra(self, custom_excluded: frozenset = frozenset()) -> str: + if self._system_prompt_extra is not None: + return self._system_prompt_extra + active = [r for r in self._actions if r['name'] not in custom_excluded] + if not active: + return '' + bullets = '\n'.join(f"- {r['name']} โ€” {r['description']}" for r in active) + return f'\n\n## Custom actions\n{bullets}' + + def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str: + custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions) + base_kwargs = {'exclude_actions': builtin_excluded} if builtin_excluded else {} + return self._base.get_system_prompt(**base_kwargs) + self._prompt_extra(custom_excluded) + + def get_mcp_prompt(self) -> str: + return self._base.get_mcp_prompt() + self._prompt_extra() + + # --- dispatch --- + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + *, + exclude_actions: Optional[list] = None, + ) -> Any: + action = self._resolve_custom(tool_name, args) + if action is not None: + # Defense-in-depth parity with core: an excluded CUSTOM action is + # refused before it runs (the base can only refuse built-ins). + if exclude_actions and action['name'] in exclude_actions: + raise SuperDocError( + f"Action {action['name']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': action['name'], 'excluded': True}, + ) + return _run_custom_action(self._base, action, document_handle, args, invoke_options, + from_perform_action=tool_name == 'superdoc_perform_action') + return self._base.dispatch(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions) + + async def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + *, + exclude_actions: Optional[list] = None, + ) -> Any: + action = self._resolve_custom(tool_name, args) + if action is not None: + if exclude_actions and action['name'] in exclude_actions: + raise SuperDocError( + f"Action {action['name']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': action['name'], 'excluded': True}, + ) + return await _run_custom_action_async(self._base, action, document_handle, args, invoke_options, + from_perform_action=tool_name == 'superdoc_perform_action') + return await self._base.dispatch_async(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions) + + def _resolve_custom(self, tool_name: str, args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + # Advertised surface == dispatchable surface: in standalone mode custom + # actions are advertised as their own tools, so the (unadvertised) + # perform_action route must not execute them โ€” the call falls through + # to the base, which rejects the unknown action name. + if (not self._standalone and tool_name == 'superdoc_perform_action' + and isinstance(args, dict) and isinstance(args.get('action'), str)): + action = self._by_name.get(args['action']) + if action is not None: + return action + if self._standalone and tool_name in self._by_name: + return self._by_name[tool_name] + return None + + +def extend_preset( + base_id: str, + id: str, + actions: Sequence[Dict[str, Any]], + system_prompt_extra: Optional[str] = None, + standalone: bool = False, + description: Optional[str] = None, +) -> _ExtendedPreset: + """Wrap ``get_preset(base_id)`` with custom actions (Python mirror of + Node's ``extendPreset``).""" + base = get_preset(base_id) + action_list = list(actions or []) + _assert_actions_valid(action_list, id, bool(standalone)) + return _ExtendedPreset( + id=id, + description=description or f'{base.description} + {len(action_list)} custom action(s).', + supports_cache_control=getattr(base, 'supports_cache_control', True), + _base=base, + _actions=tuple(action_list), + _by_name={r['name']: r for r in action_list}, + _standalone=bool(standalone), + _system_prompt_extra=system_prompt_extra, + ) + + +# --------------------------------------------------------------------------- +# compose_preset โ€” build a preset over core, filtering its surface +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _ComposedPreset: + id: str + description: str + supports_cache_control: bool + _base: Any + _base_id: str + _actions: tuple + _by_name: dict + _include_core: Optional[frozenset] + _drop_superdoc_execute_code: bool + _system_prompt: Optional[str] + + def get_tools(self, provider: ToolProvider, *, cache: bool = False, + exclude_actions: Optional[list] = None) -> Dict[str, Any]: + custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions) + # The allowlist is implemented as a DERIVED exclusion forwarded to the + # base, so core narrows the enum, the grouped description, AND the + # advertised argument properties natively โ€” no hand-rebuilt schemas + # here (a second implementation of that narrowing is what drifts). + derived = sorted(BUILTIN_ACTION_NAMES - self._include_core) if self._include_core is not None else [] + merged_excludes = sorted({*derived, *(builtin_excluded or [])}) + base_kwargs = {'exclude_actions': merged_excludes} if merged_excludes else {} + result = self._base.get_tools(provider, cache=cache, **base_kwargs) + tools = list(result.get('tools') or []) + if self._drop_superdoc_execute_code: + tools = [t for t in tools if _tool_name(t) != 'superdoc_execute_code'] + active = [r for r in self._actions if r['name'] not in custom_excluded] + if active: + tools = _merge_or_synthesize_perform_action(tools, active, provider) + # Dropping superdoc_execute_code can strip the marker off the (former) last tool; + # re-normalize so the anthropic cached prefix is still correct. + tools = _renormalize_anthropic_cache_marker( + tools, provider, bool(cache) and result.get('cacheStrategy') != 'disabled') + return {**result, 'tools': tools} + + def get_catalog(self) -> Dict[str, Any]: + catalog = self._base.get_catalog() + rows = list(catalog.get('tools') or []) + if self._drop_superdoc_execute_code: + rows = [r for r in rows if r.get('toolName') != 'superdoc_execute_code'] + # Advertised == dispatchable, catalog included: when include_core + # narrows the surface, the catalog's superdoc_perform_action row must + # narrow WITH it โ€” enum, description, AND argument properties โ€” or + # get_tool_catalog() still advertises inputs for actions the preset + # refuses. Source the narrowed row from the base's own get_tools (the + # same host builder that narrows get_tools natively), so there is one + # narrowing implementation, not a second that drifts. + if self._include_core is not None: + derived = sorted(BUILTIN_ACTION_NAMES - self._include_core) + narrowed_tools = (self._base.get_tools('anthropic', exclude_actions=derived) or {}).get('tools') or [] + desc, schema = _perform_action_shape(narrowed_tools) + updated = [] + for row in rows: + if row.get('toolName') != 'superdoc_perform_action': + updated.append(row) + continue + # No built-in tool from the host means every built-in was + # excluded; drop the row rather than keep advertising built-ins + # (custom actions still appear as their own rows below). + if schema is None: + continue + row = {**row, 'inputSchema': schema} + if desc is not None: + row['description'] = desc + updated.append(row) + rows = updated + rows = rows + [ + { + 'toolName': r['name'], + 'description': r['description'], + 'inputSchema': r['inputSchema'], + 'mutates': True, + 'operations': [], + } + for r in self._actions + ] + return {**catalog, 'toolCount': len(rows), 'tools': rows} + + def _prompt(self, base_prompt: str, custom_excluded: frozenset = frozenset()) -> str: + if self._system_prompt is not None: + return self._system_prompt + active = [r for r in self._actions if r['name'] not in custom_excluded] + if not active: + return base_prompt + bullets = '\n'.join(f"- {r['name']} โ€” {r['description']}" for r in active) + return base_prompt + f'\n\n## Custom actions\n{bullets}' + + def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str: + if self._system_prompt is not None: + return self._system_prompt + # include_core narrows the ENUM; the prompt must narrow WITH it โ€” a + # per-action manual for an uncallable action teaches the model to call it. + custom_excluded, builtin_requested = _split_exclusions(self._by_name, exclude_actions) + derived = sorted(BUILTIN_ACTION_NAMES - self._include_core) if self._include_core is not None else [] + merged = sorted({*derived, *(builtin_requested or [])}) + base_kwargs = {'exclude_actions': merged} if merged else {} + return self._prompt(self._base.get_system_prompt(**base_kwargs), custom_excluded) + + def get_mcp_prompt(self) -> str: + if self._system_prompt is not None: + return self._system_prompt + return self._prompt(self._base.get_mcp_prompt()) + + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + *, + exclude_actions: Optional[list] = None, + ) -> Any: + if tool_name == 'superdoc_perform_action' and isinstance(args, dict) and isinstance(args.get('action'), str): + action = self._by_name.get(args['action']) + if action is not None: + if exclude_actions and action['name'] in exclude_actions: + raise SuperDocError( + f"Action {action['name']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': action['name'], 'excluded': True}, + ) + return _run_custom_action(self._base, action, document_handle, args, invoke_options) + # Composition allowlist is a dispatch boundary too: a built-in + # outside include_core_actions is not advertised and must not + # execute on a guessed/stale call. + if (self._include_core is not None and args['action'] in BUILTIN_ACTION_NAMES + and args['action'] not in self._include_core): + raise SuperDocError( + f"Action {args['action']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': args['action'], 'excluded': True, + 'excludedBy': 'includeCoreActions'}, + ) + return self._base.dispatch(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions) + + async def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + *, + exclude_actions: Optional[list] = None, + ) -> Any: + if tool_name == 'superdoc_perform_action' and isinstance(args, dict) and isinstance(args.get('action'), str): + action = self._by_name.get(args['action']) + if action is not None: + if exclude_actions and action['name'] in exclude_actions: + raise SuperDocError( + f"Action {action['name']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': action['name'], 'excluded': True}, + ) + return await _run_custom_action_async(self._base, action, document_handle, args, invoke_options) + if (self._include_core is not None and args['action'] in BUILTIN_ACTION_NAMES + and args['action'] not in self._include_core): + raise SuperDocError( + f"Action {args['action']} is excluded by configuration.", + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'action': args['action'], 'excluded': True, + 'excludedBy': 'includeCoreActions'}, + ) + return await self._base.dispatch_async(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions) + + +def compose_preset( + id: str, + base_id: str = 'core', + include_core_actions: Optional[Sequence[str]] = None, + include_superdoc_execute_code: Optional[bool] = None, + actions: Optional[Sequence[Dict[str, Any]]] = None, + system_prompt: Optional[str] = None, + description: Optional[str] = None, +) -> _ComposedPreset: + """Compose a preset over ``core`` (Python mirror of Node's + ``composePreset``).""" + action_list = list(actions or []) + _assert_actions_valid(action_list, id) + if include_core_actions is not None: + for name in include_core_actions: + if name not in BUILTIN_ACTION_NAMES: + raise SuperDocError( + f'includeCoreActions: unknown action "{name}".', + code='INVALID_ARGUMENT', + details={'presetId': id, 'unknownAction': name}, + ) + base = get_preset(base_id) + return _ComposedPreset( + id=id, + description=description or f'Composed over {base_id} with {len(action_list)} custom action(s).', + supports_cache_control=getattr(base, 'supports_cache_control', True), + _base=base, + _base_id=base_id, + _actions=tuple(action_list), + _by_name={r['name']: r for r in action_list}, + _include_core=frozenset(include_core_actions) if include_core_actions is not None else None, + _drop_superdoc_execute_code=(include_superdoc_execute_code is False), + _system_prompt=system_prompt, + ) diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index 3c5d999b55..45f111a292 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -185,6 +185,59 @@ def create_agent_toolkit( preset = preset_arg if preset_arg is not None else DEFAULT_PRESET exclude_actions = list(input.get('excludeActions') or input.get('exclude_actions') or []) or None + # One-call custom-actions path: hand your actions to the toolkit and use it. + # Build an ephemeral extended/composed preset over `base` (default 'core') + # and drive it directly โ€” no register_preset, no preset id to thread through + # dispatch. Advanced callers can still extend_preset/compose_preset + preset. + actions = input.get('actions') or [] + include_core = input.get('includeCoreActions') + if include_core is None: + include_core = input.get('include_core_actions') + if actions or include_core is not None: + from .presets.custom import extend_preset, compose_preset # lazy: avoid import cycle + provider = input.get('provider') + # Validate provider up front (parity with choose_tools) โ€” the actions + # path builds tools directly, so it must not skip this check. + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): + raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + # `preset` doubles as the base to extend here; `base` wins if both given. + base_arg = input.get('base') + base_id = base_arg if base_arg is not None else (preset_arg if preset_arg is not None else 'core') + if include_core is not None: + descriptor = compose_preset(id='custom_superdoc_preset', base_id=base_id, + include_core_actions=include_core, actions=actions) + else: + descriptor = extend_preset(base_id, id='custom_superdoc_preset', actions=actions) + tools_res = descriptor.get_tools(provider, cache=bool(input.get('cache')), + exclude_actions=exclude_actions) + tools = tools_res.get('tools') if isinstance(tools_res.get('tools'), list) else [] + sys_prompt = descriptor.get_system_prompt(exclude_actions=exclude_actions) + + def _dispatch(document_handle: Any, tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None) -> Any: + return descriptor.dispatch(document_handle, tool_name, args, invoke_options, + exclude_actions=exclude_actions) + + async def _dispatch_async(document_handle: Any, tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None) -> Any: + return await descriptor.dispatch_async(document_handle, tool_name, args, invoke_options, + exclude_actions=exclude_actions) + + return { + 'tools': tools, + 'meta': { + 'provider': provider, + 'preset': descriptor.id, + 'toolCount': len(tools), + 'cacheStrategy': tools_res.get('cacheStrategy', 'disabled'), + }, + 'system_prompt': sys_prompt, + 'dispatch': _dispatch, + 'dispatch_async': _dispatch_async, + } + chosen = choose_tools({**input, 'preset': preset}) system_prompt = ( get_system_prompt(preset, exclude_actions=exclude_actions) diff --git a/packages/sdk/langs/python/tests/footnote_fixture.py b/packages/sdk/langs/python/tests/footnote_fixture.py new file mode 100644 index 0000000000..e1adba0298 --- /dev/null +++ b/packages/sdk/langs/python/tests/footnote_fixture.py @@ -0,0 +1,126 @@ +"""Footnote actions โ€” the shared test fixture for custom actions (Python). + +Five namespaced ``run``-tier custom actions over the typed ``doc.footnotes.*`` +Document API. They run in the caller's process against the session-bound +handle โ€” the tier a customer reaches for when the built-in actions don't cover +their domain. + +Test fixture only: it is NOT part of the shipped package and lives under +``tests/`` so it never ships in the wheel. Both the unit tests (against a fake +base) and the smoke test (against the real CLI host) import it. +""" + +from typing import Any, Dict + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from superdoc.presets.custom import define_action # noqa: E402 + + +def _add(doc: Any, args: Dict[str, Any]) -> Any: + return doc.footnotes.insert({ + 'at': args['at'], + 'type': args.get('type', 'footnote'), + 'content': args['content'], + }) + + +def _list(doc: Any, args: Dict[str, Any]) -> Any: + return doc.footnotes.list({'type': args['type']} if args.get('type') else {}) + + +def _edit(doc: Any, args: Dict[str, Any]) -> Any: + return doc.footnotes.update({ + 'target': {'kind': 'entity', 'entityType': 'footnote', 'noteId': args['noteId']}, + 'patch': {'content': args['content']}, + }) + + +def _remove(doc: Any, args: Dict[str, Any]) -> Any: + return doc.footnotes.remove({ + 'target': {'kind': 'entity', 'entityType': 'footnote', 'noteId': args['noteId']}, + }) + + +def _renumber(doc: Any, args: Dict[str, Any]) -> Any: + numbering: Dict[str, Any] = {} + if args.get('format'): + numbering['format'] = args['format'] + if args.get('start') is not None: + numbering['start'] = args['start'] + return doc.footnotes.configure({ + 'type': args.get('type', 'footnote'), + 'scope': {'kind': 'document'}, + 'numbering': numbering, + }) + + +footnote_add = define_action( + name='footnotes.add', + description=( + "Insert a footnote (or endnote) at a text target. args: { at: TextTarget " + "{kind:'text',segments:[{blockId,range:{start,end}}]}, content: string, type?: 'footnote'|'endnote' }." + ), + input_schema={ + 'type': 'object', 'additionalProperties': False, 'required': ['at', 'content'], + 'properties': { + 'at': {'type': 'object'}, + 'content': {'type': 'string'}, + 'type': {'type': 'string', 'enum': ['footnote', 'endnote']}, + }, + }, + run=_add, +) + +footnote_list = define_action( + name='footnotes.list', + description="List footnotes (or endnotes). args: { type?: 'footnote'|'endnote' }.", + input_schema={ + 'type': 'object', 'additionalProperties': False, + 'properties': {'type': {'type': 'string', 'enum': ['footnote', 'endnote']}}, + }, + run=_list, +) + +footnote_edit = define_action( + name='footnotes.edit', + description="Edit a footnote's content by noteId. args: { noteId: string, content: string }.", + input_schema={ + 'type': 'object', 'additionalProperties': False, 'required': ['noteId', 'content'], + 'properties': {'noteId': {'type': 'string'}, 'content': {'type': 'string'}}, + }, + run=_edit, +) + +footnote_remove = define_action( + name='footnotes.remove', + description='Remove a footnote by noteId. args: { noteId: string }.', + input_schema={ + 'type': 'object', 'additionalProperties': False, 'required': ['noteId'], + 'properties': {'noteId': {'type': 'string'}}, + }, + run=_remove, +) + +footnote_renumber = define_action( + name='footnotes.renumber', + description=( + "Reconfigure footnote numbering for the whole document. " + "args: { type?: 'footnote'|'endnote', format?: string, start?: number }." + ), + input_schema={ + 'type': 'object', 'additionalProperties': False, + 'properties': { + 'type': {'type': 'string', 'enum': ['footnote', 'endnote']}, + 'format': {'type': 'string', + 'enum': ['decimal', 'lowerRoman', 'upperRoman', 'lowerLetter', 'upperLetter', 'symbol']}, + 'start': {'type': 'number'}, + }, + }, + run=_renumber, +) + +footnote_actions = [footnote_add, footnote_list, footnote_edit, footnote_remove, footnote_renumber] diff --git a/packages/sdk/langs/python/tests/smoke_custom_actions.py b/packages/sdk/langs/python/tests/smoke_custom_actions.py new file mode 100644 index 0000000000..be3e114faf --- /dev/null +++ b/packages/sdk/langs/python/tests/smoke_custom_actions.py @@ -0,0 +1,133 @@ +"""Smoke test for Python custom actions (footnotes) over the `core` preset. + +Mirrors ``smoke_core_preset.py``. Verifies the cross-language path: + + 1. ``register_preset(extend_preset('core', id='acme', actions=footnote_actions))`` + makes ``acme`` resolvable. + 2. ``choose_tools({preset:'acme', provider:'anthropic'})`` shows the footnote + action names in the ``superdoc_perform_action`` enum. + 3. ``dispatch_superdoc_tool(doc, 'superdoc_perform_action', {action:'footnotes.add', ...}, + preset='acme')`` runs the run-tier action against the host; ``footnotes.list`` + then shows the inserted note. + +Run with ``SUPERDOC_CLI_BIN=/abs/path/to/apps/cli/dist/index.js`` if the +companion-CLI package is not installed in the local interpreter. +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict + +HERE = Path(__file__).resolve().parent +SDK_ROOT = HERE.parent +sys.path.insert(0, str(SDK_ROOT)) + +from superdoc import ( # noqa: E402 + SuperDocClient, + choose_tools, + dispatch_superdoc_tool, + list_presets, + register_preset, + unregister_preset, +) +from superdoc.presets.custom import extend_preset # noqa: E402 +from footnote_fixture import footnote_actions # noqa: E402 + +REPO_ROOT = HERE.parents[4] +# Same fixture the Node custom-actions e2e test uses. +FIXTURE_PATH = str( + REPO_ROOT / 'packages' / 'super-editor' / 'src' / 'editors' / 'v1' / 'tests' / 'data' / 'advanced-text.docx' +) + + +def _section(title: str) -> None: + print() + print(f'== {title} ==') + + +def _assert(condition: bool, message: str) -> None: + if not condition: + print(f'FAIL: {message}') + sys.exit(1) + print(f' ok โ€” {message}') + + +def main() -> Dict[str, Any]: + fixture = Path(FIXTURE_PATH) + if not fixture.exists(): + print(f'FAIL: fixture missing at {fixture}') + sys.exit(1) + + # ---- register acme --------------------------------------------------- + register_preset(extend_preset('core', id='acme', actions=footnote_actions)) + try: + _assert('acme' in list_presets(), "list_presets() includes 'acme'") + + # ---- choose_tools ------------------------------------------------ + _section('choose_tools({preset:acme, provider:anthropic})') + chooser = choose_tools({'provider': 'anthropic', 'preset': 'acme'}) + action_tool = next((t for t in chooser['tools'] if t.get('name') == 'superdoc_perform_action'), None) + _assert(action_tool is not None, 'superdoc_perform_action tool present') + names = action_tool['input_schema']['properties']['action']['enum'] + for r in footnote_actions: + _assert(r['name'] in names, f"{r['name']} in superdoc_perform_action enum") + + # ---- dispatch footnotes.add + footnotes.list --------------------- + with tempfile.TemporaryDirectory() as tmp: + working_copy = Path(tmp) / 'fixture.docx' + shutil.copy2(fixture, working_copy) + + env_overrides: Dict[str, str] = {} + cli_bin_env = os.environ.get('SUPERDOC_CLI_BIN') + if not cli_bin_env: + default_dev_cli = str(SDK_ROOT.parents[3] / 'apps/cli/dist/index.js') + if Path(default_dev_cli).exists(): + env_overrides['SUPERDOC_CLI_BIN'] = default_dev_cli + + with SuperDocClient(env=env_overrides or None) as client: + doc = client.open({'doc': str(working_copy)}) + + blocks = doc.blocks.list({'limit': 50, 'includeText': True}) + para = next( + (b for b in blocks['blocks'] if b['nodeType'] == 'paragraph' and (b.get('text') or '')), + None, + ) + _assert(para is not None, 'found a non-empty body paragraph to anchor on') + at = {'kind': 'text', 'segments': [{'blockId': para['nodeId'], 'range': {'start': 0, 'end': 0}}]} + + _section('dispatch footnotes.add (preset=acme)') + add = dispatch_superdoc_tool( + doc, + 'superdoc_perform_action', + {'action': 'footnotes.add', 'at': at, 'content': 'Inserted by footnotes.add (Python).'}, + preset='acme', + ) + print(f' receipt status: {add.get("status")}, action: {add.get("action")}') + _assert(add.get('action') == 'footnotes.add', 'receipt action is footnotes.add') + _assert(add.get('status') == 'succeeded', 'footnotes.add succeeded') + + _section('dispatch footnotes.list (preset=acme)') + listed = dispatch_superdoc_tool( + doc, 'superdoc_perform_action', {'action': 'footnotes.list'}, preset='acme' + ) + items = (listed.get('result') or {}).get('items') or [] + print(f' footnotes listed: {len(items)}') + _assert(listed.get('status') == 'succeeded', 'footnotes.list succeeded') + _assert(len(items) >= 1, 'at least one footnote present after add') + + doc.close({'discard': True}) + finally: + unregister_preset('acme') + + print() + print('SMOKE PASSED.') + return {'mutation_observed': True} + + +if __name__ == '__main__': + main() diff --git a/packages/sdk/langs/python/tests/test_custom_actions.py b/packages/sdk/langs/python/tests/test_custom_actions.py new file mode 100644 index 0000000000..a4de09a078 --- /dev/null +++ b/packages/sdk/langs/python/tests/test_custom_actions.py @@ -0,0 +1,1060 @@ +"""Custom-action unit tests (Python) โ€” no CLI host required. + +Mirrors the host-free portion of the Node ``custom-actions.test.ts``: codegen +template, tool merging, collision/duplicate checks, registry register/unregister, +and the superdoc_execute_code rewrite + receipt mapping (against a fake base preset). +""" + +import os +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from superdoc import ( # noqa: E402 + SuperDocError, + get_preset, + register_preset, + unregister_preset, +) +from superdoc.presets import _BUILTIN_IDS # noqa: E402 +from superdoc.presets.custom import ( # noqa: E402 + BUILTIN_ACTION_NAMES, + compose_preset, + define_action, + extend_preset, +) +from footnote_fixture import footnote_actions # noqa: E402 + + +def _cli_available() -> bool: + if os.environ.get('SUPERDOC_CLI_BIN'): + return True + try: + from superdoc.embedded_cli import resolve_embedded_cli_path + + resolve_embedded_cli_path() + return True + except Exception: # noqa: BLE001 โ€” no binary on this platform/CI stage + return False + + +# The python core preset proxies get_tools/dispatch through the CLI host, so +# tests exercising the REAL core preset need a binary. They run locally and in +# host-equipped CI stages; the pure-registry/fake-base tests cover the logic +# everywhere else (Node runs the same scenarios natively). +requires_host = pytest.mark.skipif(not _cli_available(), reason='SuperDoc CLI binary unavailable') + + +@pytest.fixture +def cleanup_registered(): + ids = [] + yield ids + for pid in ids: + try: + unregister_preset(pid) + except SuperDocError: + pass + + +# --------------------------------------------------------------------------- +# define_action โ€” two tiers +# --------------------------------------------------------------------------- + + +def test_define_action_run_tier_keeps_callable(): + fn = lambda doc, args: args # noqa: E731 + spec = define_action( + name='demo.echo', + description='echo', + input_schema={'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']}, + run=fn, + ) + assert spec['name'] == 'demo.echo' + assert spec['run'] is fn + assert 'source' not in spec + assert spec['inputSchema']['required'] == ['x'] + + +def test_define_action_steps_tier_validates_builtins(): + spec = define_action( + name='demo.steps', description='d', + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}], + ) + assert spec['steps'] == [{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}] + with pytest.raises(SuperDocError): + define_action(name='demo.bad', description='d', steps=[{'action': 'not_a_real_action'}]) + + +def test_define_action_requires_exactly_one_tier(): + with pytest.raises(SuperDocError): + define_action(name='x.none', description='d') + with pytest.raises(SuperDocError): + define_action(name='x.two', description='d', run=lambda d, a: None, + steps=[{'action': 'insert_paragraphs'}]) + + +# --------------------------------------------------------------------------- +# collision / duplicate +# --------------------------------------------------------------------------- + + +def test_rejects_collision_with_builtin(): + builtin = next(iter(BUILTIN_ACTION_NAMES)) + bad = define_action(name=builtin, description='x', run=lambda d, a: None) + with pytest.raises(SuperDocError) as ex: + extend_preset('core', id='bad', actions=[bad]) + assert ex.value.code == 'INVALID_ARGUMENT' + + +def test_rejects_duplicate_names(): + a = define_action(name='dup.one', description='a', run=lambda d, a: None) + b = define_action(name='dup.one', description='b', run=lambda d, a: None) + with pytest.raises(SuperDocError): + extend_preset('core', id='dup', actions=[a, b]) + + +# The canonical 40 names from ACTION_NAMES_LIST in +# node/src/agent/actions.ts (source of truth). Asserting the EXACT set โ€” not +# just the count โ€” makes any drift from Node fail loudly. +_CANONICAL_ACTION_NAMES = sorted([ + 'accept_tracked_changes', + 'add_comments', + 'add_hyperlink', + 'add_list_items', + 'append_list', + 'apply_letter_spacing', + 'apply_style', + 'attach_numbering', + 'comment_paragraphs', + 'convert_list', + 'create_table', + 'delete_table', + 'delete_table_column', + 'delete_table_row', + 'delete_text', + 'fill_placeholders', + 'format_paragraph', + 'format_text', + 'insert_heading', + 'insert_page_break', + 'insert_paragraphs', + 'insert_table_column', + 'insert_table_row', + 'insert_toc', + 'move_range', + 'move_table', + 'move_text', + 'normalize_body_font_size', + 'redo_changes', + 'reject_tracked_changes', + 'reply_to_comment', + 'resolve_comments', + 'replace_text', + 'rewrite_block', + 'set_font_family', + 'set_paragraph_spacing', + 'split_list', + 'split_table', + 'style_table', + 'undo_changes', +]) + + +def test_builtin_action_names_match_node_exactly(): + assert sorted(BUILTIN_ACTION_NAMES) == _CANONICAL_ACTION_NAMES + + +def test_builtin_action_names_match_node_source(): + """The real drift guard: parse Node's ACTION_NAMES (the source of truth) + and compare the exact set. The hand-copied list above is only a fallback + for environments without the Node source tree.""" + node_actions = ( + Path(__file__).resolve().parents[2] / 'node' / 'src' / 'agent' / 'actions.ts' + ) + if not node_actions.exists(): + pytest.skip('Node source tree not available (installed-package run)') + source = node_actions.read_text() + match = re.search(r'const ACTION_NAMES: readonly ActionName\[\] = \[(.*?)\];', source, re.S) + assert match, 'ACTION_NAMES declaration not found in node actions.ts' + node_names = set(re.findall(r"'([a-z_]+)'", match.group(1))) + assert node_names == set(BUILTIN_ACTION_NAMES), ( + f'drift: only-in-node={sorted(node_names - BUILTIN_ACTION_NAMES)} ' + f'only-in-python={sorted(BUILTIN_ACTION_NAMES - node_names)}' + ) + + +# --------------------------------------------------------------------------- +# registry +# --------------------------------------------------------------------------- + + +def test_register_resolve_unregister(cleanup_registered): + p = extend_preset('core', id='tmp-preset', actions=footnote_actions) + register_preset(p) + cleanup_registered.append('tmp-preset') + assert get_preset('tmp-preset').id == 'tmp-preset' + unregister_preset('tmp-preset') + with pytest.raises(SuperDocError): + get_preset('tmp-preset') + + +def test_cannot_overwrite_builtin(): + p = extend_preset('core', id='will-rename', actions=[]) + object.__setattr__(p, 'id', 'core') # frozen dataclass + with pytest.raises(SuperDocError): + register_preset(p) + + +def test_cannot_unregister_builtin(): + for pid in _BUILTIN_IDS: + with pytest.raises(SuperDocError): + unregister_preset(pid) + + +# --------------------------------------------------------------------------- +# tool merging (against a fake base โ€” no host) +# --------------------------------------------------------------------------- + + +class _FakeBase: + """A minimal core-shaped base preset that records superdoc_execute_code calls.""" + + id = 'fake-base' + description = 'fake' + supports_cache_control = True + + def __init__(self): + self.captured = {} + + def get_tools(self, provider, *, cache=False, exclude_actions=None): + # core-shaped superdoc_perform_action with an enum + flat properties. + action_tool = { + 'name': 'superdoc_perform_action', + 'description': 'base action tool.', + 'input_schema': { + 'type': 'object', + 'properties': { + 'action': {'type': 'string', 'enum': ['insert_paragraphs']}, + 'text': {'type': 'string'}, + }, + }, + } + execute = {'name': 'superdoc_execute_code', 'input_schema': {'type': 'object', 'properties': {}}} + tools = [action_tool, execute] + # Mirror core's marker placement so the wrapper's re-normalize path is + # exercised: anthropic cache marks the LAST tool. + if cache and provider == 'anthropic': + tools[-1] = {**tools[-1], 'cache_control': {'type': 'ephemeral'}} + return {'tools': tools, 'cacheStrategy': 'explicit'} + return {'tools': tools, 'cacheStrategy': 'disabled'} + + def get_catalog(self): + return { + 'contractVersion': 'fake', + 'generatedAt': None, + 'toolCount': 1, + 'tools': [{'toolName': 'superdoc_execute_code', 'description': 'x', 'inputSchema': {}, 'mutates': True, 'operations': []}], + } + + def get_system_prompt(self, *, exclude_actions=None): + return 'base prompt' + + def get_mcp_prompt(self): + return 'base mcp' + + def dispatch(self, handle, tool_name, args=None, invoke_options=None): + assert tool_name == 'superdoc_execute_code' + self.captured['code'] = (args or {}).get('code') + return {'ok': True, 'result': {'success': True, 'footnote': {'noteId': 'fn-1'}}, 'logs': []} + + +def _register_fake(cleanup_registered): + fake = _FakeBase() + register_preset(fake) + cleanup_registered.append('fake-base') + return fake + + +def test_extend_merges_into_superdoc_perform_action(cleanup_registered): + _register_fake(cleanup_registered) + acme = extend_preset('fake-base', id='acme-merge', actions=footnote_actions) + register_preset(acme) + cleanup_registered.append('acme-merge') + tools = acme.get_tools('anthropic')['tools'] + action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + names = action_tool['input_schema']['properties']['action']['enum'] + for r in footnote_actions: + assert r['name'] in names + assert 'insert_paragraphs' in names # base preserved + assert 'noteId' in action_tool['input_schema']['properties'] # unioned + + +def _safe_actions(): + """Footnote actions with provider-safe (dot-free) names for standalone mode.""" + return [{**r, 'name': r['name'].replace('.', '_')} for r in footnote_actions] + + +def test_extend_standalone_tools(cleanup_registered): + _register_fake(cleanup_registered) + safe = _safe_actions() + acme = extend_preset('fake-base', id='acme-standalone', actions=safe, standalone=True) + register_preset(acme) + cleanup_registered.append('acme-standalone') + tools = acme.get_tools('openai')['tools'] + names = [t.get('function', t).get('name') for t in tools] + for r in safe: + assert r['name'] in names + + +def test_standalone_vercel_uses_flat_core_dialect(cleanup_registered): + _register_fake(cleanup_registered) + safe = _safe_actions() + acme = extend_preset('fake-base', id='acme-vercel', actions=safe, standalone=True) + register_preset(acme) + cleanup_registered.append('acme-vercel') + tools = acme.get_tools('vercel')['tools'] + add = next(t for t in tools if t.get('name') == 'footnotes_add') + # Core's vercel dialect is flat {name, description, inputSchema}. + assert 'function' not in add and 'type' not in add + assert add['inputSchema'] is not None + + +@requires_host +def test_merged_vercel_advertises_customs_in_flat_dialect(cleanup_registered): + acme = extend_preset('core', id='acme-vercel-merged', actions=footnote_actions) + register_preset(acme) + cleanup_registered.append('acme-vercel-merged') + tools = acme.get_tools('vercel')['tools'] + perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + assert 'footnotes.add' in perform['inputSchema']['properties']['action']['enum'] + + +@requires_host +def test_exclude_actions_coherent_on_extended_preset(cleanup_registered): + from superdoc import choose_tools as _choose + + acme = extend_preset('core', id='acme-excl', actions=footnote_actions) + register_preset(acme) + cleanup_registered.append('acme-excl') + exclude = ['footnotes.add', 'create_table'] + tools = _choose({'provider': 'openai', 'preset': 'acme-excl', 'excludeActions': exclude})['tools'] + perform = next(t['function'] for t in tools if t['function']['name'] == 'superdoc_perform_action') + names = perform['parameters']['properties']['action']['enum'] + assert 'footnotes.add' not in names # custom excluded by the wrapper + assert 'create_table' not in names # builtin excluded by the base + assert 'footnotes.list' in names # other customs survive + + prompt = acme.get_system_prompt(exclude_actions=exclude) + assert '- footnotes.add โ€”' not in prompt + assert 'footnotes.list' in prompt + + with pytest.raises(SuperDocError) as ex: + acme.dispatch(object(), 'superdoc_perform_action', + {'action': 'footnotes.add', 'at': {}, 'content': 'x'}, + exclude_actions=exclude) + assert ex.value.details.get('excluded') is True + + +def test_steps_partial_step_aggregates_partial(cleanup_registered): + _, acme = _register_steps(cleanup_registered, [{'status': 'ok'}, {'status': 'partial'}]) + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'}) + assert receipt['status'] == 'partial' + assert receipt['failedStep']['index'] == 1 + + +def test_steps_template_text_parity_for_non_strings(cleanup_registered): + base = _StepsBase([{'status': 'ok'}]) + register_preset(base) + cleanup_registered.append('steps-base') + spec = define_action( + name='acme.parity', + description='d', + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['flag={{flag}} items={{items}} obj={{obj}}']}}], + ) + acme = extend_preset('steps-base', id='acme-parity', actions=[spec]) + register_preset(acme) + cleanup_registered.append('acme-parity') + acme.dispatch(object(), 'superdoc_perform_action', + {'action': 'acme.parity', 'flag': True, 'items': [1, 2], 'obj': {'a': 1}}) + # MUST match Node: JSON text forms, not Python str() forms. + assert base.calls[0]['texts'] == ['flag=true items=[1,2] obj={"a":1}'] + + +def test_steps_missing_template_dropped_from_arrays(cleanup_registered): + base = _StepsBase([{'status': 'ok'}]) + register_preset(base) + cleanup_registered.append('steps-base') + spec = define_action( + name='acme.arrmiss', + description='d', + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{present}}', '{{absent}}']}}], + ) + acme = extend_preset('steps-base', id='acme-arrmiss', actions=[spec]) + register_preset(acme) + cleanup_registered.append('acme-arrmiss') + acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.arrmiss', 'present': 'AAA'}) + assert base.calls[0]['texts'] == ['AAA'] + + +def test_raw_spec_with_empty_steps_rejected(): + raw = {'name': 'acme.empty', 'description': 'd', 'inputSchema': {'type': 'object', 'properties': {}}, + 'steps': []} + with pytest.raises(SuperDocError): + extend_preset('core', id='acme-empty', actions=[raw]) + + +def test_sync_dispatch_of_async_run_is_refused(cleanup_registered): + _register_fake(cleanup_registered) + + async def _async_run(doc, args): + return {'never': 'awaited'} + + native = define_action(name='acme.async-native', description='d', run=_async_run) + acme = extend_preset('fake-base', id='acme-async-native', actions=[native]) + register_preset(acme) + cleanup_registered.append('acme-async-native') + receipt = acme.dispatch(_RevisionHandle(['0', '0']), 'superdoc_perform_action', {'action': 'acme.async-native'}) + assert receipt['status'] == 'failed' + assert 'dispatch_async' in receipt['errors'][0]['message'] + + +def test_standalone_does_not_route_customs_through_perform_action(cleanup_registered): + fake = _register_fake(cleanup_registered) + + def _spy_dispatch(handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None): + return {'delegated': tool_name, 'action': (args or {}).get('action')} + + fake.dispatch = _spy_dispatch # type: ignore[assignment] + safe = _safe_actions() + acme = extend_preset('fake-base', id='acme-standalone-gate', actions=safe, standalone=True) + register_preset(acme) + cleanup_registered.append('acme-standalone-gate') + # perform_action route must fall through to the base (delegated), not run the custom. + result = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'footnotes_add', 'at': {}, 'content': 'x'}) + assert result == {'delegated': 'superdoc_perform_action', 'action': 'footnotes_add'} + + +def test_standalone_keeps_action_named_argument(cleanup_registered): + received = {} + + def _run(doc, args): + received.update(args) + return {'ok': True} + + spec = define_action( + name='wf_trigger', description='run a workflow step', + input_schema={'type': 'object', 'properties': {'action': {'type': 'string', 'enum': ['approve', 'reject']}}, + 'required': ['action']}, + run=_run, + ) + acme = extend_preset('core', id='acme-standalone-arg', actions=[spec], standalone=True) + register_preset(acme) + cleanup_registered.append('acme-standalone-arg') + # Dispatched as its OWN tool: `action` is a real arg, must survive. + receipt = acme.dispatch(object(), 'wf_trigger', {'action': 'approve'}) + assert receipt['status'] == 'succeeded' + assert received == {'action': 'approve'} + + +def test_standalone_rejects_dotted_names_merged_accepts(): + # Dotted name invalid as a provider tool name (standalone), valid as enum + # value (merged). + with pytest.raises(SuperDocError) as ex: + extend_preset('core', id='bad-standalone', actions=footnote_actions, standalone=True) + assert ex.value.code == 'INVALID_ARGUMENT' + # merged mode (default) accepts the dotted names without raising. + extend_preset('core', id='ok-merged', actions=footnote_actions) + + +def test_anthropic_cache_marks_exactly_last_tool(cleanup_registered): + _register_fake(cleanup_registered) + safe = _safe_actions() + acme = extend_preset('fake-base', id='acme-cache', actions=safe, standalone=True) + register_preset(acme) + cleanup_registered.append('acme-cache') + tools = acme.get_tools('anthropic', cache=True)['tools'] + with_marker = [t for t in tools if isinstance(t, dict) and t.get('cache_control') is not None] + assert len(with_marker) == 1 + assert tools[-1]['cache_control'] == {'type': 'ephemeral'} + + +def test_dispatch_validates_required(cleanup_registered): + _register_fake(cleanup_registered) + acme = extend_preset('fake-base', id='acme-validate', actions=footnote_actions) + register_preset(acme) + cleanup_registered.append('acme-validate') + with pytest.raises(SuperDocError) as ex: + acme.dispatch(object(), 'superdoc_perform_action', {'action': 'footnotes.add', 'content': 'x'}) + assert ex.value.code == 'INVALID_ARGUMENT' + + +def test_dispatch_delegates_non_custom(cleanup_registered): + fake = _register_fake(cleanup_registered) + + delegated = {} + + def _spy_dispatch(handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None): + delegated['tool'] = tool_name + return {'delegated': tool_name} + + fake.dispatch = _spy_dispatch # type: ignore[assignment] + acme = extend_preset('fake-base', id='acme-delegate', actions=footnote_actions) + register_preset(acme) + cleanup_registered.append('acme-delegate') + result = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'insert_paragraphs', 'text': 'hi'}) + assert delegated['tool'] == 'superdoc_perform_action' + assert result == {'delegated': 'superdoc_perform_action'} + + +# --------------------------------------------------------------------------- +# compose_preset +# --------------------------------------------------------------------------- + + +def test_compose_drops_superdoc_execute_code(cleanup_registered): + _register_fake(cleanup_registered) + composed = compose_preset(id='composed-1', base_id='fake-base', include_superdoc_execute_code=False, actions=footnote_actions) + register_preset(composed) + cleanup_registered.append('composed-1') + tools = composed.get_tools('generic')['tools'] + names = [t.get('function', t).get('name') for t in tools] + assert 'superdoc_execute_code' not in names + action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + assert 'footnotes.add' in action_tool['parameters' if 'parameters' in action_tool else 'input_schema']['properties']['action']['enum'] + + +@requires_host +def test_compose_custom_only_synthesizes_perform_action(cleanup_registered): + composed = compose_preset( + id='composed-custom-only', base_id='core', + include_core_actions=[], actions=footnote_actions, + ) + register_preset(composed) + cleanup_registered.append('composed-custom-only') + tools = composed.get_tools('openai')['tools'] + perform = next((t for t in tools if (t.get('function') or t).get('name') == 'superdoc_perform_action'), None) + assert perform is not None # synthesized โ€” the base dropped it + names = perform['function']['parameters']['properties']['action']['enum'] + for r in footnote_actions: + assert r['name'] in names + assert 'insert_paragraphs' not in names + + +@requires_host +def test_compose_catalog_narrowed_by_allowlist(cleanup_registered): + from superdoc import get_tool_catalog + + composed = compose_preset( + id='composed-cat', base_id='core', + include_core_actions=['insert_paragraphs'], actions=footnote_actions, + ) + register_preset(composed) + cleanup_registered.append('composed-cat') + catalog = get_tool_catalog('composed-cat') + perform = next(t for t in catalog['tools'] if t['toolName'] == 'superdoc_perform_action') + props = perform['inputSchema']['properties'] + names = props['action']['enum'] + assert 'insert_paragraphs' in names + assert 'create_table' not in names # outside the allowlist โ€” not in the catalog + # The row narrows BEYOND the enum: create_table-only args are gone, so a + # catalog-driven UI/validator can't offer inputs for a refused action. + assert 'rows' not in props and 'columns' not in props + assert 'text' in props # insert_paragraphs' own arg survives + assert 'create_table' not in perform.get('description', '') # description narrows too + # custom actions appear as their own catalog rows (not merged into the + # perform_action enum the way get_tools does) + assert any(t['toolName'] == 'footnotes.add' for t in catalog['tools']) + + +@requires_host +def test_compose_catalog_row_matches_get_tools_without_custom(cleanup_registered): + from superdoc import choose_tools, get_tool_catalog + + composed = compose_preset(id='composed-cat-nocustom', base_id='core', + include_core_actions=['insert_paragraphs']) + register_preset(composed) + cleanup_registered.append('composed-cat-nocustom') + row = next(t for t in get_tool_catalog('composed-cat-nocustom')['tools'] + if t['toolName'] == 'superdoc_perform_action') + # With no custom actions the catalog row and the advertised tool coincide โ€” + # one narrowing, no drift. + tools = choose_tools({'provider': 'generic', 'preset': 'composed-cat-nocustom'})['tools'] + advertised = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + assert row['inputSchema']['properties']['action']['enum'] == advertised['parameters']['properties']['action']['enum'] + assert sorted(row['inputSchema']['properties']) == sorted(advertised['parameters']['properties']) + + +@requires_host +def test_compose_allowlist_no_enum_leak_and_dispatch_enforced(cleanup_registered): + composed = compose_preset( + id='composed-leak', base_id='core', + include_core_actions=['insert_paragraphs'], actions=footnote_actions, + ) + register_preset(composed) + cleanup_registered.append('composed-leak') + tools = composed.get_tools('generic', exclude_actions=['footnotes.add'])['tools'] + perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + names = perform['parameters']['properties']['action']['enum'] + assert 'footnotes.add' not in names # must not leak back via the allowlist rebuild + assert 'footnotes.list' in names + assert 'insert_paragraphs' in names + # The DESCRIPTION and advertised args narrow with the allowlist too โ€” the + # base rebuilds them from the derived exclusion, not a hand-rolled filter. + assert 'create_table' not in perform['description'] + # allowlist enforced at dispatch too + with pytest.raises(SuperDocError) as ex: + composed.dispatch(object(), 'superdoc_perform_action', {'action': 'create_table', 'rows': 1, 'columns': 1}) + assert ex.value.details.get('excludedBy') == 'includeCoreActions' + + +def test_reserved_tool_names_match_node_catalog(): + node_catalog = ( + Path(__file__).resolve().parents[2] / 'node' / 'src' / 'agent' / 'catalog.ts' + ) + if not node_catalog.exists(): + pytest.skip('Node source tree not available (installed-package run)') + source = node_catalog.read_text() + match = re.search(r'export const AGENT_TOOL_NAMES = \[(.*?)\] as const;', source, re.S) + assert match, 'AGENT_TOOL_NAMES declaration not found in node catalog.ts' + node_names = set(re.findall(r"'([a-z_]+)'", match.group(1))) + from superdoc.presets.custom import _RESERVED_TOOL_NAMES + + assert node_names == set(_RESERVED_TOOL_NAMES), ( + f'drift: only-in-node={sorted(node_names - _RESERVED_TOOL_NAMES)} ' + f'only-in-python={sorted(set(_RESERVED_TOOL_NAMES) - node_names)}' + ) + + +def test_explicit_none_enum_value_rejected(cleanup_registered): + base = _StepsBase([{'status': 'ok'}]) + register_preset(base) + cleanup_registered.append('steps-base') + spec = define_action( + name='acme.enumnull', description='d', + input_schema={'type': 'object', 'properties': { + 'mode': {'type': 'string', 'enum': ['a', 'b'], 'default': 'a'}}}, + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['x']}}], + ) + acme = extend_preset('steps-base', id='acme-enumnull', actions=[spec]) + register_preset(acme) + cleanup_registered.append('acme-enumnull') + # Explicit None is a VALUE, not an absence โ€” reject (Node parity), don't + # silently forward a live None past the default. + with pytest.raises(SuperDocError) as ex: + acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.enumnull', 'mode': None}) + assert ex.value.code == 'INVALID_ARGUMENT' + # Omitting the key entirely still applies the default and succeeds. + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.enumnull'}) + assert receipt['status'] == 'succeeded' + + +def test_reserved_tool_name_rejected(): + bad = define_action(name='superdoc_execute_code', description='x', run=lambda d, a: None) + with pytest.raises(SuperDocError): + extend_preset('core', id='bad-reserved', actions=[bad], standalone=True) + + +def test_required_with_default_satisfied_by_default(cleanup_registered): + base = _StepsBase([{'status': 'ok'}]) + register_preset(base) + cleanup_registered.append('steps-base') + spec = define_action( + name='acme.reqdef', description='d', + input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'D'}}, + 'required': ['label']}, + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}], + ) + acme = extend_preset('steps-base', id='acme-reqdef', actions=[spec]) + register_preset(acme) + cleanup_registered.append('acme-reqdef') + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.reqdef'}) + assert receipt['status'] == 'succeeded' + assert base.calls[0]['texts'] == ['D'] + + +@requires_host +def test_compose_exclude_actions_coherent(cleanup_registered): + from superdoc import choose_tools as _choose + + composed = compose_preset(id='composed-excl', base_id='core', actions=footnote_actions) + register_preset(composed) + cleanup_registered.append('composed-excl') + exclude = ['footnotes.add', 'create_table'] + tools = _choose({'provider': 'generic', 'preset': 'composed-excl', 'excludeActions': exclude})['tools'] + perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + names = perform['parameters']['properties']['action']['enum'] + assert 'footnotes.add' not in names + assert 'create_table' not in names + assert 'footnotes.list' in names + prompt = composed.get_system_prompt(exclude_actions=exclude) + assert '- footnotes.add โ€”' not in prompt + + +def test_compose_filters_core_actions(cleanup_registered): + _register_fake(cleanup_registered) + composed = compose_preset( + id='composed-2', + base_id='fake-base', + include_core_actions=['insert_paragraphs'], + actions=footnote_actions, + ) + register_preset(composed) + cleanup_registered.append('composed-2') + tools = composed.get_tools('anthropic')['tools'] + action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action') + names = action_tool['input_schema']['properties']['action']['enum'] + assert 'insert_paragraphs' in names + assert 'footnotes.add' in names + + +# --------------------------------------------------------------------------- +# steps tier โ€” declarative composition (mirrors Node's steps-tier tests) +# --------------------------------------------------------------------------- + + +class _StepsBase: + """Fake base recording superdoc_perform_action step dispatches with scripted receipts.""" + + id = 'steps-base' + description = 'fake' + supports_cache_control = True + + def __init__(self, receipts): + self.calls = [] + self._receipts = receipts + self._cursor = 0 + + def get_tools(self, provider, *, cache=False): + return {'tools': [], 'cacheStrategy': 'disabled'} + + def get_catalog(self): + return {'contractVersion': 'x', 'generatedAt': None, 'toolCount': 0, 'tools': []} + + def get_system_prompt(self): + return 'base' + + def get_mcp_prompt(self): + return 'base' + + def dispatch(self, handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None): + assert tool_name == 'superdoc_perform_action' + self.calls.append(args) + item = self._receipts[min(self._cursor, len(self._receipts) - 1)] + self._cursor += 1 + if isinstance(item, Exception): + raise item + return item + + +def _stamp_spec(): + return define_action( + name='acme.stamp', + description='banner + comment', + input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'CONFIDENTIAL'}}}, + steps=[ + {'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}}, + {'action': 'add_comments', 'args': { + 'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}']}], + 'commentText': 'Stamped: {{label}} โ€” verify.', + }}, + ], + ) + + +def _register_steps(cleanup_registered, receipts): + base = _StepsBase(receipts) + register_preset(base) + cleanup_registered.append('steps-base') + acme = extend_preset('steps-base', id='acme-steps', actions=[_stamp_spec()]) + register_preset(acme) + cleanup_registered.append('acme-steps') + return base, acme + + +def test_steps_templating_defaults_and_success(cleanup_registered): + base, acme = _register_steps(cleanup_registered, [{'status': 'ok', 'verificationPassed': True}]) + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'}) + assert receipt['status'] == 'succeeded' + assert len(receipt['steps']) == 2 + # Whole-string template preserved the raw list value; default applied. + assert base.calls[0] == { + 'action': 'insert_paragraphs', + 'texts': ['CONFIDENTIAL'], + 'placement': {'at': 'document_start'}, + } + # Partial template interpolated as text. + assert base.calls[1]['commentText'] == 'Stamped: CONFIDENTIAL โ€” verify.' + assert base.calls[1]['selectors'] == [{'kind': 'textSearch', 'terms': ['CONFIDENTIAL']}] + + +def test_steps_change_mode_passthrough(cleanup_registered): + base, acme = _register_steps(cleanup_registered, [{'status': 'ok'}]) + acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp', 'label': 'X', 'changeMode': 'tracked'}) + assert base.calls[0]['changeMode'] == 'tracked' + assert base.calls[1]['changeMode'] == 'tracked' + + +def test_steps_do_not_inherit_surface_exclusions(cleanup_registered): + # Base that mirrors core's defense-in-depth: refuse any call whose action is + # in the invoke-time exclude_actions. If a surface exclusion leaked into the + # steps, the insert_paragraphs step would be refused and acme.stamp + # (advertised!) would fail โ€” the coherence bug this guards against. + seen = [] + + class _ExclEnforcingBase: + id = 'steps-excl-base' + description = 'fake base enforcing invoke-time exclusions' + supports_cache_control = True + + def get_tools(self, provider, *, cache=False, exclude_actions=None): + return {'tools': [], 'cacheStrategy': 'disabled'} + + def get_catalog(self): + return {'contractVersion': 'x', 'generatedAt': None, 'toolCount': 0, 'tools': []} + + def get_system_prompt(self, *, exclude_actions=None): + return 'base' + + def get_mcp_prompt(self): + return 'base' + + def dispatch(self, handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None): + seen.append(exclude_actions) + action = (args or {}).get('action') + if exclude_actions and action in exclude_actions: + raise SuperDocError(f"Action {action} is excluded by configuration.", + code='INVALID_ARGUMENT', details={'excluded': True}) + return {'status': 'ok', 'verificationPassed': True} + + register_preset(_ExclEnforcingBase()) + cleanup_registered.append('steps-excl-base') + acme = extend_preset('steps-excl-base', id='acme-steps-excl', actions=[_stamp_spec()]) + register_preset(acme) + cleanup_registered.append('acme-steps-excl') + + # Hide insert_paragraphs from the model, but acme.stamp composes it. + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'}, + exclude_actions=['insert_paragraphs']) + assert receipt['status'] == 'succeeded' # step NOT refused + assert len(receipt['steps']) == 2 + assert all(s is None for s in seen) # internal steps saw NO exclude_actions + + # Guard preserved: a DIRECT model call to the hidden built-in is still refused. + with pytest.raises(SuperDocError): + acme.dispatch(object(), 'superdoc_perform_action', {'action': 'insert_paragraphs', 'text': 'x'}, + exclude_actions=['insert_paragraphs']) + + +def test_steps_second_failure_aggregates_partial(cleanup_registered): + _, acme = _register_steps( + cleanup_registered, + [{'status': 'ok'}, {'status': 'failed', 'errors': [{'message': 'no match'}]}], + ) + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'}) + assert receipt['status'] == 'partial' + assert receipt['failedStep']['index'] == 1 + + +def test_steps_thrown_first_step_aggregates_failed(cleanup_registered): + _, acme = _register_steps(cleanup_registered, [SuperDocError('bad args', code='INVALID_ARGUMENT')]) + receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'}) + assert receipt['status'] == 'failed' + assert receipt['steps'][0]['status'] == 'failed' + + +def test_steps_reject_unknown_builtin_at_define_time(): + with pytest.raises(SuperDocError) as ex: + define_action(name='demo.bad', description='d', steps=[{'action': 'not_a_real_action'}]) + assert ex.value.code == 'INVALID_ARGUMENT' + + +# --------------------------------------------------------------------------- +# run tier โ€” native Python callable with a synthesized receipt +# --------------------------------------------------------------------------- + + +class _RevisionHandle: + def __init__(self, revisions): + self._revisions = revisions + self._i = 0 + + def info(self, params): + revision = self._revisions[min(self._i, len(self._revisions) - 1)] + self._i += 1 + return {'revision': revision} + + +def test_run_tier_success_receipt(cleanup_registered): + _register_fake(cleanup_registered) + native = define_action( + name='acme.native', + description='d', + input_schema={'type': 'object', 'properties': {'x': {'type': 'number', 'default': 7}}}, + run=lambda doc, args: {'got': args['x']}, + ) + acme = extend_preset('fake-base', id='acme-native', actions=[native]) + register_preset(acme) + cleanup_registered.append('acme-native') + receipt = acme.dispatch(_RevisionHandle(['0', '1']), 'superdoc_perform_action', {'action': 'acme.native'}) + assert receipt['status'] == 'succeeded' + assert receipt['result'] == {'got': 7} + assert receipt['preRevision'] == '0' + assert receipt['postRevision'] == '1' + + +def test_run_tier_failure_partial_mutation(cleanup_registered): + _register_fake(cleanup_registered) + + def _boom(doc, args): + raise RuntimeError('boom between ops') + + native = define_action(name='acme.native-fail', description='d', run=_boom) + acme = extend_preset('fake-base', id='acme-native-fail', actions=[native]) + register_preset(acme) + cleanup_registered.append('acme-native-fail') + receipt = acme.dispatch(_RevisionHandle(['3', '4']), 'superdoc_perform_action', {'action': 'acme.native-fail'}) + assert receipt['status'] == 'failed' + assert receipt['partialMutation'] is True + assert receipt['recovery']['kind'] == 'revert' + + +def test_run_tier_failure_clean_retry(cleanup_registered): + _register_fake(cleanup_registered) + + def _boom(doc, args): + raise RuntimeError('failed before touching the doc') + + native = define_action(name='acme.native-cf', description='d', run=_boom) + acme = extend_preset('fake-base', id='acme-native-cf', actions=[native]) + register_preset(acme) + cleanup_registered.append('acme-native-cf') + receipt = acme.dispatch(_RevisionHandle(['5', '5']), 'superdoc_perform_action', {'action': 'acme.native-cf'}) + assert receipt['status'] == 'failed' + assert receipt['partialMutation'] is False + assert receipt['recovery']['kind'] == 'retry' + + +# --------------------------------------------------------------------------- +# schema guards + one-call toolkit (review follow-ups) +# --------------------------------------------------------------------------- + + +def test_define_action_rejects_non_dict_input_schema(): + class _Schemaish: # stand-in for a Zod/Pydantic schema object + pass + + with pytest.raises(SuperDocError): + define_action(name='x.bad', description='d', input_schema=_Schemaish(), + run=lambda doc, args: None) + + +@requires_host +def test_create_agent_toolkit_actions_one_call(): + from superdoc import create_agent_toolkit, list_presets + + stamp = define_action( + name='superdoc.demo_stamp', description='Insert a demo banner.', + input_schema={'type': 'object', 'properties': {'label': {'type': 'string'}}}, + steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}], + ) + kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp]}) + perform = next(t for t in kit['tools'] if (t.get('function') or {}).get('name') == 'superdoc_perform_action') + names = perform['function']['parameters']['properties']['action']['enum'] + assert 'superdoc.demo_stamp' in names # custom action advertised + assert 'superdoc.demo_stamp' in kit['system_prompt'] # prompt narrows WITH it + assert callable(kit['dispatch']) and callable(kit['dispatch_async']) + assert kit['meta']['preset'] == 'custom_superdoc_preset' + assert 'custom_superdoc_preset' not in list_presets() # ephemeral โ€” no global register + + +@requires_host +def test_create_agent_toolkit_actions_conflict_rejected(): + from superdoc import create_agent_toolkit + + a = define_action(name='x.a', description='a', + input_schema={'type': 'object', 'properties': {'foo': {'type': 'string'}}}, + run=lambda doc, args: None) + b = define_action(name='x.b', description='b', + input_schema={'type': 'object', 'properties': {'foo': {'type': 'number'}}}, + run=lambda doc, args: None) + with pytest.raises(SuperDocError): + create_agent_toolkit({'provider': 'openai', 'actions': [a, b]}) + + +@requires_host +def test_create_agent_toolkit_identical_schema_reordered_keys_allowed(): + from superdoc import create_agent_toolkit + + a = define_action(name='x.a', description='a', + input_schema={'type': 'object', + 'properties': {'foo': {'type': 'string', 'description': 'the foo'}}}, + run=lambda doc, args: None) + b = define_action(name='x.b', description='b', + input_schema={'type': 'object', + 'properties': {'foo': {'description': 'the foo', 'type': 'string'}}}, + run=lambda doc, args: None) + kit = create_agent_toolkit({'provider': 'generic', 'actions': [a, b]}) + perform = next(t for t in kit['tools'] if t.get('name') == 'superdoc_perform_action') + assert 'x.b' in perform['parameters']['properties']['action']['enum'] + + +@requires_host +def test_create_agent_toolkit_shared_arg_compatible_desc_only_allowed(): + """Reusing a built-in arg name with a compatible type + extra DESCRIPTION + only is allowed (only a structural difference conflicts).""" + from superdoc import create_agent_toolkit + + a = define_action(name='x.a', description='a', + input_schema={'type': 'object', 'properties': {'foo': {'type': 'string'}}}, + run=lambda doc, args: None) + b = define_action(name='x.b', description='b', + input_schema={'type': 'object', + 'properties': {'foo': {'type': 'string', 'description': 'documented'}}}, + run=lambda doc, args: None) + kit = create_agent_toolkit({'provider': 'generic', 'actions': [a, b]}) + perform = next(t for t in kit['tools'] if t.get('name') == 'superdoc_perform_action') + assert 'x.b' in perform['parameters']['properties']['action']['enum'] + + +@requires_host +def test_create_agent_toolkit_shared_arg_one_sided_enum_rejected(): + """A shared arg name where one side adds an enum the other lacks is a real + conflict (differs beyond description).""" + from superdoc import create_agent_toolkit + + a = define_action(name='x.a', description='a', + input_schema={'type': 'object', 'properties': {'mode': {'type': 'string'}}}, + run=lambda doc, args: None) + b = define_action(name='x.b', description='b', + input_schema={'type': 'object', + 'properties': {'mode': {'type': 'string', 'enum': ['fast', 'slow']}}}, + run=lambda doc, args: None) + with pytest.raises(SuperDocError): + create_agent_toolkit({'provider': 'generic', 'actions': [a, b]}) + + +@requires_host +def test_create_agent_toolkit_dispatch_runs_a_custom_action(): + """Review item: run a custom action through the RETURNED toolkit dispatch.""" + from superdoc import SuperDocClient, create_agent_toolkit + + stamp = define_action( + name='superdoc.stamp_banner', + description='Insert a banner at the top of the document.', + input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'CONFIDENTIAL'}}}, + steps=[{'action': 'insert_paragraphs', + 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}}], + ) + kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp]}) + with SuperDocClient() as client: + doc = client.open({}) # embedded blank document + receipt = kit['dispatch'](doc, 'superdoc_perform_action', + {'action': 'superdoc.stamp_banner', 'label': 'CONFIDENTIAL'}) + assert receipt.get('status') == 'succeeded', receipt + texts = [b.get('text') or '' for b in doc.blocks.list({'includeText': True, 'limit': 6})['blocks']] + assert any('CONFIDENTIAL' in t for t in texts), texts + doc.close({'discard': True}) diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py index 874364f4b7..7b6282b72a 100644 --- a/packages/sdk/langs/python/tests/test_presets.py +++ b/packages/sdk/langs/python/tests/test_presets.py @@ -284,6 +284,23 @@ def test_create_agent_toolkit_empty_preset_fails_fast(): assert exc_info.value.code == 'PRESET_NOT_FOUND' +@pytest.mark.parametrize('base_selection', [ + {'base': '', 'preset': 'core'}, + {'preset': ''}, +]) +def test_create_agent_toolkit_custom_surface_empty_base_fails_fast(base_selection): + from superdoc import SuperDocError, create_agent_toolkit + + with pytest.raises(SuperDocError) as exc_info: + create_agent_toolkit({ + 'provider': 'generic', + 'includeCoreActions': [], + **base_selection, + }) + + assert exc_info.value.code == 'PRESET_NOT_FOUND' + + def test_tools_api_all_exports_create_agent_toolkit(): import superdoc.tools_api as tools_api diff --git a/packages/sdk/skills/superdoc-custom-actions/SKILL.md b/packages/sdk/skills/superdoc-custom-actions/SKILL.md new file mode 100644 index 0000000000..e880c4e083 --- /dev/null +++ b/packages/sdk/skills/superdoc-custom-actions/SKILL.md @@ -0,0 +1,432 @@ +--- +name: superdoc-custom-actions +description: >- + Author a custom SuperDoc LLM-tools "action" โ€” a named, deterministic + document-edit verb that plugs into the core preset's superdoc_perform_action + tool. Use when the user wants to add a new action (e.g. "build an action that + adds footnotes / bolds table borders / stamps a banner") or asks how to + extend SuperDoc's LLM tools with their own document operation. Works with + installed packages only โ€” no SuperDoc source checkout needed or expected. +--- + +# Build a SuperDoc custom action + +A **custom action** is a named, namespaced verb (`superdoc.`, e.g. +`superdoc.add_footnote`) that a customer authors once and exposes to an LLM +through `superdoc_perform_action`, alongside the 40 built-in core actions. An +action is **deterministic application code**, not a prompt. + +Your job when this skill is active: turn a plain-language request into a +correct, registered, **live-verified** ActionSpec, plus a test. Never hand back +an action you haven't executed against a real document. + +--- + +## 1. The canonical ActionSpec โ€” two execution tiers + +``` +ActionSpec = { + name: "superdoc." # namespaced; must not collide with built-ins + description: string # shown to the model: what it does + when to use it + input: JSON Schema (object) # the action's FLAT args; defaults are honored + # exactly ONE of: + steps: [ {action, args}, ... ] # TIER 1 โ€” declarative (PREFER THIS) + run: (doc, args) -> result # TIER 2 โ€” native function in your process +} +``` + +**Tier selection rule โ€” always try in this order:** + +1. **`steps`** โ€” can the goal be expressed as a sequence of built-in core + actions? Then it MUST be steps: you inherit target resolution, placement + handling, receipts, and per-step verification that the built-ins already + guarantee. Steps are pure data (shippable as JSON) and behave identically + from Node and Python. +2. **`run`** โ€” the goal needs an API domain the built-in actions don't cover + (footnotes, headers/footers, table borders, bookmarksโ€ฆ) or computed logic. + A native function in YOUR language, called with the typed session-bound + `doc` handle. + +### Tier 1 example โ€” verified working + +```python +from superdoc import define_action + +stamp = define_action( + name='superdoc.stamp_confidential', + description='Insert a confidentiality banner at the top and flag it with a review comment.', + input_schema={'type': 'object', 'properties': { + 'label': {'type': 'string', 'default': 'CONFIDENTIAL'}, + }, 'required': []}, + steps=[ + {'action': 'insert_paragraphs', + 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}}, + {'action': 'add_comments', + 'args': {'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}'], 'occurrence': 1}], + 'commentText': 'Auto-stamped. Verify distribution before sending.'}}, + ], +) +``` + +Templating: `"{{label}}"` as a WHOLE string substitutes the raw value (arrays +and objects survive); `"Stamped: {{label}}"` interpolates as text. A caller's +`changeMode` flows into every step that doesn't pin its own. Receipts +aggregate per step: `succeeded` / `partial` (some steps landed โ€” `failedStep` +says which and why) / `failed`. + +### Tier 2 example โ€” verified working + +Take MODEL-FRIENDLY args (anchor text, ordinals โ€” things an LLM can actually +produce) and resolve low-level targets INSIDE the action. Never expose raw +`TextTarget`/`ref` shapes as action args. + +```python +def _add_footnote(doc, args): + # Resolve anchor text -> a zero-width TextTarget right after the match. + # NOTE: use includeText + block['text'] โ€” the default textPreview field is + # truncated to 80 chars, so anchors later in a paragraph are never found. + # On large documents, paginate with offset/limit (see examples/reference_pack.py) + # โ€” one unpaginated call may not return every block. + needle = args['anchorText'] + for block in doc.blocks.list({'includeText': True})['blocks']: + text = block.get('text') or '' + pos = text.find(needle) + if pos >= 0: + end = pos + len(needle) + at = {'kind': 'text', 'segments': [{'blockId': block['nodeId'], 'range': {'start': end, 'end': end}}]} + result = doc.footnotes.insert({'at': at, 'type': args.get('type', 'footnote'), 'content': args['content']}) + after = doc.footnotes.list({}) # re-inspect: prove the footnote exists + return {'inserted': result, 'footnoteCount': after.get('total', len(after.get('items', [])))} + raise ValueError(f'anchorText {needle!r} not found in any block') + +add_footnote = define_action( + name='superdoc.add_footnote', + description='Insert a footnote whose marker lands right after the given anchor text. args: {anchorText, content, type?}.', + input_schema={'type': 'object', 'properties': { + 'anchorText': {'type': 'string'}, 'content': {'type': 'string'}, + 'type': {'type': 'string', 'enum': ['footnote', 'endnote'], 'default': 'footnote'}, + }, 'required': ['anchorText', 'content']}, + run=_add_footnote, +) +``` + +**Sync vs async โ€” match the host app's doc handle (this trips agents).** The +example above is synchronous. But if the app dispatches through +`dispatch_async` / drives an `AsyncSuperDocClient` (a very common real case โ€” +check the app first), your `run` MUST be `async def` and MUST `await` every +`doc.*` call. The kit awaits the value your run *returns*, but it does NOT make +the inner `doc.*` calls awaitable for you โ€” a sync body on an async handle gets +un-awaited coroutines and fails with `'coroutine' object is not subscriptable`. +Async variant of the body above: + +```python +async def _add_footnote(doc, args): + needle = args['anchorText'] + for block in (await doc.blocks.list({'includeText': True}))['blocks']: + pos = (block.get('text') or '').find(needle) + if pos >= 0: + end = pos + len(needle) + at = {'kind': 'text', 'segments': [{'blockId': block['nodeId'], 'range': {'start': end, 'end': end}}]} + result = await doc.footnotes.insert({'at': at, 'type': args.get('type', 'footnote'), 'content': args['content']}) + after = await doc.footnotes.list({}) + return {'inserted': result, 'footnoteCount': after.get('total')} + raise ValueError(f'anchorText {needle!r} not found in any block') +``` + +`run` receipts are synthesized for you: `preRevision`/`postRevision`, and on +failure `partialMutation` + a recovery hint. Raise on failure โ€” the exception +becomes a truthful failed receipt with shape +`{status:'failed', errors:[{code, message}], partialMutation, recovery}` โ€” the +message lives at `errors[0].message` (NOT a top-level `error`); assert on that +in your tests. The `examples/` directory next to this file +contains complete, live-verified packs (footnotes + page headers, table +borders) authored exactly this way (in the synchronous form โ€” apply the async +rule above if the host is async). + +--- + +## 2. Register + advertise + +**Simplest path โ€” hand your actions to the toolkit (no preset to manage):** + +```python +from superdoc import create_agent_toolkit + +kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp, add_footnote]}) +# kit['tools'] enum + kit['system_prompt'] now include your actions; +# kit['dispatch'] / kit['dispatch_async'] route them. No register_preset, no id. +``` + +`create_agent_toolkit({actions})` builds the extended preset internally over +`core` and returns tools + system prompt + dispatch that already agree; provider +shaping (anthropic/openai/vercel/generic) is automatic. (Node: +`createAgentToolkit({ provider, actions })`.) Pass `base` to extend a preset +other than `core`, or `includeCoreActions` to keep only a subset of the +built-ins alongside yours. Schema enum, tool description, system prompt, and +dispatch move together โ€” coherence is the kit's job, not yours. + +**Dispatch args are FLAT** โ€” `{action, ...yourArgs}`, never a nested `args` +object: + +```python +# async host (this uses dispatch_async); a sync host uses kit['dispatch'] +receipt = await kit['dispatch_async'](doc, 'superdoc_perform_action', + {'action': 'superdoc.add_footnote', 'anchorText': 'the report', 'content': 'See appendix.'}) +# NOT {'action': ..., 'args': {...}} โ€” a nested `args` raises +# "Unknown argument(s) for superdoc_perform_action: args". +``` + +**Integrating into the host app โ€” find its toolkit seam.** Real apps differ; +read the app before writing files: + +- The common case: the app already calls `create_agent_toolkit(...)` / + `createAgentToolkit(...)` (often in one startup function). Just add your + `actions` to THAT call โ€” one import + one arg. No `register_preset`, no + `PRESET` constant, no preset id threaded through dispatch. +- If the app has an action-discovery convention (an `actions/` folder it + auto-loads, an `ACTIONS` list it imports), follow it and feed that list into + the toolkit call. Do not add scaffolding, CLI flags, or discovery frameworks + the app doesn't have. + +**Advanced โ€” a named, registered preset.** Only if the app uses the standalone +functions (`choose_tools` / `get_system_prompt` / `dispatch_superdoc_tool`) or +genuinely needs a reusable registered preset: + +```python +from superdoc import extend_preset, register_preset + +register_preset(extend_preset('core', id='custom_superdoc_preset', actions=[stamp, add_footnote])) +# then pass preset='custom_superdoc_preset' to choose_tools / get_system_prompt / dispatch_superdoc_tool +``` + +Reuse the app's existing extended-preset id if it has one; otherwise the +conventional default is `custom_superdoc_preset` โ€” not an invented codename. + +--- + +## 3. Discovery โ€” you have no source access, and you don't need it + +**Built-in action names + args** (decides steps-vs-run): + +```python +from superdoc import choose_tools +perform = next(t['function'] for t in choose_tools({'provider': 'openai', 'preset': 'core'})['tools'] + if t['function']['name'] == 'superdoc_perform_action') +print(perform['parameters']['properties']['action']['enum']) # the 40 built-ins +print(sorted(perform['parameters']['properties'])) # every advertised arg +print(perform['description']) # grouped hints per action +``` + +**Native `doc.*` surface** (for the run tier) โ€” the client self-describes; use +that FIRST, it beats trial-and-error: + +```python +from superdoc import SuperDocClient +with SuperDocClient() as client: + contract = client.describe() # ALL operations (~439) with ids like 'doc.tables.setBorders' + print(sorted({op['id'].split('.')[1] for op in contract['operations']})) # the namespaces + spec = client.describe_command({'operationId': 'tables.setBorders'}) # full input contract for one op + print(spec['operation']) +``` + +Then confirm against a live session on a COPY of a sample doc: + +```python + doc = client.open({'doc': 'work-copy.docx'}) + print([m for m in dir(doc.tables) if not m.startswith('_')]) # methods (snake_case + camelCase both work) + print(doc.blocks.list()['blocks'][:3]) # rows: ordinal, nodeId, nodeType, textPreview, ref + doc.close({'discard': True}) +``` + +Note: `dir(doc)` does NOT list the namespaces (they're dynamic attributes on +the Python handle) โ€” `client.describe()` is the namespace directory; +`dir(doc.)` works once you know the namespace. + +**Let errors teach you โ€” but confirm params with `describe_command`, never +with a successful call.** Most inputs are validated against the real contract +and error messages enumerate the valid unions. Two traps, both observed live: + +- Some ops **silently drop unknown params**: `headerFooters.parts.create` + returns `success: true` for `{'content': ...}` while writing nothing โ€” the + success receipt lies about the param having done anything. +- Union error messages can mislead: `blocks.list({'in': ...})` prints + `in.kind must be one of: "story","story","story"` and `storyType must equal + "body"` even though richer story kinds are valid. Only + `describe_command({'operationId': 'blocks.list'})` shows the real union. + +Budget for iteration: some ops reveal ONE missing required field per probe +(`tables.setBorders` took six rounds that way โ€” `describe_command` gives the +whole contract in one). + +**Stories (header/footer content).** Body is only one story of a document. +Header/footer text is written with ordinary content ops pointed INTO a story: +`doc.create.paragraph({..., 'in': {'kind':'story','storyType':'headerFooterPart','refId': ...}})`, +or a slot story with `onWrite: 'materializeIfInherited'` (auto-creates and +wires the part). Wiring evidence comes from `doc.headerFooters.get(...)` +(refId/isExplicit); **header/footer TEXT has no in-session read-back** +(`blocks.list` returns body blocks regardless of `in`, `doc.get` omits header +parts) โ€” save to a file and inspect the zip (`word/header1.xml`) for +text-level evidence. + +**Finding table/row/cell handles:** `doc.blocks.list()` rows with +`nodeType == 'table'` carry the table `nodeId`; `doc.tables.get_cells(...)` / +`doc.tables.get_properties(...)` take it from there (probe their input shape +with a small call and read the validation error). + +--- + +## 4. Verification โ€” two layers; degrade honestly, never silently + +**Layer 1 โ€” contract validation. ALWAYS, no document needed.** Every operation +your action calls must be confirmed against +`client.describe_command({'operationId': ...})`: the op exists and your arg +shapes match its contract. Never confirm a param with a successful call โ€” +some ops silently drop unknown params and report success anyway. This layer is +seconds of work and catches most authoring mistakes. + +**Layer 2 โ€” live semantic verification. When a document is available.** A run +on a COPY of a real document showing a `succeeded` receipt AND independent +evidence the document actually changed (re-inspect: `doc.blocks.list()` shows +the banner at ordinal 0, `doc.footnotes.list({})` count went up, borders read +back different; for header/footer text, saved-zip XML). + +**Finding a document for layer 2, in order:** + +1. Glob the workspace for `*.docx`, skipping outputs (`out*.docx`), Word lock + files (`~$*`), and `.venv`/`node_modules`/state dirs. Prefer conventional + homes first: `sample_docs/`, `fixtures/`, `tests/data/`. +2. Check suitability, not just existence: open a COPY, one `countsOnly` + inspect โ€” does it contain what the action targets (images, footnotes, + tables)? If not, can you PLANT the conditions on the copy first (built-in + actions or raw ops, e.g. `doc.footnotes.insert` before testing a renumber + action)? +3. No usable document? **Open a blank one โ€” built in.** `client.open({})` + (no `doc` at all) starts a session from the SDK's embedded blank document, + in both languages; `doc.save({'out': ...})` materializes it as a real file. + PLANT the conditions your action needs on it (built-in actions or raw ops, + e.g. `create_table`, `doc.footnotes.insert`), then verify against it. +4. Conditions you cannot synthesize (images, embedded objects, messy + real-world structure) and a human is present: **ask the user for a + representative document.** This is the one legitimate reason to stop and + ask. +5. Nobody to ask (headless/CI) and synthesis impossible: **deliver anyway, as + a DRAFT** โ€” do not block, do not fabricate. + +A generated-fixture verification is real layer-2 evidence for the mechanics, +but say so in your summary ("verified against a generated fixture") โ€” it +proves the action works, not that it handles the customer's real documents. + +**Draft labeling โ€” when layer 2 did not run:** the action file's docstring AND +your final message must say plainly: *"DRAFT โ€” contract-validated, not yet +executed against a document."* Still write the pytest (see below) so the +customer's first real run IS the verification. Never present an unexecuted +action as verified; a false "tested โœ“" is worse than no action at all. + +**A test is required in BOTH modes** โ€” in `tests/`, following the existing +suite's pattern; if the app has NO test suite yet, create `tests/` plus a +conftest that wires the environment the same way the app does (e.g. read +`SUPERDOC_CLI_BIN` from the app's `.env`). Tests open a fresh doc (or tmp +copy), dispatch through the toolkit (or a registered preset), and assert receipt +fields plus re-inspected evidence. Failure honesty matters โ€” if your action can partially +apply, test that path too. + +Two receipt/fixture facts your tests must account for: +- **Status vocabulary differs by layer**: custom-action receipts report + `succeeded`/`partial`/`failed`, but dispatching a BUILT-IN action directly + (e.g. while planting conditions) returns `status: "ok"`. Accept both where + appropriate. +- **A truly blank document interleaves empty paragraphs** around inserted + texts (`['', 'AAA', '', 'BBB']`), so tail/count assertions against the bare + embedded blank doc are unstable โ€” plant body content first, then assert. + +Never claim success from `status: 'succeeded'` alone โ€” receipts synthesized for +`run`-tier actions confirm your code returned, not that the effect you intended +landed. Re-inspect. + +**Clean up when you finish.** Verification leaves scratch behind โ€” delete it so +the only new files are the action module and its test. Remove: any `.docx` you +opened/saved/generated for verification, session-state dirs the host created +(e.g. `.superdoc-state/`, `.superdoc-cli-state/`), a stray `out.docx`, and +`__pycache__/` from test runs. Do NOT delete the app's own sample documents, +seed assets, or anything that existed before you started. When in doubt, list +what you created and remove exactly that. + +"Before" evidence can legitimately be ABSENT: style-inherited formatting reads +back with no explicit property at all (e.g. a `TableGrid`-styled table has no +`borders` key until direct borders are set). Compare "absent โ†’ explicit value", +don't assume a numeric before-state exists. + +Read-back may NORMALIZE identifiers (e.g. a bookmark inserted with one blockId +can read back with a normalized id). Verify by stable evidence โ€” names, +offsets, counts, saved-XML content โ€” not by identifier equality. + +--- + +## 5. Naming, schema, and description discipline + +- Names: `superdoc.` โ€” dots are valid in the merged enum. (Only + `standalone` mode forbids dots, because provider tool names must match + `^[A-Za-z0-9_-]{1,64}$`.) +- Never collide with built-in names โ€” the kit rejects it. +- `description` is model-facing: one sentence on WHAT + one on WHEN, and name + the args inline (models call better with `args: {at, content}` spelled out). +- Every arg gets a JSON-Schema property; put `default` on optionals โ€” defaults + are applied on all tiers. Mark true requirements in `required`. + +## 6. Pitfalls (each of these bit a real implementation) + +- **Reusing a built-in arg name:** custom actions merge into the ONE + `superdoc_perform_action` schema, so a shared arg name maps to a single + schema. Reusing a built-in name (`anchorText`, `text`, `caseSensitive`, โ€ฆ) is + fine **only if your schema matches the built-in's exactly except for + `description`** โ€” a differing `type`, `enum` (including one side having one), + `default`, limit, or pattern is a real conflict and the kit rejects it (the + merged surface can advertise only one schema per name). A description-only + difference is allowed, but the built-in's schema wins the merge so your + description is not advertised โ€” use a distinct arg name if you need it. Cross- + check with ยง3's `sorted(perform['parameters']['properties'])`. +- **Async host apps (very common โ€” inspect the app first):** if it dispatches + via `dispatch_async` or an `AsyncSuperDocClient`, your `run` function MUST be + `async def` and `await` every `doc.*` call. A synchronous body on an async + handle hands back un-awaited coroutines and fails with `'coroutine' object is + not subscriptable`. (See ยง1's async variant.) A synchronous app is the + mirror: a plain `def run`. +- **Do not insert content via raw ops in `run` actions** โ€” e.g. + `doc.create.paragraph({placement: 'before', ...})` silently IGNORES placement + and appends at document end. The built-in `insert_paragraphs` action resolves + placement correctly โ€” compose it via `steps` instead (or mix: a steps action + for the insert, run for the rest). +- **Comment anchoring:** `doc.comments.create` with a `{text: ...}` target can + return success without a visible comment. Use the built-in `add_comments` + action (selector-resolved, verified) via `steps`. +- **Two failure shapes:** argument-validation errors THROW; runtime failures + return `{'status': 'failed', ...}` receipts. Wrap dispatch in try/except and + handle both. +- **Refs expire on mutation.** Any `ref` you fetched before an edit is invalid + after it โ€” re-list/re-search instead of caching handles across mutations. +- **Python handles return plain dicts** and accept both `snake_case` and + `camelCase` method names; results use camelCase keys (`textPreview`, + `nodeId`). +- **Some open string inputs are NOT enum-validated by the host** (e.g. + `tables.setBorders` persists any `lineStyle` string verbatim โ€” `zigzag` + round-trips). Constrain such inputs with an `enum` in YOUR action's input + schema so the model can't invent values. +- **`insert_paragraphs` rejects empty strings in `texts`** โ€” you cannot + compose a blank spacer paragraph declaratively; a `""` entry fails the whole + action. Design steps-tier layouts without empty-line spacers. +- **Sandboxed agent environments:** the SDK's host bootstrap may `chmod` its + bundled binary and write state under `~/.superdoc-cli` โ€” both can be blocked + by an agent sandbox. If `client.open` fails on permissions, set + `SUPERDOC_CLI_BIN` to the installed binary and `SUPERDOC_CLI_STATE_DIR` to a + writable path inside your workspace. + +## 7. Worked examples + +The `examples/` directory next to this file contains complete, live-verified +action packs (declarative steps, footnotes/page headers, table borders) with +their receipt-synthesis and verification patterns โ€” in Python and JavaScript +(`policy_pack.py` / `policy_pack.mjs` are twins; the kit API maps 1:1 across +languages). Pattern-match them. There is +no bundled API reference by design โ€” the live contract from `client.describe()` +/ `client.describe_command(...)` (ยง3) is always current; a bundled copy would +drift. diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs new file mode 100644 index 0000000000..012bfcdf5a --- /dev/null +++ b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs @@ -0,0 +1,68 @@ +/** + * ACME's in-house custom actions โ€” Node/JavaScript twin of policy_pack.py. + * The kit API maps 1:1 across languages: defineAction/extendPreset/ + * registerPreset here are define_action/extend_preset/register_preset there, + * and steps/run specs behave identically (same templating, same receipts). + * + * Two tiers demonstrated: + * - `steps` (declarative): compose built-in core actions โ€” inherits target + * resolution, placement handling, receipts, and verification. + * - `run` (native): an async function against the typed doc handle, for + * things the built-in actions can't express. + * + * Wire into your app at its toolkit seam โ€” hand the actions to the toolkit + * (no registerPreset, no preset id): + * import { createAgentToolkit } from '@superdoc-dev/sdk'; + * import { ACTIONS } from './actions/policy_pack.mjs'; + * const kit = await createAgentToolkit({ provider: 'openai', actions: ACTIONS }); + */ + +import { defineAction } from '@superdoc-dev/sdk'; + +export const stampConfidential = defineAction({ + name: 'superdoc.stamp_confidential', + description: + 'Insert a confidentiality banner paragraph at the very top of the document ' + + 'and flag it with a review comment for the distribution team.', + input: { + type: 'object', + properties: { + label: { type: 'string', default: 'CONFIDENTIAL', description: 'Banner text' }, + }, + required: [], + }, + steps: [ + { + action: 'insert_paragraphs', + args: { texts: ['{{label}}'], placement: { at: 'document_start' } }, + }, + { + action: 'add_comments', + args: { + selectors: [{ kind: 'textSearch', terms: ['{{label}}'], occurrence: 1 }], + commentText: 'Auto-stamped by ACME policy. Verify the distribution list before sending.', + }, + }, + ], +}); + +export const docStats = defineAction({ + name: 'superdoc.doc_stats', + description: 'Report document structure statistics: block/table/comment counts and empty-block count.', + input: { type: 'object', properties: {}, required: [] }, + async run(doc, _args) { + // Cross-domain read the built-in actions can't express as one verb. + const blocks = await doc.blocks.list(); + const comments = await doc.comments.list({}); + const rows = blocks.blocks ?? []; + const commentItems = comments.comments ?? comments.items ?? []; + return { + blocks: blocks.total, + emptyBlocks: rows.filter((b) => b.isEmpty).length, + tables: rows.filter((b) => b.nodeType === 'table').length, + comments: commentItems.length, + }; + }, +}); + +export const ACTIONS = [stampConfidential, docStats]; diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py new file mode 100644 index 0000000000..fcd4f55040 --- /dev/null +++ b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py @@ -0,0 +1,65 @@ +"""ACME's in-house custom actions for SuperDoc agents. + +Two tiers demonstrated: + - `steps` (declarative): compose built-in core actions โ€” inherits target + resolution, placement handling, receipts, and verification. + - `run` (native): a Python function against the typed doc handle, for things + the built-in actions can't express. + +Every module in this directory that exposes an ``ACTIONS`` list is loaded by +app.py and merged into the agent's tool surface. +""" + +from superdoc import define_action + +stamp_confidential = define_action( + name='superdoc.stamp_confidential', + description=( + 'Insert a confidentiality banner paragraph at the very top of the document ' + 'and flag it with a review comment for the distribution team.' + ), + input_schema={ + 'type': 'object', + 'properties': { + 'label': {'type': 'string', 'default': 'CONFIDENTIAL', 'description': 'Banner text'}, + }, + 'required': [], + }, + steps=[ + { + 'action': 'insert_paragraphs', + 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}, + }, + { + 'action': 'add_comments', + 'args': { + 'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}'], 'occurrence': 1}], + 'commentText': 'Auto-stamped by ACME policy. Verify the distribution list before sending.', + }, + }, + ], +) + + +def _doc_stats(doc, args): + """Cross-domain read the built-in actions can't express as one verb.""" + blocks = doc.blocks.list() + comments = doc.comments.list({}) + rows = blocks['blocks'] + comment_items = comments.get('comments') or comments.get('items') or [] + return { + 'blocks': blocks['total'], + 'emptyBlocks': sum(1 for b in rows if b.get('isEmpty')), + 'tables': sum(1 for b in rows if b.get('nodeType') == 'table'), + 'comments': len(comment_items), + } + + +doc_stats = define_action( + name='superdoc.doc_stats', + description='Report document structure statistics: block/table/comment counts and empty-block count.', + input_schema={'type': 'object', 'properties': {}, 'required': []}, + run=_doc_stats, +) + +ACTIONS = [stamp_confidential, doc_stats] diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py b/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py new file mode 100644 index 0000000000..3cceeebbf7 --- /dev/null +++ b/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py @@ -0,0 +1,275 @@ +"""ACME custom actions: footnotes + page headers (both `run` tier). + +Why run tier (SKILL.md ยง1 tier rule): neither domain is covered by the 40 +built-in core actions โ€” the `superdoc_perform_action` enum has no footnote or +header/footer verbs โ€” and the built-in `insert_paragraphs` placement cannot +target a header/footer story. So these are native Python functions against the +typed doc handle. + +Discovery notes (probed live, not guessed โ€” SKILL.md ยง3): + - `doc.footnotes.insert({at, type, content})` anchors a note at a TextTarget; + a zero-width range `{start: N, end: N}` puts the reference marker right + after the anchor text (verified in word/document.xml + word/footnotes.xml). + - `doc.headerFooters.parts.create({kind})` creates an EMPTY part and returns + `{refId, partPath}`. It has NO content parameter โ€” unknown params are + silently ignored (a `content` arg "succeeds" but writes nothing). + - Header text is written by `doc.create.paragraph({text, in: })` + with `in = {kind:'story', storyType:'headerFooterPart', refId}`, then the + section slot is wired with `doc.headerFooters.refs.set({target, refId})` + where target is `{kind:'headerFooterSlot', section, headerFooterKind, + variant}`. Creating a fresh part per call gives true SET/replace semantics. + - `doc.blocks.list({'in': })` validates the story locator but does NOT + honor it (returns body blocks), so header read-back uses + `doc.headerFooters.get` (refId + isExplicit) in-session; text-level + evidence requires save + zip inspection (done in tests). +""" + +from superdoc import define_action + +_PAGE = 200 + + +def _iter_text_blocks(doc): + """Paginate body blocks with full text, by stable nodeId.""" + offset = 0 + total = None + while total is None or offset < total: + page = doc.blocks.list({'offset': offset, 'limit': _PAGE, 'includeText': True}) + total = page['total'] + blocks = page.get('blocks') or [] + if not blocks: + break + yield from blocks + offset += len(blocks) + + +def _find_anchor(doc, anchor_text, occurrence, case_sensitive): + """Locate the Nth non-overlapping occurrence of anchor_text in reading + order. Returns (block_nodeId, offset_after_match, matches_seen).""" + needle = anchor_text if case_sensitive else anchor_text.lower() + seen = 0 + for block in _iter_text_blocks(doc): + text = block.get('text') or '' + hay = text if case_sensitive else text.lower() + start = 0 + while True: + idx = hay.find(needle, start) + if idx == -1: + break + seen += 1 + if seen == occurrence: + return block['nodeId'], idx + len(anchor_text), seen + start = idx + len(needle) + return None, None, seen + + +# --------------------------------------------------------------------------- +# superdoc.add_footnote โ€” run tier +# --------------------------------------------------------------------------- + +def _add_footnote(doc, args): + anchor_text = str(args.get('anchorText') or '') + content = str(args.get('content') or '') + if not anchor_text.strip(): + raise ValueError('superdoc.add_footnote: "anchorText" must be a non-empty string.') + if not content.strip(): + raise ValueError('superdoc.add_footnote: "content" must be a non-empty string.') + raw_occurrence = args.get('occurrence') + occurrence = 1 if raw_occurrence is None else int(raw_occurrence) + if occurrence < 1: + raise ValueError('superdoc.add_footnote: "occurrence" must be >= 1.') + note_type = args.get('type') or 'footnote' + case_sensitive = args.get('caseSensitive') is True + + block_id, end_offset, seen = _find_anchor(doc, anchor_text, occurrence, case_sensitive) + if block_id is None: + raise ValueError( + f'superdoc.add_footnote: anchor text {anchor_text!r} occurrence {occurrence} ' + f'not found in the document body (found {seen} match(es)).' + ) + + before = doc.footnotes.list({'type': note_type}) + before_count = before['total'] + + receipt = doc.footnotes.insert({ + # Zero-width target = insertion point immediately AFTER the anchor text, + # which is where Word places a footnote reference marker. + 'at': {'kind': 'text', 'segments': [{'blockId': block_id, 'range': {'start': end_offset, 'end': end_offset}}]}, + 'type': note_type, + 'content': content, + }) + note = (receipt or {}).get('footnote') or {} + note_id = note.get('noteId') + if not (isinstance(receipt, dict) and receipt.get('success') and note_id is not None): + raise RuntimeError(f'superdoc.add_footnote: footnotes.insert did not return a note id: {receipt!r}') + + # Read-back verification (SKILL.md ยง4): the note count grew by exactly one + # and the created note carries our content. Never trust the insert receipt. + after = doc.footnotes.list({'type': note_type}) + if after['total'] != before_count + 1: + raise RuntimeError( + f'superdoc.add_footnote: verification failed โ€” expected {note_type} count ' + f'{before_count + 1}, read back {after["total"]}.' + ) + created = next((item for item in after.get('items', []) if item.get('noteId') == note_id), None) + if created is None or created.get('content') != content: + raise RuntimeError( + f'superdoc.add_footnote: verification failed โ€” note {note_id} content mismatch ' + f'(read back {created!r}).' + ) + + return { + 'noteId': note_id, + 'displayNumber': created.get('displayNumber'), + 'type': note_type, + 'anchorBlockId': block_id, + 'anchorOffset': end_offset, + 'notesOfTypeAfter': after['total'], + } + + +add_footnote = define_action( + name='superdoc.add_footnote', + description=( + 'Insert a footnote (or endnote) whose reference marker lands immediately after the Nth ' + 'occurrence of a body text snippet. Use when a citation, source, or clarification must be ' + 'attached to specific wording. args: {anchorText, content, occurrence?, type?, caseSensitive?}.' + ), + input_schema={ + 'type': 'object', + 'properties': { + 'anchorText': { + 'type': 'string', + 'description': 'Body text to anchor on; the note marker is placed right after this text.', + }, + 'content': {'type': 'string', 'description': 'The note body text.'}, + 'occurrence': { + 'type': 'number', + 'default': 1, + 'description': '1-based occurrence of anchorText to anchor on (reading order).', + }, + 'type': {'type': 'string', 'enum': ['footnote', 'endnote'], 'default': 'footnote'}, + # `caseSensitive` is also a built-in arg (bare boolean); to reuse the + # name we keep the schema identical except for the description (a + # differing default/enum/limit would be a real conflict). The run + # body applies the false default itself. + 'caseSensitive': { + 'type': 'boolean', + 'description': 'Match anchorText case-sensitively. Defaults to false.', + }, + }, + 'required': ['anchorText', 'content'], + }, + run=_add_footnote, +) + + +# --------------------------------------------------------------------------- +# superdoc.set_page_header โ€” run tier +# --------------------------------------------------------------------------- + +_VARIANTS = ('default', 'first', 'even') + + +def _set_page_header(doc, args): + text = str(args.get('text') or '') + if not text.strip(): + raise ValueError('superdoc.set_page_header: "text" must be a non-empty string.') + variant = args.get('variant') or 'default' + if variant not in _VARIANTS: + raise ValueError(f'superdoc.set_page_header: "variant" must be one of {list(_VARIANTS)}.') + + sections = doc.sections.list({})['items'] + section_index = args.get('sectionIndex') + if section_index is None: + targets = sections + else: + index = int(section_index) + if index < 0 or index >= len(sections): + raise ValueError( + f'superdoc.set_page_header: sectionIndex {index} out of range ' + f'(document has {len(sections)} section(s)).' + ) + targets = [sections[index]] + + if variant == 'even': + # Even-page headers only render when odd/even headers are enabled. + doc.sections.set_odd_even_headers_footers({'enabled': True}) + + slots = [] + for section in targets: + section_id = section['id'] + part = doc.header_footers.parts.create({'kind': 'header'}) + ref_id = part.get('refId') if isinstance(part, dict) else None + if not (isinstance(part, dict) and part.get('success') and ref_id): + raise RuntimeError(f'superdoc.set_page_header: parts.create failed: {part!r}') + + # Write the header text into the FRESH part's story. parts.create has no + # content parameter (probed: unknown params are silently ignored), so the + # paragraph must be created inside the part story explicitly. + para = doc.create.paragraph({ + 'text': text, + 'in': {'kind': 'story', 'storyType': 'headerFooterPart', 'refId': ref_id}, + }) + if not (isinstance(para, dict) and para.get('success')): + raise RuntimeError(f'superdoc.set_page_header: create.paragraph failed for part {ref_id}: {para!r}') + + # Point the section slot at the new part โ€” replaces any previous header. + slot_target = { + 'kind': 'headerFooterSlot', + 'section': {'kind': 'section', 'sectionId': section_id}, + 'headerFooterKind': 'header', + 'variant': variant, + } + wired = doc.header_footers.refs.set({'target': slot_target, 'refId': ref_id}) + if not (isinstance(wired, dict) and wired.get('success')): + raise RuntimeError(f'superdoc.set_page_header: refs.set failed for section {section_id}: {wired!r}') + + if variant == 'first': + # First-page headers only render when the section has a title page. + doc.sections.set_title_page({'target': {'kind': 'section', 'sectionId': section_id}, 'enabled': True}) + + # Read-back verification (SKILL.md ยง4): the slot must now explicitly + # reference OUR part. (Header text itself is not readable in-session โ€” + # blocks.list story scoping is not honored โ€” so tests additionally save + # and inspect the .docx part XML.) + read_back = doc.header_footers.get({'target': slot_target}) + if not (isinstance(read_back, dict) and read_back.get('isExplicit') and read_back.get('refId') == ref_id): + raise RuntimeError( + f'superdoc.set_page_header: verification failed for section {section_id} โ€” ' + f'slot reads {read_back!r}, expected explicit refId {ref_id}.' + ) + slots.append({'sectionId': section_id, 'refId': ref_id, 'partPath': part.get('partPath')}) + + return {'text': text, 'variant': variant, 'sectionsUpdated': len(slots), 'slots': slots} + + +set_page_header = define_action( + name='superdoc.set_page_header', + description=( + 'Set (replace) the page header text for every section, or one section via sectionIndex. ' + 'Creates a fresh header part, writes the text into it, and wires the section header slot ' + 'to it; variant "first"/"even" also enables the title-page / odd-even setting so the ' + 'header actually renders. args: {text, variant?, sectionIndex?}.' + ), + input_schema={ + 'type': 'object', + 'properties': { + 'text': {'type': 'string', 'description': 'The header paragraph text.'}, + 'variant': { + 'type': 'string', + 'enum': list(_VARIANTS), + 'default': 'default', + 'description': 'Which header slot to set: default (all pages), first (first page), even (even pages).', + }, + 'sectionIndex': { + 'type': 'number', + 'description': '0-based section to target. Omit to set the header on every section.', + }, + }, + 'required': ['text'], + }, + run=_set_page_header, +) + +ACTIONS = [add_footnote, set_page_header] diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py b/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py new file mode 100644 index 0000000000..4daac690db --- /dev/null +++ b/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py @@ -0,0 +1,138 @@ +"""ACME custom action: make a table's borders bold (heavier weight / style). + +Tier choice: ``run`` (native Python). No built-in core action exposes border +weight/style โ€” ``style_table`` applies a fixed "professional look" preset and +offers no border control โ€” so this composes the Document API's +``doc.tables.set_borders`` directly. + +Probed contract (superdoc-sdk 1.20.0, learned from live validation errors): + doc.tables.set_borders({ + 'mode': 'applyTo', # 'applyTo' | 'edges' + 'nodeId': , # or a resolved target + 'applyTo': 'all', # all|outside|inside|top|bottom|left|right|insideH|insideV + 'border': {'lineStyle': str, 'lineWeightPt': float > 0, 'color': str}, # ALL required + }) + doc.tables.get_properties({'nodeId': ...}) # -> {'borders': {side: {...}}} once set + +NOTE: the host stores ``lineStyle`` verbatim (even nonsense values), so this +action constrains it to known OOXML styles via its input schema. +""" + +from superdoc import define_action + +# Which border sides each applyTo mode is expected to touch โ€” used to verify +# the mutation actually landed by reading properties back. +_SIDES_FOR_APPLY_TO = { + 'all': ('top', 'bottom', 'left', 'right', 'insideH', 'insideV'), + 'outside': ('top', 'bottom', 'left', 'right'), + 'inside': ('insideH', 'insideV'), + 'top': ('top',), + 'bottom': ('bottom',), + 'left': ('left',), + 'right': ('right',), + 'insideH': ('insideH',), + 'insideV': ('insideV',), +} + + +def _norm_color(value): + return str(value or '').lstrip('#').upper() + + +def _embolden_table_borders(doc, args): + ordinal = int(args.get('tableOrdinal', 1)) + apply_to = args.get('applyTo', 'all') + border = { + 'lineStyle': args.get('lineStyle', 'single'), + 'lineWeightPt': args.get('lineWeightPt', 2.25), + 'color': args.get('color', '#000000'), + } + + tables = [b for b in doc.blocks.list()['blocks'] if b.get('nodeType') == 'table'] + if not tables: + raise ValueError('No tables in this document.') + if not 1 <= ordinal <= len(tables): + raise ValueError( + f'tableOrdinal {ordinal} is out of range: the document has ' + f'{len(tables)} table(s) (ordinals 1..{len(tables)}).' + ) + node_id = tables[ordinal - 1]['nodeId'] + + before = doc.tables.get_properties({'nodeId': node_id}).get('borders') + + doc.tables.set_borders({ + 'mode': 'applyTo', + 'nodeId': node_id, + 'applyTo': apply_to, + 'border': border, + }) + + # Re-inspect: never trust the call's own success flag alone. + after = doc.tables.get_properties({'nodeId': node_id}).get('borders') or {} + mismatched = [ + side for side in _SIDES_FOR_APPLY_TO[apply_to] + if (after.get(side) or {}).get('lineStyle') != border['lineStyle'] + or (after.get(side) or {}).get('lineWeightPt') != border['lineWeightPt'] + or _norm_color((after.get(side) or {}).get('color')) != _norm_color(border['color']) + ] + if mismatched: + raise RuntimeError( + f'Border update did not land on side(s) {mismatched}: read-back {after!r}.' + ) + + return { + 'tableOrdinal': ordinal, + 'nodeId': node_id, + 'applyTo': apply_to, + 'applied': border, + 'bordersBefore': before, + 'bordersAfter': after, + } + + +embolden_table_borders = define_action( + name='superdoc.embolden_table_borders', + description=( + 'Make a table\'s borders bold: apply a heavier border weight and/or style to a table. ' + 'Use when asked to bold/thicken/emphasize table borders. ' + 'args: {tableOrdinal? (1-based, default 1), applyTo? (all|outside|inside|top|bottom|left|right|insideH|insideV, ' + 'default all), lineWeightPt? (default 2.25), lineStyle? (default single), color? (hex, default #000000)}.' + ), + input_schema={ + 'type': 'object', + 'properties': { + 'tableOrdinal': { + 'type': 'number', + 'default': 1, + 'description': '1-based table position in the document (1 = first table).', + }, + 'applyTo': { + 'type': 'string', + 'enum': ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'], + 'default': 'all', + 'description': 'Which borders to embolden (default: every border, outer and inner).', + }, + 'lineWeightPt': { + 'type': 'number', + 'exclusiveMinimum': 0, + 'default': 2.25, + 'description': 'Border line weight in points (Word default grid is 0.5pt; 2.25pt reads as bold).', + }, + 'lineStyle': { + 'type': 'string', + 'enum': ['single', 'thick', 'double', 'dashed', 'dotted'], + 'default': 'single', + 'description': 'OOXML border line style.', + }, + 'color': { + 'type': 'string', + 'default': '#000000', + 'description': 'Border color as hex, e.g. #000000.', + }, + }, + 'required': [], + }, + run=_embolden_table_borders, +) + +ACTIONS = [embolden_table_borders] diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs index b80d38b795..35332f1faf 100644 --- a/packages/superdoc/.releaserc.cjs +++ b/packages/superdoc/.releaserc.cjs @@ -61,17 +61,29 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = !isLocalPreview && Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases const notesPlugin = - isLocalPreview || isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; + isLocalPreview || isPrerelease + ? createReleaseNotesGenerator() + : [ + 'semantic-release-ai-notes', + { + style: 'concise', + scope: { + name: 'SuperDoc', + paths: SUPERDOC_PACKAGES, + audience: 'Developers embedding the SuperDoc editor in their own applications', + instructions: + 'packages/super-editor, packages/layout-engine, packages/word-layout, and packages/preset-geometry are internal engine packages bundled directly into SuperDoc. Treat their changes as part of the editor itself, not as an external dependency.', + }, + }, + ]; const config = { branches, @@ -120,13 +132,12 @@ if (!isLocalPreview) { } // GitHub plugin comes last -if (!isLocalPreview) { +if (shouldPublishGitHubRelease) { config.plugins.push([ '@semantic-release/github', { successComment: ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, }, ]); } diff --git a/packages/template-builder/.releaserc.cjs b/packages/template-builder/.releaserc.cjs index e1d61522a5..1230f8c674 100644 --- a/packages/template-builder/.releaserc.cjs +++ b/packages/template-builder/.releaserc.cjs @@ -22,12 +22,10 @@ const branches = [ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); -// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. -// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. -const shouldCommentOnRelease = !isPrerelease; -// Linear release comments are the shipped-version breadcrumb inside Linear -// itself, so keep them on for prereleases even while GitHub PR comments stay -// gated separately. +// GitHub Releases are stable-only; prerelease tags and package publishing still proceed. +const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease; +// Linear release comments remain the shipped-version breadcrumb, so +// prereleases link to their Git tags when no GitHub Release exists. const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases @@ -65,13 +63,14 @@ config.plugins.push([ }, ]); -config.plugins.push([ - '@semantic-release/github', - { - successComment: - ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **template-builder** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', - successCommentCondition: shouldCommentOnRelease ? undefined : false, - }, -]); +if (shouldPublishGitHubRelease) { + config.plugins.push([ + '@semantic-release/github', + { + successComment: + ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **template-builder** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})', + }, + ]); +} module.exports = config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cb8da1114..61a090fd6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,8 +238,8 @@ catalogs: specifier: ^24.2.7 version: 24.2.9 semantic-release-ai-notes: - specifier: ^0.2.3 - version: 0.2.3 + specifier: ^0.4.0 + version: 0.4.0 semantic-release-commit-filter: specifier: ^1.0.2 version: 1.0.2 @@ -413,7 +413,7 @@ importers: version: 24.2.9(typescript@5.9.3) semantic-release-ai-notes: specifier: 'catalog:' - version: 0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6) + version: 0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6) semantic-release-commit-filter: specifier: 'catalog:' version: 1.0.2 @@ -543,7 +543,7 @@ importers: version: 24.2.9(typescript@5.9.3) semantic-release-ai-notes: specifier: 'catalog:' - version: 0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6) + version: 0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6) semantic-release-commit-filter: specifier: 'catalog:' version: 1.0.2 @@ -3780,21 +3780,6 @@ packages: peerDependencies: zod: ^4.0.0 - '@anthropic-ai/claude-agent-sdk@0.2.86': - resolution: {integrity: sha512-Vynvs18smLzPSeSYk2/k/75IeiSa8AmrhkWgcwaVpZhLZOCuye5GYRv6uMZea0We/3N82c0udI5+hf2aOLiwMg==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^4.0.0 - - '@anthropic-ai/sdk@0.74.0': - resolution: {integrity: sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@anthropic-ai/sdk@0.80.0': resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} hasBin: true @@ -20474,8 +20459,8 @@ packages: resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} engines: {node: '>=18'} - semantic-release-ai-notes@0.2.3: - resolution: {integrity: sha512-xVcvNroQZbfKKlM3jepJFdxtMhzoXSnNQ6C37gNRUc20r15wlOe9nLj6bOroGFId5GIVhJU7wevFw7bEk8TzZw==} + semantic-release-ai-notes@0.4.0: + resolution: {integrity: sha512-4xqORLBAB8ILr3SkUcZTXyH6r1zgjlKrPokQEHOPC9hG9u7Dt+n2uOcEMnBNc0yQC6Ohphp30gFdjUKfQ8mz9w==} engines: {node: '>=18'} peerDependencies: semantic-release: '>=20.0.0' @@ -23510,31 +23495,6 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/claude-agent-sdk@0.2.86(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.74.0(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.28.0(zod@4.3.6) - zod: 4.3.6 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - transitivePeerDependencies: - - '@cfworker/json-schema' - - supports-color - - '@anthropic-ai/sdk@0.74.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -45522,9 +45482,9 @@ snapshots: '@peculiar/x509': 1.14.3 pkijs: 3.4.0 - semantic-release-ai-notes@0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6): + semantic-release-ai-notes@0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6): dependencies: - '@anthropic-ai/claude-agent-sdk': 0.2.86(zod@4.3.6) + '@anthropic-ai/claude-agent-sdk': 0.2.105(zod@4.3.6) '@semantic-release/error': 4.0.0 semantic-release: 24.2.9(typescript@5.9.3) transitivePeerDependencies: @@ -48222,7 +48182,7 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.14 rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: @@ -48241,7 +48201,7 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.14 rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 63cf41b152..7b29e4dd91 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -107,7 +107,7 @@ catalog: rollup-plugin-copy: ^3.5.0 rollup-plugin-visualizer: ^5.12.0 semantic-release: ^24.2.7 - semantic-release-ai-notes: ^0.2.3 + semantic-release-ai-notes: ^0.4.0 semantic-release-commit-filter: ^1.0.2 sirv: ^3.0.2 tippy.js: ^6.3.7 diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index bd4ab3b30d..739840019b 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -32,6 +33,44 @@ function assertOrder(content, first, second, context) { assert.ok(firstIndex < secondIndex, `${context}: expected "${first}" before "${second}"`); } +function normalizePyprojectReleaseArtifact(content) { + return spawnSync('python3', [path.join(REPO_ROOT, 'scripts/normalize-pyproject-release-artifact.py')], { + input: content, + encoding: 'utf8', + }); +} + +function extractShellFunction(content, functionName, context) { + const signature = `${functionName}() {`; + const start = content.indexOf(signature); + assert.notEqual(start, -1, `${context}: missing ${functionName}`); + + const lineStart = content.lastIndexOf('\n', start) + 1; + const indentation = content.slice(lineStart, start); + const closing = `\n${indentation}}`; + const end = content.indexOf(closing, start); + assert.notEqual(end, -1, `${context}: missing closing brace for ${functionName}`); + + return { + block: content.slice(start, end + closing.length), + end: end + closing.length, + }; +} + +function extractConflictResolutionLoop(content, startAt, context) { + const startMarker = 'while read -r f; do'; + const endMarker = 'done < <(git diff --name-only --diff-filter=U)'; + const start = content.indexOf(startMarker, startAt); + const end = content.indexOf(endMarker, start); + assert.notEqual(start, -1, `${context}: missing conflict-resolution loop`); + assert.notEqual(end, -1, `${context}: missing conflict-resolution loop terminator`); + return content.slice(start, end + endMarker.length); +} + +function countOccurrences(content, needle) { + return content.split(needle).length - 1; +} + test('inferDryRunWouldRelease detects pending release previews', () => { assert.equal(inferDryRunWouldRelease('[semantic-release] โ€บ โ„น The next release version is 1.2.3'), true); assert.equal(inferDryRunWouldRelease('There are no relevant changes, so no new version is released.'), false); @@ -152,13 +191,16 @@ test('root release:dry-run script uses the local preview helper', async () => { test('superdoc releaserc uses preview mode to avoid AI notes and side-effect plugins', async () => { const content = await readRepoFile('packages/superdoc/.releaserc.cjs'); + // Collapse whitespace so this tolerates reformatting (e.g. prettier wrapping + // a long ternary across lines) while still checking the real condition. + const normalized = content.replace(/\s+/g, ' '); assert.ok( content.includes("const isLocalPreview = process.env.SUPERDOC_RELEASE_PREVIEW === '1'"), 'packages/superdoc/.releaserc.cjs: must detect local preview mode', ); assert.ok( - content.includes('const notesPlugin =') && - content.includes('isLocalPreview || isPrerelease ? createReleaseNotesGenerator()'), + normalized.includes('const notesPlugin =') && + normalized.includes('isLocalPreview || isPrerelease ? createReleaseNotesGenerator()'), 'packages/superdoc/.releaserc.cjs: preview mode must fall back to conventional release notes', ); assert.ok( @@ -187,7 +229,12 @@ test('stable orchestrator releases tools chain (CLI, SDK, MCP) and core chain (f assertOrder(content, "name: 'sdk'", "name: 'mcp'", 'scripts/release-local-stable.mjs (sdk before mcp)'); assertOrder(content, "name: 'fonts'", "name: 'superdoc'", 'scripts/release-local-stable.mjs (fonts before superdoc)'); assertOrder(content, "name: 'superdoc'", "name: 'react'", 'scripts/release-local-stable.mjs (superdoc before react)'); - assertOrder(content, "name: 'react'", "name: 'vscode-ext'", 'scripts/release-local-stable.mjs (react before vscode-ext)'); + assertOrder( + content, + "name: 'react'", + "name: 'vscode-ext'", + 'scripts/release-local-stable.mjs (react before vscode-ext)', + ); assert.ok( content.includes("name: 'fonts'") && content.includes('npmPackages: FONTS_NPM_PACKAGES'), 'scripts/release-local-stable.mjs: orchestrator must release @superdoc-dev/fonts before superdoc points users at it', @@ -221,12 +268,7 @@ test('fonts release workflow refuses automated releases before the 0.1.0 bootstr workflow.includes('npm view @superdoc-dev/fonts@0.1.0 version'), '.github/workflows/release-fonts.yml: must require the 0.1.0 npm package before semantic-release runs', ); - assertOrder( - workflow, - 'Verify 0.1.0 bootstrap', - 'pnpx semantic-release', - '.github/workflows/release-fonts.yml', - ); + assertOrder(workflow, 'Verify 0.1.0 bootstrap', 'pnpx semantic-release', '.github/workflows/release-fonts.yml'); }); test('stable tooling bundle emits SDK release coordinates so Python publish does not depend on HEAD', async () => { @@ -380,7 +422,9 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag `${file}: refresh step must not be gated on stable-only; queued main runs also need to refresh to avoid stale-checkout pushes`, ); assert.ok( - /git fetch origin "\$\{\{ github\.ref_name \}\}" --tags\s*\n\s*git checkout -B "\$\{\{ github\.ref_name \}\}" "origin\/\$\{\{ github\.ref_name \}\}"/.test(content) || + /git fetch origin "\$\{\{ github\.ref_name \}\}" --tags\s*\n\s*git checkout -B "\$\{\{ github\.ref_name \}\}" "origin\/\$\{\{ github\.ref_name \}\}"/.test( + content, + ) || // release-stable.yml is stable-only and refreshes against the literal `stable` ref. /git fetch origin stable --tags\s*\n\s*git checkout -B stable origin\/stable/.test(content), `${file}: must refresh to the current branch head before semantic-release runs`, @@ -388,15 +432,26 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag } }); -test('stable-to-main sync waits for stable release completion', async () => { +test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); + const laneDrainStart = workflow.indexOf('- name: Wait for stable release lane to drain'); + const laneDrainEnd = workflow.indexOf('- name: Generate token'); + + assert.notEqual(laneDrainStart, -1, '.github/workflows/sync-patches.yml: must include the stable lane drain step'); + assert.notEqual(laneDrainEnd, -1, '.github/workflows/sync-patches.yml: must include the token generation step'); + assert.ok( + laneDrainStart < laneDrainEnd, + '.github/workflows/sync-patches.yml: must drain the stable lane before checkout', + ); + + const laneDrainStep = workflow.slice(laneDrainStart, laneDrainEnd); assert.ok( workflow.includes('workflow_run:'), '.github/workflows/sync-patches.yml: must trigger from release workflow completion, not directly from stable pushes', ); assert.ok( - /workflows:\s*\n\s*-\s*"๐Ÿ“ฆ Release stable tooling \(CLI\/SDK\/MCP\)"/.test(workflow), + /workflows:\s*\n\s*-\s*['"]๐Ÿ“ฆ Release stable tooling \(CLI\/SDK\/MCP\)['"]/.test(workflow), '.github/workflows/sync-patches.yml: must trigger after the stable release orchestrator completes', ); assert.equal( @@ -409,9 +464,17 @@ test('stable-to-main sync waits for stable release completion', async () => { '.github/workflows/sync-patches.yml: must scope automatic syncs to stable release runs', ); assert.ok( - workflow.includes("github.event.workflow_run.conclusion == 'success'") && - workflow.includes("github.event.workflow_run.conclusion == 'failure'"), - '.github/workflows/sync-patches.yml: must wait for release completion while still surfacing failed-release sync PRs for review', + workflow.includes("github.event.workflow_run.conclusion == 'success'"), + '.github/workflows/sync-patches.yml: automatic syncs must require a successful stable tooling release', + ); + assert.equal( + workflow.includes("github.event.workflow_run.conclusion == 'failure'"), + false, + '.github/workflows/sync-patches.yml: failed stable tooling releases must not push to main automatically', + ); + assert.ok( + workflow.includes("github.event_name == 'workflow_dispatch'"), + '.github/workflows/sync-patches.yml: manual dispatch must remain available for investigated recovery', ); assert.ok( workflow.includes('actions: read'), @@ -423,10 +486,41 @@ test('stable-to-main sync waits for stable release completion', async () => { workflow.includes('"๐Ÿ“ฆ Release template-builder"'), '.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable', ); + assert.equal( + laneDrainStep.includes("if: github.event_name == 'workflow_run'"), + false, + '.github/workflows/sync-patches.yml: manual recovery must also wait for active stable release runs to finish', + ); + for (const removedGate of [ + 'Verify stable release lane succeeded', + 'id: verify_release_lane', + 'require_successful_release()', + 'steps.verify_release_lane.outputs.stable_sha', + ]) { + assert.equal( + workflow.includes(removedGate), + false, + `.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`, + ); + } }); test('stable-to-main sync preserves stable release ancestry', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); + const artifactConflictFunction = extractShellFunction( + workflow, + 'release_artifact_only_conflict', + '.github/workflows/sync-patches.yml', + ); + const conflictResolutionLoop = extractConflictResolutionLoop( + workflow, + artifactConflictFunction.end, + '.github/workflows/sync-patches.yml', + ); + const stableShaAssignment = 'stable_sha=$(git rev-parse origin/stable)'; + const pinIndex = workflow.indexOf(stableShaAssignment); + const verificationIndex = workflow.indexOf('git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"'); + const mergeIndex = workflow.indexOf('git merge --no-ff --no-edit "$stable_sha"'); assert.equal( workflow.includes('git merge --squash'), @@ -434,31 +528,145 @@ test('stable-to-main sync preserves stable release ancestry', async () => { '.github/workflows/sync-patches.yml: stable-to-main sync must not squash because semantic-release needs stable tags reachable from main', ); assert.ok( - workflow.includes('git merge --no-ff --no-edit origin/stable'), - '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit', - ); - assert.ok( - workflow.includes('git merge-base --is-ancestor origin/stable origin/main') && - workflow.includes('git merge-base --is-ancestor origin/stable HEAD'), - '.github/workflows/sync-patches.yml: sync must guard on and verify stable ancestry', - ); - assert.ok( - workflow.includes('release_artifact_only_conflict') && - workflow.includes('version-only') && - workflow.includes('git checkout --theirs "$f"'), + mergeIndex !== -1, + '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the pinned stable SHA', + ); + assert.ok( + pinIndex !== -1 && + (workflow.match(/stable_sha=\$\(git rev-parse origin\/stable\)/g) ?? []).length === 1 && + workflow.indexOf('git fetch origin main stable --tags --prune') < pinIndex && + pinIndex < mergeIndex && + !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('git fetch origin') && + !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('origin/stable') && + pinIndex < verificationIndex && + verificationIndex < mergeIndex && + workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') && + workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') && + workflow.includes('semantic-release-bot@martynus.net') && + workflow.includes('release_subject_pattern=') && + workflow.includes('git diff-tree --no-commit-id --name-only -r "$sha"') && + workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') && + workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'), + '.github/workflows/sync-patches.yml: must verify release writeback provenance and files before merging the pinned stable SHA', + ); + assert.ok( + workflow.includes('version-only') && + conflictResolutionLoop.includes('release_artifact_only_conflict "$f"') && + conflictResolutionLoop.includes('git checkout --theirs "$f"') && + countOccurrences(artifactConflictFunction.block, 'python3 scripts/normalize-pyproject-release-artifact.py') === 2, '.github/workflows/sync-patches.yml: release artifact conflicts must resolve to stable only when the conflict is version-only', ); assert.equal( workflow.includes('git add -A'), false, - '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers into review PRs', + '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers', ); assert.ok( - workflow.includes("This PR must be merged with GitHub's merge-commit option"), - '.github/workflows/sync-patches.yml: generated PRs must warn reviewers not to squash away stable ancestry', + workflow.includes('git push origin HEAD:main'), + '.github/workflows/sync-patches.yml: sync must push the verified merge commit directly so stable ancestry is preserved', ); }); +test('pyproject release normalization accepts coordinated Python SDK platform pins', () => { + const pyproject = (version) => `[project] +name = "superdoc-sdk" +version = "${version}" +dependencies = [ + "superdoc-sdk-cli-darwin-arm64==${version}; platform_system == 'Darwin'", + "superdoc-sdk-cli-darwin-x64==${version}; platform_system == 'Darwin'", + "superdoc-sdk-cli-linux-arm64==${version}; platform_system == 'Linux'", + "superdoc-sdk-cli-linux-x64==${version}; platform_system == 'Linux'", + "superdoc-sdk-cli-windows-x64==${version}; platform_system == 'Windows'", +] +`; + + const main = normalizePyprojectReleaseArtifact(pyproject('1.20.1')); + const stable = normalizePyprojectReleaseArtifact(pyproject('1.20.2')); + + assert.equal(main.status, 0, main.stderr); + assert.equal(stable.status, 0, stable.stderr); + assert.equal(main.stdout, stable.stdout); +}); + +test('pyproject release normalization preserves real dependency changes', () => { + const main = normalizePyprojectReleaseArtifact(`[project] +version = "1.20.1" +dependencies = ["httpx>=0.27"] +`); + const stable = normalizePyprojectReleaseArtifact(`[project] +version = "1.20.2" +dependencies = ["httpx>=0.28"] +`); + + assert.equal(main.status, 0, main.stderr); + assert.equal(stable.status, 0, stable.stderr); + assert.notEqual(main.stdout, stable.stdout); +}); + +test('pyproject release normalization rejects a platform pin that diverges from the project version', () => { + const result = normalizePyprojectReleaseArtifact(`[project] +version = "1.20.2" +dependencies = ["superdoc-sdk-cli-linux-x64==1.20.1; platform_system == 'Linux'"] +`); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /does not match project version 1\.20\.2/); +}); + +test('pyproject release normalization ignores embedded package names and comments', () => { + const result = normalizePyprojectReleaseArtifact(`[project] +version = "1.20.2" +dependencies = [ + "internal-superdoc-sdk-cli-linux-x64==0.5.0", +] +# superdoc-sdk-cli-linux-x64==0.5.0 +# "superdoc-sdk-cli-windows-x64==0.5.0" +`); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /internal-superdoc-sdk-cli-linux-x64==0\.5\.0/); + assert.match(result.stdout, /# superdoc-sdk-cli-linux-x64==0\.5\.0/); + assert.match(result.stdout, /# "superdoc-sdk-cli-windows-x64==0\.5\.0"/); +}); + +test('pyproject release normalization distinguishes URL fragments from TOML comments', () => { + const pyproject = (pinVersion) => `[project] +version = "1.20.2" +dependencies = ["custom @ https://example.invalid/pkg.whl#fragment", "superdoc-sdk-cli-linux-x64==${pinVersion}; platform_system == 'Linux'"] +`; + + const valid = normalizePyprojectReleaseArtifact(pyproject('1.20.2')); + const mismatched = normalizePyprojectReleaseArtifact(pyproject('1.20.1')); + + assert.equal(valid.status, 0, valid.stderr); + assert.match(valid.stdout, /pkg\.whl#fragment/); + assert.match(valid.stdout, /superdoc-sdk-cli-linux-x64==/); + assert.notEqual(mismatched.status, 0); +}); + +test('both stable sync directions use checked pyproject release normalization', async () => { + const workflows = [ + ['.github/workflows/sync-patches.yml', 'git checkout --theirs "$f"'], + ['.github/workflows/promote-stable.yml', 'git checkout --ours "$f"'], + ]; + + for (const [workflowPath, expectedCheckout] of workflows) { + const workflow = await readRepoFile(workflowPath); + const artifactConflictFunction = extractShellFunction(workflow, 'release_artifact_only_conflict', workflowPath); + const conflictResolutionLoop = extractConflictResolutionLoop(workflow, artifactConflictFunction.end, workflowPath); + + assert.equal( + countOccurrences(artifactConflictFunction.block, 'python3 scripts/normalize-pyproject-release-artifact.py'), + 2, + `${workflowPath}: both pyproject conflict stages must use checked release normalization`, + ); + assert.ok( + conflictResolutionLoop.includes(expectedCheckout), + `${workflowPath}: conflict resolution uses the wrong side`, + ); + } +}); + test('MCP releaserc builds the package before publish so the tarball ships dist/', async () => { const content = await readRepoFile('apps/mcp/.releaserc.cjs'); assert.ok( @@ -470,11 +678,12 @@ test('MCP releaserc builds the package before publish so the tarball ships dist/ test('stable recovery filters prerelease tags so *-next.* never resumes as @latest', async () => { const content = await readRepoFile('scripts/release-local-stable.mjs'); assert.ok( - content.includes('listStableMergedTags') && content.includes("isPrereleaseTag"), + content.includes('listStableMergedTags') && content.includes('isPrereleaseTag'), 'scripts/release-local-stable.mjs: must expose a stable-only tag filter that excludes -next.* prereleases', ); assert.ok( - content.includes("expectedBranch === 'stable'") && content.includes('listStableMergedTags(pkg.tagPattern, branchRef)'), + content.includes("expectedBranch === 'stable'") && + content.includes('listStableMergedTags(pkg.tagPattern, branchRef)'), 'scripts/release-local-stable.mjs: stable recovery must consult the prerelease-filtered list', ); }); @@ -531,16 +740,20 @@ test('stable recovery tracks PyPI gaps when SDK PyPI publishing is enabled', asy 'scripts/release-local-stable.mjs: must keep the SDK PyPI enabled state next to the recovery decision', ); assert.ok( - /name: 'sdk'[\s\S]*\.\.\.\(SDK_PYPI_ENABLED[\s\S]*pythonPackages: SDK_PYTHON_PACKAGES[\s\S]*preparePythonSnapshot: prepareSdkPythonSnapshot/.test(content), + /name: 'sdk'[\s\S]*\.\.\.\(SDK_PYPI_ENABLED[\s\S]*pythonPackages: SDK_PYTHON_PACKAGES[\s\S]*preparePythonSnapshot: prepareSdkPythonSnapshot/.test( + content, + ), 'scripts/release-local-stable.mjs: SDK Python tracking and snapshot recovery must move together behind SDK_PYPI_ENABLED', ); assert.ok( - /function prepareSdkPythonSnapshot[\s\S]*pnpm'[\s\S]*'run'[\s\S]*'generate:all'[\s\S]*build-python-sdk\.mjs/.test(content), + /function prepareSdkPythonSnapshot[\s\S]*pnpm'[\s\S]*'run'[\s\S]*'generate:all'[\s\S]*build-python-sdk\.mjs/.test( + content, + ), 'scripts/release-local-stable.mjs: recovered SDK Python snapshots must regenerate artifacts before building wheels', ); }); -test('release configs keep GitHub prerelease comments gated while Linear uses the dedicated release-comment policy', async () => { +test('release configs publish GitHub releases only for stable versions while Linear keeps prerelease breadcrumbs', async () => { const releasercPaths = [ 'packages/superdoc/.releaserc.cjs', 'packages/fonts/.releaserc.cjs', @@ -568,15 +781,21 @@ test('release configs keep GitHub prerelease comments gated while Linear uses th `${releasercPath}: must use the commit-message Linear sync plugin, not the PR-branch-based external plugin`, ); - assert.ok( - content.includes('const shouldCommentOnRelease = !isPrerelease'), - `${releasercPath}: must define shouldCommentOnRelease = !isPrerelease so the prerelease comment gate is consistent across configs`, - ); - if (usesGithubPlugin) { assert.ok( - content.includes('successCommentCondition: shouldCommentOnRelease ? undefined : false'), - `${releasercPath}: @semantic-release/github must gate successCommentCondition through shouldCommentOnRelease so prereleases don't re-comment on every PR after a stable -> main sync`, + content.includes('const shouldPublishGitHubRelease =') && + content.includes('Boolean(branch)') && + content.includes('!isPrerelease'), + `${releasercPath}: must fail closed when the release branch is unknown and publish GitHub releases only for stable versions`, + ); + assert.ok( + content.includes('if (shouldPublishGitHubRelease)'), + `${releasercPath}: must gate @semantic-release/github behind shouldPublishGitHubRelease`, + ); + assert.equal( + content.includes('successCommentCondition:'), + false, + `${releasercPath}: prereleases must omit the GitHub plugin instead of configuring its success hook`, ); } @@ -587,7 +806,7 @@ test('release configs keep GitHub prerelease comments gated while Linear uses th ); assert.ok( content.includes('addComment: shouldCommentOnLinearRelease'), - `${releasercPath}: Linear addComment must use the dedicated Linear comment gate so prerelease Linear breadcrumbs stay on while GitHub PR comments remain gated`, + `${releasercPath}: Linear addComment must use the dedicated Linear comment gate so prerelease breadcrumbs remain available without GitHub Releases`, ); assert.equal( content.includes('addComment: true'), @@ -635,7 +854,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', '.github/workflows/promote-stable-docs.yml: must trigger off the stable orchestrator workflow', ); assert.equal( - /"๐Ÿ“ฆ Release CLI"|"๐Ÿ“ฆ Release SDK"|"๐Ÿ“ฆ Release MCP"|"๐Ÿ“ฆ Release react"|"๐Ÿ“ฆ Release esign"|"๐Ÿ“ฆ Release template-builder"|"๐Ÿ“ฆ Release vscode-ext"/.test(workflowRunBlock), + /"๐Ÿ“ฆ Release CLI"|"๐Ÿ“ฆ Release SDK"|"๐Ÿ“ฆ Release MCP"|"๐Ÿ“ฆ Release react"|"๐Ÿ“ฆ Release esign"|"๐Ÿ“ฆ Release template-builder"|"๐Ÿ“ฆ Release vscode-ext"/.test( + workflowRunBlock, + ), false, '.github/workflows/promote-stable-docs.yml: must trigger only off the orchestrator, not per-package workflows', ); @@ -653,7 +874,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', assert.ok( promoteWorkflow.includes('Wait for stable release lane to drain') && promoteWorkflow.includes('gh run list') && - promoteWorkflow.includes('"๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder"'), + promoteWorkflow.includes( + '"๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder"', + ), '.github/workflows/promote-stable-docs.yml: must wait for the stable release lane to drain before inspecting origin/stable', ); assert.ok( @@ -678,7 +901,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', '.github/workflows/promote-stable-docs.yml: must refuse to overwrite docs commits that exist only on docs-stable', ); assert.ok( - promoteWorkflow.includes('git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable"'), + promoteWorkflow.includes( + 'git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable"', + ), '.github/workflows/promote-stable-docs.yml: must use force-with-lease when promoting the docs-stable pointer', ); }); diff --git a/scripts/normalize-pyproject-release-artifact.py b/scripts/normalize-pyproject-release-artifact.py new file mode 100644 index 0000000000..7cb9ced53a --- /dev/null +++ b/scripts/normalize-pyproject-release-artifact.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import re +import sys + + +PROJECT_VERSION = re.compile( + r"(?m)^([ \t]*version[ \t]*=[ \t]*)([\"'])([^\"'\r\n]+)\2([ \t]*(?:#.*)?(?:\r?\n|$))" +) +SDK_PLATFORM_PIN = re.compile( + r"([\"'])(superdoc-sdk-cli-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)|windows-x64))(==)([^;\"'\s]+)" +) +NORMALIZED_VERSION = "" + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) + + +content = sys.stdin.read() +version_matches = list(PROJECT_VERSION.finditer(content)) + +if len(version_matches) != 1: + fail(f"expected exactly one project version, found {len(version_matches)}") + +project_version = version_matches[0].group(3) + + +def is_in_toml_comment(match: re.Match[str]) -> bool: + line_start = match.string.rfind("\n", 0, match.start()) + 1 + quote = None + escaped = False + + for character in match.string[line_start : match.start()]: + if quote == '"': + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + elif quote == "'": + if character == quote: + quote = None + elif character == "#": + return True + elif character in {'"', "'"}: + quote = character + + return False + + +for pin_match in SDK_PLATFORM_PIN.finditer(content): + if is_in_toml_comment(pin_match): + continue + if pin_match.group(4) != project_version: + fail( + f"{pin_match.group(2)} pin {pin_match.group(4)} " + f"does not match project version {project_version}" + ) + + +def normalize_project_version(match: re.Match[str]) -> str: + prefix, quote, _, suffix = match.groups() + return f"{prefix}{quote}{NORMALIZED_VERSION}{quote}{suffix}" + + +def normalize_sdk_platform_pin(match: re.Match[str]) -> str: + if is_in_toml_comment(match): + return match.group(0) + quote, package_name, operator, _ = match.groups() + return f"{quote}{package_name}{operator}{NORMALIZED_VERSION}" + + +normalized = PROJECT_VERSION.sub(normalize_project_version, content, count=1) +normalized = SDK_PLATFORM_PIN.sub(normalize_sdk_platform_pin, normalized) +sys.stdout.write(normalized) diff --git a/scripts/semantic-release/linear-commit-sync.cjs b/scripts/semantic-release/linear-commit-sync.cjs index 501101373e..efb5b86322 100644 --- a/scripts/semantic-release/linear-commit-sync.cjs +++ b/scripts/semantic-release/linear-commit-sync.cjs @@ -266,7 +266,7 @@ function getLabelColor(releaseType) { return colors[releaseType] || '#4752C4'; } -function buildReleaseUrl(repositoryUrl, gitTag) { +function buildReleaseUrl(repositoryUrl, gitTag, channel) { if (!repositoryUrl || !gitTag) { return ''; } @@ -274,13 +274,14 @@ function buildReleaseUrl(repositoryUrl, gitTag) { if (!match) { return ''; } - return `https://github.com/${match[1]}/${match[2]}/releases/tag/${gitTag}`; + const tagPath = channel === 'next' ? 'tree' : 'releases/tag'; + return `https://github.com/${match[1]}/${match[2]}/${tagPath}/${gitTag}`; } function formatComment(template, version, channel, packageName, gitTag, repositoryUrl) { const channelText = channel ? `(${channel} channel)` : ''; const packageText = packageName ? `**${packageName}**` : ''; - const releaseUrl = buildReleaseUrl(repositoryUrl, gitTag); + const releaseUrl = buildReleaseUrl(repositoryUrl, gitTag, channel); const releaseLink = releaseUrl ? `[${version}](${releaseUrl})` : version; const tpl = template || 'Released in {package} v{releaseLink} {channel}'; return tpl diff --git a/scripts/semantic-release/linear-commit-sync.test.cjs b/scripts/semantic-release/linear-commit-sync.test.cjs index 02a17116d1..9cf8c28daa 100644 --- a/scripts/semantic-release/linear-commit-sync.test.cjs +++ b/scripts/semantic-release/linear-commit-sync.test.cjs @@ -89,7 +89,7 @@ test('collectIssueIdsFromCommits ignores generated release commits with old note }); -test('formatComment keeps the existing release comment template behavior', () => { +test('formatComment links prerelease comments to the Git tag', () => { assert.equal( formatComment( 'shipped in {package} {releaseLink} {channel}', @@ -99,7 +99,21 @@ test('formatComment keeps the existing release comment template behavior', () => 'v1.2.3', 'https://github.com/superdoc-dev/superdoc.git', ), - 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/releases/tag/v1.2.3) (next channel)', + 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/tree/v1.2.3) (next channel)', + ); +}); + +test('formatComment links stable comments to the GitHub release', () => { + assert.equal( + formatComment( + 'shipped in {package} {releaseLink} {channel}', + '1.2.3', + 'latest', + 'superdoc', + 'v1.2.3', + 'https://github.com/superdoc-dev/superdoc.git', + ), + 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/releases/tag/v1.2.3) (latest channel)', ); });