From 83a0867416e11dbdd8cd80b00298ccae6583c191 Mon Sep 17 00:00:00 2001 From: LogosVeritas Date: Thu, 22 Oct 2026 16:24:46 +0800 Subject: [PATCH] feat(sdk): support session permission commands --- docs/module-graph.md | 6 +- docs/module-graph.zh.md | 6 +- packages/sdk/client/README.i18n.yaml | 8 +- packages/sdk/client/README.md | 6 +- packages/sdk/client/README.zh.md | 6 +- packages/sdk/client/src/client.ts | 2 +- packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 2 +- packages/sdk/protocol/README.zh.md | 2 +- packages/sdk/protocol/src/types.ts | 2 +- packages/sdk/server/README.i18n.yaml | 20 +- packages/sdk/server/README.md | 22 ++- packages/sdk/server/README.zh.md | 22 ++- packages/sdk/server/package.json | 3 + packages/sdk/server/src/query-input.ts | 17 +- packages/sdk/server/src/server.ts | 16 +- packages/sdk/server/tests/commands.spec.ts | 29 ++- .../server/tests/permission-parity.spec.ts | 187 ++++++++++++++++++ packages/sdk/server/tests/query-input.spec.ts | 20 +- pnpm-lock.yaml | 9 + python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 6 +- python/sdk/README.zh.md | 6 +- python/sdk/examples/README.i18n.yaml | 7 +- python/sdk/examples/README.md | 14 ++ python/sdk/examples/README.zh.md | 14 ++ python/sdk/examples/permission.py | 58 ++++++ python/sdk/src/deepseek_harness/api.py | 2 + python/sdk/tests/test_commands_runtime.py | 174 +++++++++++++++- python/sdk/tests/test_queries.py | 2 +- snapshots/sdk/manual-compact/session.v4.jsonl | 4 +- .../sdk/permission-switch/capture.cordis.yml | 5 + .../sdk/permission-switch/session.v4.jsonl | 41 ++++ snapshots/sdk/permission-switch/snapshot.yml | 7 + snapshots/sdk/sdk.snapshot.ts | 81 +++++++- 35 files changed, 737 insertions(+), 77 deletions(-) create mode 100644 packages/sdk/server/tests/permission-parity.spec.ts create mode 100644 python/sdk/examples/permission.py create mode 100644 snapshots/sdk/permission-switch/capture.cordis.yml create mode 100644 snapshots/sdk/permission-switch/session.v4.jsonl create mode 100644 snapshots/sdk/permission-switch/snapshot.yml diff --git a/docs/module-graph.md b/docs/module-graph.md index f17471eb34..917ad51073 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1396,6 +1396,7 @@ flowchart TD pkg_tool_ralph --> pkg_system_prompt pkg_tool_ralph --> pkg_tools pkg_tool_ralph --> pkg_workflow + pkg_sdk_protocol --> pkg_commands pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1462,6 +1463,7 @@ flowchart TD pkg_tool_schedule --> pkg_tools pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_commands pkg_sdk_jsonrpc_server --> pkg_llm pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key pkg_sdk_jsonrpc_server --> pkg_schedule @@ -1831,7 +1833,7 @@ flowchart TD | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | @@ -1839,5 +1841,5 @@ flowchart TD | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index baf721dc22..de2c76da92 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1398,6 +1398,7 @@ flowchart TD pkg_tool_ralph --> pkg_system_prompt pkg_tool_ralph --> pkg_tools pkg_tool_ralph --> pkg_workflow + pkg_sdk_protocol --> pkg_commands pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1464,6 +1465,7 @@ flowchart TD pkg_tool_schedule --> pkg_tools pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_commands pkg_sdk_jsonrpc_server --> pkg_llm pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key pkg_sdk_jsonrpc_server --> pkg_schedule @@ -1833,7 +1835,7 @@ flowchart TD | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | @@ -1841,5 +1843,5 @@ flowchart TD | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 541ad6c737..e0a1a8652a 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -21,11 +21,11 @@ en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 /deepseek-ai-dsh-sdk-client/use-this-package/sending-input-and-receiving-responses: - en: a93ae66b4b36f085 - zh: 58e62ffdd73c5e36 + en: 2b9e5a85f9ce897f + zh: e836600a5fb23baf /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: d5abaacd885c2534 - zh: 3ce12e3f9cc1b960 + en: 271e6adf7f4c51c0 + zh: 74532addae434cd6 /deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: en: ea40f378444c8153 zh: 96e29c99aa5c15f0 diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 1d41840dab..0419fd05d7 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -55,7 +55,7 @@ The subprocess starts lazily on first use and stays owned by the instance across ### Sending input and receiving responses -`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The runtime intercepts bare `/compact`; other text, including `/plan` and `/compact extra`, retains its ordinary-prompt behavior. `run("/compact")` also remains an ordinary prompt. Query is text-only; use `run()` for content-block prompts. +`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The full SDK intercepts bare `/compact` and `/permission ` with a non-blank preset, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/plan`, `/model`, and `/compact extra` remain ordinary prompts. `run()` never dispatches these commands. Query is text-only; use `run()` for content-block prompts. ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' @@ -73,9 +73,11 @@ Each `receiveResponse()` call takes the oldest pending query and returns an inde Receivers may run concurrently, so an outstanding model response does not prevent sending and receiving `/compact` with its native busy result. Subscriptions are installed before sending and released before the final result is yielded. Close an unused iterator with `return()`, even before its first `next()`, to discard its buffered events and abandon its local RPC wait. This sends no runtime cancellation and authorizes no automatic retry. Continue servicing the Session until its work finishes or close the harness; command completion does not end independent model activity. +Launch options provide initial values, not a shared live configuration object. A permission command changes only its target Session; later queries retain that state, other Sessions keep their own state, and new Sessions use the configured defaults. It does not update the options object or saved user defaults. An approval-policy change during a running turn can inject a notice and cause another step. See [permission presets](../../interaction/permission-presets/README.md) for native transition and restoration behavior. + ### Lower-level control with HarnessClient -`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` is the advanced strict-command entry and currently supports `/compact`; it waits for the native handler, including auxiliary summary work. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. +`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` is the strict entry for `/compact` and `/permission `; it rejects bare `/permission`. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. `HarnessClient` is the protocol client under the run API: explicit `start()`, `initialize()`, `prompt()`, `request()`, and `close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it and never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 4aac2556f6..0300375f03 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -55,7 +55,7 @@ console.log(result.finalResponse) ### 发送输入与接收响应 -`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。运行时只接管裸 `/compact`;包括 `/plan` 和 `/compact extra` 在内的其他文本保留普通提示词行为。`run("/compact")` 也仍是普通提示词。query 只接受文本,内容块提示词请使用 `run()`。 +`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。完整 SDK 接管裸 `/compact` 和带非空 preset 的 `/permission `,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/plan`、`/model` 和 `/compact extra` 仍是普通提示词。`run()` 不分派这些命令。query 只接受文本,内容块提示词请使用 `run()`。 ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' @@ -73,9 +73,11 @@ for await (const message of session.receiveResponse()) { 多个接收器可以并行运行,因此等待模型响应时仍可发送并接收 `/compact` 的原生 busy 结果。订阅在发送前建立,在最终结果产出前释放。对不再使用的迭代器调用 `return()`,即使尚未调用第一次 `next()`,也会丢弃缓冲事件并放弃本地 RPC 等待。该操作不发送运行时取消,也不允许自动重试。请持续服务 Session,直到其工作结束,或关闭 harness;命令完成不代表独立模型活动结束。 +启动选项提供初始值,不是共享的实时配置对象。权限命令只修改目标 Session;后续 query 保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。命令不更新 options 对象或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外 step。原生切换和恢复行为见[权限预设](../../interaction/permission-presets/README.zh.md)。 + ### 用 HarnessClient 做低层控制 -`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 是高级用法的严格命令入口,当前支持 `/compact`;它等待原生 handler,包括辅助摘要工作。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 +`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 是 `/compact` 和 `/permission ` 的严格入口,会拒绝裸 `/permission`。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 `HarnessClient` 是运行 API 之下的协议客户端:显式 `start()`、`initialize()`、`prompt()`、`request()` 与 `close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 id,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。 diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 01c9ec44d8..b0f4de921f 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -364,7 +364,7 @@ export class HarnessClient { /** * Execute a strict command without waiting for an inbox receipt or Agent idle. * @param sessionId - target Session; an unknown id creates it. - * @param line - complete command line; currently only `/compact` is supported. + * @param line - `/compact` or `/permission `; native handlers validate arguments. * @returns the native command result, including a business error when the handler refuses it. * @throws on protocol rejection, malformed response, timeout or transport loss; never retries. */ diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 4e03357fd8..176301adf6 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: 54c01fad1cfcf11d - zh: f30ae08e73474380 + en: 6acb920e2b3f3b14 + zh: 8f0d8a0ade242ce8 /deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions: en: 9dcd999ae8c66b71 zh: 174c5fe0cbb0a9e4 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 81e0cf116f..31587dc0c4 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 exactly `{ sessionId, text, delivery }`, where text is non-blank and delivery is `queue` or `steer`. The runtime replies with `{ kind: "prompt", messageId }` for an ordinary input, or `{ kind: "command", execution }` after a supported command settles. 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 exactly `{ sessionId, line }` after initialization and executes only `/compact` through the composed native command registry. It returns the native `CommandExecution` after the handler settles, including business failures as `result.kind: "error"`; unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no ordinary-prompt fallback or attachment input. Command lifecycle notifications precede the response; no inbox receipt or ordinary turn is required. `session/prompt` remains unchanged. +`session/command` accepts exactly `{ sessionId, line }` after initialization and delegates `/compact` or `/permission ` to the native registry. 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. Command lifecycle notifications precede the response; no inbox receipt or ordinary turn is required. `session/prompt` remains unchanged. `session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{}` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 89fea62325..6cc8b97712 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, text, delivery }`,text 不能全为空白,delivery 为 `queue` 或 `steer`。普通输入返回 `{ kind: "prompt", messageId }`,受支持命令结束后返回 `{ kind: "command", execution }`。Prompt 回执不是模型结果,命令完成也不要求 inbox 回执或 idle 通知。识别规则及受支持命令由 [SDK 服务端](../server/README.zh.md)定义。 -`session/command` 在初始化后接受且仅接受 `{ sessionId, line }`,通过已组成的原生命令注册器执行 `/compact`。Handler 结束后返回原生 `CommandExecution`,业务失败以 `result.kind: "error"` 返回;命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件。命令生命周期通知在响应前到达,不要求 inbox 回执或普通 turn。`session/prompt` 保持不变。 +`session/command` 在初始化后接受且仅接受 `{ sessionId, line }`,将 `/compact` 或 `/permission ` 委托给原生注册器。preset 必须非空,裸 `/permission` 会被拒绝。Handler 结束后返回 `CommandExecution`,未知 preset 或其他业务失败以 `result.kind: "error"` 返回。命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件。命令生命周期通知在响应前到达,不要求 inbox 回执或普通 turn。`session/prompt` 保持不变。 `session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index e917890bf8..4674aac953 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -90,7 +90,7 @@ export interface SessionPromptResult { messageId: string } -/** Strict command invocation; the SDK currently supports only `/compact`. */ +/** Strict invocation of `/compact` or `/permission `. */ export interface SessionCommandParams { /** SDK-owned Session identity; an unknown id creates its Agent. */ sessionId: SessionId diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index e7c82ec70e..dfe209bc4b 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: 1d441cdad2ebeb6d - zh: 0afccd29fe853d8a + en: 134300cf15bec54f + zh: 34fae5fc83e195ab /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 @@ -60,8 +60,8 @@ en: 04d1b73cd8e06dfe zh: cd67fbbfdf39f776 /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/what-the-model-sees: - en: ee8630f8f5eeda84 - zh: b4f6b0b45fa2b9c2 + en: 5568cba6770f9372 + zh: 51b9e4102f4ed054 /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/token-effect: en: 372ed851986b76d2 zh: 075b94a565275368 @@ -80,6 +80,18 @@ /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/manual-compaction/kv-cache-effect: en: 6575950aede7f71e zh: bfbfbba8b10fc448 +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/session-permission-changes: + en: 9a6bbed77944026e + zh: 0cf9e179dddeeeb5 +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/session-permission-changes/what-the-model-sees: + en: 2ee1c7fe78fd7637 + zh: d8f4efac56c1606f +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/session-permission-changes/token-effect: + en: 6fd7b795a324c0e9 + zh: e13a949d27e91e4d +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/session-permission-changes/kv-cache-effect: + en: a6c8c78f7e558ed1 + zh: 410aef8f5f230d67 /deepseek-ai-dsh-sdk-jsonrpc-server/known-limitations-and-deferred-work: en: a6d0b12d02961572 zh: 92822e033db55980 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 49dea4f8f4..d257fd54ce 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -33,9 +33,11 @@ Set `initialize.approvalRequests` to `true` to send internal approvals as server The plugin publishes the `sdkSessionActivation` readiness marker once its reminder activation handler is installed; an SDK composition injects this marker into Schedule instead of the Web Session controller. Scheduled delivery uses this same SDK Session owner. Due startup reminders wait for successful SDK initialization, then resume with its provider, model, reasoning effort, and output limit. The client can resume a reminder-restored Session once; concurrent reminder and client restoration share one handle. Shutdown releases reminders waiting for initialization and drains accepted restorations. -`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation; the default is supplied explicitly by the client. The SDK recognizes `/compact` and its registered Desktop alias, then reuses the native command path. `/compact extra`, unknown slash tokens and paths remain ordinary prompts. All other slash input, including registered commands not yet supported and UI names such as `/model`, `/file` and `/export`, keeps the existing ordinary-prompt behavior. If no compact command is composed, that input also remains ordinary text; a failure after compact has been selected never falls back to a prompt. Query does not change permissions. Inputs are text-only; `session/prompt` retains its existing content-block API. +`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation. The full SDK recognizes bare `/compact` and `/permission ` with a non-blank preset, including Desktop aliases bound to their current definitions. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/plan` and `/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. Inputs are text-only; `session/prompt` retains its content-block API. -`session/command` accepts `{ sessionId, line }` and currently exposes `/compact` only. The full sdk profile already composes its native handler; profiles without that command reject the call. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. The command owns its model request, history replacement and checkpoint; an active Agent receives the native busy result. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. A timeout does not establish rollback, and clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. +`session/command` accepts `{ sessionId, line }` for `/compact` or `/permission ` and rejects bare `/permission`. The full sdk profile composes both 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. `session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context. @@ -124,7 +126,7 @@ Read these pages when the plugin contract is not enough. They move from the wire #### What the model sees -Ordinary `session/query` text and accepted `session/prompt` content enter a user message verbatim. Query delivery selects a queued turn or the next available step, without changing permissions. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. +Ordinary `session/query` text and accepted `session/prompt` content enter a user message verbatim. Ordinary delivery selects a queued turn or the next available step without reapplying permission defaults. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect @@ -148,6 +150,20 @@ The compaction owner pays the summary request cost and reduces later history tok The replaced history changes the reusable prefix according to the native compactor. The SDK adds no separate invalidation. +### Session permission changes + +#### What the model sees + +The command text and result stay outside model messages. The [permission preset owner](../../interaction/permission-presets/README.md#model-experience) updates the runtime-context user snapshot and may inject an approval-policy notice. A notice in a running turn can cause another step; the default presets do not change system text or tool schemas. + +#### Token effect + +Native policy snapshots and notices add their normal history tokens; SDK framing adds none. + +#### KV Cache effect + +The native context updates append after retained history without rewriting the system prefix. + ## Known Limitations and Deferred Work diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 4e9722ef19..459bdb3a36 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -33,9 +33,11 @@ kind: "package-reference" 插件在注册提醒激活处理方后发布 `sdkSessionActivation` 就绪标记;SDK 组合让 Schedule 依赖此标记,而非 Web Session controller。定时提醒也通过同一个 SDK Session 所有者投递。启动时已到期的提醒等待 SDK 成功初始化,再使用初始化指定的提供方、模型、推理强度和输出上限恢复会话。客户端可以首次接管提醒恢复的 Session;并发的提醒与客户端恢复共用一个 handle。关闭时释放等待初始化的提醒,并等待已经接纳的恢复完成。 -`session/query` 是统一文本入口。普通文本保留原文,使用选定 queue/steer 操作,默认值由客户端显式发送。SDK 识别 `/compact` 及绑定到其当前定义的 Desktop 别名,再复用原生命令路径。`/compact extra`、未知 slash token 和路径仍为普通 prompt。其他 slash 输入,包括已注册但尚不支持的命令及 `/model`、`/file`、`/export` 等 UI 名称,保持原有普通 prompt 行为。未组成 compact 命令时,该输入也保留为普通文本;一旦选中 compact,执行失败不会再回退为 prompt。Query 不改变权限。此入口仅接受文本,`session/prompt` 保留已有内容块 API。 +`session/query` 是统一文本入口。普通文本保留原文,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact` 和带非空 preset 的 `/permission `,包括绑定到当前定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/plan`、`/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。此入口仅接受文本,`session/prompt` 保留内容块 API。 -`session/command` 接受 `{ sessionId, line }`,目前仅开放 `/compact`。完整 sdk profile 已组成其原生 handler,缺少该命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。模型请求、历史替换和 checkpoint 由命令所有者处理;活动 Agent 返回原生 busy 结果。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不代表回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 +`session/command` 接受用于 `/compact` 或 `/permission ` 的 `{ sessionId, line }`,并拒绝裸 `/permission`。完整 sdk profile 组成这两个原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 + +权限命令只修改接收命令的 Session。后续 query 和恢复的 Session 保留其持久化权限事实;其他 Session 保留各自状态,新 Session 使用配置的默认值。调用方的启动配置和已保存的用户默认值均不会被重写。模型选择不是 SDK slash 命令;恢复时的模型覆盖仍按上文描述的独立行为执行。 `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 @@ -124,7 +126,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 #### 模型看到什么 -普通 `session/query` 文本和已接受的 `session/prompt` 内容原样进入用户消息。Query 递送选择排队的新 turn 或下个可用 step,不改变权限。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 +普通 `session/query` 文本和已接受的 `session/prompt` 内容原样进入用户消息。普通输入的递送选择排队的新 turn 或下个可用 step,不会重新应用权限默认值。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 @@ -148,6 +150,20 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 历史替换按原生压缩器规则改变可复用前缀,SDK 不增加独立失效。 +### Session 权限变更 + +#### 模型看到什么 + +命令文本和结果不进入模型消息。[权限预设所有者](../../interaction/permission-presets/README.zh.md#model-experience)更新 runtime-context 用户快照,并可能注入审批策略通知。运行中 turn 的通知可能触发额外 step;默认预设不会改变 system 文本或工具 schema。 + +#### Token 影响 + +原生策略快照和通知增加其正常历史 token;SDK 分帧不增加 token。 + +#### KV Cache 影响 + +原生上下文更新追加在保留历史之后,不重写 system 前缀。 + ## 已知限制与延期工作 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index f90fc860d0..9b465d848c 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -60,12 +60,15 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", diff --git a/packages/sdk/server/src/query-input.ts b/packages/sdk/server/src/query-input.ts index 5983d6381a..24aa757a2a 100644 --- a/packages/sdk/server/src/query-input.ts +++ b/packages/sdk/server/src/query-input.ts @@ -3,16 +3,21 @@ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' /** - * Recognize the supported bare compact form; all other input retains ordinary prompt behavior. + * Recognize bare compact and permission with an explicit preset; other input 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 `/compact` for its effective name or Desktop alias, otherwise undefined. + * @returns the supported command line using its effective name, otherwise undefined. */ export function queryCommand(text: string, commands: readonly CommandDescriptor[]): string | undefined { const trimmed = text.trim() - if (!trimmed.startsWith('/') || /\s/u.test(trimmed)) return undefined - const name = trimmed.slice(1) + 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' : undefined const descriptor = commands.find(command => command.name === name) - ?? (name === '压缩' ? commands.find(command => command.definitionId === '@deepseek-ai/dsh-command-compact') : undefined) - return descriptor?.name === 'compact' ? '/compact' : undefined + ?? (alias === undefined ? undefined : commands.find(command => command.definitionId === alias)) + if (descriptor?.name === 'compact' && separator === -1) return '/compact' + if (descriptor?.name === 'permission' && separator !== -1) return `/permission${trimmed.slice(separator)}` + return undefined } diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index d374124488..ffd65351f8 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -284,10 +284,10 @@ export class HarnessSdkJsonRpcServer { } /** - * Dispatch text as a Desktop-style input without changing Session permissions. + * Dispatch text as a Desktop-style input; native commands own Session state changes. * @param params - text, receiving Session and ordinary-prompt delivery. - * @returns an enqueue receipt for text or the native command result for `/compact`. - * @throws on runtime failure; a recognized compact failure never falls back to a prompt. + * @returns an enqueue receipt for text or the supported native command's result. + * @throws on runtime failure; a recognized command failure never falls back to a prompt. */ async query(params: SessionQueryParams): Promise { if (!this.initialized) throw new Error('SDK server is not initialized') @@ -312,7 +312,7 @@ export class HarnessSdkJsonRpcServer { } /** - * Execute `/compact` through the native command registry without enqueueing a prompt. + * Execute `/compact` or `/permission ` through the native command registry. * @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. @@ -320,8 +320,10 @@ export class HarnessSdkJsonRpcServer { async executeCommand(params: SessionCommandParams): Promise { if (!this.initialized) throw new Error('SDK server is not initialized') if (this.shuttingDown) throw new Error('SDK server is shutting down') - if (parseCommand(params.line)?.name !== 'compact') { - throw new TypeError('The SDK command endpoint supports only /compact') + const parsed = parseCommand(params.line) + if (parsed === undefined || (parsed.name !== 'compact' + && (parsed.name !== 'permission' || parsed.rawInput.trim() === ''))) { + throw new TypeError('The SDK command endpoint supports only /compact or /permission ') } const commands = this.ctx.get('commands') if (commands === undefined) { @@ -335,7 +337,7 @@ export class HarnessSdkJsonRpcServer { try { const result = await task if (result === undefined) { - throw new Error('/compact is unavailable in this SDK profile') + throw new Error(`/${parsed.name} is unavailable in this SDK profile`) } return result } finally { diff --git a/packages/sdk/server/tests/commands.spec.ts b/packages/sdk/server/tests/commands.spec.ts index 8376c2a60a..f0f693d2a1 100644 --- a/packages/sdk/server/tests/commands.spec.ts +++ b/packages/sdk/server/tests/commands.spec.ts @@ -84,7 +84,7 @@ describe('SDK command dispatch', () => { expect(h.ctx.agents.list()).toEqual([]) }) - it.each(['text', '/plan', '/unknown'])('rejects unsupported input %s without invoking another command', async (line) => { + it.each(['text', '/plan', '/unknown', '/permission', '/permission \t\n'])('rejects unsupported input %s without invoking another command', async (line) => { const h = await setup() await h.initialize() const handler = vi.fn(() => ({ kind: 'success' as const })) @@ -102,6 +102,26 @@ describe('SDK command dispatch', () => { const empty = await setup() await empty.initialize() await expect(empty.command()).rejects.toThrow('/compact is unavailable') + await expect(empty.server.executeCommand({ sessionId: SessionId('commands'), line: '/permission read-only' })) + .rejects.toThrow('/permission is unavailable') + }) + + it.each(['strict', 'query'] as const)('passes explicit permission input to the native handler through %s', async (entry) => { + 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 })) + const sessionId = SessionId('permission') + const line = '/permission deployment-preset' + const result = entry === 'strict' + ? await h.server.executeCommand({ sessionId, line }) + : await h.server.query({ sessionId, text: line, delivery: 'steer' }) + expect(handler).toHaveBeenCalledOnce() + expect(handler.mock.calls[0]![0].rawInput).toBe(' deployment-preset') + const expected = { result: { kind: 'error', text: 'native preset refusal' } } + expect(result).toMatchObject(entry === 'strict' ? expected : { kind: 'command', execution: expected }) + expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().map(event => event.type)) + .toEqual(['command/run', 'command/done']) }) it('passes arguments unchanged and propagates handler failures', async () => { @@ -182,9 +202,9 @@ describe('SDK query dispatch', () => { expect(h.ctx.agents.list()).toEqual([]) }) - it('preserves ordinary input without a command registry and requires a live initialized server', async () => { + it.each(['/compact', '/permission read-only'])('preserves %s without a command registry and requires a live initialized server', async (text) => { const h = await setup(false) - const params = { sessionId: SessionId('q'), text: '/compact', delivery: 'queue' as const } + const params = { sessionId: SessionId('q'), text, delivery: 'queue' as const } await expect(h.server.query(params)).rejects.toThrow('not initialized') const model = new TextModel() h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) @@ -236,13 +256,14 @@ describe('SDK query dispatch', () => { expect(queued).not.toHaveBeenCalled() }) - it.each(['/plan inspect', '/model', '/file', '/export'])('preserves existing prompt behavior for unimplemented input %s', async (text) => { + it.each(['/plan inspect', '/model', '/file', '/export', '/permission', ' \n/权限\t '])('preserves existing prompt behavior for unimplemented input %s', async (text) => { 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: 'plan', description: 'Plan', input: { hint: 'Task' }, handler })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', description: 'Permission', input: { hint: 'Preset' }, handler })) await expect(h.server.query({ sessionId: SessionId('q'), text, delivery: 'queue' })).resolves.toMatchObject({ kind: 'prompt' }) await h.server.wait({ sessionId: 'q' }) expect(handler).not.toHaveBeenCalled() diff --git a/packages/sdk/server/tests/permission-parity.spec.ts b/packages/sdk/server/tests/permission-parity.spec.ts new file mode 100644 index 0000000000..2fe5b35e6c --- /dev/null +++ b/packages/sdk/server/tests/permission-parity.spec.ts @@ -0,0 +1,187 @@ +/** Native permission switching and its next-request context through the SDK. */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import PermissionPresetService from '@deepseek-ai/dsh-permission-presets' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import ShellExecutor from '@deepseek-ai/dsh-shell' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { ApprovalService } from '@deepseek-ai/dsh-user-approval' +import { expect, it } from 'vitest' +import { HarnessSdkJsonRpcServer } from '../src/server.ts' + +/** The model never executes tools; only the shell's declared confinement is needed. */ +class UnusedShell extends ShellExecutor { + static inject = ['sandboxPolicy'] + + override get sandboxMode() { return this.ctx.sandboxPolicy.defaultMode } + override resolve(): never { throw new Error('The permission model does not execute shell commands') } + override execute(): never { throw new Error('The permission model does not execute shell commands') } +} + +function modelInput(options: GenerateOptions) { + return structuredClone({ + provider: options.provider, + model: options.model, + system: options.system, + messages: options.messages.map(message => ({ role: message.role, content: message.content })), + tools: options.tools, + toolHistory: options.toolHistory, + maxTokens: options.maxTokens, + reasoningEffort: options.reasoningEffort, + temperature: options.temperature, + stop: options.stop, + purpose: options.purpose, + }) +} + +class PermissionModel extends LlmAdapter { + readonly requests: ReturnType[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model }) + } + + override async *stream(options: GenerateOptions): AsyncIterable { + this.requests.push(modelInput(options)) + const text = this.requests.length === 1 ? 'PERMISSION_READY' : 'PERMISSION_CONTINUED' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function switchAndContinue(path: 'sdk' | 'native', cwd: string) { + const ctx = new Context() + let server: HarnessSdkJsonRpcServer | undefined + const sessionId = SessionId('permission-parity') + const events: SessionEvent[] = [] + try { + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: cwd }) + await ctx.plugin(UnusedShell) + await ctx.plugin(ApprovalService, { policy: 'never' }) + await ctx.plugin(CommandRuntime) + await ctx.plugin(PermissionPresetService, { + presets: { + 'read-only': { sandbox: 'read-only', approval: 'ask' }, + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' }, + }, + }) + ctx.on('session/event', (session, event) => { + if (session.id === sessionId) events.push(event) + }) + ctx.effect(() => ctx.tools.register(defineTool({ + name: 'read_marker', + description: 'Read a workspace marker.', + parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: () => Promise.reject(new Error('The permission model does not execute tools')), + }))) + const model = new PermissionModel() + ctx.effect(() => ctx.llm.registerAdapter(['permission-parity'], model)) + server = new HarnessSdkJsonRpcServer(ctx, { + request: async () => { throw new Error('The permission model does not request approval') }, + notify: () => {}, + }) + const runtime = server + const initialConfig = { cwd, provider: 'permission-parity', model: 'permission-model', maxTokens: 321 } + await server.initialize(initialConfig) + await server.prompt({ sessionId, contentBlocks: [{ type: 'text', text: 'Keep the original task in context.' }] }) + await server.wait({ sessionId }) + const agent = ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('The initial prompt did not create its Agent') + const dispatch = async (line: string) => { + if (path === 'native') return ctx.commands.execute(agent, line, [], new AbortController().signal) + const routed = await runtime.query({ sessionId, text: line, delivery: 'queue' }) + if (routed.kind !== 'command') throw new Error('The explicit permission input was submitted as a prompt') + return routed.execution + } + const beforeSwitch = events.length + const changed = await dispatch('/permission read-only') + expect(changed?.result).toEqual({ kind: 'success', text: 'preset read-only' }) + const switchEvents = events.slice(beforeSwitch) + expect(switchEvents.map(event => event.type)).toEqual([ + 'command/run', 'permission/preset', 'sandbox/mode', 'approval/policy', 'agent/inbox/spliced', 'command/done', + ]) + const beforeNoop = events.length + const unchanged = await dispatch('/permission read-only') + expect(unchanged?.result).toEqual(changed?.result) + expect(events.slice(beforeNoop).map(event => event.type)).toEqual(['command/run', 'command/done']) + const beforeInvalid = events.length + const invalid = await dispatch('/permission missing-preset') + expect(invalid?.result).toEqual({ + kind: 'error', text: 'unknown preset "missing-preset" (available: read-only, workspace-write, danger-full-access)', + }) + expect(events.slice(beforeInvalid).map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(model.requests).toHaveLength(1) + expect(ctx.permissionPresets.current(agent.session)).toBe('read-only') + expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe('read-only') + expect(switchEvents.find(event => event.type === 'approval/policy')?.data).toEqual({ policy: 'ask' }) + + await server.prompt({ sessionId, contentBlocks: [{ type: 'text', text: 'Continue the original task without tools.' }] }) + await server.wait({ sessionId }) + await server.getWorkingDirectory({ sessionId: 'permission-fresh' }) + const fresh = ctx.agents.get(SessionId('permission-fresh')) + if (fresh === undefined) throw new Error('The second Session was not created') + expect(ctx.permissionPresets.current(fresh.session)).toBe('danger-full-access') + const defaults = { + preset: ctx.permissionPresets.defaultPreset, + sandbox: ctx.sandboxPolicy.defaultMode, + approval: ctx.approval.config.policy, + } + expect(defaults).toEqual({ preset: 'danger-full-access', sandbox: 'danger-full-access', approval: 'never' }) + expect(initialConfig).toEqual({ cwd, provider: 'permission-parity', model: 'permission-model', maxTokens: 321 }) + return { + requests: model.requests, + defaults, + permissionEvents: events.flatMap((event) => { + if (event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy') { + return [{ type: event.type, data: event.data }] + } + return [] + }), + turns: events.flatMap(event => event.type === 'turn/start' ? [event.data.turn] : []), + steps: events.flatMap(event => event.type === 'step/start' ? [{ turn: event.data.turn, step: event.data.step }] : []), + } + } finally { + try { await server?.shutdown() } finally { await ctx.fiber.dispose() } + } +} + +it('preserves native permission events, unchanged defaults, and the next model request through query', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-sdk-permission-parity-')) + try { + const native = await switchAndContinue('native', cwd) + const sdk = await switchAndContinue('sdk', cwd) + expect(sdk).toEqual(native) + expect(sdk.requests).toHaveLength(2) + expect(sdk.turns).toEqual([1, 2]) + expect(sdk.steps).toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }]) + expect(sdk.requests.map(request => request.maxTokens)).toEqual([321, 321]) + expect(sdk.requests[1]?.tools).toEqual(sdk.requests[0]?.tools) + expect(sdk.requests[1]?.messages[0]).toEqual(sdk.requests[0]?.messages[0]) + const content = sdk.requests[1]?.messages.flatMap(message => message.content) ?? [] + const texts = content.flatMap(block => block.type === 'text' ? [block.text] : []) + expect(texts).toContain('PERMISSION_READY') + expect(texts).toContain('The approval policy changed from "never" to "ask" (changed by the user).') + expect(texts.some(text => text.includes('Current DSH file policy: read-only.'))).toBe(true) + expect(texts.some(text => text.includes('Approval policy: ask.'))).toBe(true) + expect(texts).not.toContain('/permission read-only') + expect(texts).not.toContain('preset read-only') + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk/server/tests/query-input.spec.ts b/packages/sdk/server/tests/query-input.spec.ts index 3e9f43f45b..80b0787bb4 100644 --- a/packages/sdk/server/tests/query-input.spec.ts +++ b/packages/sdk/server/tests/query-input.spec.ts @@ -14,29 +14,43 @@ const commands: readonly CommandDescriptor[] = [ describe('SDK query text recognition', () => { it.each([ ['/compact', '/compact'], [' \n/compact \t', '/compact'], ['/压缩', '/compact'], + ['/permission workspace-write', '/permission workspace-write'], + [' \n/权限\tread-only \t', '/permission\tread-only'], + ['/permission\ncustom-deployment-preset', '/permission\ncustom-deployment-preset'], + ['/permission missing extra', '/permission missing extra'], ])('recognizes the Desktop form %j', (text, expected) => { expect(queryCommand(text, commands)).toBe(expected) }) it.each(['ordinary text', '/', '/unknown', '/usr/bin', '/COMPACT', '/模型', '/文件', '/compact extra', '/压缩 extra', '/export path', '/model extra', '/file path', - '/plan inspect', '/计划 inspect', '/goal finish', '/权限 read-only', '/反馈 problem', + '/plan inspect', '/计划 inspect', '/goal finish', '/反馈 problem', + '/permission', ' \n/permission \t', '/权限', '/权限\n\t', '/permissions read-only', '/导出', '/export', '/model', '/file'])('leaves %j as ordinary text', (text) => { expect(queryCommand(text, commands)).toBeUndefined() }) it('does not apply an alias to an unrelated scoped replacement', () => { - const replaced = [{ name: 'compact', description: 'Scoped command' }] + const replaced = [{ name: 'compact', description: 'Scoped command' }, { name: 'permission', description: 'Scoped permission' }] expect(queryCommand('/compact', replaced)).toBe('/compact') expect(queryCommand('/压缩', replaced)).toBeUndefined() + expect(queryCommand('/permission read-only', replaced)).toBe('/permission read-only') + expect(queryCommand('/权限 read-only', replaced)).toBeUndefined() }) - it('leaves other registered commands and unavailable compact unchanged', () => { + it('leaves other registered commands and unavailable supported commands unchanged', () => { expect(queryCommand('/plan', commands)).toBeUndefined() expect(queryCommand('/compact', [])).toBeUndefined() 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() + expect(queryCommand('/权限 read-only', [{ name: 'access', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Renamed permission' }])) + .toBeUndefined() }) + it('preserves exact-name priority over localized aliases', () => { + const shadowed = [...commands, { name: '权限', description: 'Another definition' }] + expect(queryCommand('/权限 read-only', shadowed)).toBeUndefined() + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 758883e516..54780d85fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13423,6 +13423,12 @@ importers: '@deepseek-ai/dsh-llm-deepseek-api-key': specifier: workspace:* version: link:../../llm/llm-deepseek-api-key + '@deepseek-ai/dsh-permission-presets': + specifier: workspace:* + version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:* + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-schedule': specifier: workspace:* version: link:../../schedule/schedule @@ -13441,6 +13447,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection + '@deepseek-ai/dsh-shell': + specifier: workspace:* + version: link:../../shell/shell '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6e50f3f36c..a2ce3bcf40 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,8 +12,8 @@ en: c4c30913fe2642ef zh: 0ed42bca37fba60b /deepseek-harness-python-sdk/query-and-response: - en: b3ad6ea82350b467 - zh: 85dacc783fd12992 + en: a3f6c4ee867928a8 + zh: f5af634f4fcdc547 /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 diff --git a/python/sdk/README.md b/python/sdk/README.md index b7d2252968..4ecbd34a09 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -61,13 +61,15 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: ## Query and response -Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for the runtime's response. The runtime intercepts only `/compact` as a command; all other text, including `/plan` and other slash-prefixed input, remains an ordinary prompt. This input API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. +Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact` and `/permission ` with a non-blank preset, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/plan`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. `receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value. `CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline. -The full `sdk` profile provides the intercepted `/compact` command. It can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. The default `sdk-minimal` profile does not provide command execution. `session.run("/compact")` remains ordinary prompt text. The lower-level blocking `HarnessClient.session_command(session_id, line)` remains available for strict command execution and rejects command names other than `/compact`. See the [full SDK compaction example](examples/README.md#compact-a-full-sdk-session). +The full `sdk` profile provides both commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact` or `/permission ` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session). + +`/permission ` delegates to the native permission handler. An unknown preset returns `CommandExecution.result.kind="error"` without changing permission state. A successful change belongs to that Session: later queries and restoration retain it, other Sessions retain their own state, and new Sessions use configured defaults. `DeepSeekHarnessConfig` remains startup input, not a shared live state mirror; the command does not rewrite it or saved user defaults. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions). ## Client approvals diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 5037b87308..1d95996069 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -61,13 +61,15 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: ## 查询与接收 -调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时响应。运行时只将 `/compact` 拦截为命令;包括 `/plan` 和其他以斜杠开头的输入在内,其余文本仍作为普通提示词。此输入 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 +调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact` 和带非空 preset 的 `/permission `,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/plan`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 `receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。 `CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。 -完整 `sdk` profile 提供被拦截的 `/compact` 命令。它可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。默认 `sdk-minimal` profile 不提供命令执行能力。`session.run("/compact")` 仍按普通提示词文本处理。底层阻塞式 `HarnessClient.session_command(session_id, line)` 仍可用于严格命令执行,并拒绝 `/compact` 之外的命令名。另见[完整 SDK 压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 +完整 `sdk` profile 提供这两个命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact` 或 `/permission `,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 + +`/permission ` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。 ## 客户端审批 diff --git a/python/sdk/examples/README.i18n.yaml b/python/sdk/examples/README.i18n.yaml index eb2644edc5..1a5a1b1f8f 100644 --- a/python/sdk/examples/README.i18n.yaml +++ b/python/sdk/examples/README.i18n.yaml @@ -9,8 +9,11 @@ en: 4b79fd6576e2759d zh: 6258540acaf4bc37 /python-sdk-example/compact-a-full-sdk-session: - en: 0bada22014bf5e6c - zh: 4308ea1a14b48f57 + en: ad4d67225570ed01 + zh: 0d59f6dac40129d6 +/python-sdk-example/change-session-permissions: + en: c1162e41cd57dec4 + zh: 96f608fa25e26c77 /python-sdk-example/add-plugins: en: 5e1e30dac13f6c9e zh: 0af98ebb75c88f43 diff --git a/python/sdk/examples/README.md b/python/sdk/examples/README.md index 80722e4d97..470f697606 100644 --- a/python/sdk/examples/README.md +++ b/python/sdk/examples/README.md @@ -42,6 +42,20 @@ python python/sdk/examples/compact.py \ The script consumes notifications and prints the command id and outcome separately from the model's responses. A failed turn or command stops the example. See [Query and response](../README.md#query-and-response) for completion and error behavior; `minimal.py` retains its `run()` entry and does not enable command execution. + +## Change Session permissions + +The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission `, then submits an ordinary query on the same Session. Supply the preset explicitly: + +```sh +python python/sdk/examples/permission.py \ + --dsh-home /absolute/path/to/permission-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --preset read-only +``` + +The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later query uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Query and response](../README.md#query-and-response) for result and state semantics. + ## Add plugins Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes: diff --git a/python/sdk/examples/README.zh.md b/python/sdk/examples/README.zh.md index 5a208da7f6..a3f0968e33 100644 --- a/python/sdk/examples/README.zh.md +++ b/python/sdk/examples/README.zh.md @@ -42,6 +42,20 @@ python python/sdk/examples/compact.py \ 脚本消费通知,并将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[查询与接收](../README.zh.md#query-and-response);`minimal.py` 保留其 `run()` 入口,不启用命令执行。 + +## 修改 Session 权限 + +[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission `,随后在同一个 Session 中提交普通 query。请显式指定 preset: + +```sh +python python/sdk/examples/permission.py \ + --dsh-home /absolute/path/to/permission-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --preset read-only +``` + +命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续 query 使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[查询与接收](../README.zh.md#query-and-response)。 + ## 添加插件 对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更: diff --git a/python/sdk/examples/permission.py b/python/sdk/examples/permission.py new file mode 100644 index 0000000000..d97012f962 --- /dev/null +++ b/python/sdk/examples/permission.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Select a full SDK Session's permission preset and continue without resetting it.""" + +from __future__ import annotations + +import argparse +import os +from contextlib import closing +from pathlib import Path + +from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session + + +def receive(session: Session) -> None: + """Print a command or activity result and stop on a reported failure.""" + with closing(session.receive_response()) as responses: + for result in responses: + if isinstance(result, CommandExecution): + print(f"Command {result.command_id}: {result.result.kind}") + if result.result.kind == "error": + raise RuntimeError(result.result.text) + if result.result.text is not None: + print(result.result.text) + elif isinstance(result, RunResult): + if result.finish_reason != "completed": + raise RuntimeError(f"Agent turn ended with {result.finish_reason}") + print(result.final_response) + + +def main() -> None: + """Send an explicit permission command, then query the same Session.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dsh-home", required=True, type=Path) + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--preset", required=True) + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) + args = parser.parse_args() + if not args.preset.strip(): + parser.error("--preset must be non-blank") + + with DeepSeekHarness( + dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()), + profile="sdk", provider=args.provider, model=args.model, + env={"DSH_PERMISSION_MODE": "workspace-write"}, + ) as harness: + session = harness.start_session() + session.query("Summarize the current file and approval policy in one sentence. Do not use tools.") + receive(session) + session.query(f"/permission {args.preset}") + receive(session) + print(f"Launch setting: {harness.config.env['DSH_PERMISSION_MODE']}") + session.query("Summarize the current file and approval policy again. Do not use tools.") + receive(session) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 9fd5e1e88f..ba61b14a24 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -20,6 +20,8 @@ class DeepSeekHarnessConfig: The runtime inherits the caller's environment by default, so existing DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to intentionally override or inject variables for a subprocess. + These values are startup input, not a live Session state mirror; Session + commands do not rewrite this object. """ provider: str = "deepseek-official" diff --git a/python/sdk/tests/test_commands_runtime.py b/python/sdk/tests/test_commands_runtime.py index 343e9f1e3d..9335647df2 100644 --- a/python/sdk/tests/test_commands_runtime.py +++ b/python/sdk/tests/test_commands_runtime.py @@ -1,4 +1,4 @@ -"""Exercise /compact through Python and the real sdk profile with a local model. +"""Exercise Session commands through Python and the real sdk profile with a local model. The default carrier is the repository's built dsh CLI. Set DSH_COMMANDS_TEST_RUNTIME=installed to run these same cases against the imported @@ -171,9 +171,10 @@ class _Runtime: @contextmanager def _runtime( tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None, + initial_permission: str = "danger-full-access", ) -> Iterator[_Runtime]: workspace = tmp_path / "workspace" - workspace.mkdir() + workspace.mkdir(exist_ok=True) home = tmp_path / "home" logs = tmp_path / "sessions" patch = tmp_path / "commands.patch.yml" @@ -191,7 +192,7 @@ def _runtime( dsh_bin=dsh_bin, profile="sdk", patches=tuple(str(item) for item in patches), model=_MODEL, base_url=model.url, api_key="keyless-commands-runtime", initialize_timeout_seconds=60, request_timeout_seconds=20, shutdown_timeout_seconds=20, - env={"DSH_PERMISSION_MODE": "danger-full-access", + env={"DSH_PERMISSION_MODE": initial_permission, "DSH_TELEMETRY_DISABLED": "1", "DSH_TOOLS_MODE": "native"}, ) try: @@ -203,9 +204,14 @@ def _runtime( harness.close() -def _read_events(root: Path, *, live: bool = False) -> list[dict]: - paths = list(root.rglob("session*.jsonl")) - assert len(paths) == 1, f"expected one fresh Session log, found {paths}" +def _read_events(root: Path, *, live: bool = False, session_id: str = _SESSION_ID) -> list[dict]: + paths = [] + for path in root.rglob("session*.jsonl"): + with path.open(encoding="utf-8") as log: + header = json.loads(log.readline()) + if header.get("id") == session_id: + paths.append(path) + assert len(paths) == 1, f"expected one Session log for {session_id}, found {paths}" content = paths[0].read_bytes() if not live: assert content.endswith(b"\n"), "closed Session log has an incomplete final row" @@ -213,7 +219,7 @@ def _read_events(root: Path, *, live: bool = False) -> list[dict]: complete = content[:content.rfind(b"\n") + 1] records = [json.loads(line) for line in complete.splitlines()] assert records and records[0]["type"] == "session" - assert records[0]["id"] == _SESSION_ID + assert records[0]["id"] == session_id events = records[1:] for seq, event in enumerate(events): assert isinstance(event, dict) @@ -225,13 +231,13 @@ def _of_type(events: list[dict], name: str) -> list[dict]: return [event for event in events if event["type"] == name] -def _command_pair(events: list[dict], execution: CommandExecution) -> None: +def _command_pair(events: list[dict], execution: CommandExecution, name: str = "compact") -> None: runs = [event for event in _of_type(events, "command/run") if event["data"]["commandId"] == execution.command_id] done = [event for event in _of_type(events, "command/done") if event["data"]["commandId"] == execution.command_id] assert len(runs) == len(done) == 1 - assert runs[0]["data"]["name"] == "compact" + assert runs[0]["data"]["name"] == name assert runs[0]["seq"] < done[0]["seq"] assert done[0]["data"]["kind"] == execution.result.kind assert done[0]["data"].get("text") == execution.result.text @@ -463,3 +469,153 @@ def test_unimplemented_slash_remains_plain_text_without_changing_plan_mode(tmp_p assert not _of_type(events, "command/run") assert not any(event["data"].get("active") is True for event in _of_type(events, "plan/mode")) assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 + + +_PERMISSION_FACTS = {"permission/preset", "sandbox/mode", "approval/policy"} +_ASK_POLICY = "Approval policy: ask." +_NEVER_POLICY = "Approval prompts are disabled in this session" + + +def _assert_request_permission(request: dict, sandbox: str, approval: str) -> None: + contexts = [text for text in _request_texts(request) if text.startswith("Current runtime context.")] + assert contexts, "model request has no runtime-context snapshot" + assert f"Current DSH file policy: {sandbox}." in contexts[-1] + assert (_ASK_POLICY if approval == "ask" else _NEVER_POLICY) in contexts[-1] + + +def _permission_facts(events: list[dict]) -> list[dict]: + return [{"type": event["type"], "data": event["data"]} + for event in events if event["type"] in _PERMISSION_FACTS] + + +def _permission_command(runtime: _Runtime, session: Session, preset: str) -> CommandExecution: + assert runtime.call(session.query, f"/permission {preset}") is None + result = runtime.call(_receive_result, session) + assert isinstance(result, CommandExecution) + return result + + +def test_permission_changes_only_the_selected_session_and_retains_prompt_semantics(tmp_path: Path) -> None: + completions = [_Completion(f"PERMISSION_REPLY_{index}") for index in range(9)] + with _runtime(tmp_path, completions, initial_permission="workspace-write") as runtime: + main = runtime.harness.start_session(_SESSION_ID) + other = runtime.harness.start_session("permission-other") + initial_config = dict(runtime.harness.config.env) + for session, text in [(main, "Initial main policy; do not use tools."), + (other, "Initial other policy; do not use tools.")]: + runtime.call(session.query, text) + assert isinstance(runtime.call(_receive_result, session), RunResult) + + readonly = _permission_command(runtime, main, "read-only") + invalid = _permission_command(runtime, main, "no-such-preset") + repeated = _permission_command(runtime, main, "read-only") + assert readonly.result.kind == repeated.result.kind == "success" + assert invalid.result.kind == "error" + assert "unknown preset" in invalid.result.text + assert len(runtime.model.captured()) == 2, "idle permission commands woke the model" + + runtime.call(main.query, "Read-only policy after a plain query; do not use tools.") + assert isinstance(runtime.call(_receive_result, main), RunResult) + full = _permission_command(runtime, main, "danger-full-access") + assert full.result.kind == "success" + assert len(runtime.model.captured()) == 3 + runtime.call(main.query, "Full policy after a plain query; do not use tools.") + assert isinstance(runtime.call(_receive_result, main), RunResult) + restored = _permission_command(runtime, main, "workspace-write") + assert restored.result.kind == "success" + runtime.call(main.query, "Switched-back policy; do not use tools.") + assert isinstance(runtime.call(_receive_result, main), RunResult) + + for session in [other, runtime.harness.start_session("permission-new")]: + runtime.call(session.query, "Report your own policy; do not use tools.") + assert isinstance(runtime.call(_receive_result, session), RunResult) + ordinary = runtime.call(main.run, "/permission danger-full-access") + assert isinstance(ordinary, RunResult) + runtime.call(main.query, "/permission") + assert isinstance(runtime.call(_receive_result, main), RunResult) + assert runtime.harness.config.env == initial_config + + requests = runtime.model.captured() + assert len(requests) == 9 + for index in [0, 1, 4, 5, 6, 7, 8]: + _assert_request_permission(requests[index], "workspace-write", "ask") + _assert_request_permission(requests[2], "read-only", "ask") + _assert_request_permission(requests[3], "danger-full-access", "never") + assert 'The approval policy changed from "ask" to "never" (changed by the user).' in _request_texts(requests[3]) + assert 'The approval policy changed from "never" to "ask" (changed by the user).' in _request_texts(requests[4]) + for index in [2, 3, 4]: + assert requests[index]["system"] == requests[0]["system"] + assert requests[index]["tools"] == requests[0]["tools"] + assert "/permission danger-full-access" in _request_texts(requests[7]) + assert "/permission" in _request_texts(requests[8]) + + events = _read_events(runtime.logs) + executions = [readonly, invalid, repeated, full, restored] + assert len(_of_type(events, "command/run")) == len(executions) + for execution in executions: + _command_pair(events, execution, "permission") + for execution in [invalid, repeated]: + start = next(event["seq"] for event in _of_type(events, "command/run") + if event["data"]["commandId"] == execution.command_id) + done = next(event["seq"] for event in _of_type(events, "command/done") + if event["data"]["commandId"] == execution.command_id) + assert not _permission_facts(events[start + 1:done]) + for session_id in [_SESSION_ID, "permission-other", "permission-new"]: + state = {event["type"]: event["data"] for event in _permission_facts(_read_events(runtime.logs, session_id=session_id))} + assert state["permission/preset"] == {"preset": "workspace-write"} + assert state["sandbox/mode"]["mode"] == "workspace-write" + assert state["approval/policy"] == {"policy": "ask"} + + +def test_running_permission_change_keeps_the_native_extra_step_and_notifications(tmp_path: Path) -> None: + first = _Completion("PERMISSION_FIRST_DONE", blocked=True) + second = _Completion("PERMISSION_SWITCH_SEEN") + with _runtime(tmp_path, [first, second], initial_permission="workspace-write") as runtime: + session = runtime.harness.start_session(_SESSION_ID) + runtime.call(session.query, "Wait for the policy change, then finish without tools.") + assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures + claimed = threading.Event() + observing = runtime.workers.submit(_receive_result, session, claimed) + assert claimed.wait(_WAIT_SECONDS) + execution = _permission_command(runtime, session, "danger-full-access") + assert execution.result.kind == "success" + assert not first.release.is_set() + assert not observing.done() + assert len(runtime.model.captured()) == 1 + first.release.set() + result = observing.result(timeout=_WAIT_SECONDS) + assert isinstance(result, RunResult) + assert result.final_response == second.text + requests = runtime.model.captured() + assert len(requests) == 2 + _assert_request_permission(requests[0], "workspace-write", "ask") + _assert_request_permission(requests[1], "danger-full-access", "never") + assert requests[0]["system"] == requests[1]["system"] + assert requests[0]["tools"] == requests[1]["tools"] + notice = 'The approval policy changed from "ask" to "never" (changed by the user).' + assert notice not in _request_texts(requests[0]) + assert notice in _request_texts(requests[1]) + assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write" + events = _read_events(runtime.logs) + _command_pair(events, execution, "permission") + assert [event["data"] for event in _of_type(events, "step/start")] == [ + {"turn": 1, "step": 1}, {"turn": 1, "step": 2}, + ] + assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 1 + + +def test_resumed_permission_survives_different_launch_defaults(tmp_path: Path) -> None: + with _runtime(tmp_path, [], initial_permission="workspace-write") as runtime: + session = runtime.harness.start_session(_SESSION_ID) + execution = _permission_command(runtime, session, "read-only") + assert execution.result.kind == "success" + assert runtime.model.captured() == [] + saved = _permission_facts(_read_events(runtime.logs)) + with _runtime(tmp_path, [_Completion("RESUMED_PERMISSION")], initial_permission="danger-full-access") as resumed: + session = resumed.call(resumed.harness.resume_session, _SESSION_ID) + resumed.call(session.query, "Report the restored policy without using tools.") + assert isinstance(resumed.call(_receive_result, session), RunResult) + [request] = resumed.model.captured() + _assert_request_permission(request, "read-only", "ask") + assert resumed.harness.config.env["DSH_PERMISSION_MODE"] == "danger-full-access" + assert _permission_facts(_read_events(resumed.logs)) == saved diff --git a/python/sdk/tests/test_queries.py b/python/sdk/tests/test_queries.py index 7ae561ed4e..39a24bcfb4 100644 --- a/python/sdk/tests/test_queries.py +++ b/python/sdk/tests/test_queries.py @@ -141,7 +141,7 @@ def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> assert_released(sdk) -@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/plan"]) +@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/plan", "/permission"]) @pytest.mark.parametrize("delivery", ["queue", "steer"]) def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None: session = sdk.start_session("main") diff --git a/snapshots/sdk/manual-compact/session.v4.jsonl b/snapshots/sdk/manual-compact/session.v4.jsonl index 7c0db5f61f..5786bc1b59 100644 --- a/snapshots/sdk/manual-compact/session.v4.jsonl +++ b/snapshots/sdk/manual-compact/session.v4.jsonl @@ -20,10 +20,10 @@ {"type":"command/run","data":{"commandId":"{{command:1}}","name":"compact","args":"","source":{"kind":"user"}}} {"type":"compaction/start","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":19}} -{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","summary":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"rawOutput":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":10},"shadowedSeqs":[8,9,10],"shadowedTokenCount":3714,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":65536,"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}} +{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","summary":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"rawOutput":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":10},"shadowedSeqs":[8,9,10],"shadowedTokenCount":3764,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":65536,"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}} {"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."},{"type":"text","text":""}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}"},"role":"user","id":"{{message:6}}"},"sourceEventSeqs":[19,21,8,9,10],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":10}} {"type":"compaction/end","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} -{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Compacted 3 history items (~3714 tokens).","sourceEventSeq":21}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Compacted 3 history items (~3764 tokens).","sourceEventSeq":21}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"DSH_COMMANDS_CONTINUE: answer using the retained checkpoint."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} diff --git a/snapshots/sdk/permission-switch/capture.cordis.yml b/snapshots/sdk/permission-switch/capture.cordis.yml new file mode 100644 index 0000000000..047956c58c --- /dev/null +++ b/snapshots/sdk/permission-switch/capture.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: permission-request-capture + name: '../manual-compact/capture-requests.mjs' + config: + path: !!js dshHomePath('permission-requests.jsonl') diff --git a/snapshots/sdk/permission-switch/session.v4.jsonl b/snapshots/sdk/permission-switch/session.v4.jsonl new file mode 100644 index 0000000000..3f2955a361 --- /dev/null +++ b/snapshots/sdk/permission-switch/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":"Remember this task. Reply SDK_PERMISSION_READY without using tools."}],"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":"Remember this task. Reply SDK_PERMISSION_READY without using tools."}],"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":"Remember this task. Reply SDK_PERMISSION","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":"permission","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 approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"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":"permission","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":"permission","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 approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"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 policy: ask. 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 policy: ask. 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/permission-switch/snapshot.yml b/snapshots/sdk/permission-switch/snapshot.yml new file mode 100644 index 0000000000..abd0b723b1 --- /dev/null +++ b/snapshots/sdk/permission-switch/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: permission-switch +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 793299faf8..b8ab2ea1e2 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -107,6 +107,10 @@ interface SdkAssertions { approvalOutcomes?: readonly ('allowed-once' | 'rejected')[] /** Additional profile patches applied after the shared composition. */ patches?: readonly string[] + /** Captured model-request JSONL filename under this scenario's private DSH home. */ + modelRequestsFile?: string + /** Replay caller-owned commands between turns; profile fixtures own any in-turn commands. */ + replayStandaloneCommands?: boolean /** Wait for the recorded provider title before sending each subsequent prompt. */ waitForProviderTitle?: boolean /** Final response required from a completed turn before updating goldens. */ @@ -148,6 +152,15 @@ const SDK_ASSERTIONS: Readonly> = { 'manual-compact': { expectedFinalResponse: 'SDK_COMPACT_OK', patches: [fileURLToPath(new URL('./manual-compact/capture.cordis.yml', import.meta.url))], + modelRequestsFile: 'compact-requests.jsonl', + replayStandaloneCommands: true, + }, + 'permission-switch': { + expectedFinalResponse: 'SDK_PERMISSION_OK', + environment: { DSH_PERMISSION_MODE: 'danger-full-access' }, + patches: [fileURLToPath(new URL('./permission-switch/capture.cordis.yml', import.meta.url))], + modelRequestsFile: 'permission-requests.jsonl', + replayStandaloneCommands: true, }, 'minimal-image-compaction': { expectedFinalResponse: 'MINIMAL_IMAGE_OK', @@ -492,14 +505,15 @@ function modelFromSession(log: string): { provider: string; model: string } { throw new Error('SDK snapshot session has no request model') } -/** Replay caller inputs from the log; in-turn commands need an explicit timing-aware controller. */ -function sdkActions(log: string): SdkAction[] { +/** Replay caller inputs; profile fixtures recreate their own in-turn command activity. */ +function sdkActions(log: string, replayStandaloneCommands: boolean): SdkAction[] { const actions: SdkAction[] = [] let current: TurnAction | undefined for (const record of records(log)) { if (record.type === 'command/run') { - if (current !== undefined) throw new Error('SDK snapshot cannot replay a command inside an open turn') + if (!replayStandaloneCommands || current !== undefined) continue const data = record.data as JsonObject | undefined + if ((data?.source as JsonObject | undefined)?.kind !== 'user') continue if (typeof data?.name !== 'string' || typeof data.args !== 'string') { throw new Error('SDK snapshot command/run requires a name and recorded string arguments') } @@ -664,13 +678,15 @@ async function runScenario(scenario: CorpusScenario): Promise<{ }> { const workspaceParent = await realpath(tmpdir()) let workspacePrefix = `sdk-snapshot-${scenario.name}-` - if (scenario.name === 'minimal-image-compaction') { + const fixedWidthCwd = scenario.name === 'minimal-image-compaction' || scenario.name === 'manual-compact' + if (fixedWidthCwd) { // mkdtemp appends six characters; JSON quoting is part of the runtime-context text. const paddingLength = 192 - JSON.stringify(join(workspaceParent, `${workspacePrefix}XXXXXX`)).length if (paddingLength < 0) throw new Error('compaction snapshot temporary root exceeds its fixed cwd text length') workspacePrefix += '-'.repeat(paddingLength) } const cwd = await realpath(await mkdtemp(join(workspaceParent, workspacePrefix))) + if (fixedWidthCwd) expect(JSON.stringify(cwd)).toHaveLength(192) const dshHome = join(cwd, '.dsh') const sessionsRoot = join(dshHome, 'sessions') const replayFixtures = recording ? [] : await hydrateReplayFixtures(scenario, cwd) @@ -711,7 +727,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) const [parentFixture, ...childFixtures] = replayFixtures - const actions = sdkActions(primaryFixture) + const actions = sdkActions(primaryFixture, assertions.replayStandaloneCommands === true) const resumeReplayFile = scenario.name === 'session-resume' && !recording ? join(patchRoot, 'resume-replay.json') : undefined const prepareTurnReplay = async (turn: number): Promise => { @@ -826,10 +842,10 @@ async function runScenario(scenario: CorpusScenario): Promise<{ expect(session.id).toBe(sessionId) } let result: RunResult | undefined - if (scenario.name === 'manual-compact') { + if (scenario.name === 'manual-compact' || scenario.name === 'permission-switch') { const content = action.content[0] if (action.content.length !== 1 || content?.type !== 'text' || typeof content.text !== 'string') { - throw new Error('manual compact query fixture requires one text block per prompt') + throw new Error(`${scenario.name} query fixture requires one text block per prompt`) } await session.query(content.text) for await (const message of session.receiveResponse()) { @@ -914,8 +930,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const finalWorkspace = await captureWorkspaceSnapshot(cwd, { ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) - const modelRequests = scenario.name === 'manual-compact' - ? records(await readFile(join(dshHome, 'compact-requests.jsonl'), 'utf8')) + const modelRequests = assertions.modelRequestsFile !== undefined + ? records(await readFile(join(dshHome, assertions.modelRequestsFile), 'utf8')) : [] return { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd, modelRequests } } finally { @@ -1090,6 +1106,53 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { ) reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate') const actualContext = contextOf(ordered, cwd) + if (scenario.name === 'permission-switch') { + const parent = ordered[0] + if (parent === undefined) throw new Error('permission switch has no persisted Session') + const events = parseSessionLog(parent.content) + const commands = events.filter(event => event.type === 'command/run') + expect(commands.map(event => ({ name: event.data.name, args: event.data.args }))).toEqual([ + { name: 'permission', args: ' read-only' }, + { name: 'permission', args: ' read-only' }, + { name: 'permission', args: ' missing-preset' }, + ]) + const completed = events.filter(event => event.type === 'command/done') + expect(completed.map(event => event.data.kind)).toEqual(['success', 'success', 'error']) + expect(completed.map(event => event.data.commandId)).toEqual(commands.map(event => event.data.commandId)) + expect(events.filter(event => event.type === 'permission/preset').map(event => event.data.preset)) + .toEqual(['danger-full-access', 'read-only']) + expect(events.filter(event => event.type === 'sandbox/mode').map(event => event.data.mode)) + .toEqual(['danger-full-access', 'read-only']) + expect(events.filter(event => event.type === 'approval/policy').map(event => event.data.policy)) + .toEqual(['never', 'ask']) + expect(events.filter(event => event.type === 'turn/start').map(event => event.data.turn)).toEqual([1, 2]) + expect(events.filter(event => event.type === 'step/start')).toHaveLength(2) + const notices = events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user-approval') + expect(notices).toHaveLength(1) + const noticeText = 'The approval policy changed from "never" to "ask" (changed by the user).' + expect(notices[0]?.data.content).toEqual([{ type: 'text', text: noticeText }]) + expect(modelRequests).toHaveLength(2) + const [initialRequest, continuationRequest] = modelRequests + if (initialRequest === undefined || continuationRequest === undefined) { + throw new Error('permission switch requires its initial and continuation request') + } + const initialMessages = initialRequest.messages as JsonObject[] + const continuationMessages = continuationRequest.messages as JsonObject[] + expect(continuationMessages[0]).toEqual(initialMessages[0]) + for (const field of ['provider', 'model', 'maxTokens', 'reasoningEffort', 'tools']) { + expect(continuationRequest[field], `permission switch retained ${field}`).toEqual(initialRequest[field]) + } + const runtimeContexts = continuationMessages.filter(message => (message.source as JsonObject | undefined)?.kind === 'runtime-context') + const runtimeText = JSON.stringify(runtimeContexts.at(-1)) + expect(runtimeText).toContain('Current DSH file policy: read-only.') + expect(runtimeText).toContain('Approval policy: ask.') + const texts = continuationMessages.flatMap(message => message.content as JsonObject[]) + .flatMap(block => block.type === 'text' ? [block.text] : []) + expect(texts).toContain('SDK_PERMISSION_READY') + expect(texts).toContain(noticeText) + expect(texts).not.toContain('/permission read-only') + expect(texts).not.toContain('preset read-only') + } if (scenario.name === 'manual-compact') { const parent = ordered[0] if (parent === undefined) throw new Error('manual compact has no persisted Session')