diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index f5d9ae016f..f0c8ab3174 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -31,6 +31,22 @@ import type { CliOnlyOperation } from '../src/cli/types'; import { CLI_ONLY_OPERATION_DEFINITIONS } from '../src/cli/cli-only-operation-definitions'; import { HOST_PROTOCOL_VERSION, HOST_PROTOCOL_FEATURES, HOST_PROTOCOL_NOTIFICATIONS } from '../src/host/protocol'; +// --------------------------------------------------------------------------- +// SDK surface classification +// --------------------------------------------------------------------------- + +type SdkSurface = 'client' | 'document' | 'internal'; + +const CLIENT_OPERATIONS = new Set(['doc.open', 'doc.describe', 'doc.describeCommand']); +const INTERNAL_OPERATIONS = new Set(['doc.status']); + +function classifySdkSurface(operationId: string): SdkSurface { + if (CLIENT_OPERATIONS.has(operationId)) return 'client'; + if (INTERNAL_OPERATIONS.has(operationId)) return 'internal'; + if (operationId.startsWith('doc.session.')) return 'internal'; + return 'document'; +} + // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- @@ -81,6 +97,7 @@ function buildSdkContract() { // Base fields shared by all operations const entry: Record = { operationId: cliOpId, + sdkSurface: classifySdkSurface(cliOpId), command: metadata.command, commandTokens: [...cliCommandTokens(cliOpId)], category: cliCategory(cliOpId), diff --git a/apps/cli/src/commands/close.ts b/apps/cli/src/commands/close.ts index 998d46eb8a..c4efe27c7e 100644 --- a/apps/cli/src/commands/close.ts +++ b/apps/cli/src/commands/close.ts @@ -31,8 +31,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi 'close', async ({ metadata, paths }) => { const effectiveMetadata = metadata; - const activeSessionId = await getActiveSessionId(); - const wasDefaultSession = activeSessionId === effectiveMetadata.contextId; if (effectiveMetadata.dirty && !mode.discard) { throw new CliError( @@ -44,6 +42,15 @@ export async function runClose(tokens: string[], context: CommandContext): Promi ); } + // Only read/clear the project-global active-session pointer in oneshot + // (CLI) mode. Host mode must never touch this file — it causes + // cross-document contamination between SDK clients. + let wasDefaultSession = false; + if (context.executionMode !== 'host') { + const activeSessionId = await getActiveSessionId(); + wasDefaultSession = activeSessionId === effectiveMetadata.contextId; + } + const result = { command: 'close', data: { @@ -74,5 +81,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi return result; }, context.sessionId, + context.executionMode, ); } diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 47856c4018..5d572b3dde 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -178,7 +178,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const bootstrap = 'bootstrap' in opened ? opened.bootstrap : undefined; let adoptedToHostPool = false; try { - const output = await exportToPath(opened.editor, paths.workingDocPath, true); + await exportToPath(opened.editor, paths.workingDocPath, true); const sourcePath = opened.meta.source === 'path' && opened.meta.path ? resolveSourcePathForMetadata(opened.meta.path) @@ -195,7 +195,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis }); await writeContextMetadata(paths, metadata); - await setActiveSessionId(metadata.contextId); + + // Only update the project-global active-session pointer in oneshot (CLI) mode. + // Host mode must never write this file — it causes cross-document contamination + // when multiple SDK clients share the same project root. + if (context.executionMode !== 'host') { + await setActiveSessionId(metadata.contextId); + } if (context.executionMode === 'host' && context.sessionPool) { context.sessionPool.adoptFromOpen(sessionId, opened, { diff --git a/apps/cli/src/commands/save.ts b/apps/cli/src/commands/save.ts index 314871bf01..ea9c4c2f1e 100644 --- a/apps/cli/src/commands/save.ts +++ b/apps/cli/src/commands/save.ts @@ -140,5 +140,6 @@ export async function runSave(tokens: string[], context: CommandContext): Promis }; }, context.sessionId, + context.executionMode, ); } diff --git a/apps/cli/src/lib/context.ts b/apps/cli/src/lib/context.ts index c0b916bd7d..60ce6f6510 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -8,7 +8,7 @@ import { CliError } from './errors'; import { asRecord, pathExists } from './guards'; import type { CollaborationProfile } from './collaboration'; import { validateSessionId } from './session'; -import type { CliIO, UserIdentity } from './types'; +import type { CliIO, ExecutionMode, UserIdentity } from './types'; const CONTEXT_VERSION = 'v1'; const ACTIVE_SESSION_FILENAME = 'active-session'; @@ -538,16 +538,44 @@ export async function clearContext(paths: ContextPaths): Promise { await rm(paths.contextDir, { recursive: true, force: true }); } +/** + * Resolve the target session id for an operation, respecting execution mode. + * + * - If an explicit session id is provided, return it immediately. + * - In host mode, an explicit session id is **required** — never fall back to + * the project-global active-session file. This prevents cross-document + * contamination between SDK clients sharing the same project root. + * - In oneshot (CLI) mode, fall back to the active-session file as a + * convenience for single-terminal workflows. + */ +export async function resolveSessionId( + sessionId: string | undefined, + executionMode: ExecutionMode | undefined, +): Promise { + if (sessionId) return sessionId; + + if (executionMode === 'host') { + throw new CliError( + 'SESSION_REQUIRED', + 'Host-mode operations require an explicit session id. Use the SDK document handle or pass --session.', + ); + } + + const activeSessionId = await getActiveSessionId(); + if (!activeSessionId) { + throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open " first.'); + } + return activeSessionId; +} + export async function withActiveContext( io: CliIO, command: string, action: (state: { metadata: ContextMetadata; paths: ContextPaths }) => Promise, contextId?: string, + executionMode?: ExecutionMode, ): Promise { - const resolvedContextId = contextId ?? (await getActiveSessionId()); - if (!resolvedContextId) { - throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open " first.'); - } + const resolvedContextId = await resolveSessionId(contextId, executionMode); return withContextLock( io, diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index f83b48e937..4fc9616347 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -2,6 +2,7 @@ export type CliErrorCode = | 'INVALID_ARGUMENT' | 'SESSION_ID_INVALID' | 'SESSION_NOT_FOUND' + | 'SESSION_REQUIRED' | 'UNKNOWN_COMMAND' | 'VALIDATION_ERROR' | 'MISSING_REQUIRED' diff --git a/apps/cli/src/lib/introspection-dispatch.ts b/apps/cli/src/lib/introspection-dispatch.ts index 981eb2ed7d..35218ddd71 100644 --- a/apps/cli/src/lib/introspection-dispatch.ts +++ b/apps/cli/src/lib/introspection-dispatch.ts @@ -226,7 +226,9 @@ const INTROSPECTION_INVOKERS: Partial { - const activeSessionId = await getActiveSessionId(); + // In host mode, do not read or report the project-global active session id. + // It is a CLI-only convenience and has no meaning in host/SDK execution. + const activeSessionId = context.executionMode === 'host' ? null : await getActiveSessionId(); try { return await withActiveContext( @@ -273,9 +275,10 @@ const INTROSPECTION_INVOKERS: Partial { The SDKs do not expose browser event subscriptions. Call `doc.info()` at workflow boundaries — after opening a document, after a batch of mutations, or before saving. ```ts -const doc = await client.open('./contract.docx'); -const info = doc.info(); +const doc = await client.open({ doc: './contract.docx' }); +const info = await doc.info(); console.log( `${info.counts.words} words, ${info.counts.characters} characters, ${info.counts.trackedChanges} tracked changes`, ); diff --git a/apps/docs/document-engine/ai-agents/integrations.mdx b/apps/docs/document-engine/ai-agents/integrations.mdx index d304485cbc..389d777836 100644 --- a/apps/docs/document-engine/ai-agents/integrations.mdx +++ b/apps/docs/document-engine/ai-agents/integrations.mdx @@ -26,7 +26,7 @@ LLM tools are in alpha. Tool names and schemas may change betwe const client = createSuperDocClient(); await client.connect(); - await client.doc.open({ doc: './contract.docx' }); + const doc = await client.open({ doc: './contract.docx' }); // Get tools in Anthropic format, convert to Bedrock toolSpec shape const { tools } = await chooseTools({ provider: 'anthropic' }); @@ -63,14 +63,15 @@ LLM tools are in alpha. Tool names and schemas may change betwe const results = []; for (const block of toolUses) { const { name, input, toolUseId } = block.toolUse; - const result = await dispatchSuperDocTool(client, name, input ?? {}); + const result = await dispatchSuperDocTool(doc, name, input ?? {}); const json = typeof result === 'object' && result !== null ? result : { result }; results.push({ toolResult: { toolUseId, content: [{ json }] } }); } messages.push({ role: 'user', content: results }); } - await client.doc.save(); + await doc.save(); + await doc.close(); await client.dispose(); ``` @@ -85,7 +86,7 @@ LLM tools are in alpha. Tool names and schemas may change betwe client = SuperDocClient() client.connect() - client.doc.open({"doc": "./contract.docx"}) + doc = client.open({"doc": "./contract.docx"}) # Get tools in Anthropic format, convert to Bedrock toolSpec shape sd_tools = choose_tools({"provider": "anthropic"}) @@ -123,14 +124,15 @@ LLM tools are in alpha. Tool names and schemas may change betwe tool_results = [] for block in tool_uses: tu = block["toolUse"] - result = dispatch_superdoc_tool(client, tu["name"], tu.get("input", {})) + result = dispatch_superdoc_tool(doc, tu["name"], tu.get("input", {})) json_result = result if isinstance(result, dict) else {"result": result} tool_results.append( {"toolResult": {"toolUseId": tu["toolUseId"], "content": [{"json": json_result}]}} ) messages.append({"role": "user", "content": tool_results}) - client.doc.save() + doc.save({}) + doc.close({}) client.dispose() ``` diff --git a/apps/docs/document-engine/ai-agents/llm-tools.mdx b/apps/docs/document-engine/ai-agents/llm-tools.mdx index 3ff6b4f5de..b1e718b876 100644 --- a/apps/docs/document-engine/ai-agents/llm-tools.mdx +++ b/apps/docs/document-engine/ai-agents/llm-tools.mdx @@ -28,7 +28,7 @@ Install the SDK, create a client, and wire up an agentic loop. const client = createSuperDocClient(); await client.connect(); - await client.doc.open({ doc: './contract.docx' }); + const doc = await client.open({ doc: './contract.docx' }); const { tools } = await chooseTools({ provider: 'openai' }); const openai = new OpenAI(); @@ -53,13 +53,14 @@ Install the SDK, create a client, and wire up an agentic loop. for (const call of message.tool_calls) { const result = await dispatchSuperDocTool( - client, call.function.name, JSON.parse(call.function.arguments), + doc, call.function.name, JSON.parse(call.function.arguments), ); messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); } } - await client.doc.save({ inPlace: true }); + await doc.save({ inPlace: true }); + await doc.close(); await client.dispose(); ``` @@ -74,9 +75,9 @@ Install the SDK, create a client, and wire up an agentic loop. client = SuperDocClient() client.connect() - client.doc.open(doc="./contract.docx") + doc = client.open({"doc": "./contract.docx"}) - result = choose_tools(provider="openai") + result = choose_tools({"provider": "openai"}) tools = result["tools"] messages = [ @@ -96,7 +97,7 @@ Install the SDK, create a client, and wire up an agentic loop. for call in message.tool_calls: result = dispatch_superdoc_tool( - client, call.function.name, json.loads(call.function.arguments) + doc, call.function.name, json.loads(call.function.arguments) ) messages.append({ "role": "tool", @@ -104,7 +105,8 @@ Install the SDK, create a client, and wire up an agentic loop. "content": json.dumps(result), }) - client.doc.save(in_place=True) + doc.save({"inPlace": True}) + doc.close({}) client.dispose() ``` @@ -128,9 +130,7 @@ Install the SDK, create a client, and wire up an agentic loop. ```python from superdoc import choose_tools - result = choose_tools( - provider="openai", - ) + result = choose_tools({"provider": "openai"}) tools = result["tools"] ``` @@ -165,21 +165,21 @@ Multi-action tools use an `action` argument to select the underlying operation. ```typescript import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - const result = await dispatchSuperDocTool(client, toolName, args); + const result = await dispatchSuperDocTool(doc, toolName, args); ``` ```python from superdoc import dispatch_superdoc_tool - result = dispatch_superdoc_tool(client, tool_name, args) + result = dispatch_superdoc_tool(doc, tool_name, args) ``` ```python from superdoc import dispatch_superdoc_tool_async - result = await dispatch_superdoc_tool_async(client, tool_name, args) + result = await dispatch_superdoc_tool_async(doc, tool_name, args) ``` @@ -278,7 +278,7 @@ Keep it to 3-5 tool calls total when possible. | Function | Description | | --- | --- | | `chooseTools(input)` | Load grouped tool definitions for a provider | -| `dispatchSuperDocTool(client, name, args)` | Execute a tool call against a connected client | +| `dispatchSuperDocTool(doc, name, args)` | Execute a tool call against a bound document handle | | `listTools(provider)` | List all tool definitions for a provider | | `getToolCatalog()` | Load the full tool catalog with metadata | | `getSystemPrompt()` | Read the bundled system prompt for intent tools | diff --git a/apps/docs/document-engine/sdk-diffing.mdx b/apps/docs/document-engine/sdk-diffing.mdx index 41dc1b7fcc..5946729625 100644 --- a/apps/docs/document-engine/sdk-diffing.mdx +++ b/apps/docs/document-engine/sdk-diffing.mdx @@ -39,44 +39,35 @@ const client = new SuperDocClient({ user: { name: 'Review Bot', email: 'bot@example.com' }, }); -await client.doc.open({ +const base = await client.open({ sessionId: 'base', doc: './Doc1.docx', }); -await client.doc.open({ +const target = await client.open({ sessionId: 'target', doc: './Doc2.docx', }); -const targetSnapshot = await client.doc.diff.capture({ - sessionId: 'target', -}); +const targetSnapshot = await target.diff.capture({}); -await client.doc.close({ - sessionId: 'target', -}); +await target.close({}); -const diff = await client.doc.diff.compare({ - sessionId: 'base', +const diff = await base.diff.compare({ targetSnapshot, }); -await client.doc.diff.apply({ - sessionId: 'base', +await base.diff.apply({ diff, changeMode: 'tracked', }); -await client.doc.save({ - sessionId: 'base', +await base.save({ out: './Doc3.docx', force: true, }); -await client.doc.close({ - sessionId: 'base', -}); +await base.close({}); ``` ## Python @@ -91,44 +82,35 @@ async def main(): async with AsyncSuperDocClient( user={"name": "Review Bot", "email": "bot@example.com"} ) as client: - await client.doc.open({ + base = await client.open({ "sessionId": "base", "doc": "./Doc1.docx", }) - await client.doc.open({ + target = await client.open({ "sessionId": "target", "doc": "./Doc2.docx", }) - target_snapshot = await client.doc.diff.capture({ - "sessionId": "target", - }) + target_snapshot = await target.diff.capture({}) - await client.doc.close({ - "sessionId": "target", - }) + await target.close({}) - diff = await client.doc.diff.compare({ - "sessionId": "base", + diff = await base.diff.compare({ "targetSnapshot": target_snapshot, }) - await client.doc.diff.apply({ - "sessionId": "base", + await base.diff.apply({ "diff": diff, "changeMode": "tracked", }) - await client.doc.save({ - "sessionId": "base", + await base.save({ "out": "./Doc3.docx", "force": True, }) - await client.doc.close({ - "sessionId": "base", - }) + await base.close({}) asyncio.run(main()) diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 6f5faf2c39..6258e0cf81 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -50,17 +50,17 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); }); // Open a document - await client.doc.open({ doc: './contract.docx' }); + const doc = await client.open({ doc: './contract.docx' }); // Find and replace text with query + mutation plan - const match = await client.doc.query.match({ + const match = await doc.query.match({ select: { type: 'text', pattern: 'ACME Corp' }, require: 'first', }); const ref = match.items?.[0]?.handle?.ref; if (ref) { - await client.doc.mutations.apply({ + await doc.mutations.apply({ expectedRevision: match.evaluatedRevision, atomic: true, steps: [ @@ -75,8 +75,8 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); } // Save and close - await client.doc.save(); - await client.doc.close(); + await doc.save(); + await doc.close(); ``` @@ -89,17 +89,17 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); }); // Open a document - await client.doc.open({ doc: './contract.docx' }); + const doc = await client.open({ doc: './contract.docx' }); // Find and replace text with query + mutation plan - const match = await client.doc.query.match({ + const match = await doc.query.match({ select: { type: 'text', pattern: 'ACME Corp' }, require: 'first', }); const ref = match.items?.[0]?.handle?.ref; if (ref) { - await client.doc.mutations.apply({ + await doc.mutations.apply({ expectedRevision: match.evaluatedRevision, atomic: true, steps: [ @@ -114,8 +114,8 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); } // Save and close - await client.doc.save(); - await client.doc.close(); + await doc.save(); + await doc.close(); } main(); @@ -131,10 +131,10 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); async def main(): async with AsyncSuperDocClient(default_change_mode="tracked") as client: # Open a document - await client.doc.open({"doc": "./contract.docx"}) + doc = await client.open({"doc": "./contract.docx"}) # Find and replace text with query + mutation plan - match = await client.doc.query.match( + match = await doc.query.match( { "select": {"type": "text", "pattern": "ACME Corp"}, "require": "first", @@ -145,7 +145,7 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); first_item = items[0] if items else {} ref = first_item.get("handle", {}).get("ref") if ref: - await client.doc.mutations.apply( + await doc.mutations.apply( { "expectedRevision": match["evaluatedRevision"], "atomic": True, @@ -161,8 +161,8 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); ) # Save and close - await client.doc.save({"inPlace": True}) - await client.doc.close({}) + await doc.save({"inPlace": True}) + await doc.close({}) asyncio.run(main()) @@ -171,7 +171,7 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); Set `defaultChangeMode: 'tracked'` (Node) or `default_change_mode='tracked'` (Python) to make mutations use tracked changes by default. If you pass `changeMode` on a specific call, that explicit value overrides the default. -The Python SDK also exposes synchronous `SuperDocClient` with the same `doc.*` operations when you prefer non-async code paths. +The Python SDK also exposes synchronous `SuperDocClient` with the same document-handle methods when you prefer non-async code paths. ## Need file-to-file diffing? @@ -198,7 +198,7 @@ By default the SDK attributes edits to a generic "CLI" user. Set `user` on the c -The `user` is injected into every `doc.open` call. If you pass `userName` or `userEmail` on a specific `doc.open`, those per-call values take precedence. +The `user` is injected into every `client.open` call. If you pass `userName` or `userEmail` on a specific `client.open`, those per-call values take precedence. ## Collaboration sessions @@ -206,7 +206,7 @@ Use this when your app already has a live collaboration room (Liveblocks, Hocusp ### Join an existing room -Pass `collabUrl` and `collabDocumentId` to `doc.open`: +Pass `collabUrl` and `collabDocumentId` to `client.open`: @@ -216,12 +216,12 @@ Pass `collabUrl` and `collabDocumentId` to `doc.open`: const client = new SuperDocClient(); await client.connect(); - await client.doc.open({ + const doc = await client.open({ collabUrl: 'ws://localhost:4000', collabDocumentId: 'my-doc-room', }); - await client.doc.insert({ + await doc.insert({ target: { type: 'end' }, content: 'Added by the SDK', }); @@ -236,12 +236,12 @@ Pass `collabUrl` and `collabDocumentId` to `doc.open`: async def main(): async with AsyncSuperDocClient() as client: - await client.doc.open({ + doc = await client.open({ "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", }) - await client.doc.insert({ + await doc.insert({ "target": {"type": "end"}, "content": "Added by the SDK", }) @@ -259,7 +259,7 @@ If the room is empty, pass `doc` together with collaboration params: ```typescript - await client.doc.open({ + const doc = await client.open({ doc: './starting-template.docx', collabUrl: 'ws://localhost:4000', collabDocumentId: 'my-doc-room', @@ -268,7 +268,7 @@ If the room is empty, pass `doc` together with collaboration params: ```python - await client.doc.open({ + doc = await client.open({ "doc": "./starting-template.docx", "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", @@ -311,7 +311,7 @@ The three `onMissing` values: ```typescript // Safe reopen — throws if the room is unexpectedly empty - await client.doc.open({ + const doc = await client.open({ collabUrl: 'ws://localhost:4000', collabDocumentId: 'my-doc-room', onMissing: 'error', @@ -321,7 +321,7 @@ The three `onMissing` values: ```python # Safe reopen — throws if the room is unexpectedly empty - await client.doc.open({ + doc = await client.open({ "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", "onMissing": "error", @@ -332,30 +332,30 @@ The three `onMissing` values: ### Check if the SDK seeded or joined -`doc.open` returns bootstrap details in collaboration mode: +`client.open` returns a bound document handle. In collaboration mode, bootstrap details are available on the handle's initial open result: ```typescript - const result = await client.doc.open({ + const doc = await client.open({ doc: './starting-template.docx', collabUrl: 'ws://localhost:4000', collabDocumentId: 'my-doc-room', }); - console.log(result.bootstrap); + console.log(doc.openResult.bootstrap); // { roomState, bootstrapApplied, bootstrapSource } ``` ```python - result = await client.doc.open({ + doc = await client.open({ "doc": "./starting-template.docx", "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", }) - print(result.get("bootstrap")) + print(doc.open_result.get("bootstrap")) ``` @@ -363,7 +363,7 @@ The three `onMissing` values: {/* SDK_OPERATIONS_START */} ## Available operations -The SDKs expose all operations from the [Document API](/document-api/overview) plus lifecycle and session commands. The tables below are grouped by category. +The SDKs expose all operations from the [Document API](/document-api/overview) plus lifecycle and client commands. `client.open()` returns a bound document handle — all document operations run on that handle. @@ -800,20 +800,20 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the active editor. | | `doc.history.redo` | `history redo` | Redo the most recently undone action in the active editor. | -#### Session +#### Lifecycle | Operation | CLI command | Description | | --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `client.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | | `doc.save` | `save` | Save the current session to the original file or a new path. | | `doc.close` | `close` | Close the active editing session and clean up resources. | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | -| `doc.session.list` | `session list` | List all active editing sessions. | -| `doc.session.save` | `session save` | Persist the current session state. | -| `doc.session.close` | `session close` | Close a specific editing session by ID. | -| `doc.session.setDefault` | `session set-default` | Set the default session for subsequent commands. | + +#### Client + +| Operation | CLI command | Description | +| --- | --- | --- | +| `client.describe` | `describe` | List all available CLI operations and contract metadata. | +| `client.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | @@ -1250,20 +1250,20 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the active editor. | | `doc.history.redo` | `history redo` | Redo the most recently undone action in the active editor. | -#### Session +#### Lifecycle | Operation | CLI command | Description | | --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `client.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | | `doc.save` | `save` | Save the current session to the original file or a new path. | | `doc.close` | `close` | Close the active editing session and clean up resources. | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | -| `doc.session.list` | `session list` | List all active editing sessions. | -| `doc.session.save` | `session save` | Persist the current session state. | -| `doc.session.close` | `session close` | Close a specific editing session by ID. | -| `doc.session.set_default` | `session set-default` | Set the default session for subsequent commands. | + +#### Client + +| Operation | CLI command | Description | +| --- | --- | --- | +| `client.describe` | `describe` | List all available CLI operations and contract metadata. | +| `client.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | @@ -1277,8 +1277,8 @@ The SDKs are request/response wrappers around the CLI. They do **not** expose br - If you are building a browser live counter, use the [SuperEditor events](/core/supereditor/events) or [SuperDoc events](/core/superdoc/events) instead. ```ts -const doc = await client.open('./report.docx'); -const info = doc.info(); +const doc = await client.open({ doc: './report.docx' }); +const info = await doc.info(); console.log( `${info.counts.words} words, ` + `${info.counts.characters} characters, ` + diff --git a/apps/docs/scripts/generate-sdk-overview.ts b/apps/docs/scripts/generate-sdk-overview.ts index e734d93f91..6742540d8e 100644 --- a/apps/docs/scripts/generate-sdk-overview.ts +++ b/apps/docs/scripts/generate-sdk-overview.ts @@ -4,6 +4,14 @@ * Reads the SDK contract JSON and injects a categorized operations table * into the marker block in `apps/docs/document-engine/sdks.mdx`. * + * Key behaviors: + * - Filters out internal operations (sdkSurface === 'internal'). + * - Remaps contract `doc.*` operationIds to SDK-accurate paths using the + * `sdkSurface` field: client-surface ops render as `client.*`, document- + * surface ops render as `doc.*`. + * - Splits the flat `session` category into two rendered sections — + * Lifecycle (open, save, close) and Client (describe, describeCommand). + * * Requires: `apps/cli/generated/sdk-contract.json` to exist on disk. * Run `pnpm run cli:export-sdk-contract` first if it doesn't. */ @@ -46,6 +54,7 @@ function replaceMarkerBlock(content: string, replacement: string): string { interface ContractOperation { operationId: string; + sdkSurface: 'client' | 'document' | 'internal'; command: string; category: string; description: string; @@ -58,6 +67,63 @@ interface SdkContract { operations: Record; } +// --------------------------------------------------------------------------- +// SDK surface mapping +// +// The contract uses `doc.*` operationIds for everything, but the SDK exposes +// operations on two distinct handles: +// +// sdkSurface 'client' → client.open(), client.describe(), ... +// sdkSurface 'document' → doc.save(), doc.format.bold(), ... +// sdkSurface 'internal' → not exposed in the SDK (filtered out) +// +// Session-category operations are split into two rendered sections: +// +// lifecycle — session management: open, save, close +// client — introspection: describe, describeCommand +// --------------------------------------------------------------------------- + +const SURFACE_HANDLE_PREFIX: Record = { + client: 'client', + document: 'doc', +}; + +/** Session operations that manage the document lifecycle (open, save, close). */ +const LIFECYCLE_OPERATION_IDS = new Set(['doc.open', 'doc.save', 'doc.close']); + +// --------------------------------------------------------------------------- +// Contract → renderable transformation +// --------------------------------------------------------------------------- + +interface RenderableOperation { + sdkPath: string; + command: string; + category: string; + description: string; +} + +function sdkMethodPath(operationId: string, sdkSurface: string): string { + const prefix = SURFACE_HANDLE_PREFIX[sdkSurface] ?? 'doc'; + const memberPath = operationId.replace(/^doc\./, ''); + return `${prefix}.${memberPath}`; +} + +function resolveRenderCategory(op: ContractOperation): string { + if (op.category !== 'session') return op.category; + return LIFECYCLE_OPERATION_IDS.has(op.operationId) ? 'lifecycle' : 'client'; +} + +function prepareOperations(raw: ContractOperation[]): RenderableOperation[] { + return raw + .filter((op) => op.sdkSurface !== 'internal') + .map((op) => ({ + sdkPath: sdkMethodPath(op.operationId, op.sdkSurface), + command: op.command, + category: resolveRenderCategory(op), + description: op.description, + })); +} + // --------------------------------------------------------------------------- // Rendering metadata // --------------------------------------------------------------------------- @@ -86,7 +152,8 @@ const CATEGORY_DISPLAY_ORDER = [ 'comments', 'trackChanges', 'history', - 'session', + 'lifecycle', + 'client', ] as const; const CATEGORY_LABELS: Record = { @@ -101,15 +168,16 @@ const CATEGORY_LABELS: Record = { comments: 'Comments', trackChanges: 'Track changes', history: 'History', - session: 'Session', + lifecycle: 'Lifecycle', + client: 'Client', }; // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- -function groupByCategory(operations: ContractOperation[]): Map { - const groups = new Map(); +function groupByCategory(operations: RenderableOperation[]): Map { + const groups = new Map(); for (const op of operations) { const list = groups.get(op.category) ?? []; @@ -129,18 +197,16 @@ function toSnakeCase(value: string): string { .toLowerCase(); } -function operationPathForLanguage(operationId: string, language: SdkLanguage): string { - if (language === 'node') { - return operationId; - } +function formatPathForLanguage(sdkPath: string, language: SdkLanguage): string { + if (language === 'node') return sdkPath; - return operationId + return sdkPath .split('.') .map((token, index) => (index === 0 ? token : toSnakeCase(token))) .join('.'); } -function resolveCategoryOrder(operations: ContractOperation[]): string[] { +function resolveCategoryOrder(operations: RenderableOperation[]): string[] { const availableCategories = Array.from(new Set(operations.map((op) => op.category))); const preferredCategories = CATEGORY_DISPLAY_ORDER.filter((category) => availableCategories.includes(category)); @@ -171,7 +237,7 @@ function escapeTableCell(value: string): string { return value.replace(/\|/g, '\\|').replace(/\n/g, ' '); } -function renderOperationsTable(operations: ContractOperation[], language: SdkLanguage): string { +function renderOperationsTable(operations: RenderableOperation[], language: SdkLanguage): string { const grouped = groupByCategory(operations); const categoryOrder = resolveCategoryOrder(operations); @@ -184,8 +250,8 @@ function renderOperationsTable(operations: ContractOperation[], language: SdkLan const label = humanizeCategoryName(category); const rows = ops .map((op) => { - const operationPath = operationPathForLanguage(op.operationId, language); - return `| \`${operationPath}\` | \`${op.command}\` | ${escapeTableCell(op.description)} |`; + const path = formatPathForLanguage(op.sdkPath, language); + return `| \`${path}\` | \`${op.command}\` | ${escapeTableCell(op.description)} |`; }) .join('\n'); @@ -195,7 +261,7 @@ function renderOperationsTable(operations: ContractOperation[], language: SdkLan return sections.join('\n\n'); } -function renderLanguageTab(operations: ContractOperation[], languageTab: SdkLanguageTab): string { +function renderLanguageTab(operations: RenderableOperation[], languageTab: SdkLanguageTab): string { const table = renderOperationsTable(operations, languageTab.id); return ` @@ -205,13 +271,13 @@ ${table} `; } -function renderMarkerBlock(operations: ContractOperation[]): string { +function renderMarkerBlock(operations: RenderableOperation[]): string { const tabs = SDK_LANGUAGE_TABS.map((languageTab) => renderLanguageTab(operations, languageTab)).join('\n'); return `${MARKER_START} ## Available operations -The SDKs expose all operations from the [Document API](/document-api/overview) plus lifecycle and session commands. The tables below are grouped by category. +The SDKs expose all operations from the [Document API](/document-api/overview) plus lifecycle and client commands. \`client.open()\` returns a bound document handle — all document operations run on that handle. ${tabs} @@ -226,7 +292,7 @@ ${MARKER_END}`; async function main(): Promise { const contractRaw = await readFile(CONTRACT_PATH, 'utf8'); const contract: SdkContract = JSON.parse(contractRaw); - const operations = Object.values(contract.operations); + const operations = prepareOperations(Object.values(contract.operations)); const overviewContent = await readFile(SDK_OVERVIEW_PATH, 'utf8'); const block = renderMarkerBlock(operations); diff --git a/evals/providers/superdoc-agent-gateway.mjs b/evals/providers/superdoc-agent-gateway.mjs index f2ccce434c..25a7026138 100644 --- a/evals/providers/superdoc-agent-gateway.mjs +++ b/evals/providers/superdoc-agent-gateway.mjs @@ -45,26 +45,26 @@ async function openDocument(sdk, docPath, stateDir) { }, }); await client.connect(); - await client.doc.open({ doc: docPath }); - return client; + const doc = await client.open({ doc: docPath }); + return { client, doc }; } -async function closeDocument(client, { save = false } = {}) { - if (save) await client.doc.save().catch(() => {}); - await client.doc.close().catch(() => {}); +async function closeDocument({ client, doc }, { save = false } = {}) { + if (save) await doc.save().catch(() => {}); + await doc.close().catch(() => {}); await client.dispose().catch(() => {}); } // --- Tool conversion --- -function convertTool(fn, sdk, client, toolLog) { +function convertTool(fn, sdk, doc, toolLog) { return tool({ description: fn.description || '', inputSchema: jsonSchema(fn.parameters || { type: 'object', properties: {} }), execute: async (args) => { const cleaned = cleanArgs(args); try { - const result = await sdk.dispatchSuperDocTool(client, fn.name, cleaned); + const result = await sdk.dispatchSuperDocTool(doc, fn.name, cleaned); toolLog.push({ tool: fn.name, args: cleaned, ok: true }); return result; } catch (err) { @@ -75,7 +75,7 @@ function convertTool(fn, sdk, client, toolLog) { }); } -async function buildTools(sdk, client) { +async function buildTools(sdk, doc) { const { tools: sdkTools } = await sdk.chooseTools({ provider: 'vercel' }); const toolLog = []; @@ -83,7 +83,7 @@ async function buildTools(sdk, client) { for (const t of sdkTools) { const fn = t.function; - if (fn?.name) tools[fn.name] = convertTool(fn, sdk, client, toolLog); + if (fn?.name) tools[fn.name] = convertTool(fn, sdk, doc, toolLog); } return { tools, toolLog }; @@ -117,9 +117,9 @@ export default class SuperDocAgentGatewayProvider { const evalId = context?.evaluationId || `eval-${Date.now()}`; const outputPath = keepFile ? resolveOutputPath(evalId, fixture, task) : null; - let client; + let handle; try { - client = await openDocument(sdk, docPath, stateDir); + handle = await openDocument(sdk, docPath, stateDir); } catch (err) { cleanupTemp(docPath, stateDir); return { error: `Failed to open document: ${err.message}` }; @@ -127,11 +127,11 @@ export default class SuperDocAgentGatewayProvider { let tools, toolLog; try { - const result = await buildTools(sdk, client); + const result = await buildTools(sdk, handle.doc); tools = result.tools; toolLog = result.toolLog; } catch (err) { - await closeDocument(client); + await closeDocument(handle); cleanupTemp(docPath, stateDir); return { error: `Failed to build tools: ${err.message}` }; } @@ -146,8 +146,8 @@ export default class SuperDocAgentGatewayProvider { temperature: 0, }); - const documentText = await client.doc.getText(); - await closeDocument(client, { save: keepFile }); + const documentText = await handle.doc.getText(); + await closeDocument(handle, { save: keepFile }); if (keepFile && outputPath) copyFileSync(docPath, outputPath); cleanupTemp(docPath, stateDir); @@ -182,7 +182,7 @@ export default class SuperDocAgentGatewayProvider { writeCache(key, result); return result; } catch (err) { - await closeDocument(client); + await closeDocument(handle); cleanupTemp(docPath, stateDir); return { error: `Agent loop failed: ${err.message}` }; } diff --git a/evals/providers/superdoc-agent.mjs b/evals/providers/superdoc-agent.mjs index b3a3263021..8c22ec75ef 100644 --- a/evals/providers/superdoc-agent.mjs +++ b/evals/providers/superdoc-agent.mjs @@ -40,13 +40,13 @@ async function openDocument(sdk, docPath, stateDir) { env: { SUPERDOC_CLI_STATE_DIR: stateDir }, }); await client.connect(); - await client.doc.open({ doc: docPath }); - return client; + const doc = await client.open({ doc: docPath }); + return { client, doc }; } -async function closeDocument(client, { save = false } = {}) { - if (save) await client.doc.save().catch(() => {}); - await client.doc.close().catch(() => {}); +async function closeDocument({ client, doc }, { save = false } = {}) { + if (save) await doc.save().catch(() => {}); + await doc.close().catch(() => {}); await client.dispose().catch(() => {}); } @@ -64,7 +64,7 @@ async function loadTools(sdk) { // --- Agent loop --- -async function runAgentLoop(sdk, client, activeToolMap, task, model) { +async function runAgentLoop(sdk, doc, activeToolMap, task, model) { const openai = new OpenAI(); const messages = [ { role: 'system', content: SYSTEM_PROMPT }, @@ -92,7 +92,7 @@ async function runAgentLoop(sdk, client, activeToolMap, task, model) { let result; try { - result = await sdk.dispatchSuperDocTool(client, toolName, cleanArgs(toolArgs)); + result = await sdk.dispatchSuperDocTool(doc, toolName, cleanArgs(toolArgs)); } catch (err) { result = { ok: false, error: err.message }; } @@ -138,9 +138,9 @@ export default class SuperDocAgentProvider { const outputPath = keepFile ? resolveOutputPath(evalId, fixture, task) : null; // Open document - let client; + let handle; try { - client = await openDocument(sdk, docPath, stateDir); + handle = await openDocument(sdk, docPath, stateDir); } catch (err) { cleanupTemp(docPath, stateDir); return { error: `Failed to open document: ${err.message}` }; @@ -151,17 +151,17 @@ export default class SuperDocAgentProvider { try { activeToolMap = await loadTools(sdk); } catch (err) { - await closeDocument(client); + await closeDocument(handle); cleanupTemp(docPath, stateDir); return { error: `Failed to load tools: ${err.message}` }; } // Run agent loop try { - const toolLog = await runAgentLoop(sdk, client, activeToolMap, task, model); - const documentText = await client.doc.getText(); + const toolLog = await runAgentLoop(sdk, handle.doc, activeToolMap, task, model); + const documentText = await handle.doc.getText(); - await closeDocument(client, { save: keepFile }); + await closeDocument(handle, { save: keepFile }); if (keepFile && outputPath) copyFileSync(docPath, outputPath); cleanupTemp(docPath, stateDir); @@ -177,7 +177,7 @@ export default class SuperDocAgentProvider { writeCache(key, result); return result; } catch (err) { - await closeDocument(client); + await closeDocument(handle); cleanupTemp(docPath, stateDir); return { error: `Agent loop failed: ${err.message}` }; } diff --git a/examples/ai/bedrock/index.py b/examples/ai/bedrock/index.py index 042d88493b..f784a946ab 100644 --- a/examples/ai/bedrock/index.py +++ b/examples/ai/bedrock/index.py @@ -66,7 +66,7 @@ def main(): shutil.copy2(input_path, output_path) client = SuperDocClient() client.connect() - client.doc.open({"doc": output_path}) + doc = client.open({"doc": output_path}) # 2. Get tools in Anthropic format and convert to Bedrock toolSpec shape sd_tools = choose_tools({"provider": "anthropic"}) @@ -119,7 +119,7 @@ def main(): tool_config["tools"].extend(to_bedrock_tools([t])) result = discovered else: - result = dispatch_superdoc_tool(client, name, tool_use.get("input", {})) + result = dispatch_superdoc_tool(doc, name, tool_use.get("input", {})) tool_results.append(bedrock_tool_result(tool_use["toolUseId"], result)) except Exception as e: @@ -128,7 +128,7 @@ def main(): messages.append({"role": "user", "content": tool_results}) # 4. Save (in-place to the copy) - client.doc.save() + doc.save() client.dispose() print(f"\nSaved to {output_path}") diff --git a/examples/ai/bedrock/index.ts b/examples/ai/bedrock/index.ts index b9286bd277..f3b56128d2 100644 --- a/examples/ai/bedrock/index.ts +++ b/examples/ai/bedrock/index.ts @@ -60,7 +60,7 @@ async function main() { copyFileSync(inputPath, outputPath); const client = createSuperDocClient(); await client.connect(); - await client.doc.open({ doc: outputPath }); + const doc = await client.open({ doc: outputPath }); // 2. Get tools in Anthropic format and convert to Bedrock toolSpec shape const { tools: sdTools } = await chooseTools({ provider: 'anthropic' }); @@ -116,7 +116,7 @@ async function main() { } result = discovered; } else { - result = await dispatchSuperDocTool(client, name!, (input ?? {}) as Record); + result = await dispatchSuperDocTool(doc, name!, (input ?? {}) as Record); } results.push(bedrockToolResult(toolUseId!, result)); @@ -128,7 +128,7 @@ async function main() { } // 4. Save (in-place to the copy) - await client.doc.save(); + await doc.save(); await client.dispose(); console.log(`\nSaved to ${outputPath}`); } diff --git a/examples/collaboration/fastapi/main.py b/examples/collaboration/fastapi/main.py index 0128cc3d3a..4eeb15eba9 100644 --- a/examples/collaboration/fastapi/main.py +++ b/examples/collaboration/fastapi/main.py @@ -43,7 +43,7 @@ async def lifespan(app: FastAPI): logger.info("collaboration token env: %s", COLLAB_TOKEN_ENV) async with AsyncSuperDocClient(watchdog_timeout_ms=WATCHDOG_TIMEOUT_MS) as client: - open_result = await client.doc.open( + doc = await client.open( { "doc": str(DOC_PATH), "collaboration": { @@ -57,14 +57,14 @@ async def lifespan(app: FastAPI): timeout_ms=OPEN_TIMEOUT_MS, ) markdown_content = MARKDOWN_PATH.read_text(encoding="utf-8") - await client.doc.insert({"value": markdown_content, "type": "markdown"}) + await doc.insert({"value": markdown_content, "type": "markdown"}) - app.state.client = client - app.state.open_result = open_result + app.state.doc = doc + app.state.open_result = doc.open_result try: yield finally: - await client.doc.close({}) + await doc.close({}) app = FastAPI(title="SuperDoc FastAPI Collaboration Demo", lifespan=lifespan) @@ -84,18 +84,17 @@ def root() -> dict: @app.get("/status") async def status() -> dict: - return await app.state.client.doc.status({}) + return {"ok": True, "sessionId": app.state.doc.session_id} @app.get("/insert") async def insert(text: str = Query(...)) -> dict: - return await app.state.client.doc.insert({"value": text}) + return await app.state.doc.insert({"value": text}) @app.get("/markdown") async def markdown() -> HTMLResponse: - doc_api = app.state.client.doc - markdown_result = await doc_api.get_markdown() + markdown_result = await app.state.doc.get_markdown() md = markdown_result if not isinstance(md, str): md = str(md) @@ -112,7 +111,7 @@ async def markdown() -> HTMLResponse: @app.get("/download") async def download() -> FileResponse: DOWNLOAD_PATH.parent.mkdir(parents=True, exist_ok=True) - await app.state.client.doc.save({"out": str(DOWNLOAD_PATH), "force": True}) + await app.state.doc.save({"out": str(DOWNLOAD_PATH), "force": True}) return FileResponse( path=str(DOWNLOAD_PATH), media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", diff --git a/examples/collaboration/node-sdk/server.mjs b/examples/collaboration/node-sdk/server.mjs index fc0046e5e6..8ad89f7703 100644 --- a/examples/collaboration/node-sdk/server.mjs +++ b/examples/collaboration/node-sdk/server.mjs @@ -25,6 +25,8 @@ const PORT = Number(process.env.PORT ?? 8001); /** @type {SuperDocClient | null} */ let client = null; +/** @type {import('@superdoc-dev/sdk').SuperDocDocument | null} */ +let doc = null; let openResult = null; let initialized = false; let shuttingDown = false; @@ -47,7 +49,7 @@ async function ensureInitialized() { client = new SuperDocClient({ watchdogTimeoutMs: WATCHDOG_TIMEOUT_MS }); await client.connect(); - openResult = await client.doc.open( + doc = await client.open( { doc: DOC_PATH, collaboration: { @@ -60,9 +62,10 @@ async function ensureInitialized() { }, { timeoutMs: OPEN_TIMEOUT_MS }, ); + openResult = doc.openResult; const markdownContent = await readFile(MARKDOWN_PATH, 'utf8'); - await client.doc.insert({ value: markdownContent, type: 'markdown' }); + await doc.insert({ value: markdownContent, type: 'markdown' }); initialized = true; } @@ -70,9 +73,9 @@ async function shutdown() { if (shuttingDown) return; shuttingDown = true; - if (!client) return; + if (!doc) return; try { - await client.doc.close({}); + await doc.close({}); } catch (error) { console.error('[node-sdk] doc.close failed:', error); } @@ -120,7 +123,7 @@ async function handleRequest(req, res) { } if (url.pathname === '/status') { - sendJson(res, 200, await client.doc.status({})); + sendJson(res, 200, { ok: true, sessionId: doc.sessionId }); return; } @@ -130,12 +133,12 @@ async function handleRequest(req, res) { sendJson(res, 400, { ok: false, error: 'Missing query param: text' }); return; } - sendJson(res, 200, await client.doc.insert({ value: text })); + sendJson(res, 200, await doc.insert({ value: text })); return; } if (url.pathname === '/markdown') { - const markdown = await client.doc.getMarkdown({}); + const markdown = await doc.getMarkdown({}); const html = ` @@ -158,7 +161,7 @@ async function handleRequest(req, res) { if (url.pathname === '/download') { await mkdir(dirname(DOWNLOAD_PATH), { recursive: true }); - await client.doc.save({ out: DOWNLOAD_PATH, force: true }); + await doc.save({ out: DOWNLOAD_PATH, force: true }); res.writeHead(200, { 'content-type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', diff --git a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts index 14be6fbc2d..f06b895fef 100644 --- a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts +++ b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts @@ -40,6 +40,7 @@ type Contract = { skipAsATool?: boolean; intentGroup?: string; intentAction?: string; + sdkSurface?: 'client' | 'document' | 'internal' | string; } >; }; @@ -56,6 +57,44 @@ type IntentCatalog = { }>; }; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function extractToolSchema(tool: Record): Record { + if (isRecord(tool.inputSchema)) return tool.inputSchema; + if (isRecord(tool.parameters)) return tool.parameters; + if (isRecord(tool.input_schema)) return tool.input_schema; + if (isRecord(tool.function) && isRecord(tool.function.parameters)) return tool.function.parameters; + + throw new Error(`Unable to extract tool schema from ${JSON.stringify(tool).slice(0, 200)}`); +} + +function collectForbiddenSchemaKeys( + node: unknown, + forbiddenKeys: ReadonlySet, + path: string[] = [], + matches: string[] = [], +): string[] { + if (Array.isArray(node)) { + node.forEach((value, index) => { + collectForbiddenSchemaKeys(value, forbiddenKeys, [...path, String(index)], matches); + }); + return matches; + } + + if (!isRecord(node)) return matches; + + for (const [key, value] of Object.entries(node)) { + if (forbiddenKeys.has(key)) { + matches.push([...path, key].join('.')); + } + collectForbiddenSchemaKeys(value, forbiddenKeys, [...path, key], matches); + } + + return matches; +} + describe('Contract integrity', () => { let contract: Contract; @@ -209,6 +248,48 @@ describe('Intent tool catalog integrity', () => { } }); + test('all catalog operations are document-surface operations', async () => { + const contract = await loadJson(CONTRACT_PATH); + const catalog = await loadJson(CATALOG_PATH); + + for (const tool of catalog.tools) { + for (const op of tool.operations) { + expect(contract.operations[op.operationId]?.sdkSurface).toBe('document'); + } + } + }); + + test('tool schemas exclude doc and sessionId in catalog and provider bundles', async () => { + const forbiddenKeys = new Set(['doc', 'sessionId']); + const providers = ['openai', 'anthropic', 'vercel', 'generic']; + const catalog = await loadJson(CATALOG_PATH); + + for (const tool of catalog.tools) { + expect(collectForbiddenSchemaKeys(tool.inputSchema, forbiddenKeys)).toEqual([]); + } + + for (const provider of providers) { + const bundle = await loadJson<{ tools: Array> }>( + path.join(REPO_ROOT, `packages/sdk/tools/tools.${provider}.json`), + ); + for (const tool of bundle.tools) { + expect(collectForbiddenSchemaKeys(extractToolSchema(tool), forbiddenKeys)).toEqual([]); + } + } + }); + + test('client/internal intent-annotated operations never reach the catalog', async () => { + const contract = await loadJson(CONTRACT_PATH); + const catalog = await loadJson(CATALOG_PATH); + const catalogOperationIds = new Set(catalog.tools.flatMap((tool) => tool.operations.map((op) => op.operationId))); + + for (const op of Object.values(contract.operations)) { + if (!op.intentGroup) continue; + if (op.sdkSurface === 'document') continue; + expect(catalogOperationIds.has(op.operationId)).toBe(false); + } + }); + test('system prompt file exists and is non-empty', async () => { const promptPath = path.join(REPO_ROOT, 'packages/sdk/tools/system-prompt.md'); const content = await readFile(promptPath, 'utf8'); diff --git a/packages/sdk/codegen/src/generate-intent-tools.mjs b/packages/sdk/codegen/src/generate-intent-tools.mjs index 6e16631f12..28b9032514 100644 --- a/packages/sdk/codegen/src/generate-intent-tools.mjs +++ b/packages/sdk/codegen/src/generate-intent-tools.mjs @@ -1,5 +1,5 @@ import path from 'node:path'; -import { loadContract, REPO_ROOT, writeGeneratedFile } from './shared.mjs'; +import { loadContract, REPO_ROOT, stripBoundParams, writeGeneratedFile } from './shared.mjs'; const TOOLS_OUTPUT_DIR = path.join(REPO_ROOT, 'packages/sdk/tools'); @@ -103,7 +103,10 @@ function buildInputSchemaFromParams(operation) { const properties = {}; const required = []; - for (const param of operation.params ?? []) { + // Strip doc/sessionId — the document handle manages targeting. + const params = stripBoundParams(operation.params); + + for (const param of params) { if (param.agentVisible === false) continue; let schema; @@ -198,6 +201,8 @@ function buildIntentTools(contract) { const groups = new Map(); for (const [operationId, operation] of Object.entries(contract.operations)) { if (operation.skipAsATool) continue; + // Tool dispatch targets a document handle — only document-surface operations qualify. + if (operation.sdkSurface !== 'document') continue; if (!operation.intentGroup) continue; const group = operation.intentGroup; diff --git a/packages/sdk/codegen/src/generate-node.mjs b/packages/sdk/codegen/src/generate-node.mjs index 040b3cbb56..28b8f097c5 100644 --- a/packages/sdk/codegen/src/generate-node.mjs +++ b/packages/sdk/codegen/src/generate-node.mjs @@ -2,11 +2,13 @@ import path from 'node:path'; import { camelCase, createOperationTree, + filterOperationsBySurface, loadContract, pascalCase, resolveRef, REPO_ROOT, sanitizeOperationId, + stripBoundParams, toNodeType, writeGeneratedFile, } from './shared.mjs'; @@ -213,18 +215,77 @@ function renderTreeNode(treeNode, paramTypeMap, resultTypeMap, indent = ' ') return rendered.join('\n'); } +// --------------------------------------------------------------------------- +// Bound param interface generation (strips doc and sessionId) +// --------------------------------------------------------------------------- + +function generateBoundParamInterface(operationId, operation, $defs) { + const name = `Doc${pascalCase(sanitizeOperationId(operationId))}BoundParams`; + const boundParams = stripBoundParams(operation.params); + const lines = [`export interface ${name} {`]; + + for (const param of boundParams) { + const opt = param.required ? '' : '?'; + let paramType; + if (param.type === 'json' && param.schema) { + paramType = toTsType(param.schema, ' ', $defs); + } else { + paramType = toNodeType(param.type); + } + lines.push(` ${param.name}${opt}: ${paramType};`); + } + + lines.push('}'); + return { name, source: lines.join('\n') }; +} + +// --------------------------------------------------------------------------- +// Bound tree rendering (same as raw but uses bound param types) +// --------------------------------------------------------------------------- + +function renderBoundTreeNode(treeNode, paramTypeMap, resultTypeMap, indent = ' ') { + const entries = Object.entries(treeNode); + const rendered = entries.map(([key, value]) => { + if (value.__operation) { + const op = value.__operation; + const typeName = paramTypeMap.get(op.id); + const resultTypeName = resultTypeMap.get(op.id); + const boundParams = stripBoundParams(op.params ?? []); + const hasRequired = boundParams.some((p) => p.required); + const paramsArg = hasRequired ? `params: ${typeName}` : `params: ${typeName} = {}`; + const envelopeKey = ENVELOPE_KEY_BY_OPERATION_ID[op.id]; + if (envelopeKey) { + if (STRING_ENVELOPE_KEY_BY_OPERATION_ID[op.id]) { + return `${indent}${camelCase(key)}: async (${paramsArg}, options?: InvokeOptions): Promise<${resultTypeName}> => unwrapStringEnvelope(await runtime.invoke(CONTRACT.operations[${JSON.stringify(op.id)}], params as unknown as Record, options), ${JSON.stringify(envelopeKey)}),`; + } + return `${indent}${camelCase(key)}: async (${paramsArg}, options?: InvokeOptions): Promise<${resultTypeName}> => unwrapEnvelope<${resultTypeName}>(await runtime.invoke(CONTRACT.operations[${JSON.stringify(op.id)}], params as unknown as Record, options), ${JSON.stringify(envelopeKey)}),`; + } + return `${indent}${camelCase(key)}: (${paramsArg}, options?: InvokeOptions) => runtime.invoke<${resultTypeName}>(CONTRACT.operations[${JSON.stringify(op.id)}], params as unknown as Record, options),`; + } + + const nested = renderBoundTreeNode(value, paramTypeMap, resultTypeMap, `${indent} `); + return `${indent}${camelCase(key)}: {\n${nested}\n${indent}},`; + }); + + return rendered.join('\n'); +} + // --------------------------------------------------------------------------- // client.ts generation // --------------------------------------------------------------------------- function generateClientTs(contract) { const $defs = contract.$defs; + const allOperations = contract.operations; + const documentOperations = filterOperationsBySurface(allOperations, 'document'); + + // Raw param/result types for all operations (used by internal raw API) const paramInterfaces = []; const resultTypes = []; const paramTypeMap = new Map(); const resultTypeMap = new Map(); - for (const [operationId, operation] of Object.entries(contract.operations)) { + for (const [operationId, operation] of Object.entries(allOperations)) { const { name: pName, source: pSource } = generateParamInterface(operationId, operation, $defs); paramTypeMap.set(operationId, pName); paramInterfaces.push(pSource); @@ -234,15 +295,30 @@ function generateClientTs(contract) { resultTypes.push(rSource); } - const tree = createOperationTree(contract.operations); - const treeSource = renderTreeNode(tree, paramTypeMap, resultTypeMap); + // Bound param types for document-surface operations only (strip doc/sessionId) + const boundParamInterfaces = []; + const boundParamTypeMap = new Map(); + + for (const [operationId, operation] of Object.entries(documentOperations)) { + const { name: bpName, source: bpSource } = generateBoundParamInterface(operationId, operation, $defs); + boundParamTypeMap.set(operationId, bpName); + boundParamInterfaces.push(bpSource); + } + + // Raw operation tree (all operations — internal use only) + const rawTree = createOperationTree(allOperations); + const rawTreeSource = renderTreeNode(rawTree, paramTypeMap, resultTypeMap); + + // Bound operation tree (document-surface only — public document handle API) + const boundTree = createOperationTree(documentOperations); + const boundTreeSource = renderBoundTreeNode(boundTree, boundParamTypeMap, resultTypeMap); return [ '/* eslint-disable */', '// Auto-generated by packages/sdk/codegen/src/generate-node.mjs', '', "import { CONTRACT } from './contract.js';", - "import type { SuperDocRuntime, InvokeOptions } from '../runtime/process.js';", + "import type { RuntimeInvoker, InvokeOptions } from '../runtime/process.js';", '', '/** Extract a payload value from a CLI response envelope like `{ document, result: {...} }`. */', 'function unwrapEnvelope(value: unknown, key: string): T {', @@ -260,18 +336,46 @@ function generateClientTs(contract) { ' return extracted as string;', '}', '', + '// ---------------------------------------------------------------------------', + '// Raw param/result types (all operations)', + '// ---------------------------------------------------------------------------', + '', paramInterfaces.join('\n\n'), '', resultTypes.join('\n\n'), '', - 'export function createDocApi(runtime: SuperDocRuntime) {', + '// ---------------------------------------------------------------------------', + '// Bound param types (document-surface only — doc/sessionId stripped)', + '// ---------------------------------------------------------------------------', + '', + boundParamInterfaces.join('\n\n'), + '', + '// ---------------------------------------------------------------------------', + '// Raw API (all operations — internal use only)', + '// ---------------------------------------------------------------------------', + '', + '/** @internal Raw operation tree for SDK internals. Use createBoundDocApi for public document handles. */', + 'export function createDocApi(runtime: RuntimeInvoker) {', ' return {', - treeSource, + rawTreeSource, ' };', '}', '', 'export type SuperDocDocApi = ReturnType;', '', + '// ---------------------------------------------------------------------------', + '// Bound API (document-surface only — public document handle)', + '// ---------------------------------------------------------------------------', + '', + '/** Bound document operation tree. The runtime injects sessionId; callers never pass doc or sessionId. */', + 'export function createBoundDocApi(runtime: RuntimeInvoker) {', + ' return {', + boundTreeSource, + ' };', + '}', + '', + 'export type BoundDocApi = ReturnType;', + '', ].join('\n'); } diff --git a/packages/sdk/codegen/src/generate-python.mjs b/packages/sdk/codegen/src/generate-python.mjs index 0024ea6331..12e07b8e2c 100644 --- a/packages/sdk/codegen/src/generate-python.mjs +++ b/packages/sdk/codegen/src/generate-python.mjs @@ -2,11 +2,13 @@ import path from 'node:path'; import { camelCase, createOperationTree, + filterOperationsBySurface, loadContract, pascalCase, resolveRef, REPO_ROOT, sanitizeOperationId, + stripBoundParams, writeGeneratedFile, } from './shared.mjs'; @@ -133,6 +135,18 @@ function buildParamsObjectSpec(operation) { return { type: 'object', properties, required }; } +function buildBoundParamsObjectSpec(operation) { + const properties = {}; + const required = []; + + for (const param of stripBoundParams(operation.params)) { + properties[param.name] = paramTypeSpec(param); + if (param.required) required.push(param.name); + } + + return { type: 'object', properties, required }; +} + // --------------------------------------------------------------------------- // Response and param type generation // --------------------------------------------------------------------------- @@ -297,16 +311,49 @@ function renderAllClasses(treeNode, pathParts, asyncMode, resultTypeMap, paramTy // client.py generation // --------------------------------------------------------------------------- +function generateBoundParamTypes(operations, generatedClasses, classBlocks, $defs) { + const map = new Map(); + + for (const [operationId, operation] of Object.entries(operations)) { + const paramsClassName = `Doc${pascalCase(sanitizeOperationId(operationId))}BoundParams`; + const paramsType = toPythonType( + buildBoundParamsObjectSpec(operation), + paramsClassName, + generatedClasses, + classBlocks, + $defs, + ); + map.set(operationId, paramsType); + } + + return map; +} + function generateClientPy(contract) { const $defs = contract.$defs; - const tree = createOperationTree(contract.operations); + const allOperations = contract.operations; + const documentOperations = filterOperationsBySurface(allOperations, 'document'); const generatedClasses = new Set(); const classBlocks = []; - const resultTypeMap = generateResponseTypes(contract.operations, generatedClasses, classBlocks, $defs); - const paramTypeMap = generateParamTypes(contract.operations, generatedClasses, classBlocks, $defs); + + // Raw types for all operations (internal) + const resultTypeMap = generateResponseTypes(allOperations, generatedClasses, classBlocks, $defs); + const paramTypeMap = generateParamTypes(allOperations, generatedClasses, classBlocks, $defs); + + // Bound types for document-surface operations (public) + const boundParamTypeMap = generateBoundParamTypes(documentOperations, generatedClasses, classBlocks, $defs); + const sharedTypes = classBlocks.join('\n\n'); - const syncClasses = renderAllClasses(tree, ['doc'], false, resultTypeMap, paramTypeMap); - const asyncClasses = renderAllClasses(tree, ['doc'], true, resultTypeMap, paramTypeMap); + + // Raw operation trees (all operations — internal) + const rawTree = createOperationTree(allOperations); + const rawSyncClasses = renderAllClasses(rawTree, ['doc'], false, resultTypeMap, paramTypeMap); + const rawAsyncClasses = renderAllClasses(rawTree, ['doc'], true, resultTypeMap, paramTypeMap); + + // Bound operation trees (document-surface only — public) + const boundTree = createOperationTree(documentOperations); + const boundSyncClasses = renderAllClasses(boundTree, ['bound', 'doc'], false, resultTypeMap, boundParamTypeMap); + const boundAsyncClasses = renderAllClasses(boundTree, ['bound', 'doc'], true, resultTypeMap, boundParamTypeMap); return [ '# Auto-generated by packages/sdk/codegen/src/generate-python.mjs', @@ -329,9 +376,21 @@ function generateClientPy(contract) { '', sharedTypes, '', - syncClasses, + '# ---------------------------------------------------------------------------', + '# Raw API (all operations — internal use only)', + '# ---------------------------------------------------------------------------', + '', + rawSyncClasses, + '', + rawAsyncClasses, + '', + '# ---------------------------------------------------------------------------', + '# Bound API (document-surface only — public document handle)', + '# ---------------------------------------------------------------------------', + '', + boundSyncClasses, '', - asyncClasses, + boundAsyncClasses, '', ].join('\n'); } @@ -349,7 +408,11 @@ export async function generatePythonSdk(contract) { writeGeneratedFile(path.join(PYTHON_GENERATED_DIR, 'client.py'), clientContent), writeGeneratedFile( path.join(PYTHON_GENERATED_DIR, '__init__.py'), - 'from .client import _SyncDocApi, _AsyncDocApi\n', + [ + 'from .client import _SyncDocApi, _AsyncDocApi', + 'from .client import _SyncBoundDocApi, _AsyncBoundDocApi', + '', + ].join('\n'), ), ]); } diff --git a/packages/sdk/codegen/src/shared.mjs b/packages/sdk/codegen/src/shared.mjs index b009d3e20c..5147195268 100644 --- a/packages/sdk/codegen/src/shared.mjs +++ b/packages/sdk/codegen/src/shared.mjs @@ -107,3 +107,33 @@ export function createOperationTree(operations) { return root; } + +// --------------------------------------------------------------------------- +// SDK surface filtering helpers +// --------------------------------------------------------------------------- + +/** Param names that are injected by the bound document handle, not by the caller. */ +const BOUND_INJECTED_PARAMS = new Set(['doc', 'sessionId']); + +/** + * Filter operations by their sdkSurface classification. + * Returns a new Record containing only matching operations. + */ +export function filterOperationsBySurface(operations, surface) { + const filtered = {}; + for (const [operationId, operation] of Object.entries(operations)) { + if (operation.sdkSurface === surface) { + filtered[operationId] = operation; + } + } + return filtered; +} + +/** + * Strip bound-injected params (doc, sessionId) from an operation's params array. + * Used for generating bound document handle types where the handle injects these. + */ +export function stripBoundParams(params) { + return (params ?? []).filter((p) => !BOUND_INJECTED_PARAMS.has(p.name)); +} + diff --git a/packages/sdk/langs/node/README.md b/packages/sdk/langs/node/README.md index 6aff4e2f25..84b93ea9d2 100644 --- a/packages/sdk/langs/node/README.md +++ b/packages/sdk/langs/node/README.md @@ -36,20 +36,26 @@ import { createSuperDocClient } from '@superdoc-dev/sdk'; const client = createSuperDocClient(); await client.connect(); -await client.doc.open({ doc: './contract.docx' }); +const doc = await client.open({ doc: './contract.docx' }); -const info = await client.doc.info(); +const info = await doc.info(); console.log(info.counts); -const results = await client.doc.find({ type: 'text', pattern: 'termination' }); - -await client.doc.replace({ - target: results.items[0].context.target, - text: 'expiration', +const match = await doc.query.match({ + select: { type: 'text', pattern: 'termination' }, + require: 'first', }); -await client.doc.save({ inPlace: true }); -await client.doc.close(); +const target = match.items?.[0]?.target; +if (target) { + await doc.replace({ + target, + text: 'expiration', + }); +} + +await doc.save({ inPlace: true }); +await doc.close(); await client.dispose(); ``` @@ -65,29 +71,29 @@ await client.connect(); // start the host process await client.dispose(); // shut down gracefully ``` -All document operations are on `client.doc`: +Open documents from the client, then operate on the returned handle: ```ts -client.doc.open(params) -client.doc.find(params) -client.doc.insert(params) -// ... etc +const doc = await client.open(params) +await doc.find(params) +await doc.insert(params) +await doc.save(params) +await doc.close(params) ``` ### Operations | Category | Operations | |----------|-----------| -| **Query** | `find`, `getNode`, `getNodeById`, `info` | +| **Query** | `find`, `query.match`, `getNode`, `getNodeById`, `info` | | **Mutation** | `insert`, `replace`, `delete` | -| **Format** | `format.bold`, `format.italic`, `format.underline`, `format.strikethrough` | +| **Format** | `format.bold`, `format.italic`, `format.underline`, `format.strike` | | **Create** | `create.paragraph` | | **Lists** | `lists.list`, `lists.get`, `lists.insert`, `lists.create`, `lists.attach`, `lists.detach`, `lists.indent`, `lists.outdent`, `lists.join`, `lists.separate`, `lists.setLevel`, `lists.setValue`, `lists.continuePrevious`, `lists.setLevelRestart`, `lists.convertToText`, `lists.canJoin`, `lists.canContinuePrevious` | | **Comments** | `comments.create`, `comments.patch`, `comments.delete`, `comments.get`, `comments.list` | | **Track Changes** | `trackChanges.list`, `trackChanges.get`, `trackChanges.decide` | -| **Lifecycle** | `open`, `save`, `close` | -| **Session** | `session.list`, `session.save`, `session.close`, `session.setDefault` | -| **Introspection** | `status`, `describe`, `describeCommand` | +| **Lifecycle** | `client.open`, `doc.save`, `doc.close` | +| **Client** | `client.describe`, `client.describeCommand` | ### AI Tool Integration @@ -109,7 +115,8 @@ const { tools, meta } = await chooseTools({ const catalog = await getToolCatalog(); // Dispatch a tool call from the AI model -const result = await dispatchSuperDocTool(client, toolName, args); +const doc = await client.open({ doc: './contract.docx' }); +const result = await dispatchSuperDocTool(doc, toolName, args); ``` The current catalog contains 9 grouped tools: @@ -121,7 +128,7 @@ Multi-action tools use an `action` field to select the underlying operation. Sin |----------|-------------| | `chooseTools(input)` | Load grouped tool definitions for a provider | | `listTools(provider)` | List all tool definitions for a provider | -| `dispatchSuperDocTool(client, toolName, args)` | Execute a tool call against a client | +| `dispatchSuperDocTool(doc, toolName, args)` | Execute a tool call against a bound document handle | | `getToolCatalog()` | Load the grouped tool catalog with metadata | | `getSystemPrompt()` | Read the bundled system prompt for intent tools | diff --git a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts new file mode 100644 index 0000000000..a6a3338587 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import type { BoundDocApi } from '../generated/client.js'; +import { SuperDocDocument } from '../index.ts'; +import { SuperDocCliError } from '../runtime/errors.js'; +import { dispatchSuperDocTool } from '../tools.ts'; + +describe('SuperDocDocument', () => { + test('exposes generated bound operations on the handle root', () => { + const boundRuntime = { + invoke: async () => ({}), + markClosed: () => {}, + }; + const client = { removeHandle: () => {} }; + + const doc = new SuperDocDocument(boundRuntime as any, 'session-1', { contextId: 'session-1' }, client as any); + + expect(typeof doc.getMarkdown).toBe('function'); + expect(typeof doc.query.match).toBe('function'); + expect('api' in (doc as unknown as Record)).toBe(false); + }); +}); + +describe('dispatchSuperDocTool', () => { + test('dispatches against root-bound document methods', async () => { + const calls: unknown[] = []; + const args = { select: { type: 'text', pattern: 'termination' } }; + const documentHandle = { + query: { + match: async (args: unknown) => { + calls.push(args); + return { ok: true }; + }, + }, + } as unknown as BoundDocApi; + + const result = await dispatchSuperDocTool(documentHandle, 'superdoc_search', args); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([args]); + }); + + test('rejects legacy doc/session targeting args', async () => { + const documentHandle = { + query: { + match: async () => ({ ok: true }), + }, + } as unknown as BoundDocApi; + + try { + await dispatchSuperDocTool(documentHandle, 'superdoc_search', { doc: './contract.docx' }); + throw new Error('Expected dispatchSuperDocTool to reject legacy doc/session args.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + expect((error as SuperDocCliError).code).toBe('INVALID_ARGUMENT'); + } + }); +}); diff --git a/packages/sdk/langs/node/src/helpers/format.ts b/packages/sdk/langs/node/src/helpers/format.ts index 8c5d52ee0c..877a953c30 100644 --- a/packages/sdk/langs/node/src/helpers/format.ts +++ b/packages/sdk/langs/node/src/helpers/format.ts @@ -12,15 +12,16 @@ * * const client = createSuperDocClient(); * await client.connect(); + * const doc = await client.open({ doc: './file.docx' }); * * // Apply bold ON: - * await formatBold(client.doc, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }); + * await formatBold(doc.format.apply, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }); * * // Apply explicit bold OFF (override style inheritance): - * await unformatBold(client.doc, { blockId: 'p1', start: 0, end: 5 }); + * await unformatBold(doc.format.apply, { blockId: 'p1', start: 0, end: 5 }); * * // Clear direct bold formatting (inherit from style cascade): - * await clearBold(client.doc, { blockId: 'p1', start: 0, end: 5 }); + * await clearBold(doc.format.apply, { blockId: 'p1', start: 0, end: 5 }); * ``` */ @@ -30,16 +31,12 @@ import type { InvokeOptions, OperationSpec } from '../runtime/transport-common.j * Minimal operation spec for `format.apply`. Used to invoke the canonical * operation through the runtime without depending on generated code. * - * Only canonical params are listed here. Flat-flag shortcuts (blockId, - * start, end) are accepted via FormatHelperParams and normalized into - * a `target` object before invoke. + * doc and sessionId are omitted — the bound document handle injects them. */ const FORMAT_APPLY_SPEC: OperationSpec = { operationId: 'doc.format.apply', commandTokens: ['format', 'apply'], params: [ - { name: 'doc', kind: 'doc', type: 'string' }, - { name: 'sessionId', kind: 'doc', flag: 'session', type: 'string' }, { name: 'target', kind: 'jsonFlag', type: 'json' }, { name: 'inline', kind: 'jsonFlag', type: 'json' }, { name: 'dryRun', kind: 'flag', type: 'boolean' }, @@ -49,8 +46,6 @@ const FORMAT_APPLY_SPEC: OperationSpec = { }; export interface FormatHelperParams { - doc?: string; - sessionId?: string; target?: { kind: 'text'; blockId: string; range: { start: number; end: number } }; /** Flat-flag shorthand for target.blockId (normalized before dispatch). */ blockId?: string; @@ -64,9 +59,8 @@ export interface FormatHelperParams { } /** - * Generic invoke function that works with the SuperDocRuntime. - * The doc API proxy created by `createDocApi(runtime)` exposes generated methods, - * but helpers call the runtime directly for forward-compatibility. + * Generic invoke function that works with a bound document handle runtime. + * Accepts the same signature as SuperDocRuntime.invoke. */ type RuntimeInvokeFn = ( operation: OperationSpec, diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index ac62b04834..3656ff529d 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -1,29 +1,232 @@ -import { createDocApi } from './generated/client.js'; -import { SuperDocRuntime, type SuperDocClientOptions } from './runtime/process.js'; +import { + createDocApi, + createBoundDocApi, + type BoundDocApi, + type DocCloseBoundParams, + type DocCloseResult, + type DocSaveBoundParams, + type DocSaveResult, +} from './generated/client.js'; +import { CONTRACT } from './generated/contract.js'; +import { + SuperDocRuntime, + type SuperDocClientOptions, + type InvokeOptions, + type OperationSpec, + type RuntimeInvoker, +} from './runtime/process.js'; +import { SuperDocCliError } from './runtime/errors.js'; + +// --------------------------------------------------------------------------- +// Session-bound runtime wrapper +// --------------------------------------------------------------------------- /** - * High-level client for interacting with SuperDoc documents via the CLI. + * Wraps a raw runtime and injects a fixed sessionId into every invoke call. + * Implements RuntimeInvoker so generated code can use it directly. * - * Provides a typed `doc` API for opening, querying, and mutating documents. - * Call {@link connect} before operations and {@link dispose} when finished - * to manage the host process lifecycle. + * @internal + */ +class BoundRuntime implements RuntimeInvoker { + private readonly runtime: SuperDocRuntime; + private readonly sessionId: string; + private closed = false; + + constructor(runtime: SuperDocRuntime, sessionId: string) { + this.runtime = runtime; + this.sessionId = sessionId; + } + + async invoke( + operation: OperationSpec, + params: Record = {}, + options: InvokeOptions = {}, + ): Promise { + if (this.closed) { + throw new SuperDocCliError('Document handle is closed.', { + code: 'DOCUMENT_CLOSED', + details: { sessionId: this.sessionId }, + }); + } + return this.runtime.invoke(operation, { ...params, sessionId: this.sessionId }, options); + } + + markClosed(): void { + this.closed = true; + } +} + +// --------------------------------------------------------------------------- +// Document handle +// --------------------------------------------------------------------------- + +/** + * Bound document handle. All document operations are available as typed methods. + * The handle injects its session id automatically — callers never pass + * doc or sessionId. + */ +class SuperDocDocumentCore { + private readonly boundRuntime: BoundRuntime; + private readonly _sessionId: string; + private readonly _openResult: Record; + private readonly client: SuperDocClient; + + /** @internal */ + constructor( + boundRuntime: BoundRuntime, + sessionId: string, + openResult: Record, + client: SuperDocClient, + ) { + this.boundRuntime = boundRuntime; + this._sessionId = sessionId; + this._openResult = openResult; + this.client = client; + attachBoundDocApi(this, createBoundDocApi(this.boundRuntime)); + } + + get sessionId(): string { + return this._sessionId; + } + + /** Read-only snapshot of the initial doc.open response metadata. */ + get openResult(): Record { + return this._openResult; + } + + async save(params: DocSaveBoundParams = {}, options: InvokeOptions = {}): Promise { + return this.boundRuntime.invoke( + CONTRACT.operations['doc.save'], + params as unknown as Record, + options, + ); + } + + async close(params: DocCloseBoundParams = {}, options: InvokeOptions = {}): Promise { + const result = await this.boundRuntime.invoke( + CONTRACT.operations['doc.close'], + params as unknown as Record, + options, + ); + this.boundRuntime.markClosed(); + this.client.removeHandle(this._sessionId); + return result; + } + + /** @internal */ + markClosed(): void { + this.boundRuntime.markClosed(); + } +} + +type SuperDocDocumentInstance = SuperDocDocumentCore & BoundDocApi; + +function attachBoundDocApi(target: SuperDocDocumentCore, api: BoundDocApi): void { + const { save: _save, close: _close, ...boundMethods } = api; + Object.assign(target, boundMethods); +} + +export const SuperDocDocument: new ( + boundRuntime: BoundRuntime, + sessionId: string, + openResult: Record, + client: SuperDocClient, +) => SuperDocDocumentInstance = SuperDocDocumentCore as unknown as new ( + boundRuntime: BoundRuntime, + sessionId: string, + openResult: Record, + client: SuperDocClient, +) => SuperDocDocumentInstance; + +export type SuperDocDocument = SuperDocDocumentInstance; + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export interface DocOpenParams { + doc?: string; + sessionId?: string; + [key: string]: unknown; +} + +export interface DocDescribeCommandParams { + operationId: string; + [key: string]: unknown; +} + +/** + * SuperDoc client — transport manager and document factory. + * + * Use `client.open()` to get bound document handles. Each handle is + * independently session-scoped and safe for concurrent use. + * + * const client = new SuperDocClient({ user: { name: 'bot' } }); + * await client.connect(); + * const doc = await client.open({ doc: './file.docx' }); + * const markdown = await doc.getMarkdown(); + * await doc.close(); + * await client.dispose(); */ export class SuperDocClient { private readonly runtime: SuperDocRuntime; - readonly doc: ReturnType; + private readonly rawApi: ReturnType; + private readonly handles = new Map(); constructor(options: SuperDocClientOptions = {}) { this.runtime = new SuperDocRuntime(options); - this.doc = createDocApi(this.runtime); + this.rawApi = createDocApi(this.runtime); } async connect(): Promise { await this.runtime.connect(); } + /** + * Open a document and return a bound document handle. + * + * The returned handle injects its session id into every operation + * automatically. The same file can be opened multiple times with + * different session ids (useful for diff workflows). + */ + async open(params: DocOpenParams, options?: InvokeOptions): Promise { + const explicitSessionId = params.sessionId; + if (typeof explicitSessionId === 'string' && this.handles.has(explicitSessionId)) { + throw new SuperDocCliError(`Session id already open in this client: ${explicitSessionId}`, { + code: 'SESSION_ALREADY_OPEN', + details: { sessionId: explicitSessionId }, + }); + } + + const result = (await this.rawApi.open(params, options)) as Record; + const contextId = result.contextId as string; + + const boundRuntime = new BoundRuntime(this.runtime, contextId); + const handle = new SuperDocDocument(boundRuntime, contextId, result, this); + this.handles.set(contextId, handle); + return handle; + } + + async describe(params: Record = {}, options?: InvokeOptions): Promise { + return this.rawApi.describe(params, options); + } + + async describeCommand(params: DocDescribeCommandParams, options?: InvokeOptions): Promise { + return this.rawApi.describeCommand(params, options); + } + async dispose(): Promise { + for (const handle of this.handles.values()) { + handle.markClosed(); + } + this.handles.clear(); await this.runtime.dispose(); } + + /** @internal */ + removeHandle(sessionId: string): void { + this.handles.delete(sessionId); + } } export function createSuperDocClient(options: SuperDocClientOptions = {}): SuperDocClient { @@ -34,5 +237,11 @@ export { getSkill, installSkill, listSkills } from './skills.js'; export { chooseTools, dispatchSuperDocTool, getSystemPrompt, getToolCatalog, listTools } from './tools.js'; export { dispatchIntentTool } from './generated/intent-dispatch.generated.js'; export { SuperDocCliError } from './runtime/errors.js'; -export type { InvokeOptions, OperationSpec, OperationParamSpec, SuperDocClientOptions } from './runtime/process.js'; +export type { + InvokeOptions, + OperationSpec, + OperationParamSpec, + RuntimeInvoker, + SuperDocClientOptions, +} from './runtime/process.js'; export type { ToolChooserInput, ToolProvider } from './tools.js'; diff --git a/packages/sdk/langs/node/src/runtime/process.ts b/packages/sdk/langs/node/src/runtime/process.ts index 4ea8f290ba..d148e0c4ba 100644 --- a/packages/sdk/langs/node/src/runtime/process.ts +++ b/packages/sdk/langs/node/src/runtime/process.ts @@ -1,6 +1,12 @@ import { HostTransport } from './host.js'; import { resolveEmbeddedCliBinary } from './embedded-cli.js'; -import type { InvokeOptions, OperationParamSpec, OperationSpec, SuperDocClientOptions } from './transport-common.js'; +import type { + InvokeOptions, + OperationParamSpec, + OperationSpec, + RuntimeInvoker, + SuperDocClientOptions, +} from './transport-common.js'; /** * Internal runtime that delegates CLI invocations to a persistent host transport. @@ -37,4 +43,4 @@ export class SuperDocRuntime { } } -export type { InvokeOptions, OperationParamSpec, OperationSpec, SuperDocClientOptions }; +export type { InvokeOptions, OperationParamSpec, OperationSpec, RuntimeInvoker, SuperDocClientOptions }; diff --git a/packages/sdk/langs/node/src/runtime/transport-common.ts b/packages/sdk/langs/node/src/runtime/transport-common.ts index f7d0b71b99..eb278ac8ea 100644 --- a/packages/sdk/langs/node/src/runtime/transport-common.ts +++ b/packages/sdk/langs/node/src/runtime/transport-common.ts @@ -20,6 +20,18 @@ export interface InvokeOptions { stdinBytes?: Uint8Array; } +/** + * Minimal invoke interface that both SuperDocRuntime and BoundRuntime satisfy. + * Generated code depends on this interface, not on the concrete runtime class. + */ +export interface RuntimeInvoker { + invoke( + operation: OperationSpec, + params?: Record, + options?: InvokeOptions, + ): Promise; +} + export type ChangeMode = 'direct' | 'tracked'; export interface UserIdentity { diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index f928822262..1c1b45e0d5 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { BoundDocApi } from './generated/client.js'; import type { InvokeOptions } from './runtime/process.js'; import { SuperDocCliError } from './runtime/errors.js'; import { dispatchIntentTool } from './generated/intent-dispatch.generated.js'; @@ -131,11 +132,11 @@ export async function chooseTools(input: ToolChooserInput): Promise<{ } function resolveDocApiMethod( - client: { doc: Record }, + documentHandle: BoundDocApi, operationId: string, ): (args: unknown, options?: InvokeOptions) => Promise { const tokens = operationId.split('.').slice(1); - let cursor: unknown = client.doc; + let cursor: unknown = documentHandle; for (const token of tokens) { if (!isRecord(cursor) || !(token in cursor)) { @@ -257,8 +258,14 @@ function validateOperationRequired( } } +/** + * Dispatch a tool call against a bound document handle. + * + * The document handle injects session targeting automatically. + * Tool arguments should not contain `doc` or `sessionId`. + */ export async function dispatchSuperDocTool( - client: { doc: Record }, + documentHandle: BoundDocApi, toolName: string, args: Record = {}, invokeOptions?: InvokeOptions, @@ -281,11 +288,8 @@ export async function dispatchSuperDocTool( } validateToolArgs(toolName, args, tool); - // Strip doc/sessionId — the SDK client manages session targeting after doc.open(). - const { doc: _doc, sessionId: _sid, ...cleanArgs } = args; - - return dispatchIntentTool(toolName, cleanArgs, (operationId, input) => { - const method = resolveDocApiMethod(client, operationId); + return dispatchIntentTool(toolName, args, (operationId, input) => { + const method = resolveDocApiMethod(documentHandle, operationId); return method(input, invokeOptions); }); } diff --git a/packages/sdk/langs/python/README.md b/packages/sdk/langs/python/README.md index bd387e42ae..0f224908e9 100644 --- a/packages/sdk/langs/python/README.md +++ b/packages/sdk/langs/python/README.md @@ -29,10 +29,10 @@ from superdoc import AsyncSuperDocClient async def main(): async with AsyncSuperDocClient(default_change_mode="tracked") as client: # Open a document - await client.doc.open({"doc": "./contract.docx"}) + doc = await client.open({"doc": "./contract.docx"}) # Find and replace text with query + mutation plan - match = await client.doc.query.match( + match = await doc.query.match( { "select": {"type": "text", "pattern": "ACME Corp"}, "require": "first", @@ -43,7 +43,7 @@ async def main(): first_item = items[0] if items else {} ref = first_item.get("handle", {}).get("ref") if ref: - await client.doc.mutations.apply( + await doc.mutations.apply( { "expectedRevision": match["evaluatedRevision"], "atomic": True, @@ -59,8 +59,8 @@ async def main(): ) # Save and close - await client.doc.save({"inPlace": True}) - await client.doc.close({}) + await doc.save({"inPlace": True}) + await doc.close({}) asyncio.run(main()) @@ -68,7 +68,7 @@ asyncio.run(main()) Set `default_change_mode="tracked"` to make mutations use tracked changes by default. If you pass `changeMode` on a specific call, that explicit value overrides the default. -The SDK also exposes a synchronous `SuperDocClient` with the same `doc.*` operations when you prefer non-async code paths. +The SDK also exposes a synchronous `SuperDocClient` with the same document-handle methods when you prefer non-async code paths. ### Sync @@ -76,13 +76,13 @@ The SDK also exposes a synchronous `SuperDocClient` with the same `doc.*` operat from superdoc import SuperDocClient with SuperDocClient() as client: - client.doc.open({"doc": "./contract.docx"}) + doc = client.open({"doc": "./contract.docx"}) - info = client.doc.info({}) + info = doc.info({}) print(info["counts"]) - client.doc.save({"inPlace": True}) - client.doc.close({}) + doc.save({"inPlace": True}) + doc.close({}) ``` ## User identity @@ -93,7 +93,7 @@ By default the SDK attributes edits to a generic "CLI" user. Set `user` on the c client = AsyncSuperDocClient(user={"name": "Review Bot", "email": "bot@example.com"}) ``` -The `user` is injected into every `doc.open` call. If you pass `userName` or `userEmail` on a specific `doc.open`, those per-call values take precedence. +The `user` is injected into every `client.open` call. If you pass `userName` or `userEmail` on a specific `client.open`, those per-call values take precedence. ## Client lifecycle @@ -104,11 +104,13 @@ The SDK uses a persistent host process for all operations. The host is started o ```python # Sync with SuperDocClient() as client: - client.doc.find({"query": "test"}) + doc = client.open({"doc": "./test.docx"}) + doc.find({"query": "test"}) # Async async with AsyncSuperDocClient() as client: - await client.doc.find({"query": "test"}) + doc = await client.open({"doc": "./test.docx"}) + await doc.find({"query": "test"}) ``` The context manager calls `connect()` on entry and `dispose()` on exit (including on exception). @@ -118,7 +120,8 @@ The context manager calls `connect()` on entry and `dispose()` on exit (includin ```python client = SuperDocClient() client.connect() # Optional — first invoke() auto-connects -result = client.doc.find({"query": "test"}) +doc = client.open({"doc": "./test.docx"}) +result = doc.find({"query": "test"}) client.dispose() # Shuts down the host process ``` @@ -148,7 +151,7 @@ Use this when your app already has a live collaboration room (Liveblocks, Hocusp ### Join an existing room -Pass `collabUrl` and `collabDocumentId` to `doc.open`: +Pass `collabUrl` and `collabDocumentId` to `client.open`: ```python import asyncio @@ -158,12 +161,12 @@ from superdoc import AsyncSuperDocClient async def main(): async with AsyncSuperDocClient() as client: - await client.doc.open({ + doc = await client.open({ "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", }) - await client.doc.insert({ + await doc.insert({ "target": {"type": "end"}, "content": "Added by the SDK", }) @@ -177,7 +180,7 @@ asyncio.run(main()) If the room is empty, pass `doc` together with collaboration params: ```python -await client.doc.open({ +doc = await client.open({ "doc": "./starting-template.docx", "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", @@ -205,7 +208,7 @@ What happens when you pass `doc`: If you only want to join rooms that already exist, use `onMissing: 'error'`: ```python -await client.doc.open({ +doc = await client.open({ "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", "onMissing": "error", @@ -214,30 +217,30 @@ await client.doc.open({ ### Check if the SDK seeded or joined -`doc.open` returns bootstrap details in collaboration mode: +`client.open` returns a bound document handle. In collaboration mode, bootstrap details are available on `doc.open_result`: ```python -result = await client.doc.open({ +doc = await client.open({ "doc": "./starting-template.docx", "collabUrl": "ws://localhost:4000", "collabDocumentId": "my-doc-room", }) -print(result.get("bootstrap")) +print(doc.open_result.get("bootstrap")) # { roomState, bootstrapApplied, bootstrapSource } ``` ## Available operations -The SDK exposes all operations from the [Document API](https://docs.superdoc.dev/document-api/overview) plus lifecycle and session commands. +The SDK exposes all document-handle operations from the [Document API](https://docs.superdoc.dev/document-api/overview) plus client lifecycle and introspection methods. ### Lifecycle | Operation | Description | | --- | --- | -| `doc.open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `client.open` | Open a document and return a bound document handle. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | | `doc.save` | Save the current session to the original file or a new path. | -| `doc.close` | Close the active editing session and clean up resources. | +| `doc.close` | Close the bound editing session and clean up resources. | ### Query @@ -341,22 +344,12 @@ And 30+ additional formatting operations (letter spacing, vertical alignment, sm | `doc.history.undo` | Undo the most recent history-safe mutation in the active editor. | | `doc.history.redo` | Redo the most recently undone action in the active editor. | -### Session +### Client methods | Operation | Description | | --- | --- | -| `doc.session.list` | List all active editing sessions. | -| `doc.session.save` | Persist the current session state. | -| `doc.session.close` | Close a specific editing session by ID. | -| `doc.session.set_default` | Set the default session for subsequent commands. | - -### Introspection - -| Operation | Description | -| --- | --- | -| `doc.status` | Show the current session status and document metadata. | -| `doc.describe` | List all available CLI operations and contract metadata. | -| `doc.describe_command` | Show detailed metadata for a single CLI operation. | +| `client.describe` | List all available CLI operations and contract metadata. | +| `client.describe_command` | Show detailed metadata for a single CLI operation. | ## Troubleshooting diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index 752998ca17..6e7d08bfa8 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -1,4 +1,4 @@ -from .client import AsyncSuperDocClient, SuperDocClient +from .client import AsyncSuperDocClient, AsyncSuperDocDocument, SuperDocClient, SuperDocDocument from .errors import SuperDocError from .skill_api import get_skill, install_skill, list_skills from .tools_api import ( @@ -13,6 +13,8 @@ __all__ = [ "SuperDocClient", "AsyncSuperDocClient", + "SuperDocDocument", + "AsyncSuperDocDocument", "SuperDocError", "get_skill", "install_skill", diff --git a/packages/sdk/langs/python/superdoc/client.py b/packages/sdk/langs/python/superdoc/client.py index 2dd6755fb7..3d50439d9e 100644 --- a/packages/sdk/langs/python/superdoc/client.py +++ b/packages/sdk/langs/python/superdoc/client.py @@ -1,24 +1,229 @@ -"""Hand-written SuperDoc client classes with lifecycle and context-manager support. +"""SuperDoc client and document handle classes. -These classes compose the generated operation tree (_SyncDocApi / _AsyncDocApi) -with explicit connect/dispose lifecycle semantics. The generated code in -generated/client.py contains only TypedDicts and operation methods. +The client manages transport lifecycle and acts as a document factory. +Document handles bind a single open session and expose all document operations. + + client = AsyncSuperDocClient(user={"name": "bot"}) + await client.connect() + doc = await client.open({"doc": "path/to/file.docx"}) + markdown = await doc.get_markdown() + await doc.close() + await client.dispose() """ from __future__ import annotations -from typing import Dict, Literal, Optional +from typing import Any, Dict, Literal, Optional -from .generated.client import _AsyncDocApi, _SyncDocApi +from .errors import SuperDocError +from .generated.client import _AsyncDocApi, _SyncDocApi, _AsyncBoundDocApi, _SyncBoundDocApi from .runtime import SuperDocAsyncRuntime, SuperDocSyncRuntime UserIdentity = Dict[str, str] +# --------------------------------------------------------------------------- +# Session-bound runtime wrapper +# --------------------------------------------------------------------------- + +class _BoundSyncRuntime: + """Wraps a raw runtime and injects a fixed sessionId into every invoke call.""" + + def __init__(self, runtime: SuperDocSyncRuntime, session_id: str) -> None: + self._runtime = runtime + self._session_id = session_id + self._closed = False + + def invoke( + self, + operation_id: str, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Dict[str, Any]: + if self._closed: + raise SuperDocError( + 'Document handle is closed.', + code='DOCUMENT_CLOSED', + details={'sessionId': self._session_id}, + ) + merged = {**(params or {}), 'sessionId': self._session_id} + return self._runtime.invoke(operation_id, merged, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + + def mark_closed(self) -> None: + self._closed = True + + +class _BoundAsyncRuntime: + """Async version of _BoundSyncRuntime.""" + + def __init__(self, runtime: SuperDocAsyncRuntime, session_id: str) -> None: + self._runtime = runtime + self._session_id = session_id + self._closed = False + + async def invoke( + self, + operation_id: str, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Dict[str, Any]: + if self._closed: + raise SuperDocError( + 'Document handle is closed.', + code='DOCUMENT_CLOSED', + details={'sessionId': self._session_id}, + ) + merged = {**(params or {}), 'sessionId': self._session_id} + return await self._runtime.invoke(operation_id, merged, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + + def mark_closed(self) -> None: + self._closed = True + + +# --------------------------------------------------------------------------- +# Document handles +# --------------------------------------------------------------------------- + +class SuperDocDocument: + """Bound document handle for synchronous workflows. + + All document operations are available as methods on this handle. + The handle injects its session id automatically — callers never pass + doc or sessionId. + """ + + def __init__( + self, + bound_runtime: _BoundSyncRuntime, + session_id: str, + open_result: Dict[str, Any], + client: SuperDocClient, + ) -> None: + self._bound_runtime = bound_runtime + self._session_id = session_id + self._open_result = open_result + self._client = client + self._api = _SyncBoundDocApi(bound_runtime) + + @property + def session_id(self) -> str: + return self._session_id + + @property + def open_result(self) -> Dict[str, Any]: + """Read-only snapshot of the initial doc.open response metadata.""" + return self._open_result + + def __getattr__(self, name: str) -> Any: + return getattr(self._api, name) + + def close( + self, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + result = self._bound_runtime.invoke( + 'doc.close', params or {}, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes, + ) + self._bound_runtime.mark_closed() + self._client._remove_handle(self._session_id) + return result + + def save( + self, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return self._bound_runtime.invoke( + 'doc.save', params or {}, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes, + ) + + def mark_closed(self) -> None: + """Mark this handle as closed. Called by client.dispose().""" + self._bound_runtime.mark_closed() + + +class AsyncSuperDocDocument: + """Bound document handle for asynchronous workflows. + + All document operations are available as methods on this handle. + The handle injects its session id automatically — callers never pass + doc or sessionId. + """ + + def __init__( + self, + bound_runtime: _BoundAsyncRuntime, + session_id: str, + open_result: Dict[str, Any], + client: AsyncSuperDocClient, + ) -> None: + self._bound_runtime = bound_runtime + self._session_id = session_id + self._open_result = open_result + self._client = client + self._api = _AsyncBoundDocApi(bound_runtime) + + @property + def session_id(self) -> str: + return self._session_id + + @property + def open_result(self) -> Dict[str, Any]: + """Read-only snapshot of the initial doc.open response metadata.""" + return self._open_result + + def __getattr__(self, name: str) -> Any: + return getattr(self._api, name) + + async def close( + self, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + result = await self._bound_runtime.invoke( + 'doc.close', params or {}, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes, + ) + self._bound_runtime.mark_closed() + self._client._remove_handle(self._session_id) + return result + + async def save( + self, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return await self._bound_runtime.invoke( + 'doc.save', params or {}, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes, + ) + + def mark_closed(self) -> None: + """Mark this handle as closed. Called by client.dispose().""" + self._bound_runtime.mark_closed() + + +# --------------------------------------------------------------------------- +# Clients +# --------------------------------------------------------------------------- + class SuperDocClient: - """Synchronous SuperDoc client with persistent host transport.""" + """Synchronous SuperDoc client — transport manager and document factory. - doc: _SyncDocApi + Use client.open() to get bound document handles. Each handle is + independently session-scoped and safe for concurrent use. + """ def __init__( self, @@ -40,7 +245,8 @@ def __init__( default_change_mode=default_change_mode, user=user, ) - self.doc = _SyncDocApi(self._runtime) + self._raw_api = _SyncDocApi(self._runtime) + self._handles: Dict[str, SuperDocDocument] = {} def connect(self) -> None: """Explicitly connect to the host process. @@ -49,10 +255,63 @@ def connect(self) -> None: """ self._runtime.connect() + def open( + self, + params: Dict[str, Any], + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> SuperDocDocument: + """Open a document and return a bound document handle. + + The returned handle injects its session id into every operation + automatically. The same file can be opened multiple times with + different session ids (useful for diff workflows). + """ + explicit_session_id = params.get('sessionId') + if explicit_session_id and explicit_session_id in self._handles: + raise SuperDocError( + f'Session id already open in this client: {explicit_session_id}', + code='SESSION_ALREADY_OPEN', + details={'sessionId': explicit_session_id}, + ) + + result = self._raw_api.open(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + context_id = result.get('contextId', '') + + bound = _BoundSyncRuntime(self._runtime, context_id) + handle = SuperDocDocument(bound, context_id, result, self) + self._handles[context_id] = handle + return handle + + def describe( + self, + params: Dict[str, Any] | None = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return self._raw_api.describe(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + + def describe_command( + self, + params: Dict[str, Any] | None = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return self._raw_api.describe_command(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + def dispose(self) -> None: - """Gracefully shut down the host process.""" + """Gracefully shut down the host process and invalidate all open handles.""" + for handle in self._handles.values(): + handle.mark_closed() + self._handles.clear() self._runtime.dispose() + def _remove_handle(self, session_id: str) -> None: + self._handles.pop(session_id, None) + def __enter__(self) -> SuperDocClient: self.connect() return self @@ -62,9 +321,11 @@ def __exit__(self, *exc: object) -> None: class AsyncSuperDocClient: - """Asynchronous SuperDoc client with persistent host transport.""" + """Asynchronous SuperDoc client — transport manager and document factory. - doc: _AsyncDocApi + Use client.open() to get bound document handles. Each handle is + independently session-scoped and safe for concurrent use. + """ def __init__( self, @@ -88,7 +349,8 @@ def __init__( default_change_mode=default_change_mode, user=user, ) - self.doc = _AsyncDocApi(self._runtime) + self._raw_api = _AsyncDocApi(self._runtime) + self._handles: Dict[str, AsyncSuperDocDocument] = {} async def connect(self) -> None: """Explicitly connect to the host process. @@ -97,10 +359,63 @@ async def connect(self) -> None: """ await self._runtime.connect() + async def open( + self, + params: Dict[str, Any], + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> AsyncSuperDocDocument: + """Open a document and return a bound document handle. + + The returned handle injects its session id into every operation + automatically. The same file can be opened multiple times with + different session ids (useful for diff workflows). + """ + explicit_session_id = params.get('sessionId') + if explicit_session_id and explicit_session_id in self._handles: + raise SuperDocError( + f'Session id already open in this client: {explicit_session_id}', + code='SESSION_ALREADY_OPEN', + details={'sessionId': explicit_session_id}, + ) + + result = await self._raw_api.open(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + context_id = result.get('contextId', '') + + bound = _BoundAsyncRuntime(self._runtime, context_id) + handle = AsyncSuperDocDocument(bound, context_id, result, self) + self._handles[context_id] = handle + return handle + + async def describe( + self, + params: Dict[str, Any] | None = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return await self._raw_api.describe(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + + async def describe_command( + self, + params: Dict[str, Any] | None = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + return await self._raw_api.describe_command(params, timeout_ms=timeout_ms, stdin_bytes=stdin_bytes) + async def dispose(self) -> None: - """Gracefully shut down the host process.""" + """Gracefully shut down the host process and invalidate all open handles.""" + for handle in self._handles.values(): + handle.mark_closed() + self._handles.clear() await self._runtime.dispose() + def _remove_handle(self, session_id: str) -> None: + self._handles.pop(session_id, None) + async def __aenter__(self) -> AsyncSuperDocClient: await self.connect() return self diff --git a/packages/sdk/langs/python/superdoc/helpers/format.py b/packages/sdk/langs/python/superdoc/helpers/format.py index fb47d3b029..5eaa0648e9 100644 --- a/packages/sdk/langs/python/superdoc/helpers/format.py +++ b/packages/sdk/langs/python/superdoc/helpers/format.py @@ -7,20 +7,21 @@ Usage:: - from superdoc import SuperDocClient + from superdoc import AsyncSuperDocClient from superdoc.helpers import format_bold, unformat_bold, clear_bold - client = SuperDocClient() - client.connect() + client = AsyncSuperDocClient() + await client.connect() + doc = await client.open({"doc": "path/to/file.docx"}) # Apply bold ON: - result = format_bold(client.doc, block_id="p1", start=0, end=5) + result = format_bold(doc, block_id="p1", start=0, end=5) # Apply explicit bold OFF (override style inheritance): - result = unformat_bold(client.doc, block_id="p1", start=0, end=5) + result = unformat_bold(doc, block_id="p1", start=0, end=5) # Clear direct bold formatting (inherit from style cascade): - result = clear_bold(client.doc, block_id="p1", start=0, end=5) + result = clear_bold(doc, block_id="p1", start=0, end=5) """ from __future__ import annotations @@ -29,15 +30,21 @@ class FormatApplyCallable(Protocol): - """Protocol matching the generated ``doc.format_apply`` method.""" + """Protocol matching the ``format.apply`` method on a bound document handle.""" - def __call__(self, **kwargs: Any) -> Any: ... + def __call__(self, params: dict[str, Any] | None = None, **kwargs: Any) -> Any: ... -class DocApi(Protocol): - """Minimal protocol for the doc API object returned by the generated client.""" +class FormatNamespace(Protocol): + """Minimal protocol for the format namespace on a document handle.""" - format_apply: FormatApplyCallable + apply: FormatApplyCallable + + +class DocumentHandle(Protocol): + """Minimal protocol for a bound document handle with format support.""" + + format: FormatNamespace def _normalize_target( @@ -63,7 +70,7 @@ def _normalize_target( def _format_inline( - doc: DocApi, + document: DocumentHandle, inline: dict[str, str], *, target: Optional[dict[str, Any]] = None, @@ -80,22 +87,22 @@ def _format_inline( Flat-flag shortcuts (``block_id``, ``start``, ``end``) are normalized into a canonical ``target`` dict before calling the API. """ - kwargs: dict[str, Any] = {"inline": inline} + params: dict[str, Any] = {"inline": inline} resolved_target = _normalize_target(target, block_id, start, end) if resolved_target is not None: - kwargs["target"] = resolved_target + params["target"] = resolved_target if dry_run is not None: - kwargs["dry_run"] = dry_run + params["dryRun"] = dry_run if change_mode is not None: - kwargs["change_mode"] = change_mode + params["changeMode"] = change_mode if expected_revision is not None: - kwargs["expected_revision"] = expected_revision + params["expectedRevision"] = expected_revision if "inline" in extra: raise TypeError("Cannot pass 'inline' directly; it is set by the format helper.") - kwargs.update(extra) - return doc.format_apply(**kwargs) + params.update(extra) + return document.format.apply(params) # --------------------------------------------------------------------------- @@ -103,24 +110,24 @@ def _format_inline( # --------------------------------------------------------------------------- -def format_bold(doc: DocApi, **kwargs: Any) -> Any: - """Apply bold ON. Equivalent to ``format.apply(inline={"bold": "on"})``.""" - return _format_inline(doc, {"bold": "on"}, **kwargs) +def format_bold(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply bold ON. Equivalent to ``format.apply({"inline": {"bold": "on"}})``.""" + return _format_inline(document, {"bold": "on"}, **kwargs) -def format_italic(doc: DocApi, **kwargs: Any) -> Any: - """Apply italic ON. Equivalent to ``format.apply(inline={"italic": "on"})``.""" - return _format_inline(doc, {"italic": "on"}, **kwargs) +def format_italic(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply italic ON. Equivalent to ``format.apply({"inline": {"italic": "on"}})``.""" + return _format_inline(document, {"italic": "on"}, **kwargs) -def format_underline(doc: DocApi, **kwargs: Any) -> Any: - """Apply underline ON. Equivalent to ``format.apply(inline={"underline": "on"})``.""" - return _format_inline(doc, {"underline": "on"}, **kwargs) +def format_underline(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply underline ON. Equivalent to ``format.apply({"inline": {"underline": "on"}})``.""" + return _format_inline(document, {"underline": "on"}, **kwargs) -def format_strikethrough(doc: DocApi, **kwargs: Any) -> Any: - """Apply strikethrough ON. Equivalent to ``format.apply(inline={"strike": "on"})``.""" - return _format_inline(doc, {"strike": "on"}, **kwargs) +def format_strikethrough(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply strikethrough ON. Equivalent to ``format.apply({"inline": {"strike": "on"}})``.""" + return _format_inline(document, {"strike": "on"}, **kwargs) # --------------------------------------------------------------------------- @@ -128,24 +135,24 @@ def format_strikethrough(doc: DocApi, **kwargs: Any) -> Any: # --------------------------------------------------------------------------- -def unformat_bold(doc: DocApi, **kwargs: Any) -> Any: - """Apply bold OFF. Equivalent to ``format.apply(inline={"bold": "off"})``.""" - return _format_inline(doc, {"bold": "off"}, **kwargs) +def unformat_bold(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply bold OFF. Equivalent to ``format.apply({"inline": {"bold": "off"}})``.""" + return _format_inline(document, {"bold": "off"}, **kwargs) -def unformat_italic(doc: DocApi, **kwargs: Any) -> Any: - """Apply italic OFF. Equivalent to ``format.apply(inline={"italic": "off"})``.""" - return _format_inline(doc, {"italic": "off"}, **kwargs) +def unformat_italic(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply italic OFF. Equivalent to ``format.apply({"inline": {"italic": "off"}})``.""" + return _format_inline(document, {"italic": "off"}, **kwargs) -def unformat_underline(doc: DocApi, **kwargs: Any) -> Any: - """Apply underline OFF. Equivalent to ``format.apply(inline={"underline": "off"})``.""" - return _format_inline(doc, {"underline": "off"}, **kwargs) +def unformat_underline(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply underline OFF. Equivalent to ``format.apply({"inline": {"underline": "off"}})``.""" + return _format_inline(document, {"underline": "off"}, **kwargs) -def unformat_strikethrough(doc: DocApi, **kwargs: Any) -> Any: - """Apply strikethrough OFF. Equivalent to ``format.apply(inline={"strike": "off"})``.""" - return _format_inline(doc, {"strike": "off"}, **kwargs) +def unformat_strikethrough(document: DocumentHandle, **kwargs: Any) -> Any: + """Apply strikethrough OFF. Equivalent to ``format.apply({"inline": {"strike": "off"}})``.""" + return _format_inline(document, {"strike": "off"}, **kwargs) # --------------------------------------------------------------------------- @@ -153,21 +160,21 @@ def unformat_strikethrough(doc: DocApi, **kwargs: Any) -> Any: # --------------------------------------------------------------------------- -def clear_bold(doc: DocApi, **kwargs: Any) -> Any: - """Clear bold formatting. Equivalent to ``format.apply(inline={"bold": "clear"})``.""" - return _format_inline(doc, {"bold": "clear"}, **kwargs) +def clear_bold(document: DocumentHandle, **kwargs: Any) -> Any: + """Clear bold formatting. Equivalent to ``format.apply({"inline": {"bold": "clear"}})``.""" + return _format_inline(document, {"bold": "clear"}, **kwargs) -def clear_italic(doc: DocApi, **kwargs: Any) -> Any: - """Clear italic formatting. Equivalent to ``format.apply(inline={"italic": "clear"})``.""" - return _format_inline(doc, {"italic": "clear"}, **kwargs) +def clear_italic(document: DocumentHandle, **kwargs: Any) -> Any: + """Clear italic formatting. Equivalent to ``format.apply({"inline": {"italic": "clear"}})``.""" + return _format_inline(document, {"italic": "clear"}, **kwargs) -def clear_underline(doc: DocApi, **kwargs: Any) -> Any: - """Clear underline formatting. Equivalent to ``format.apply(inline={"underline": "clear"})``.""" - return _format_inline(doc, {"underline": "clear"}, **kwargs) +def clear_underline(document: DocumentHandle, **kwargs: Any) -> Any: + """Clear underline formatting. Equivalent to ``format.apply({"inline": {"underline": "clear"}})``.""" + return _format_inline(document, {"underline": "clear"}, **kwargs) -def clear_strikethrough(doc: DocApi, **kwargs: Any) -> Any: - """Clear strikethrough formatting. Equivalent to ``format.apply(inline={"strike": "clear"})``.""" - return _format_inline(doc, {"strike": "clear"}, **kwargs) +def clear_strikethrough(document: DocumentHandle, **kwargs: Any) -> Any: + """Clear strikethrough formatting. Equivalent to ``format.apply({"inline": {"strike": "clear"}})``.""" + return _format_inline(document, {"strike": "clear"}, **kwargs) diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index d485a892cd..95c2dab14b 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -101,17 +101,13 @@ def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: } -def _resolve_doc_method(client: Any, operation_id: str) -> Any: - doc = getattr(client, 'doc', None) - if doc is None: - raise SuperDocError('Client has no doc API.', code='TOOL_DISPATCH_NOT_FOUND', details={'operationId': operation_id}) - +def _resolve_doc_method(document_handle: Any, operation_id: str) -> Any: def _snake_case(token: str) -> str: token = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', token) token = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', token) return token.replace('-', '_').lower() - cursor = doc + cursor = document_handle for token in operation_id.split('.')[1:]: candidates = [token] snake_token = _snake_case(token) @@ -143,20 +139,26 @@ def _snake_case(token: str) -> str: def dispatch_superdoc_tool( - client: Any, + document_handle: Any, tool_name: str, args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: + """Dispatch a tool call against a bound document handle. + + The document handle injects session targeting automatically. + Tool arguments should not contain doc or sessionId — those are + stripped if present for backwards compatibility with older tool schemas. + """ payload = args or {} if not isinstance(payload, dict): raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - # Strip doc/sessionId — the SDK client manages session targeting after doc.open(). + # Strip doc/sessionId if present — the document handle manages targeting. payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(client, operation_id) + method = _resolve_doc_method(document_handle, operation_id) if inspect.iscoroutinefunction(method): raise SuperDocError( 'dispatch_superdoc_tool cannot call async methods. Use dispatch_superdoc_tool_async.', @@ -170,20 +172,21 @@ def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: async def dispatch_superdoc_tool_async( - client: Any, + document_handle: Any, tool_name: str, args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: + """Async version of dispatch_superdoc_tool. Dispatches against a bound document handle.""" payload = args or {} if not isinstance(payload, dict): raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - # Strip doc/sessionId — same as sync version above. + # Strip doc/sessionId if present — the document handle manages targeting. payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(client, operation_id) + method = _resolve_doc_method(document_handle, operation_id) kwargs = dict(invoke_options or {}) return method(input_args, **kwargs) diff --git a/packages/sdk/scripts/sdk-validate.mjs b/packages/sdk/scripts/sdk-validate.mjs index e98e57b2df..c05571589c 100644 --- a/packages/sdk/scripts/sdk-validate.mjs +++ b/packages/sdk/scripts/sdk-validate.mjs @@ -415,10 +415,10 @@ async function main() { `state_dir = ${JSON.stringify(stateDir)}`, 'client = SuperDocClient(env={"SUPERDOC_CLI_BIN": cli_bin, "SUPERDOC_CLI_STATE_DIR": state_dir}, watchdog_timeout_ms=120_000)', 'try:', - ' result = client.doc.open({"doc": doc_path}, timeout_ms=90_000)', - ' if result.get("active") is not True:', - ' raise RuntimeError(f"doc.open did not report an active session: {result!r}")', - ' client.doc.close({})', + ' doc = client.open({"doc": doc_path})', + ' if doc.open_result.get("active") is not True:', + ' raise RuntimeError(f"doc.open did not report an active session: {doc.open_result!r}")', + ' doc.close({})', 'finally:', ' client.dispose()', ].join('\n'); diff --git a/tests/doc-api-stories/tests/harness.ts b/tests/doc-api-stories/tests/harness.ts index a39cd9e0fc..739f8d3454 100644 --- a/tests/doc-api-stories/tests/harness.ts +++ b/tests/doc-api-stories/tests/harness.ts @@ -16,6 +16,16 @@ interface CliInvocation { prefixArgs: string[]; } +type HandleDoc = Awaited>; + +export interface LegacyStoryClient { + doc: any; + connect(): Promise; + dispose(): Promise; + describe(params?: Record): Promise; + describeCommand(params: Record): Promise; +} + function resolveInvocation(cliBin: string): CliInvocation { if (cliBin.toLowerCase().endsWith('.js')) { return { command: 'node', prefixArgs: [cliBin] }; @@ -62,7 +72,7 @@ export function unwrap(payload: any): T { } export interface StoryContext { - client: SuperDocClient; + client: LegacyStoryClient; resultsDir: string; /** Copy a source doc into the results dir and return its path. */ copyDoc(source: string, name?: string): Promise; @@ -70,6 +80,8 @@ export interface StoryContext { outPath(name: string): string; /** Run a raw CLI command with the story's state dir and parse the JSON envelope. */ runCli(args: string[], options?: { allowError?: boolean }): Promise; + /** Create a real bound-handle SDK client that shares this story's CLI state dir. */ + createHandleClient(options?: SuperDocClientOptions): Promise; } export interface StoryHarnessOptions { @@ -85,17 +97,12 @@ export interface StoryHarnessOptions { } export function useStoryHarness(storyName: string, options: StoryHarnessOptions = {}): StoryContext { - const sessionIds: string[] = []; let ctx: StoryContext | null = null; let hasPreparedResultsDir = false; const preserveResults = options.preserveResults ?? false; const clientOptions = options.clientOptions ?? {}; const cliBinMode = options.cliBinMode ?? 'auto'; - const original = { - open: undefined as any, - }; - beforeEach(async () => { const resultsDir = path.join(STORIES_ROOT, 'results', storyName); if (!preserveResults || !hasPreparedResultsDir) { @@ -115,28 +122,32 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions ); const stateDir = path.join(resultsDir, '.superdoc-cli-state'); - const client = createSuperDocClient({ - requestTimeoutMs: 30_000, - startupTimeoutMs: 30_000, - shutdownTimeoutMs: 30_000, - ...clientOptions, - env: { - ...clientOptions.env, - SUPERDOC_CLI_BIN: cliBin, - SUPERDOC_CLI_STATE_DIR: stateDir, - }, - }); + const clients: SuperDocClient[] = []; + const baseHandles = new Map(); - await client.connect(); + const createHandleClient = async (overrideOptions: SuperDocClientOptions = {}): Promise => { + const client = createSuperDocClient({ + requestTimeoutMs: 30_000, + startupTimeoutMs: 30_000, + shutdownTimeoutMs: 30_000, + ...clientOptions, + ...overrideOptions, + env: { + ...clientOptions.env, + ...overrideOptions.env, + SUPERDOC_CLI_BIN: cliBin, + SUPERDOC_CLI_STATE_DIR: stateDir, + }, + }); - // Track opened sessions for cleanup - original.open = client.doc.open.bind(client.doc); - client.doc.open = async (args: any) => { - const result = await original.open(args); - if (args.sessionId) sessionIds.push(args.sessionId); - return result; + await client.connect(); + clients.push(client); + return client; }; + const baseClient = await createHandleClient(); + const client = createLegacyStoryClient(baseClient, baseHandles); + ctx = { client, resultsDir, @@ -176,15 +187,37 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions } return envelope; }, + createHandleClient, }; + + Object.defineProperty(ctx, '__storyClients', { + value: clients, + enumerable: false, + configurable: true, + }); + Object.defineProperty(ctx, '__baseHandles', { + value: baseHandles, + enumerable: false, + configurable: true, + }); }); afterEach(async () => { if (!ctx) return; - for (const sid of sessionIds.splice(0)) { - await ctx.client.doc.close({ sessionId: sid, discard: true }).catch(() => {}); + + const internalCtx = ctx as StoryContext & { + __storyClients?: SuperDocClient[]; + __baseHandles?: Map; + }; + + for (const handle of internalCtx.__baseHandles?.values() ?? []) { + await handle.close({ discard: true }).catch(() => {}); + } + + for (const client of internalCtx.__storyClients ?? []) { + await client.dispose().catch(() => {}); } - await ctx.client.dispose(); + ctx = null; }); @@ -204,6 +237,7 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions copyDoc: (source: string, name?: string) => requireCtx().copyDoc(source, name), outPath: (name: string) => requireCtx().outPath(name), runCli: (args: string[], options?: { allowError?: boolean }) => requireCtx().runCli(args, options), + createHandleClient: (clientOptions?: SuperDocClientOptions) => requireCtx().createHandleClient(clientOptions), } as StoryContext; Object.defineProperty(api, 'resultsDir', { @@ -212,3 +246,109 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions return api; } + +function createLegacyStoryClient(client: SuperDocClient, handles: Map): LegacyStoryClient { + return { + doc: createLegacyDocProxy(client, handles), + connect: () => client.connect(), + dispose: () => client.dispose(), + describe: (params) => client.describe(params), + describeCommand: (params) => client.describeCommand(params), + }; +} + +function createLegacyDocProxy(client: SuperDocClient, handles: Map, pathTokens: string[] = []): any { + return new Proxy(() => {}, { + get: (_target, prop) => { + if (typeof prop !== 'string') return undefined; + if (prop === 'then') return undefined; + return createLegacyDocProxy(client, handles, [...pathTokens, prop]); + }, + apply: async (_target, _thisArg, argArray) => { + const [params = {}, invokeOptions] = argArray as [unknown?, unknown?]; + if (pathTokens.length === 0) { + throw new Error('Legacy story client invoked with no operation path.'); + } + + const operationPath = pathTokens.join('.'); + if (operationPath === 'open') { + const handle = await client.open(asParamsRecord(params, operationPath)); + handles.set(handle.sessionId, handle); + return handle.openResult; + } + + const { sessionId, payload } = splitSessionParams(params, operationPath); + const handle = resolveHandle(handles, sessionId, operationPath); + + if (operationPath === 'close') { + const result = await handle.close(payload, invokeOptions as any); + handles.delete(handle.sessionId); + return result; + } + + const method = resolveHandleMethod(handle, pathTokens, operationPath); + return method(payload, invokeOptions); + }, + }); +} + +function asParamsRecord(params: unknown, operationPath: string): Record { + if (params == null) return {}; + if (typeof params !== 'object' || Array.isArray(params)) { + throw new Error(`doc.${operationPath} expected an object params payload.`); + } + return params as Record; +} + +function splitSessionParams( + params: unknown, + operationPath: string, +): { + sessionId: string | undefined; + payload: Record; +} { + const payload = asParamsRecord(params, operationPath); + const { sessionId, ...rest } = payload; + return { + sessionId: typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined, + payload: rest, + }; +} + +function resolveHandle( + handles: Map, + sessionId: string | undefined, + operationPath: string, +): HandleDoc { + if (sessionId != null) { + const handle = handles.get(sessionId); + if (handle) return handle; + throw new Error(`doc.${operationPath} could not find an open handle for session "${sessionId}".`); + } + + if (handles.size === 1) { + return handles.values().next().value as HandleDoc; + } + + throw new Error(`doc.${operationPath} requires an explicit sessionId in the story harness.`); +} + +function resolveHandleMethod( + handle: HandleDoc, + pathTokens: string[], + operationPath: string, +): (...args: unknown[]) => unknown { + let cursor: unknown = handle; + let parent: unknown = handle; + + for (const token of pathTokens) { + parent = cursor; + cursor = (cursor as Record | undefined)?.[token]; + } + + if (typeof cursor !== 'function') { + throw new Error(`doc.${operationPath} is not available on the bound document handle.`); + } + + return cursor.bind(parent); +} diff --git a/tests/doc-api-stories/tests/session/two-client-handle-isolation-roundtrip.ts b/tests/doc-api-stories/tests/session/two-client-handle-isolation-roundtrip.ts new file mode 100644 index 0000000000..32cfda85b5 --- /dev/null +++ b/tests/doc-api-stories/tests/session/two-client-handle-isolation-roundtrip.ts @@ -0,0 +1,155 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useStoryHarness } from '../harness'; + +const FIXTURE_DOC_A = path.resolve(import.meta.dirname, '../diff/fixtures/diff-doc1.docx'); +const FIXTURE_DOC_B = path.resolve(import.meta.dirname, '../diff/fixtures/diff-doc2.docx'); + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function normalize(value: string): string { + return value.replace(/\r\n/g, '\n').trim(); +} + +function expectParagraphWriteSuccess(result: any): void { + const success = result?.success ?? result?.result?.success ?? result?.receipt?.success; + expect(success).toBe(true); +} + +describe('document-api story: two-client handle isolation roundtrip', () => { + const { copyDoc, outPath, createHandleClient } = useStoryHarness('session/two-client-handle-isolation-roundtrip', { + preserveResults: true, + }); + + it('keeps reads, writes, exports, and reopens isolated across two bound document handles', async () => { + const sourceDocA = await copyDoc(FIXTURE_DOC_A, 'client-a-source.docx'); + const sourceDocB = await copyDoc(FIXTURE_DOC_B, 'client-b-source.docx'); + const exportedDocA = outPath('client-a-export.docx'); + const exportedDocB = outPath('client-b-export.docx'); + + const sessionIdA = sid('client-a'); + const sessionIdB = sid('client-b'); + const reopenSessionIdA = sid('client-a-reopen'); + const reopenSessionIdB = sid('client-b-reopen'); + + const clientA = await createHandleClient({ user: { name: 'Story Client A', email: 'client-a@example.com' } }); + const clientB = await createHandleClient({ user: { name: 'Story Client B', email: 'client-b@example.com' } }); + + const docA = await clientA.open({ doc: sourceDocA, sessionId: sessionIdA }); + const docB = await clientB.open({ doc: sourceDocB, sessionId: sessionIdB }); + + expect(docA).not.toBe(docB); + expect(docA.sessionId).toBe(sessionIdA); + expect(docB.sessionId).toBe(sessionIdB); + expect(docA.sessionId).not.toBe(docB.sessionId); + + expect((docA.openResult as any).contextId).toBe(sessionIdA); + expect((docB.openResult as any).contextId).toBe(sessionIdB); + expect((docA.openResult as any).document?.path).toBe(sourceDocA); + expect((docB.openResult as any).document?.path).toBe(sourceDocB); + expect((docA.openResult as any).document?.path).not.toBe(sourceDocB); + expect((docB.openResult as any).document?.path).not.toBe(sourceDocA); + + const [initialTextA, initialTextB, initialMarkdownA, initialMarkdownB] = await Promise.all([ + docA.getText(), + docB.getText(), + docA.getMarkdown(), + docB.getMarkdown(), + ]); + + expect(normalize(initialTextA)).toContain('This is a test doc.'); + expect(normalize(initialTextA)).toContain('It contains two paragraphs and a table'); + expect(normalize(initialTextA)).not.toContain('Another paragraph'); + expect(normalize(initialTextB)).toContain('This is a test doc.'); + expect(normalize(initialTextB)).toContain('It contains three paragraphs and a table'); + expect(normalize(initialTextB)).toContain('Another paragraph'); + expect(normalize(initialMarkdownA)).not.toBe(normalize(initialMarkdownB)); + + const tokenA = `INSERTED_BY_CLIENT_A_${Date.now()}`; + const tokenB = `INSERTED_BY_CLIENT_B_${Date.now()}`; + + const [writeA, writeB] = await Promise.all([ + docA.create.paragraph({ + at: { kind: 'documentEnd' }, + text: tokenA, + }), + docB.create.paragraph({ + at: { kind: 'documentEnd' }, + text: tokenB, + }), + ]); + + expectParagraphWriteSuccess(writeA); + expectParagraphWriteSuccess(writeB); + expect(writeA?.document?.path).toBe(sourceDocA); + expect(writeB?.document?.path).toBe(sourceDocB); + expect(writeA?.document?.path).not.toBe(sourceDocB); + expect(writeB?.document?.path).not.toBe(sourceDocA); + + const [afterTextA, afterTextB, afterMarkdownA, afterMarkdownB] = await Promise.all([ + docA.getText(), + docB.getText(), + docA.getMarkdown(), + docB.getMarkdown(), + ]); + + expect(normalize(afterTextA)).toContain(tokenA); + expect(normalize(afterTextA)).not.toContain(tokenB); + expect(normalize(afterTextA)).toContain('It contains two paragraphs and a table'); + expect(normalize(afterTextB)).toContain(tokenB); + expect(normalize(afterTextB)).not.toContain(tokenA); + expect(normalize(afterTextB)).toContain('Another paragraph'); + + const snapshotMarkdownA = normalize(afterMarkdownA); + const snapshotMarkdownB = normalize(afterMarkdownB); + + const [saveA, saveB] = await Promise.all([ + docA.save({ out: exportedDocA, force: true }), + docB.save({ out: exportedDocB, force: true }), + ]); + + expect(saveA.contextId).toBe(sessionIdA); + expect(saveB.contextId).toBe(sessionIdB); + expect(saveA.saved).toBe(true); + expect(saveB.saved).toBe(true); + expect(saveA.output?.path).toBe(exportedDocA); + expect(saveB.output?.path).toBe(exportedDocB); + + const [closeA, closeB] = await Promise.all([docA.close({ discard: true }), docB.close({ discard: true })]); + expect(closeA.contextId).toBe(sessionIdA); + expect(closeB.contextId).toBe(sessionIdB); + expect(closeA.closed).toBe(true); + expect(closeB.closed).toBe(true); + + await expect(docA.getMarkdown()).rejects.toThrow(/Document handle is closed/); + await expect(docB.getMarkdown()).rejects.toThrow(/Document handle is closed/); + + const reopenClientA = await createHandleClient(); + const reopenClientB = await createHandleClient(); + const reopenedDocA = await reopenClientA.open({ doc: exportedDocA, sessionId: reopenSessionIdA }); + const reopenedDocB = await reopenClientB.open({ doc: exportedDocB, sessionId: reopenSessionIdB }); + + expect(reopenedDocA.sessionId).toBe(reopenSessionIdA); + expect(reopenedDocB.sessionId).toBe(reopenSessionIdB); + expect((reopenedDocA.openResult as any).document?.path).toBe(exportedDocA); + expect((reopenedDocB.openResult as any).document?.path).toBe(exportedDocB); + + const [reopenedTextA, reopenedTextB, reopenedMarkdownA, reopenedMarkdownB] = await Promise.all([ + reopenedDocA.getText(), + reopenedDocB.getText(), + reopenedDocA.getMarkdown(), + reopenedDocB.getMarkdown(), + ]); + + expect(normalize(reopenedTextA)).toContain(tokenA); + expect(normalize(reopenedTextA)).not.toContain(tokenB); + expect(normalize(reopenedTextA)).toContain('It contains two paragraphs and a table'); + expect(normalize(reopenedTextB)).toContain(tokenB); + expect(normalize(reopenedTextB)).not.toContain(tokenA); + expect(normalize(reopenedTextB)).toContain('Another paragraph'); + expect(normalize(reopenedMarkdownA)).toBe(snapshotMarkdownA); + expect(normalize(reopenedMarkdownB)).toBe(snapshotMarkdownB); + }); +});