diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml index 34756be6b3..43487aadcc 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md /: - en: 672f59241cf59e53 - zh: ad14a4935235c78f + en: 66fb7f6d320077f7 + zh: 5972bcc5548dcdeb /sdk-run-accepts-native-commands: en: 3976928648fffa99 zh: 924afd63d5b211e0 /sdk-run-accepts-native-commands/change: - en: 606b70efa209e059 - zh: 6c207a97b8342af0 + en: bc2b0a35fa7e0ac8 + zh: b16695b1c2941070 /sdk-run-accepts-native-commands/migration: - en: 85e131a8ffa11589 - zh: 66866ac12ee34cf0 + en: 7f7badce57324d22 + zh: af36089bf5a6346e diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md index 19657f5c1b..35e35422bf 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "SDK run methods route supported slash commands in strings and content-block arrays and can return a native command result." +description: "SDK run routes native commands by definition identity and returns prompt activity or command results." --- # SDK run accepts native commands @@ -9,17 +9,17 @@ English | [中文](guide.zh.md) ## Change -Python `DeepSeekHarness.run()` and `Session.run()`, and their TypeScript equivalents, route strings and content-block arrays through one ordinary-input and command entry. Supported slash commands execute their native handler. Ordinary input returns `RunResult`; commands return `CommandExecution`. Both input forms support queue or steer delivery for ordinary prompts. Callers that assume every run returns an assistant response must distinguish these results. +Python `DeepSeekHarness.run()` and `Session.run()`, and their TypeScript equivalents, route strings and content-block arrays through one prompt-or-command entry. Ordinary input returns `RunResult`; supported commands return `CommandExecution`. Commands are identified by their official definition IDs and execute under their current registered names. English and Chinese aliases follow renamed definitions; an unrelated exact-name replacement remains ordinary input. See the [supported command rules](../../../../packages/sdk/server/README.md#command-input). -Content-block arrays no longer bypass native slash-command routing. For example, `run([{"type": "text", "text": "/compact"}])` executes compaction. Arrays containing command text and encoded raster images follow Desktop's native attachment rules; `/plan off` with an image returns a command error without changing plan state. Command completion does not mean planning, review, or automatic goal rounds have finished. Handler refusals return a command error result; routing and transport failures raise or reject. +Arrays no longer bypass native routing: `run([{"type": "text", "text": "/compact"}])` executes compaction. Commands with encoded raster images follow native attachment rules; `/plan off` with an image returns a command error without changing plan state. Command completion does not mean subsequent planning, review, or goal rounds have finished. Handler refusals remain command results; routing and transport failures raise or reject. -`run("")`, `run([])`, and inputs containing only blank text previously entered the prompt queue. They now fail with a JSON-RPC error before Session creation or prompt enqueue. Image-only input remains accepted. +`run("")`, `run([])`, and inputs containing only blank text now fail before Session creation or prompt enqueue. Image-only input remains accepted. Strict command identity checks use the initialized receiving Agent's catalog. Identity rejection retains that Session without executing a handler or submitting a prompt. ## Migration -1. Filter blank strings, empty arrays, and arrays containing only blank text before calling `run()`. If raw queued input is required, use the low-level prompt methods below; enqueue acceptance does not guarantee the model accepts empty input. -2. Check the result before reading `final_response` in Python or `finalResponse` in TypeScript. In Python, use `isinstance(result, RunResult)`; in TypeScript, use `'finalResponse' in result`. A command result carries `command_id` in Python or `commandId` in TypeScript, plus `result.kind` and command-owned details. -3. To bypass native command routing, move the blocks to Python `client.session_prompt(session_id, blocks)` or TypeScript `client.prompt(sessionId, blocks)`. Subscribe before sending and manage subsequent notifications and `session/wait` yourself; these methods return an enqueue receipt, not `RunResult`. Runtime skill recognition still applies. -4. Select a model that supports image input. Submit command text and images together through `run([text_block, image_block])`; an encoded image contains `type: "image"`, base64 `data`, and `mimeType`. Place the complete command in the first text block; a recognized command rejects extra text blocks or non-encoded-image blocks instead of falling back to a prompt. Images may precede the text block. No additional input option is required. -5. Custom `session/query` clients must send `{ sessionId, contentBlocks, delivery }`; convert text into a `{ type: "text", text }` block. The text-only `session/command` and queued `session/prompt` methods retain their request fields. -6. Keep `on_notification` / `onNotification` for activity notifications. Follow the [SDK command completion rules](../../../../packages/sdk/server/README.md#command-input) for subsequent plan or goal work. Verify equivalent routing for strings and arrays, ordinary prompt delivery, `/plan off` image rejection, and low-level prompt bypass. +1. Filter empty or blank input before `run()`. Check the returned type before reading `final_response` in Python or `finalResponse` in TypeScript: use `isinstance(result, RunResult)` or `'finalResponse' in result`. Command results carry `command_id`/`commandId` and `result.kind`. +2. For literal queued input, use Python `client.session_prompt(session_id, blocks)` or TypeScript `client.prompt(sessionId, blocks)`. Subscribe first and manage notifications and `session/wait`; these methods return receipts, not `RunResult`. Enqueue acceptance does not guarantee the model accepts empty input. Runtime skill recognition still applies. +3. For images, select an image-capable model and use the existing `run([text_block, image_block])` form. Encoded images contain `type: "image"`, base64 `data`, and `mimeType`. A matched command requires one complete text block and only encoded images beside it; extra text or other attachments reject without prompt fallback. Images may precede the text block. +4. Custom `session/query` clients send `{ sessionId, contentBlocks, delivery }`; delivery is `queue` or `steer` for ordinary prompts. `session/command` keeps `{ sessionId, line }` and accepts ASCII registered names or canonical English aliases of supported definitions. Chinese aliases use `run()`/`session/query`; a matching name alone does not establish builtin identity. +5. Python malformed command responses from `run()` and `session_command()` raise `SdkProtocolError`, retaining the validation error as their cause. Generic `request(response_model=...)` keeps its caller-selected model validation behavior. +6. Keep `on_notification`/`onNotification` for activity. Both command entry points send lifecycle notifications before their response; forwarding transports must preserve this order. Follow the server's command-completion rules for subsequent model work. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md index e16d2817d8..fd0efd52f2 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "SDK run 方法会路由字符串和内容块数组中的已支持 slash command,并可能返回原生命令结果。" +description: "SDK run 按定义身份路由原生命令,返回提示词活动或命令结果。" --- # SDK run 接受原生命令 @@ -9,17 +9,17 @@ description: "SDK run 方法会路由字符串和内容块数组中的已支持 ## 变更 -Python 的 `DeepSeekHarness.run()`、`Session.run()` 及其 TypeScript 对应方法,会将字符串和内容块数组交给同一个普通输入与命令入口。已支持的 slash command 执行原生处理器。普通输入返回 `RunResult`,命令返回 `CommandExecution`。两种输入形式的普通提示词都支持 queue 或 steer 投递。假设所有 run 都返回助手回答的调用方,需要区分这两种结果。 +Python 的 `DeepSeekHarness.run()`、`Session.run()` 及其 TypeScript 对应方法,将字符串与内容块数组发送到同一提示词/命令入口。普通输入返回 `RunResult`,受支持命令返回 `CommandExecution`。命令按官方定义 ID 识别,并以当前注册名执行。中英文别名跟随重命名后的定义,无关的精确同名替换仍作为普通输入。详见[受支持命令规则](../../../../packages/sdk/server/README.zh.md#command-input)。 -内容块数组不再绕过原生 slash command 路由。例如,`run([{"type": "text", "text": "/compact"}])` 会执行压缩。包含命令文本和编码栅格图片的数组遵循 Desktop 的原生附件规则;`/plan off` 携图返回命令错误,不修改计划状态。命令完成不表示计划生成、审阅或自动目标轮次已经结束。处理器拒绝操作时返回命令错误结果;路由和传输失败会抛出异常或拒绝 Promise。 +数组不再绕过原生路由:`run([{"type": "text", "text": "/compact"}])` 会执行压缩。命令携带编码栅格图片时遵循原生附件规则;`/plan off` 携带图片会返回命令错误,且不改变计划状态。命令完成不表示后续计划、审阅或目标轮次已经结束。处理器拒绝仍是命令结果,路由与传输失败会抛出异常或拒绝。 -`run("")`、`run([])` 和仅包含空白文本的输入,原先会进入提示词队列。现在这些输入会在创建 Session 或提示词入队前,以 JSON-RPC 错误拒绝。仅图片输入仍然接受。 +`run("")`、`run([])` 及仅包含空白文本的输入会在创建 Session 或入队前失败,仅图片输入仍被接受。严格命令的身份校验使用已初始化的接收 Agent 目录。身份校验拒绝会保留该 Session,不执行 handler,也不提交 prompt。 ## 迁移 -1. 调用 `run()` 前过滤空白字符串、空数组和仅包含空白文本的数组。如果确实需要原始排队输入,使用下述低层 prompt 方法;入队成功不保证模型接受空输入。 -2. 在 Python 中读取 `final_response`、在 TypeScript 中读取 `finalResponse` 前检查结果类型。Python 使用 `isinstance(result, RunResult)`,TypeScript 使用 `'finalResponse' in result`。命令结果在 Python 中携带 `command_id`,在 TypeScript 中携带 `commandId`,并包含 `result.kind` 和命令自身的结果字段。 -3. 要绕过原生命令路由,将内容块改为传入 Python `client.session_prompt(session_id, blocks)` 或 TypeScript `client.prompt(sessionId, blocks)`。发送前先订阅,自行管理后续通知与 `session/wait`;这些方法返回入队回执,而非 `RunResult`。运行时 skill(技能)识别仍然适用。 -4. 先选择支持图片输入的模型。通过 `run([text_block, image_block])` 同时提交命令文本和图片;编码图片包含 `type: "image"`、base64 `data` 和 `mimeType`。将完整命令放在第一个文本块中;已识别命令会拒绝额外文本块或非编码图片块,不回退为普通提示词。图片可位于文本块前。不需要额外的输入选项。 -5. 自定义 `session/query` 客户端必须发送 `{ sessionId, contentBlocks, delivery }`;将文本转换为 `{ type: "text", text }` 块。仅接受文本的 `session/command` 和排队输入的 `session/prompt` 保留原有请求字段。 -6. 继续使用 `on_notification` / `onNotification` 观察活动通知。plan 或 goal 的后续工作遵循 [SDK 命令完成规则](../../../../packages/sdk/server/README.zh.md#command-input)。验证字符串和数组路由一致、普通提示词投递、`/plan off` 携图拒绝,以及低层 prompt 绕过行为。 +1. 在调用 `run()` 前过滤空输入或空白输入。在 Python 读取 `final_response` 或 TypeScript 读取 `finalResponse` 前检查返回类型:使用 `isinstance(result, RunResult)` 或 `'finalResponse' in result`。命令结果包含 `command_id`/`commandId` 及 `result.kind`。 +2. 原样排队输入使用 Python `client.session_prompt(session_id, blocks)` 或 TypeScript `client.prompt(sessionId, blocks)`。先订阅,再管理通知及 `session/wait`;这些方法返回回执,而不是 `RunResult`。入队被接受不保证模型接受空输入。运行时技能识别仍适用。 +3. 图片输入选择支持图片的模型,沿用 `run([text_block, image_block])`。编码图片包含 `type: "image"`、base64 `data` 和 `mimeType`。匹配到的命令要求一个完整文本块,其余只能是编码图片;额外文本或其他附件会被拒绝,不回退为提示词。图片可放在文本块之前。 +4. 自定义 `session/query` 客户端发送 `{ sessionId, contentBlocks, delivery }`,普通提示词的 delivery 为 `queue` 或 `steer`。`session/command` 保留 `{ sessionId, line }`,接受受支持定义的 ASCII 注册名或英文规范别名。中文别名使用 `run()`/`session/query`;名称相同不代表内置定义身份。 +5. Python 的 `run()` 和 `session_command()` 收到格式错误的命令响应时抛出 `SdkProtocolError`,并保留校验错误作为 cause。通用 `request(response_model=...)` 保留调用方所选模型的校验行为。 +6. 继续使用 `on_notification`/`onNotification` 观察活动。两个命令入口都先发送生命周期通知,再返回响应;转发方必须保序。后续模型工作遵循服务端的命令完成规则。 diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index ca7bbc4154..5f1cbd355e 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -82,7 +82,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-commands\/builtin$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index eed1e982c7..c1b66eb439 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: a44d56484412ac85 - zh: 6d6012485211908c + en: 99c9ca848b39d59e + zh: 24a5fa23e5b50797 /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index ef8781649e..709e6a13ff 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -55,7 +55,7 @@ Popup selection panels fill the composer overlay's width, matching the slash men
Implementation internals — click to expand -`src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin. +`src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through the shared [`dsh-commands/builtin`](../../interaction/commands/README.md) library. Client dictionaries reuse its English and Chinese tokens; `resolution.ts` selects the localized menu claim. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the menu-claim helper stay internal to the plugin. Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions. A reloaded draft that opens with a claimed command rebuilds the claim through the `matchClaim` hook, which awaits the same directory and answers only a `leadingInput` claim without invoking or executing anything. diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 7ad5b4f9a1..2938f9c7d0 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -55,7 +55,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
实现细节——点击展开 -`src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。 +`src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过共享的 [`dsh-commands/builtin`](../../interaction/commands/README.zh.md) 库解析输入命令。Client 字典复用其中的中英文 token;`resolution.ts` 选择本地化的菜单输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与菜单输入辅助函数均留在插件内部。 每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。刷新后以已认领命令开头的草稿经 `matchClaim` 钩子重建认领:它等待同一个目录就绪,只应答 `leadingInput` 认领,不调用也不执行任何东西。 diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 524e0352b9..e0a15c54dc 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -7,7 +7,7 @@ */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { resolveCommand } from './resolution.ts' +import { resolveCommand } from '@deepseek-ai/dsh-commands/builtin' export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index ae0517b5f4..a0c2f14012 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -5,6 +5,8 @@ * popupSelect shell's copy. */ +import { builtinCommandTokens } from '@deepseek-ai/dsh-commands/builtin' + /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'section.add': '添加', @@ -21,12 +23,12 @@ export const zh = { 'description.compact': '压缩以上对话内容', 'description.permission': '切换权限预设(沙箱模式与审批策略)', 'description.export': '将当前会话内容导出为 ZIP', - 'token.goal': '目标', - 'token.plan': '计划', - 'token.feedback': '反馈', - 'token.compact': '压缩', - 'token.permission': '权限', - 'token.export': '导出', + 'token.goal': builtinCommandTokens.zh.goal, + 'token.plan': builtinCommandTokens.zh.plan, + 'token.feedback': builtinCommandTokens.zh.feedback, + 'token.compact': builtinCommandTokens.zh.compact, + 'token.permission': builtinCommandTokens.zh.permission, + 'token.export': builtinCommandTokens.zh.export, 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -56,12 +58,12 @@ export const en = { 'description.compact': 'Compact older conversation history', 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', 'description.export': 'Download this Session log as a ZIP archive', - 'token.goal': 'goal', - 'token.plan': 'plan', - 'token.feedback': 'feedback', - 'token.compact': 'compact', - 'token.permission': 'permission', - 'token.export': 'export', + 'token.goal': builtinCommandTokens.en.goal, + 'token.plan': builtinCommandTokens.en.plan, + 'token.feedback': builtinCommandTokens.en.feedback, + 'token.compact': builtinCommandTokens.en.compact, + 'token.permission': builtinCommandTokens.en.permission, + 'token.export': builtinCommandTokens.en.export, 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts index 32d03930b2..f5abacd2a4 100644 --- a/packages/client/ui-commands/src/client/presentation.ts +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -9,8 +9,7 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' import type { CommandKey } from './locales.ts' -import { builtinCommandName } from './resolution.ts' -import type { BuiltinCommandName } from './resolution.ts' +import { builtinCommandName, type BuiltinCommandName } from '@deepseek-ai/dsh-commands/builtin' /** The menu's two sections. */ export type MenuSection = 'add' | 'commands' diff --git a/packages/client/ui-commands/src/client/resolution.ts b/packages/client/ui-commands/src/client/resolution.ts index 7b4951cba3..1ee4af7473 100644 --- a/packages/client/ui-commands/src/client/resolution.ts +++ b/packages/client/ui-commands/src/client/resolution.ts @@ -1,29 +1,7 @@ /** Command identity and localized input spelling over the effective Host catalog. */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { en, zh } from './locales.ts' - -const BUILTINS = { - goal: '@deepseek-ai/dsh-command-goal', - plan: '@deepseek-ai/dsh-plan-mode', - feedback: '@deepseek-ai/dsh-command-feedback', - compact: '@deepseek-ai/dsh-command-compact', - permission: '@deepseek-ai/dsh-permission-presets', - export: '@deepseek-ai/dsh-session-log-export', -} as const - -/** Names whose first-party definitions have localized client presentation. */ -export type BuiltinCommandName = keyof typeof BUILTINS - -/** - * Identify a first-party definition without interpreting its display copy. - * @param descriptor - effective Host descriptor after scoped shadowing. - * @returns its first-party name, or undefined for another definition. - */ -export function builtinCommandName(descriptor: CommandDescriptor): BuiltinCommandName | undefined { - return (Object.keys(BUILTINS) as BuiltinCommandName[]) - .find(name => descriptor.definitionId === BUILTINS[name]) -} +import { builtinCommandName } from '@deepseek-ai/dsh-commands/builtin' /** * Select the input spelling for a menu-picked command. @@ -35,25 +13,3 @@ export function claimToken(descriptor: CommandDescriptor, t: TranslateNS<'comman const name = builtinCommandName(descriptor) return name === undefined ? descriptor.name : t(`token.${name}`) } - -const TOKEN_ALIASES = new Map( - (Object.keys(BUILTINS) as BuiltinCommandName[]).flatMap(name => - [zh[`token.${name}`], en[`token.${name}`]].map(token => [token, name] as const)), -) - -/** - * Resolve typed spelling against the current Session's effective definitions. - * @param token - typed name without its leading slash. - * @param descriptors - effective descriptors in the Session's ready catalog. - * @returns the matching descriptor; aliases never select an unrelated scoped override. - */ -export function resolveCommand( - token: string, - descriptors: readonly CommandDescriptor[], -): CommandDescriptor | undefined { - const exact = descriptors.find(descriptor => descriptor.name === token) - if (exact !== undefined) return exact - const name = TOKEN_ALIASES.get(token) - if (name === undefined) return undefined - return descriptors.find(descriptor => descriptor.definitionId === BUILTINS[name]) -} diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index 4fed0002d2..feeeeac06a 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -30,8 +30,8 @@ en: 5239ef9c245c3a6b zh: 34e2f89e4e7f5554 /deepseek-ai-dsh-commands/use-this-package/dispatching-from-an-adapter: - en: a5c6aecf74468137 - zh: 5548d846d600d29a + en: b22ebb74d34d4574 + zh: 7050ff1cbeb1196c /deepseek-ai-dsh-commands/use-this-package/cancellation: en: ee26e9855ce39626 zh: 7c092436d03b84a9 @@ -39,8 +39,8 @@ en: 063b49f26f48d628 zh: 2ec899aa6f01359f /deepseek-ai-dsh-commands/understand-the-implementation/source-map: - en: 1617729234228bb0 - zh: 3bee55cd286212e3 + en: 908aaf67b8dc151a + zh: 200e471607e52029 /deepseek-ai-dsh-commands/understand-the-implementation/lifecycle-events: en: c9fcc06089f8035d zh: 74072476994a2093 diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index bae3e4ee91..d1bad957c9 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -61,6 +61,8 @@ A command may declare `input.attachments` to accept composer images and generic An interactive adapter calls `execute(agent, line, attachments, signal)` with the exact receiving agent, the full command line, and the submission's ordered attachments. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. +The stateless `@deepseek-ai/dsh-commands/builtin` entry shares first-party identity, English/Chinese input tokens, and descriptor resolution across Host and Client adapters. `builtinCommandName()` identifies the definition independently of its registered name; `resolveCommand()` preserves exact-name priority and follows aliases by definition ID. Adapters choose their supported subset; the registry itself continues to accept arbitrary definitions. + ### Cancellation The caller's abort signal stops the registry from awaiting a handler; a handler that ignores the signal may continue its own external side effects after the caller stops waiting. A cancelled or thrown handler settles as a `command/done` error in the log. @@ -82,6 +84,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi | [`src/index.ts`](src/index.ts) | `CommandRuntime` service: registration, scoping, dispatch, lifecycle events | | [`src/types.ts`](src/types.ts) | Command definition, descriptor, execution, and result types | | [`src/brand.ts`](src/brand.ts) | Stable command-definition identities and per-execution lifecycle ids | +| [`src/builtin.ts`](src/builtin.ts) | First-party identity and token resolution for Host and Client adapters | ### Lifecycle events diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index 508868ab65..0d507c6329 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -61,6 +61,8 @@ ctx.commands.register({ 交互式适配器调用 `execute(agent, line, attachments, signal)`,传入确切的接收 agent、完整命令行与本次提交的有序附件。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后用于命令发现。 +无状态入口 `@deepseek-ai/dsh-commands/builtin` 为 Host 和 Client 适配器共享第一方身份、中英文输入 token 及描述符解析。`builtinCommandName()` 根据定义识别能力,不依赖注册名;`resolveCommand()` 保留精确名称优先级,并按定义 ID 解析别名。支持范围由适配器决定,注册器本身仍接受任意定义。 + ### 取消 调用方的中止信号会让注册表停止等待处理器;无视信号的处理器可能在调用方停止等待后继续产生自身的外部副作用。被取消或抛异常的处理器在日志中以 `command/done` 错误结算。 @@ -82,6 +84,7 @@ ctx.commands.register({ | [`src/index.ts`](src/index.ts) | `CommandRuntime` 服务:注册、作用域、分派、生命周期事件 | | [`src/types.ts`](src/types.ts) | 命令定义、描述符、执行与结果类型 | | [`src/brand.ts`](src/brand.ts) | 稳定命令定义标识和每次执行的生命周期 id | +| [`src/builtin.ts`](src/builtin.ts) | Host 和 Client 适配器共享的第一方身份及输入 token 解析 | ### 生命周期事件 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 9fcf307775..3302537a36 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -36,7 +36,11 @@ }, "./src/*": "./src/*", "./package.json": "./package.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./builtin": { + "types": "./lib/types/builtin.d.ts", + "default": "./lib/types/builtin.js" + } }, "files": [ "locale/*.json", diff --git a/packages/interaction/commands/src/builtin.ts b/packages/interaction/commands/src/builtin.ts new file mode 100644 index 0000000000..a7fabebe1f --- /dev/null +++ b/packages/interaction/commands/src/builtin.ts @@ -0,0 +1,49 @@ +/** First-party command identities and input spellings shared by Host and Client adapters. */ +import type { CommandDescriptor } from './types.ts' + +const BUILTINS = { + goal: '@deepseek-ai/dsh-command-goal', + plan: '@deepseek-ai/dsh-plan-mode', + feedback: '@deepseek-ai/dsh-command-feedback', + compact: '@deepseek-ai/dsh-command-compact', + permission: '@deepseek-ai/dsh-permission-presets', + export: '@deepseek-ai/dsh-session-log-export', +} as const + +/** Stable first-party capabilities, independent of their registered command names. */ +export type BuiltinCommandName = keyof typeof BUILTINS + +/** English and Simplified Chinese spellings recognized by command input. */ +export const builtinCommandTokens = { + en: { goal: 'goal', plan: 'plan', feedback: 'feedback', compact: 'compact', permission: 'permission', export: 'export' }, + zh: { goal: '目标', plan: '计划', feedback: '反馈', compact: '压缩', permission: '权限', export: '导出' }, +} as const satisfies Record<'en' | 'zh', Record> + +/** + * Identify a first-party definition after scoped shadowing. + * @param descriptor - effective descriptor from the receiving Agent's catalog. + * @returns its capability name, or undefined for another definition. + */ +export function builtinCommandName(descriptor: CommandDescriptor): BuiltinCommandName | undefined { + return (Object.keys(BUILTINS) as BuiltinCommandName[]) + .find(name => descriptor.definitionId === BUILTINS[name]) +} + +const TOKEN_ALIASES = new Map( + (Object.keys(BUILTINS) as BuiltinCommandName[]).flatMap(name => + [builtinCommandTokens.zh[name], builtinCommandTokens.en[name]].map(token => [token, name] as const)), +) + +/** + * Resolve typed spelling without overriding an exact scoped command name. + * @param token - typed name without its leading slash. + * @param descriptors - effective descriptors in the receiving Agent's catalog. + * @returns the matching descriptor; aliases only select their first-party definition. + */ +export function resolveCommand(token: string, descriptors: readonly CommandDescriptor[]): CommandDescriptor | undefined { + const exact = descriptors.find(descriptor => descriptor.name === token) + if (exact !== undefined) return exact + const name = TOKEN_ALIASES.get(token) + if (name === undefined) return undefined + return descriptors.find(descriptor => descriptor.definitionId === BUILTINS[name]) +} diff --git a/packages/interaction/commands/tests/builtin.spec.ts b/packages/interaction/commands/tests/builtin.spec.ts new file mode 100644 index 0000000000..440f6f0fac --- /dev/null +++ b/packages/interaction/commands/tests/builtin.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { CommandDefinitionId } from '../src/brand.ts' +import { builtinCommandName, builtinCommandTokens, resolveCommand } from '../src/builtin.ts' +import type { CommandDescriptor } from '../src/types.ts' + +const definitions = [ + ['goal', '@deepseek-ai/dsh-command-goal'], + ['plan', '@deepseek-ai/dsh-plan-mode'], + ['feedback', '@deepseek-ai/dsh-command-feedback'], + ['compact', '@deepseek-ai/dsh-command-compact'], + ['permission', '@deepseek-ai/dsh-permission-presets'], + ['export', '@deepseek-ai/dsh-session-log-export'], +] as const + +describe('builtin command resolution', () => { + it.each(definitions)('follows the %s identity through canonical, Chinese and renamed input', (name, id) => { + const descriptor: CommandDescriptor = { + name: `renamed-${name}`, definitionId: CommandDefinitionId(id), description: 'Renamed command', + } + expect(builtinCommandName(descriptor)).toBe(name) + for (const token of [builtinCommandTokens.en[name], builtinCommandTokens.zh[name], descriptor.name]) { + expect(resolveCommand(token, [descriptor])).toBe(descriptor) + } + }) + + it('retains exact-name priority without assigning builtin identity to the replacement', () => { + const builtin: CommandDescriptor = { + name: 'planning', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', + } + const replacement: CommandDescriptor = { + name: 'plan', definitionId: CommandDefinitionId('example/plan'), description: 'Other command', + } + expect(resolveCommand('plan', [builtin, replacement])).toBe(replacement) + expect(builtinCommandName(replacement)).toBeUndefined() + expect(resolveCommand('计划', [builtin, replacement])).toBe(builtin) + expect(resolveCommand('missing', [builtin])).toBeUndefined() + expect(resolveCommand('plan', [])).toBeUndefined() + }) +}) diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index ca9b84828d..11a39561e9 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -24,8 +24,8 @@ en: 026ad56ba7eb2d28 zh: 4adbe9b508bcb86e /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: 2bbc630be84b1908 - zh: 211a53acc48b4582 + en: 2fef48f0f14072be + zh: 198ed6524838f656 /deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: en: f5b9d6a7363bd9e5 zh: f53b3786d22a729d diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 854d42aca9..163d54d1a2 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -80,7 +80,7 @@ Launch options supply initial values. A permission command changes only its targ ### Lower-level control with HarnessClient -`client.query(sessionId, input, delivery)` accepts a string or content-block array and waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` accepts only supported canonical English command names, without prompt fallback or attachments; it requires a non-blank permission preset. Use `run()` or `query()` for Chinese aliases. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. A request timeout abandons only the local wait; runtime work can continue afterward. +`client.query(sessionId, input, delivery)` accepts a string or content-block array and waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` accepts ASCII registered names or canonical English aliases of supported builtin definitions, without prompt fallback or attachments; it requires a non-blank permission preset. Use `run()` or `query()` for Chinese aliases. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. A request timeout abandons only the local wait; runtime work can continue afterward. `/goal` uses the native goal handler and can start automatic rounds after its `CommandExecution`. Subscribe before sending; observe a terminal `goal/change` operation before using `session/wait` for the closing reply. Idle between rounds is not goal completion. See the [server semantics](../server/README.md) for pause, clear, restoration, and permission behavior. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index d360b80dd3..3d6290b674 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -80,7 +80,7 @@ else console.log(result.finalResponse) ### 用 HarnessClient 做低层控制 -`client.query(sessionId, input, delivery)` 接受字符串或内容块数组,等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 只接受受支持的英文规范命令名,不回退为提示词,也不接受附件;permission preset 必须非空。中文别名使用 `run()` 或 `query()`。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。请求超时仅放弃本地等待,运行时工作仍可继续。 +`client.query(sessionId, input, delivery)` 接受字符串或内容块数组,等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 接受受支持内置定义的 ASCII 注册名或英文规范别名,不回退为提示词,也不接受附件;permission preset 必须非空。中文别名使用 `run()` 或 `query()`。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。请求超时仅放弃本地等待,运行时工作仍可继续。 `/goal` 使用原生目标 handler,可在 `CommandExecution` 返回后启动自动轮次。发送前应订阅;先观察终态 `goal/change` 操作,再用 `session/wait` 等待收尾回复。轮次之间的 idle 不代表目标完成。暂停、清除、恢复和权限行为见[服务端语义](../server/README.zh.md)。 diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index b68f1c14bd..9e1102faf9 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 235f340c91f59334 - zh: fe12c84e383b53dd + en: e0b3af7691d4f714 + zh: ecad2771befa34d6 /deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions: en: 65ea66231b17b6cf zh: 8e1c57d0c27f842f diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index f2598026a8..4dc3bd96a6 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -62,7 +62,7 @@ Both wire ends share one method set: client-to-server requests, opt-in server-to `session/query` accepts `{ sessionId, contentBlocks, delivery }`, where `contentBlocks` uses `SdkPromptContentBlock[]` and delivery is `queue` or `steer`. Both SDKs normalize string input into a text block and send content-block arrays through this same entry. The runtime replies with `{ kind: "prompt", messageId }` for ordinary input, preserving its block order, or `{ kind: "command", execution }` after a supported command settles. A recognized command requires one complete text block with only encoded images beside it; other combinations reject before execution or enqueue. The images go through native attachment admission and handler validation. Empty arrays and arrays containing only blank text are rejected; image-only input is allowed. A prompt receipt is not a model result; command completion does not require an inbox receipt or idle notification. Recognition and the supported command set belong to the [SDK server](../server/README.md). -`session/command` accepts `{ sessionId, line }` after initialization and delegates the [supported canonical English command names](../server/README.md#command-input) to the native registry. Chinese aliases use `session/query`. A non-blank preset is required; bare `/permission` is rejected. It returns `CommandExecution` after the handler settles, including an unknown preset or other business failure as `result.kind: "error"`. Unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no prompt fallback or attachment input; use `session/query` to submit commands with encoded images. No inbox receipt or ordinary turn is required. `session/prompt` queues content blocks without native slash-command routing; runtime skill recognition still applies. +`session/command` accepts `{ sessionId, line }` after initialization and delegates the [supported builtin definitions through their ASCII registered names or canonical English aliases](../server/README.md#command-input) to the native registry. Chinese aliases use `session/query`. A non-blank preset is required; bare `/permission` is rejected. It returns `CommandExecution` after the handler settles, including an unknown preset or other business failure as `result.kind: "error"`. Unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no prompt fallback or attachment input; use `session/query` to submit commands with encoded images. No inbox receipt or ordinary turn is required. `session/prompt` queues content blocks without native slash-command routing; runtime skill recognition still applies. Command lifecycle notifications precede their response on the same stream for both `session/command` and the command result of `session/query`. Forwarders must preserve this order. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 3ee4b4ff2e..8e438225a1 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -62,7 +62,7 @@ kind: "package-library" `session/query` 接受 `{ sessionId, contentBlocks, delivery }`,其中 `contentBlocks` 使用 `SdkPromptContentBlock[]`,delivery 为 `queue` 或 `steer`。两个 SDK 都将字符串规范化为文本块,并通过同一入口发送内容块数组。普通输入保留内容块顺序,返回 `{ kind: "prompt", messageId }`;受支持命令结束后返回 `{ kind: "command", execution }`。已识别命令要求一个完整文本块,其他块只能是编码图片;其他组合会在执行或入队前被拒绝。图片经过原生附件接纳与处理器校验。空数组和只包含空白文本的数组会被拒绝;允许仅图片输入。Prompt 回执不是模型结果,命令完成也不要求 inbox 回执或 idle 通知。识别规则及受支持命令由 [SDK 服务端](../server/README.zh.md)定义。 -`session/command` 在初始化后接受 `{ sessionId, line }`,将[支持的英文规范命令名](../server/README.zh.md#command-input)委托给原生注册器。中文别名使用 `session/query`。preset 必须非空,裸 `/permission` 会被拒绝。Handler 结束后返回 `CommandExecution`,未知 preset 或其他业务失败以 `result.kind: "error"` 返回。命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件;携带编码图片的命令使用 `session/query`。不要求 inbox 回执或普通 turn。`session/prompt` 将内容块排入队列,不经过原生 slash command 路由;运行时 skill(技能)识别仍然适用。 +`session/command` 在初始化后接受 `{ sessionId, line }`,将[支持内置定义的 ASCII 注册名或英文规范别名](../server/README.zh.md#command-input)委托给原生注册器。中文别名使用 `session/query`。preset 必须非空,裸 `/permission` 会被拒绝。Handler 结束后返回 `CommandExecution`,未知 preset 或其他业务失败以 `result.kind: "error"` 返回。命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件;携带编码图片的命令使用 `session/query`。不要求 inbox 回执或普通 turn。`session/prompt` 将内容块排入队列,不经过原生 slash command 路由;运行时 skill(技能)识别仍然适用。 对于 `session/command` 和 `session/query` 的命令结果,命令生命周期通知都在同一流上先于对应响应到达。转发方必须保留这一顺序。 diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index b226b7471b..7f9ba131a4 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -99,7 +99,7 @@ export interface SessionPromptResult { export interface SessionCommandParams { /** SDK-owned Session identity; an unknown id creates its Agent. */ sessionId: SessionId - /** Complete command line using the canonical English name; the native command validates arguments. */ + /** Complete line using an ASCII registered name or canonical English alias; the native command validates arguments. */ line: string } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 2a4b856c5f..947d3572c1 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package: - en: b19a32cb7fe97056 - zh: d7d656b5b4385370 + en: 43fc5d41059a31b2 + zh: 494ecf14a627703f /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 3f688b6f4f..28194177d5 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -37,13 +37,13 @@ The plugin publishes the `sdkSessionActivation` readiness marker once its remind The supported commands below are existing Desktop native operations that can execute without a GUI. `/permission ` uses that native path; the Desktop `/model` picker uses a separate model-selection API. -`session/query` is the wire entry used by high-level SDK `run()` for strings and content-block arrays. Ordinary inputs retain their blocks and use the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]` under those fixed canonical names. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Encoded image blocks accompany recognized commands through native attachment admission. `session/prompt` remains the low-level queued-input entry without native slash-command routing. +`session/query` is the wire entry used by high-level SDK `run()` for strings and content-block arrays. Ordinary inputs retain their blocks and use the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]` identified by their official definition IDs. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Encoded image blocks accompany recognized commands through native attachment admission. `session/prompt` remains the low-level queued-input entry without native slash-command routing. -The fixed Chinese aliases are `/压缩` → `compact`, `/权限` → `permission`, `/计划` → `plan`, and `/目标` → `goal`. Each alias requires the corresponding official `definitionId` and canonical registered name. Custom command renaming is unsupported even when `definitionId` is preserved. +Host and Desktop adapters share `dsh-commands/builtin` for definition identity and English/Chinese aliases. The aliases `/压缩`, `/权限`, `/计划`, and `/目标` follow their official definitions after renaming; execution uses the current registered name. Exact names take precedence, but an unrelated same-name definition is not an SDK builtin and remains an ordinary prompt. Other builtin capabilities remain outside the SDK command set. The first text block supplies the complete command line; encoded images may precede or follow it. When that line matches a supported command, it must be the only text block and every other block must be an encoded image. Additional text blocks, durable image references, and other content types reject with a JSON-RPC error before execution or enqueue, without prompt fallback. Ordinary prompts preserve all block content and ordering. Empty arrays and arrays containing only blank text are rejected; image-only input is allowed. -`session/command` accepts `{ sessionId, line }` with English canonical command names only; Chinese aliases and custom renamed commands are rejected with a JSON-RPC error. It requires a non-blank permission preset and lets native handlers validate other arguments. The full sdk profile composes these native handlers; profiles without the requested command reject it. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. Compaction owns its auxiliary model request, history replacement and checkpoint, and refuses a busy Agent. Permission changes retain their native state and notification behavior. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. Timeout is not rollback; clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. +`session/command` accepts `{ sessionId, line }` using ASCII registered names or canonical English aliases of the same four builtin definitions; Chinese aliases use `session/query`. Unsupported identities are rejected with a JSON-RPC error. Command identity checks use the initialized receiving Agent's catalog; identity rejection retains that Session, executes no handler and submits no prompt. It requires a non-blank permission preset and lets native handlers validate other arguments. The full sdk profile composes these native handlers; profiles without the requested command reject it. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. Compaction owns its auxiliary model request, history replacement and checkpoint, and refuses a busy Agent. Permission changes retain their native state and notification behavior. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. Timeout is not rollback; clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. Permission commands change only the receiving Session. Later queries and restored Sessions retain its persisted permission facts; other Sessions keep their own state and new Sessions use the configured defaults. Neither the caller's launch configuration nor saved user defaults are rewritten. Model selection is not an SDK slash command; its resume override remains the separate behavior described above. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index f4fe4949c3..66cecaab21 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -37,13 +37,13 @@ kind: "package-reference" 下列支持范围是桌面已有、无需图形界面即可执行的原生命令。`/permission ` 使用这条原生调用路径;Desktop 的 `/model` 选择器使用独立的模型选择 API。 -`session/query` 是高层 SDK `run()` 发送字符串和内容块数组时使用的协议入口。普通输入保留内容块,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,这些命令必须保留上述固定规范名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。随已识别命令提交的编码图片块经过原生附件接纳。`session/prompt` 保留为低层排队输入入口,不经过原生 slash command 路由。 +`session/query` 是高层 SDK `run()` 发送字符串和内容块数组时使用的协议入口。普通输入保留内容块,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,这些命令按官方定义 ID 识别。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。随已识别命令提交的编码图片块经过原生附件接纳。`session/prompt` 保留为低层排队输入入口,不经过原生 slash command 路由。 -固定中文别名为 `/压缩` → `compact`、`/权限` → `permission`、`/计划` → `plan` 和 `/目标` → `goal`。每个别名都要求对应的官方 `definitionId` 和规范注册名;即使保留 `definitionId`,自定义重命名后的命令也不受支持。 +Host 与桌面适配器共用 `dsh-commands/builtin` 的定义身份及中英文别名解析。`/压缩`、`/权限`、`/计划` 和 `/目标` 跟随重命名后的官方定义,执行使用当前注册名。精确名称优先,但无关的同名定义不是 SDK 内置命令,仍作为普通提示词。其他内置能力仍不属于 SDK 命令集合。 第一个文本块提供完整命令行;编码图片可位于它之前或之后。该行匹配到受支持命令时,它必须是唯一的文本块,其余块必须为编码图片。额外文本块、持久图片引用及其他内容类型会在执行或入队前返回 JSON-RPC 错误,不回退为普通提示词。普通提示词保留全部内容块及其顺序。空数组和只包含空白文本的数组会被拒绝;允许仅图片输入。 -`session/command` 接受 `{ sessionId, line }`,只识别英文规范命令名;中文别名和自定义重命名后的命令会被拒绝,并返回 JSON-RPC 错误。它要求 permission preset 非空,其余参数由原生 handler 校验。完整 sdk profile 组成这些原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 +`session/command` 接受 `{ sessionId, line }`,使用同样四类内置定义的 ASCII 注册名或英文规范别名;中文别名使用 `session/query`。不支持的定义身份返回 JSON-RPC 错误。命令身份校验使用已初始化的接收 Agent 目录;身份校验拒绝会保留该 Session,不执行 handler,也不提交 prompt。它要求 permission preset 非空,其余参数由原生 handler 校验。完整 sdk profile 组成这些原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 权限命令只修改接收命令的 Session。后续输入和恢复的 Session 保留其持久化权限事实;其他 Session 保留各自状态,新 Session 使用配置的默认值。调用方的启动配置和已保存的用户默认值均不会被重写。模型选择不是 SDK slash 命令;恢复时的模型覆盖仍按上文描述的独立行为执行。 diff --git a/packages/sdk/server/src/query-input.ts b/packages/sdk/server/src/query-input.ts index e093004fc5..637fd1d244 100644 --- a/packages/sdk/server/src/query-input.ts +++ b/packages/sdk/server/src/query-input.ts @@ -1,30 +1,27 @@ /** Desktop text-input recognition for the SDK; command execution stays with the native registry. */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' +import { builtinCommandName, resolveCommand } from '@deepseek-ai/dsh-commands/builtin' /** - * Recognize compact, explicit permission changes, plan, and goal using fixed names or Chinese aliases. - * Aliases require the official definitionId and canonical registered name; custom renaming is unsupported. + * Recognize compact, explicit permission changes, plan, and goal by their definition identities. + * Canonical and Chinese aliases follow the effective registered name; unrelated exact names remain prompts. * Other text remains a prompt. * @param text - original input; the caller preserves it on the ordinary-prompt path. * @param commands - exact receiving Agent's current command descriptors. - * @returns the supported command line using its canonical name and a native-compatible separator, otherwise undefined. + * @returns the supported command line using its registered name and a native-compatible separator, otherwise undefined. */ export function queryCommand(text: string, commands: readonly CommandDescriptor[]): string | undefined { const trimmed = text.trim() if (!trimmed.startsWith('/')) return undefined const separator = trimmed.search(/\s/u) const name = separator === -1 ? trimmed.slice(1) : trimmed.slice(1, separator) - const alias = name === '压缩' ? '@deepseek-ai/dsh-command-compact' - : name === '权限' ? '@deepseek-ai/dsh-permission-presets' - : name === '计划' ? '@deepseek-ai/dsh-plan-mode' - : name === '目标' ? '@deepseek-ai/dsh-command-goal' : undefined - const descriptor = commands.find(command => command.name === name) - ?? (alias === undefined ? undefined : commands.find(command => command.definitionId === alias)) + const descriptor = resolveCommand(name, commands) + if (descriptor === undefined) return undefined + const builtin = builtinCommandName(descriptor) const rawInput = separator === -1 ? '' : trimmed.slice(separator).replace(/^[^\S \t\r\n]/u, ' ') - if (descriptor?.name === 'compact' && separator === -1) return '/compact' - if (descriptor?.name === 'permission' && separator !== -1) return `/permission${rawInput}` - if (descriptor?.name === 'plan') return `/plan${rawInput}` - if (descriptor?.name === 'goal') return `/goal${rawInput}` + if (builtin === 'compact' && separator === -1) return `/${descriptor.name}` + if (builtin === 'permission' && separator !== -1) return `/${descriptor.name}${rawInput}` + if (builtin === 'plan' || builtin === 'goal') return `/${descriptor.name}${rawInput}` return undefined } diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ce330c302c..ecc8c6645d 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -12,6 +12,7 @@ import { queryCommand } from './query-input.ts' import { queryCommandImages, queryContent } from './query-content.ts' import { brandString } from '@deepseek-ai/dsh-brand' import { parseCommand } from '@deepseek-ai/dsh-commands' +import { builtinCommandName, resolveCommand } from '@deepseek-ai/dsh-commands/builtin' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { admitEncodedImages, type EncodedImageAttachment, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntime } from '@deepseek-ai/dsh-llm' @@ -317,7 +318,7 @@ export class HarnessSdkJsonRpcServer { /** * Execute supported compact, permission, plan, or goal input through the native command registry. - * Only English canonical names are accepted; Chinese aliases and custom renamed commands are rejected. + * ASCII registered names and canonical English aliases are accepted; Chinese aliases use query. * @param params - target Session and complete command line. * @returns the native handler result; command errors remain result values. * @throws for unsupported input, unavailable commands, or runtime/transport failure. @@ -330,8 +331,7 @@ export class HarnessSdkJsonRpcServer { if (!this.initialized) throw new Error('SDK server is not initialized') if (this.shuttingDown) throw new Error('SDK server is shutting down') const parsed = parseCommand(params.line) - if (parsed === undefined || (parsed.name !== 'compact' && parsed.name !== 'plan' && parsed.name !== 'goal' - && (parsed.name !== 'permission' || parsed.rawInput.trim() === ''))) { + if (parsed === undefined) { throw new TypeError('The SDK command endpoint supports only /compact, /permission , /plan [off|message], or /goal [objective|clear|edit objective|pause|resume]') } const commands = this.ctx.get('commands') @@ -341,7 +341,15 @@ export class HarnessSdkJsonRpcServer { const rec = await this.getOrCreateSession(params.sessionId) this.lifetime.signal.throwIfAborted() this.assertLiveAgent(rec, params.sessionId) - const task = commands.execute(rec.handle.agent, params.line, images.map(image => ({ + const descriptor = resolveCommand(parsed.name, commands.list(rec.handle.agent)) + if (descriptor === undefined) throw new Error(`/${parsed.name} is unavailable in this SDK profile`) + const builtin = builtinCommandName(descriptor) + if (builtin !== 'compact' && builtin !== 'plan' && builtin !== 'goal' + && (builtin !== 'permission' || parsed.rawInput.trim() === '')) { + throw new TypeError('The SDK command endpoint supports only /compact, /permission , /plan [off|message], or /goal [objective|clear|edit objective|pause|resume]') + } + const line = `/${descriptor.name}${parsed.rawInput}` + const task = commands.execute(rec.handle.agent, line, images.map(image => ({ type: 'image', data: image.data, mediaType: image.mimeType, ...image.name === undefined ? {} : { name: image.name }, })), this.lifetime.signal) diff --git a/packages/sdk/server/tests/commands.spec.ts b/packages/sdk/server/tests/commands.spec.ts index a0d9376e48..a5c82bb963 100644 --- a/packages/sdk/server/tests/commands.spec.ts +++ b/packages/sdk/server/tests/commands.spec.ts @@ -52,7 +52,7 @@ async function setup(registry = true, peer?: JsonRpcTransportPeer) { onTestFinished(async () => { await server.shutdown() }) const initialize = () => server.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock' }) const register = (handler: CommandDefinition['handler']) => { - ctx.effect(() => ctx.commands.register({ name: 'compact', description: 'Fixture command', handler })) + ctx.effect(() => ctx.commands.register({ name: 'compact', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), description: 'Fixture command', handler })) } const command = () => server.executeCommand({ sessionId: SessionId('commands'), line: '/compact' }) return { ctx, server, loop, initialize, register, command } @@ -136,7 +136,7 @@ describe('SDK command dispatch', () => { h.ctx.provide('attachments', store as AttachmentStore) const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const })) h.ctx.effect(() => h.ctx.commands.register({ - name: 'plan', description: 'Plan', input: { hint: 'Task', attachments: true }, handler, + name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler, })) const attachments = [{ type: 'image', data: 'AQ==', mimeType: 'image/png', name: 'diagram.png' }] const text = { type: 'text', text: '/plan inspect' } @@ -163,7 +163,7 @@ describe('SDK command dispatch', () => { h.ctx.provide('attachments', store as AttachmentStore) const handler = vi.fn(() => ({ kind: 'success' as const })) h.ctx.effect(() => h.ctx.commands.register({ - name: 'plan', description: 'Plan', input: { hint: 'Task', attachments: true }, handler, + name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler, })) await expect(h.server.handleRequest('session/query', { sessionId: 'refused-content', delivery: 'queue', contentBlocks: [ @@ -186,7 +186,7 @@ describe('SDK command dispatch', () => { h.ctx.provide('attachments', { saveImages } as AttachmentStore) const handler = vi.fn(() => ({ kind: 'success' as const })) h.ctx.effect(() => h.ctx.commands.register({ - name: 'plan', description: 'Plan', input: { hint: 'Task', attachments: true }, handler, + name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler, })) const request = h.server.query({ sessionId: SessionId('image-shutdown'), delivery: 'queue', contentBlocks: [{ type: 'text', text: '/plan inspect' }, { type: 'image', data: 'AQ==', mimeType: 'image/png' }], @@ -205,9 +205,10 @@ describe('SDK command dispatch', () => { await h.initialize() const handler = vi.fn(() => ({ kind: 'success' as const })) h.register(handler) - await expect(h.server.handleRequest('session/command', { sessionId: 's', line })).rejects.toThrow('only /compact') + await expect(h.server.handleRequest('session/command', { sessionId: 's', line })).rejects.toThrow() expect(handler).not.toHaveBeenCalled() - expect(h.ctx.agents.list()).toEqual([]) + expect(h.ctx.agents.list().flatMap(agent => agent.session.snapshotEvents())).toEqual([]) + expect(h.ctx.agents.list()).toHaveLength(line === 'text' ? 0 : 1) }) it.each([ @@ -269,6 +270,85 @@ describe('SDK command dispatch', () => { .toEqual(['command/run', 'command/done', 'command/run', 'command/done']) }) + it.each([ + ['compact', '@deepseek-ai/dsh-command-compact', '压缩', ''], + ['permission', '@deepseek-ai/dsh-permission-presets', '权限', ' read-only'], + ['plan', '@deepseek-ai/dsh-plan-mode', '计划', ' task'], + ['goal', '@deepseek-ai/dsh-command-goal', '目标', ' task'], + ])('dispatches the renamed %s definition through both SDK entries', async (name, id, alias, args) => { + const h = await setup() + await h.initialize() + const registered = `renamed-${name}` + const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const })) + h.ctx.effect(() => h.ctx.commands.register({ + name: registered, definitionId: CommandDefinitionId(id), description: 'Renamed builtin', handler, + })) + const sessionId = SessionId('renamed') + for (const token of [name, alias, registered]) { + await expect(h.server.handleRequest('session/query', { + sessionId, contentBlocks: [{ type: 'text', text: `/${token}${args}` }], delivery: 'queue', + })).resolves.toMatchObject({ kind: 'command', execution: { result: { kind: 'success' } } }) + } + for (const token of [name, registered]) { + await expect(h.server.handleRequest('session/command', { sessionId, line: `/${token}${args}` })) + .resolves.toMatchObject({ result: { kind: 'success' } }) + } + expect(handler).toHaveBeenCalledTimes(5) + expect(handler.mock.calls.map(([invocation]) => invocation.rawInput)).toEqual(Array.from({ length: 5 }, () => args)) + const events = h.ctx.agents.get(sessionId)!.session.snapshotEvents() + expect(events.filter(event => event.type === 'command/run').map(event => event.data.name)) + .toEqual(Array.from({ length: 5 }, () => registered)) + expect(events.every(event => event.type === 'command/run' || event.type === 'command/done')).toBe(true) + }) + + it.each([ + ['compact', ''], ['permission', ' read-only'], ['plan', ' task'], ['goal', ' task'], + ])('keeps an unrelated definition named %s outside native SDK dispatch', async (name, args) => { + const h = await setup() + const model = new TextModel() + h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) + await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) + const handler = vi.fn(() => ({ kind: 'success' as const })) + h.ctx.effect(() => h.ctx.commands.register({ + name, definitionId: CommandDefinitionId(`example/${name}`), description: 'Unrelated command', handler, + })) + const sessionId = SessionId('unrelated') + const line = `/${name}${args}` + await expect(h.server.handleRequest('session/command', { sessionId, line })).rejects.toThrow('only /compact') + expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents()).toEqual([]) + await expect(h.server.handleRequest('session/query', { + sessionId, contentBlocks: [{ type: 'text', text: line }], delivery: 'queue', + })).resolves.toMatchObject({ kind: 'prompt' }) + await h.server.wait({ sessionId }) + expect(handler).not.toHaveBeenCalled() + expect(model.requests).toHaveLength(1) + expect(model.requests[0]!.messages.some(message => message.role === 'user' + && message.content.some(block => block.type === 'text' && block.text === line))).toBe(true) + expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false) + }) + + it('requires a preset for a renamed permission definition', async () => { + const h = await setup() + await h.initialize() + const handler = vi.fn(() => ({ kind: 'success' as const })) + h.ctx.effect(() => h.ctx.commands.register({ + name: 'access', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), + description: 'Permission', handler, + })) + await expect(h.server.executeCommand({ sessionId: SessionId('empty-preset'), line: '/access' })) + .rejects.toThrow('only /compact') + expect(handler).not.toHaveBeenCalled() + expect(h.ctx.agents.get(SessionId('empty-preset'))!.session.snapshotEvents()).toEqual([]) + }) + + it('reports an execution unavailable after catalog resolution', async () => { + const h = await setup() + await h.initialize() + h.register(() => ({ kind: 'success' })) + vi.spyOn(h.ctx.commands, 'execute').mockResolvedValue(undefined) + await expect(h.command()).rejects.toThrow('/compact is unavailable') + }) + it('requires initialization and the composed registry and command', async () => { const absent = await setup(false) await expect(absent.command()).rejects.toThrow('not initialized') @@ -285,7 +365,7 @@ describe('SDK command dispatch', () => { const h = await setup() await h.initialize() const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'error' as const, text: 'native preset refusal' })) - h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', description: 'Fixture permission', input: { hint: 'Preset' }, handler })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Fixture permission', input: { hint: 'Preset' }, handler })) const sessionId = SessionId('permission') const line = '/permission deployment-preset' const result = entry === 'strict' @@ -303,7 +383,7 @@ describe('SDK command dispatch', () => { const h = await setup() await h.initialize() const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const, text: 'native plan result' })) - h.ctx.effect(() => h.ctx.commands.register({ name: 'plan', description: 'Plan', input: { hint: '[off|message]' }, handler })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: '[off|message]' }, handler })) const result = await h.server.query({ sessionId: SessionId('plan'), contentBlocks: [{ type: 'text', text: line }], delivery: 'queue' }) expect(result).toMatchObject({ kind: 'command', execution: { result: { kind: 'success', text: 'native plan result' } } }) expect(handler.mock.calls[0]![0].rawInput).toBe(line.slice('/plan'.length)) @@ -516,7 +596,7 @@ describe('SDK query dispatch', () => { await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) const handler = vi.fn(() => ({ kind: 'success' as const })) h.ctx.effect(() => h.ctx.commands.register({ name: 'feedback', description: 'Feedback', input: { hint: 'Task' }, handler })) - h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', description: 'Permission', input: { hint: 'Preset' }, handler })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Permission', input: { hint: 'Preset' }, handler })) await expect(h.server.query({ sessionId: SessionId('q'), contentBlocks: [{ type: 'text', text }], delivery: 'queue' })).resolves.toMatchObject({ kind: 'prompt' }) await h.server.wait({ sessionId: 'q' }) expect(handler).not.toHaveBeenCalled() diff --git a/packages/sdk/server/tests/query-input.spec.ts b/packages/sdk/server/tests/query-input.spec.ts index 8764a2791e..b20143e2a1 100644 --- a/packages/sdk/server/tests/query-input.spec.ts +++ b/packages/sdk/server/tests/query-input.spec.ts @@ -48,11 +48,11 @@ describe('SDK query text recognition', () => { expect(queryCommand(text, commands)).toBeUndefined() }) - it('does not apply an alias to an unrelated scoped replacement', () => { + it('does not identify unrelated scoped replacements as builtins', () => { const replaced = [{ name: 'compact', description: 'Scoped command' }, { name: 'permission', description: 'Scoped permission' }] - expect(queryCommand('/compact', replaced)).toBe('/compact') + expect(queryCommand('/compact', replaced)).toBeUndefined() expect(queryCommand('/压缩', replaced)).toBeUndefined() - expect(queryCommand('/permission read-only', replaced)).toBe('/permission read-only') + expect(queryCommand('/permission read-only', replaced)).toBeUndefined() expect(queryCommand('/权限 read-only', replaced)).toBeUndefined() expect(queryCommand('/计划', [{ name: 'plan', description: 'Scoped plan' }])).toBeUndefined() }) @@ -65,11 +65,11 @@ describe('SDK query text recognition', () => { expect(queryCommand('/export', [])).toBeUndefined() expect(queryCommand('/permission read-only', [])).toBeUndefined() expect(queryCommand('/压缩', [{ name: 'prune', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), description: 'Renamed command' }])) - .toBeUndefined() + .toBe('/prune') expect(queryCommand('/权限 read-only', [{ name: 'access', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Renamed permission' }])) - .toBeUndefined() + .toBe('/access read-only') expect(queryCommand('/计划', [{ name: 'planning', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Renamed plan' }])) - .toBeUndefined() + .toBe('/planning') expect(queryCommand('/目标', [{ name: 'goal', description: 'Scoped goal' }])).toBeUndefined() }) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 22552558f3..7e4046cf0b 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,8 +12,8 @@ en: ac793c588b13ce87 zh: 502015cda46de3ac /deepseek-harness-python-sdk/run-text-and-commands: - en: 6ff1985e095b3022 - zh: 1a8373d3a9b787bb + en: 4a70013e5ba565a9 + zh: 00bb9b06d38f91ba /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 diff --git a/python/sdk/README.md b/python/sdk/README.md index 50aa2eb25c..fcfae52e50 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -69,9 +69,9 @@ Content-block arrays use the same command routing as strings. Both `session.run( Select a model that supports image input. Submit a command and image in the existing content-block array, for example `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`; `harness.run()` accepts the same input. Plan input and goal creation or editing accept images; `/plan off`, goal controls, compact, and permission commands reject them through the [native attachment rules](../../packages/sdk/server/README.md#command-input). Native command input supports encoded raster images, not general file uploads or client-supplied attachment references. -Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity. +Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. Malformed native-command responses from `run()` or `session_command()` raise `SdkProtocolError`, with the model validation error retained as the cause. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity. -The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` accepts only supported canonical English command names, without prompt fallback or attachments. Use `run()` for Chinese aliases. See the [compaction example](examples/README.md#compact-a-full-sdk-session). +The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` accepts ASCII registered names or canonical English aliases of supported builtin definitions, without prompt fallback or attachments. Use `run()` for Chinese aliases. See the [compaction example](examples/README.md#compact-a-full-sdk-session). `/permission ` changes only the receiving Session. An unknown preset returns a command error without changing policy. Later input and restoration retain successful changes; other Sessions and the caller's `DeepSeekHarnessConfig` keep their own values. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions). diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 5409cdb090..ac35adbc88 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -69,9 +69,9 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: 先选择支持图片输入的模型。通过现有内容块数组同时提交命令和图片,例如 `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`;`harness.run()` 接受相同输入。计划输入和目标创建、编辑接受图片;`/plan off`、目标控制、压缩和权限命令按[原生附件规则](../../packages/sdk/server/README.zh.md#command-input)拒绝图片。原生命令输入支持编码栅格图片,不支持一般文件上传或客户端提供的附件引用。 -每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。 +每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。`run()` 或 `session_command()` 收到格式错误的原生命令响应时抛出 `SdkProtocolError`,并保留模型校验错误作为 cause。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。 -默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 只接受受支持的英文规范命令名,不回退为提示词,也不接受附件。中文别名使用 `run()`。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 +默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 接受受支持内置定义的 ASCII 注册名或英文规范别名,不回退为提示词,也不接受附件。中文别名使用 `run()`。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 `/permission ` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 9241cc972f..658997c933 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -218,13 +218,9 @@ class Session: raise ValueError("run delivery must be queue or steer") self.harness.start() with self.harness.client.subscribe_session_notifications(self.id) as subscription: - response = self.harness.client._request_raw("session/query", { - "sessionId": self.id, "contentBlocks": normalize_input(input), "delivery": delivery, - }) - if not isinstance(response, dict): - raise SdkProtocolError("session/query response must be a JSON object") - if response.get("kind") == "command": - execution = CommandExecution.model_validate(response.get("execution")) + response = self.harness.client._query(self.id, input, delivery) + if isinstance(response, CommandExecution): + execution = response while True: try: notification = subscription._next_queued() @@ -237,10 +233,7 @@ class Session: if _is_command_done(notification, self.id, execution.command_id): break return execution - if response.get("kind") != "prompt" or not isinstance(response.get("messageId"), str) or not response["messageId"]: - raise SdkProtocolError("session/query requires a prompt messageId or command execution result") - message_id = response["messageId"] - activity = _PromptActivity(self.id, message_id) + activity = _PromptActivity(self.id, response) while True: notification = subscription.next() if not activity.record(notification, on_notification): @@ -278,12 +271,6 @@ def _is_command_done(notification: Notification, session_id: str, command_id: st return isinstance(data, dict) and data.get("commandId") == command_id -def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: - if isinstance(input, str): - return [{"type": "text", "text": input}] - return input - - def final_response(events: list[JsonObject]) -> str: for event in reversed(events): if event.get("type") != "assistant/message": diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 946255aec3..95d1d828ca 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -10,9 +10,9 @@ import uuid from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Callable, TypeAlias, TypeVar +from typing import Callable, Literal, TypeAlias, TypeVar -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from .errors import JsonRpcError, SdkProtocolError, TransportClosedError from .models import CommandExecution, IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest @@ -227,11 +227,23 @@ class HarnessClient: def session_command(self, session_id: str, line: str) -> CommandExecution: """Execute an explicit command and return its handler result without waiting for a turn.""" - return self.request( - "session/command", - {"sessionId": session_id, "line": line}, - response_model=CommandExecution, - ) + result = self._request_raw("session/command", {"sessionId": session_id, "line": line}) + return _command_execution(result, "session/command") + + def _query( + self, session_id: str, input: str | list[JsonObject], delivery: Literal["queue", "steer"], + ) -> str | CommandExecution: + """Return a validated prompt receipt or command result for high-level run callers.""" + blocks = [{"type": "text", "text": input}] if isinstance(input, str) else input + result = self._request_raw("session/query", { + "sessionId": session_id, "contentBlocks": blocks, "delivery": delivery, + }) + if isinstance(result, dict): + if result.get("kind") == "prompt" and isinstance(result.get("messageId"), str) and result["messageId"]: + return result["messageId"] + if result.get("kind") == "command": + return _command_execution(result.get("execution"), "session/query") + raise SdkProtocolError("session/query requires a prompt messageId or command execution result") def get_working_directory(self, session_id: str) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" @@ -727,3 +739,11 @@ class _EmptyResponse(BaseModel): def _int_or_none(value: object) -> int | None: return value if isinstance(value, int) else None + + +def _command_execution(value: JsonValue, method: str) -> CommandExecution: + """Decode native command results and report malformed data as a protocol error.""" + try: + return CommandExecution.model_validate(value) + except ValidationError as error: + raise SdkProtocolError(f"{method} returned an invalid command result") from error diff --git a/python/sdk/tests/test_commands.py b/python/sdk/tests/test_commands.py index 87ca26ae90..574b6b46d6 100644 --- a/python/sdk/tests/test_commands.py +++ b/python/sdk/tests/test_commands.py @@ -9,7 +9,7 @@ import pytest from pydantic import ValidationError from deepseek_harness import CommandErrorResult, CommandExecution, CommandSuccessResult, DeepSeekHarness -from deepseek_harness.errors import JsonRpcError +from deepseek_harness.errors import JsonRpcError, SdkProtocolError @pytest.fixture @@ -125,8 +125,9 @@ def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path ]) def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response: dict) -> None: with harness(peer, tmp_path, {"result": response}) as sdk: - with pytest.raises(ValidationError): + with pytest.raises(SdkProtocolError) as raised: sdk.client.session_command("main", "/compact") + assert isinstance(raised.value.__cause__, ValidationError) def test_low_level_prompt_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None: diff --git a/python/sdk/tests/test_run.py b/python/sdk/tests/test_run.py index 0d78b64173..b163b4d33c 100644 --- a/python/sdk/tests/test_run.py +++ b/python/sdk/tests/test_run.py @@ -80,6 +80,8 @@ for line in sys.stdin: "commandId": "silent", "result": {"kind": "success"}}}}) elif text == "rpc-error": send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}}) + elif text == "custom-response": + send({"id": frame["id"], "result": json.loads(os.environ["QUERY_RESPONSE"])}) elif text == "bad-route": send({"id": frame["id"], "result": {"kind": "unknown"}}) elif text == "bad-execution": @@ -237,7 +239,7 @@ def test_command_result_needs_no_notifications(sdk: DeepSeekHarness) -> None: assert_released(sdk) -@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError)]) +@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", SdkProtocolError)]) def test_run_reports_routing_failures_without_retry(sdk: DeepSeekHarness, text: str, error: type[Exception]) -> None: session = sdk.start_session("main") with pytest.raises(error): @@ -272,3 +274,23 @@ def test_run_requires_supported_delivery(sdk: DeepSeekHarness) -> None: session.run("ordinary", delivery="unknown") with pytest.raises(ValueError, match="delivery"): session.run([{"type": "text", "text": "ordinary"}], delivery="unknown") + + +@pytest.mark.parametrize("response", [ + None, [], {"kind": "unknown"}, {"kind": "prompt"}, {"kind": "prompt", "messageId": ""}, + {"kind": "prompt", "messageId": 4}, + {"kind": "command", "execution": {"commandId": "c"}}, + {"kind": "command", "execution": {"commandId": "c", "result": {"kind": "success", "sourceEventSeq": True}}}, +]) +def test_run_rejects_invalid_routing_responses_as_protocol_errors(peer: Path, tmp_path: Path, response: object) -> None: + with DeepSeekHarness( + cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5, + _launch_args=(sys.executable, str(peer)), + env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_RESPONSE": json.dumps(response)}, + ) as sdk: + with pytest.raises(SdkProtocolError) as raised: + sdk.start_session("main").run("custom-response") + if isinstance(response, dict) and response.get("kind") == "command": + assert isinstance(raised.value.__cause__, ValidationError) + assert_released(sdk) + assert isinstance(sdk.start_session("main").run("/compact"), CommandExecution) diff --git a/snapshots/sdk/command-identity/runtime.cordis.yml b/snapshots/sdk/command-identity/runtime.cordis.yml new file mode 100644 index 0000000000..6ec37d7695 --- /dev/null +++ b/snapshots/sdk/command-identity/runtime.cordis.yml @@ -0,0 +1,7 @@ +- insert: + - id: command-identity-fixture + name: './runtime.mjs' + - id: command-identity-capture + name: '../manual-compact/capture-requests.mjs' + config: + path: !!js dshHomePath('command-identity-requests.jsonl') diff --git a/snapshots/sdk/command-identity/runtime.mjs b/snapshots/sdk/command-identity/runtime.mjs new file mode 100644 index 0000000000..622d4e3b23 --- /dev/null +++ b/snapshots/sdk/command-identity/runtime.mjs @@ -0,0 +1,26 @@ +/** Rename the native permission definition and shadow its old name for one SDK Session. */ +export const name = 'snapshot-command-identity' +export const inject = ['commands'] + +export function apply(ctx) { + ctx.on('agent/created', async ({ agent }) => { + const permission = ctx.commands.find(agent, 'permission') + if (permission === undefined) throw new Error('Snapshot requires the native permission definition') + await agent.ctx.plugin({ + name: 'snapshot-scoped-command-identity', + inject: ['commands'], + apply(inner) { + inner.effect(() => inner.commands.register({ ...permission, name: 'access' })) + inner.effect(() => inner.commands.register({ + name: 'permission', + definitionId: 'snapshot/unrelated-permission', + description: 'Unrelated same-name command', + handler: () => { throw new Error('SDK must not execute the unrelated command') }, + })) + }, + }) + if (ctx.commands.find(agent, 'permission')?.definitionId !== 'snapshot/unrelated-permission') { + throw new Error('Snapshot command shadow did not become active') + } + }) +} diff --git a/snapshots/sdk/command-identity/session.v4.jsonl b/snapshots/sdk/command-identity/session.v4.jsonl new file mode 100644 index 0000000000..671351fd84 --- /dev/null +++ b/snapshots/sdk/command-identity/session.v4.jsonl @@ -0,0 +1,41 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1792108800000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"/permission read-only"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"/permission read-only"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"/permission read-only","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":13}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_PERMISSION_READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:5}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_PERMISSION_READY"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_PERMISSION_READY"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"command/run","data":{"commandId":"{{command:1}}","name":"access","args":" read-only","source":{"kind":"user"}}} +{"type":"permission/preset","data":{"preset":"read-only"}} +{"type":"sandbox/mode","data":{"mode":"read-only"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"The user changed the approval policy from \"never ask the user for approval\" to \"ask the user for approval\"."}],"source":{"kind":"user-approval"},"role":"user","id":"{{message:6}}"}]}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"preset read-only"}} +{"type":"command/run","data":{"commandId":"{{command:2}}","name":"access","args":" read-only","source":{"kind":"user"}}} +{"type":"command/done","data":{"commandId":"{{command:2}}","kind":"success","text":"preset read-only"}} +{"type":"command/run","data":{"commandId":"{{command:3}}","name":"access","args":" missing-preset","source":{"kind":"user"}}} +{"type":"command/done","data":{"commandId":"{{command:3}}","kind":"error","text":"unknown preset \"missing-preset\" (available: read-only, workspace-write, danger-full-access)"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue the earlier task under the current permissions. Reply SDK_PERMISSION_OK without using tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"The user changed the approval policy from \"never ask the user for approval\" to \"ask the user for approval\"."}],"source":{"kind":"user-approval"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Continue the earlier task under the current permissions. Reply SDK_PERMISSION_OK without using tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":35}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_PERMISSION_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:9}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_PERMISSION_OK"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_PERMISSION_OK"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/command-identity/snapshot.yml b/snapshots/sdk/command-identity/snapshot.yml new file mode 100644 index 0000000000..b306af7921 --- /dev/null +++ b/snapshots/sdk/command-identity/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: command-identity +profile: sdk +composition: sdk-desktop +recording: authored +header: + class: sdk-desktop diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 3950b9bf99..0c7431cb56 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -168,6 +168,13 @@ const SDK_ASSERTIONS: Readonly> = { modelRequestsFile: 'permission-requests.jsonl', replayStandaloneCommands: true, }, + 'command-identity': { + expectedFinalResponse: 'SDK_PERMISSION_OK', + environment: { DSH_PERMISSION_MODE: 'danger-full-access' }, + patches: [fileURLToPath(new URL('./command-identity/runtime.cordis.yml', import.meta.url))], + modelRequestsFile: 'command-identity-requests.jsonl', + replayStandaloneCommands: true, + }, 'plan-review': { expectedFinalResponse: 'SDK_PLAN_OK', environment: { DSH_PERMISSION_MODE: 'workspace-write' }, @@ -1262,6 +1269,24 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(JSON.stringify(toolResults[1]?.data)).toContain('Plan approved') expect(JSON.stringify(toolResults[2]?.data)).toContain('The user dismissed the plan review') } + if (scenario.name === 'command-identity') { + const parent = ordered[0] + if (parent === undefined) throw new Error('Command identity has no persisted Session') + const events = parseSessionLog(parent.content) + expect(events.filter(event => event.type === 'command/run').map(event => event.data.name)) + .toEqual(['access', 'access', 'access']) + expect(events.filter(event => event.type === 'command/done').map(event => event.data.kind)) + .toEqual(['success', 'success', 'error']) + expect(events.filter(event => event.type === 'permission/preset').map(event => event.data.preset)) + .toEqual(['danger-full-access', 'read-only']) + const userMessages = events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user') + expect(userMessages[0]?.data.content).toEqual([{ type: 'text', text: '/permission read-only' }]) + expect(modelRequests).toHaveLength(2) + const messages = modelRequests[0]?.messages as JsonObject[] + expect(messages.some(message => message.role === 'user' + && (message.content as JsonObject[]).some(block => block.type === 'text' && block.text === '/permission read-only'))) + .toBe(true) + } if (scenario.name === 'permission-switch') { const parent = ordered[0] if (parent === undefined) throw new Error('permission switch has no persisted Session') diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a56dee404..0595735aa1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -197,6 +197,7 @@ "@deepseek-ai/dsh-workflow/types": ["./packages/workflow/workflow/src/types.ts"], "@deepseek-ai/dsh-tool-workflow/types": ["./packages/workflow/tool-workflow/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], + "@deepseek-ai/dsh-commands/builtin": ["./packages/interaction/commands/src/builtin.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],