diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index f9546b5cc3..d1d737defa 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
-| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
+| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 843b22525b..e251d0b75e 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -29,7 +29,7 @@
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
-| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
+| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 03d9ee8d5e..5a7793de98 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -1446,6 +1446,7 @@ flowchart TD
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
pkg_sdk_jsonrpc_server --> pkg_session
pkg_sdk_jsonrpc_server --> pkg_subagent
+ pkg_sdk_jsonrpc_server --> pkg_user_approval
pkg_sdk_jsonrpc_server --> pkg_user_questions
pkg_sdk_jsonrpc_server --> pkg_working_directory
```
@@ -1804,5 +1805,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-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), [`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 1516a3f04a..56d63f2613 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -1448,6 +1448,7 @@ flowchart TD
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
pkg_sdk_jsonrpc_server --> pkg_session
pkg_sdk_jsonrpc_server --> pkg_subagent
+ pkg_sdk_jsonrpc_server --> pkg_user_approval
pkg_sdk_jsonrpc_server --> pkg_user_questions
pkg_sdk_jsonrpc_server --> pkg_working_directory
```
@@ -1806,5 +1807,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-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), [`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 c986db7993..b949664d35 100644
--- a/packages/sdk/client/README.i18n.yaml
+++ b/packages/sdk/client/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-client/use-this-package:
- en: ff421f12a5db28a4
- zh: 2ca9b71a3458b5e0
+ en: 6c37c21a2e53376b
+ zh: e707d3e81c1242bd
/deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness:
en: 01c58dddd7e62de2
zh: 6dbd46e244f3f135
diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md
index ae9661faab..7d7048c6fc 100644
--- a/packages/sdk/client/README.md
+++ b/packages/sdk/client/README.md
@@ -25,12 +25,14 @@ English | [中文](README.zh.md)
## Use this package
+Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped.
+
+For client-owned approvals, call `harness.client.start()`, install `harness.client.onRequest(handler)`, then call `harness.start()` with `approvalRequests: true` in the Harness options. The handler receives `approval/request` parameters and returns `{ outcome: "allowed-once" | "rejected" | "cancelled" }`; a rejected handler returns a JSON-RPC error and the runtime fails that approval closed. A replacement client needs its own handler; a closed client cannot restart. Native user questions keep their `onUserQuestion` callback. Enabling approvals without the additional-request handler returns an unknown-request error and fails approvals closed as `unavailable`. Without the opt-in, existing approval answerers remain in control. See the [wire protocol](../protocol/README.md) for request fields and cancellation.
+
`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. Shell, browser, and other external processes are not restored by this API.
`HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument.
-Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped.
-
### Running agent turns with DeepSeekHarness
```ts
diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md
index 9edfd4a4cb..b55b0f399b 100644
--- a/packages/sdk/client/README.zh.md
+++ b/packages/sdk/client/README.zh.md
@@ -25,12 +25,14 @@ kind: "package-library"
## 使用本包
+当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。
+
+若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。原生问答仍由 `onUserQuestion` 回调处理。启用审批但未安装附加请求处理器时,服务端请求收到未知请求错误,审批以 `unavailable` 拒绝放行。未启用时保留现有审批应答链。请求字段与取消规则见[协议](../protocol/README.zh.md)。
+
`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
`HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。
-当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。
-
### 用 DeepSeekHarness 运行 agent 轮次
```ts
diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts
index 33436b0074..85e61a77ac 100644
--- a/packages/sdk/client/src/api.ts
+++ b/packages/sdk/client/src/api.ts
@@ -27,6 +27,7 @@ export class DeepSeekHarness implements AsyncDisposable {
private readonly model: string
private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort']
private readonly maxTokens: number | undefined
+ private readonly approvalRequests: boolean | undefined
private initialized: Promise | undefined
private closed = false
@@ -43,6 +44,7 @@ export class DeepSeekHarness implements AsyncDisposable {
this.model = options.model ?? 'deepseek-v4-flash'
this.reasoningEffort = options.reasoningEffort
this.maxTokens = options.maxTokens
+ this.approvalRequests = options.approvalRequests
}
/**
@@ -76,6 +78,7 @@ export class DeepSeekHarness implements AsyncDisposable {
model: this.model,
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
+ ...this.approvalRequests === undefined ? {} : { approvalRequests: this.approvalRequests },
})
} catch (error) {
this.initialized = undefined
diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts
index 5b7fdd6cc3..4bc8140c02 100644
--- a/packages/sdk/client/src/client.ts
+++ b/packages/sdk/client/src/client.ts
@@ -199,6 +199,7 @@ export class HarnessClient {
private closeTask: Promise | undefined
private readonly userQuestions = new Map()
private userQuestionHandler: HarnessClientOptions['onUserQuestion']
+ private requestHandler: ((method: string, params: Record) => Promise) | undefined
/** @param options - dsh profile, patch, home, process, environment, and timeout options. */
constructor(options?: HarnessClientOptions)
@@ -268,7 +269,10 @@ export class HarnessClient {
})
const transport = new JsonRpcLineTransport(child.stdout, child.stdin)
transport.onRequest(async (method, params) => {
- if (method !== 'user-question/ask') throw new SdkProtocolError(`unknown runtime request: ${method}`)
+ if (method !== 'user-question/ask') {
+ if (this.requestHandler !== undefined) return this.requestHandler(method, params)
+ throw new SdkProtocolError(`unknown runtime request: ${method}`)
+ }
const handler = this.userQuestionHandler
if (handler === undefined) throw new SdkProtocolError('user-question/ask requires an onUserQuestion callback')
if (!isUserQuestionRequest(params)) {
@@ -304,6 +308,21 @@ export class HarnessClient {
this.transport = transport
}
+ /**
+ * Install the additional-request handler; native questions retain their onUserQuestion callback.
+ * @param handler - receives wire parameters and returns the JSON-RPC result; rejection sends an error.
+ * @throws when the client has not started or has already closed.
+ */
+ onRequest(handler: (method: string, params: Record) => Promise): void {
+ if (this.closeTask !== undefined) {
+ throw new TransportClosedError('DeepSeek Harness runtime client is closed; create a new client to install a request handler')
+ }
+ if (this.transport === undefined) {
+ throw new TransportClosedError('start the SDK client before installing a request handler')
+ }
+ this.requestHandler = handler
+ }
+
/**
* Start the runtime if needed, then perform the process-wide handshake.
* @param params - workspace cwd plus the provider/model route.
diff --git a/packages/sdk/client/src/types.ts b/packages/sdk/client/src/types.ts
index 5cc34381cd..3cc26b5314 100644
--- a/packages/sdk/client/src/types.ts
+++ b/packages/sdk/client/src/types.ts
@@ -66,6 +66,8 @@ export interface DeepSeekHarnessOptions extends HarnessClientOptions {
reasoningEffort?: ReasoningEffortId
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
+ /** Enable server approval requests; install client.onRequest() after client.start() and before harness.start(). */
+ approvalRequests?: boolean
}
/** One owned session activity interval, from enqueue receipt through idle. */
diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts
index bbdec4a4e6..0ddeb7f076 100644
--- a/packages/sdk/client/tests/fake-runtime.ts
+++ b/packages/sdk/client/tests/fake-runtime.ts
@@ -48,6 +48,7 @@
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
* - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
* - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
+ * - `FAKE_APPROVAL`: send one server approval request after initialization and echo its response as a notification.
* - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
*/
@@ -245,7 +246,17 @@ const reader = createInterface({ input: process.stdin })
const settledSessions = new Set()
reader.on('line', (line) => {
if (line.trim().length === 0) return
- const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record }
+ const frame = JSON.parse(line) as {
+ id?: string | number
+ method?: string
+ params?: Record
+ result?: unknown
+ error?: unknown
+ }
+ if (frame.id === 'approval-client-test') {
+ notify('approval/response', { result: frame.result, error: frame.error })
+ return
+ }
if (frame.method === undefined || frame.id === undefined) return
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
switch (frame.method) {
@@ -282,6 +293,7 @@ reader.on('line', (line) => {
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
+ if (env.FAKE_APPROVAL !== undefined) write({ jsonrpc: '2.0', id: 'approval-client-test', method: 'approval/request', params: { sessionId: 'main', toolName: 'bash' } })
return
case 'session/resume':
if (env.FAKE_RESUME_ERROR !== undefined) {
diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts
index 7618df7218..3baea7687f 100644
--- a/packages/sdk/client/tests/sdk-client.spec.ts
+++ b/packages/sdk/client/tests/sdk-client.spec.ts
@@ -299,6 +299,7 @@ describe('DeepSeekHarness', () => {
model: 'custom-model',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 4096,
+ approvalRequests: true,
})
cleanups.push(() => harness.close())
await harness.run('one')
@@ -311,6 +312,7 @@ describe('DeepSeekHarness', () => {
model: 'custom-model',
reasoningEffort: 'max',
maxTokens: 4096,
+ approvalRequests: true,
}])
})
@@ -765,3 +767,32 @@ describe('pure helpers', () => {
])).toBe('ab')
})
})
+
+describe('server-to-client SDK requests', () => {
+ it('requires a started client and returns client decisions over the wire', async () => {
+ const client = processClient(fakeLaunch({ FAKE_APPROVAL: '1' }))
+ cleanups.push(() => client.close())
+ expect(() => { client.onRequest(async () => ({})) }).toThrow('start the SDK client')
+ client.start()
+ client.onRequest(async (method, params) => {
+ expect(method).toBe('approval/request')
+ expect(params).toEqual({ sessionId: 'main', toolName: 'bash' })
+ return { outcome: 'rejected' }
+ })
+ const notifications = client.subscribe()
+ await client.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock', approvalRequests: true })
+ expect(await notifications.next()).toMatchObject({ method: 'approval/response', params: { result: { outcome: 'rejected' } } })
+ notifications.close()
+ await client.close()
+ expect(() => { client.onRequest(async () => ({})) }).toThrow('client is closed; create a new client')
+ })
+})
+
+describe('SDK cancellation reason projection', () => {
+ it.each(['user', 'parent', 'disposed', 'legacy'])('retains the %s cancellation cause', async (kind) => {
+ const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: kind })
+ const result = await harness.run('cancelled')
+ expect(result.events.findLast(event => event.type === 'turn/end')?.data.reason)
+ .toEqual({ kind: 'aborted', reason: { kind } })
+ })
+})
diff --git a/packages/sdk/client/tests/user-questions.spec.ts b/packages/sdk/client/tests/user-questions.spec.ts
index 820ba6aba4..f9e336e41b 100644
--- a/packages/sdk/client/tests/user-questions.spec.ts
+++ b/packages/sdk/client/tests/user-questions.spec.ts
@@ -98,3 +98,20 @@ it.each(['close', 'disconnect'] as const)('aborts host work when the client or r
expect(signal.aborted).toBe(true)
await pending
})
+
+it('keeps native questions active beside the approval request handler', async () => {
+ const answer = { answers: [{ id: 'format', selected: ['CSV'] }] }
+ const f = await client(async () => answer)
+ const approvals: string[] = []
+ f.client.onRequest(async (method) => {
+ approvals.push(method)
+ return { outcome: 'allowed-once' }
+ })
+ const [question, approval] = await Promise.all([
+ f.client.request('test/question', { requestId: 'native' }),
+ f.client.request('test/question', { requestId: 'approval', method: 'approval/request' }),
+ ])
+ expect(question).toEqual({ answer, error: null })
+ expect(approval).toEqual({ answer: { outcome: 'allowed-once' }, error: null })
+ expect(approvals).toEqual(['approval/request'])
+})
diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml
index c43d1484af..2aed1dc438 100644
--- a/packages/sdk/protocol/README.i18n.yaml
+++ b/packages/sdk/protocol/README.i18n.yaml
@@ -15,14 +15,14 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-protocol/use-this-package:
- en: c4ca91b51aab452a
- zh: 1c77b1deaeb34931
+ en: 4e8a8a89c1e2c0c3
+ zh: 68b555e03ed73374
/deepseek-ai-dsh-sdk-protocol/use-this-package/framing-and-transport:
en: be53c71505c0d1b4
zh: 6210dee577715539
/deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods:
- en: 89ec24feb09e173c
- zh: 4072cfc63b5e25e4
+ en: a11a3be122c6418e
+ zh: 134659fa57d64199
/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 3b8d7c4674..93a34454af 100644
--- a/packages/sdk/protocol/README.md
+++ b/packages/sdk/protocol/README.md
@@ -27,13 +27,15 @@ English | [中文](README.zh.md)
Use this package when you build or debug an SDK wire end — the serving plugin, a client library, or custom tooling that speaks the SDK protocol. It gives you one transport for JSON-RPC 2.0 over caller-owned byte streams and the typed shapes for every SDK method and notification.
+`InitializeParams.approvalRequests: true` enables `approval/request` server requests, indexed by `HarnessSdkClientRequestMap`. `ApprovalRequestParams` contains `sessionId`, `toolName`, optional `callId`, and optional `reason`; `ApprovalRequestResult` is `{ outcome: "allowed-once" | "rejected" | "cancelled" }`. Omission or `false` leaves existing answerers in control. Only `allowed-once` grants one operation. Cancellation discards the pending request and late responses; shutdown and transport loss fail pending decisions closed. The client must keep reading and answering requests while waiting for an Agent to finish.
+
### Framing and transport
Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own. A frame with both `id` and `method` is a request, `id` alone is a response, and `method` alone is a notification; malformed lines are ignored. Requests with no registered handler answer `-32601`, handler failures answer `-32603`, and error responses reject the pending request with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `start()` attaches stream listeners and `close()` detaches them and rejects pending requests without destroying the streams.
### The SDK methods
-Both wire ends share one method set: seven client-to-server requests, one server-to-client request, and five server-to-client notifications.
+Both wire ends share one method set: client-to-server requests, opt-in server-to-client requests, and server notifications.
| Direction | Method | Payload types |
|---|---|---|
@@ -44,6 +46,7 @@ Both wire ends share one method set: seven client-to-server requests, one server
| client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
| client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
| client→server | `shutdown` | no params → `{}` |
+| server→client | `approval/request` | `ApprovalRequestParams` → `ApprovalRequestResult` |
| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` |
| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` |
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
@@ -51,7 +54,7 @@ Both wire ends share one method set: seven client-to-server requests, one server
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
-`HarnessSdkRequestMap`, `HarnessSdkHostRequestMap`, and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport.
+`HarnessSdkRequestMap`, `HarnessSdkClientRequestMap`, `HarnessSdkHostRequestMap`, and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport.
`session/wait` accepts `{ sessionId }` for an existing SDK-owned session and returns `{}` after the root stays idle and progressing managed descendant work finishes. Idle descendants with parked input remain resident without blocking this wait. Unknown ids and live Agent failures without a newer committed terminal are rejected. Notifications committed during the wait precede its response on the same transport; raw `session.status` transitions retain their whole-agent meaning.
diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md
index 3754b08ca5..f81f58c223 100644
--- a/packages/sdk/protocol/README.zh.md
+++ b/packages/sdk/protocol/README.zh.md
@@ -27,13 +27,15 @@ kind: "package-library"
当你构建或调试 SDK 协议端——服务插件、客户端库或使用该协议的自定义工具——时使用本包。它为你提供一个在调用方持有的字节流上承载 JSON-RPC 2.0 的传输,以及每个 SDK 方法与通知的类型化结构。
+`InitializeParams.approvalRequests: true` 启用由 `HarnessSdkClientRequestMap` 索引的服务端请求 `approval/request`。`ApprovalRequestParams` 包含 `sessionId`、`toolName`,以及可选的 `callId` 和 `reason`;`ApprovalRequestResult` 为 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`。省略或设为 `false` 时保留现有应答链。只有 `allowed-once` 批准单次操作。取消会丢弃待处理请求及迟到响应;shutdown 和传输断开使待处理决定拒绝放行。客户端等待 Agent 完成期间,必须继续读取并回答请求。
+
### 分帧与传输
在你拥有的字节流上,每个 `\n` 结尾的行承载一条 JSON-RPC 2.0 消息。同时带 `id` 与 `method` 的帧是请求,仅 `id` 是响应,仅 `method` 是通知;格式错误的行会被忽略。没有注册处理器的请求应答 `-32601`,处理器失败应答 `-32603`,错误响应会以 `JsonRpcResponseError` 拒绝挂起的请求,并保留协议中的 `code` 与可选 `data`。`start()` 挂接流监听器,`close()` 移除监听器并拒绝挂起请求,但不销毁流。
### SDK 方法
-两个协议端共享同一套方法:七个客户端到服务端请求、一个服务端到客户端请求与五个服务端到客户端通知。
+两个协议端共享同一套方法:客户端到服务端请求、显式启用的服务端到客户端请求,以及服务端通知。
| 方向 | 方法 | 载荷类型 |
|---|---|---|
@@ -44,6 +46,7 @@ kind: "package-library"
| 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
| 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
| client→server | `shutdown` | 无参数 → `{}` |
+| server→client | `approval/request` | `ApprovalRequestParams` → `ApprovalRequestResult` |
| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` |
| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` |
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
@@ -51,7 +54,7 @@ kind: "package-library"
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
-`HarnessSdkRequestMap`、`HarnessSdkHostRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。
+`HarnessSdkRequestMap`、`HarnessSdkClientRequestMap`、`HarnessSdkHostRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。
`session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。
diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts
index d577d6fab1..ef1c71c5e0 100644
--- a/packages/sdk/protocol/src/index.ts
+++ b/packages/sdk/protocol/src/index.ts
@@ -11,6 +11,9 @@
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
export type { JsonRpcTransportPeer } from './transport.ts'
export type {
+ ApprovalRequestParams,
+ ApprovalRequestResult,
+ HarnessSdkClientRequestMap,
HarnessSdkNotificationMap,
HarnessSdkRequestMap,
HarnessSdkHostRequestMap,
diff --git a/packages/sdk/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts
index 5668d2998b..4ee65555c0 100644
--- a/packages/sdk/protocol/src/transport.ts
+++ b/packages/sdk/protocol/src/transport.ts
@@ -36,7 +36,7 @@ export interface JsonRpcTransportPeer {
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
- * @param signal - optional cancellation; removes the pending request and ignores late responses.
+ * @param signal - optional cancellation; removes the pending waiter, rejects on abort, and ignores late responses.
* @returns the result; rejects with {@link JsonRpcResponseError} on an error
* response, and with a plain `Error` on a write failure or closure.
*/
@@ -57,11 +57,14 @@ interface PendingRequest {
/**
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
* listeners; {@link close} detaches them and rejects pending requests without
- * destroying the streams. Missing request handlers return `-32601`; handler
+ * destroying the streams. Closure, input end, and input errors permanently reject
+ * later outbound requests with the first failure; start does not reset that failure.
+ * Missing request handlers return `-32601`; handler
* failures return `-32603`. Notifications without a handler are dropped.
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
+ private failure: Error | undefined
private readonly decoder = new StringDecoder('utf8')
private started = false
private requestHandler: RequestHandler | undefined
@@ -83,7 +86,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
- * Detach listeners and reject pending requests. Safe before {@link start}.
+ * Detach listeners and reject pending and future outbound requests. Safe before
+ * {@link start}; starting again does not reset the request failure.
*/
close(): void {
this.input.off('data', this.onData)
@@ -120,6 +124,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
*/
request(method: string, params: object, signal?: AbortSignal): Promise {
+ if (this.failure !== undefined) return Promise.reject(this.failure)
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
return new Promise((resolve, reject) => {
@@ -263,6 +268,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
private failPending(error: Error): void {
+ this.failure ??= error
const pending = [...this.pending.values()]
this.pending.clear()
for (const waiter of pending) waiter.reject(error)
diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts
index 9b36a8c257..cfa746cac2 100644
--- a/packages/sdk/protocol/src/types.ts
+++ b/packages/sdk/protocol/src/types.ts
@@ -17,6 +17,8 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem } from '@deepseek-ai/ds
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
+ /** Enable server-to-client approval/request calls; omission leaves existing answerers in control. */
+ approvalRequests?: boolean
/** Provider route every SDK-created agent runs on. */
provider: string
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */
@@ -181,3 +183,26 @@ export interface HarnessSdkRequestMap {
'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult }
'shutdown': { params: undefined; result: Record }
}
+
+/** Internal approval projected to the SDK client without live runtime objects. */
+export interface ApprovalRequestParams {
+ /** Session owning the operation, including an in-process SDK descendant. */
+ sessionId: string
+ /** Tool whose operation needs approval. */
+ toolName: string
+ /** Exact tool call, when the asker supplied one. */
+ callId?: string
+ /** Asker-supplied explanation, when available. */
+ reason?: string
+}
+
+/** One-shot client decision; grants never persist beyond this operation. */
+export interface ApprovalRequestResult {
+ /** Only allowed-once permits the operation. */
+ outcome: 'allowed-once' | 'rejected' | 'cancelled'
+}
+
+/** Requests sent by the server after the client explicitly enables them. */
+export interface HarnessSdkClientRequestMap {
+ 'approval/request': { params: ApprovalRequestParams; result: ApprovalRequestResult }
+}
diff --git a/packages/sdk/protocol/tests/transport.spec.ts b/packages/sdk/protocol/tests/transport.spec.ts
index c14111be22..3c24389c70 100644
--- a/packages/sdk/protocol/tests/transport.spec.ts
+++ b/packages/sdk/protocol/tests/transport.spec.ts
@@ -235,6 +235,7 @@ describe('JsonRpcLineTransport', () => {
aToB.end()
await expect(pending).rejects.toThrow('JSON-RPC input closed')
+ await expect(b.request('after-eof', {})).rejects.toThrow('JSON-RPC input closed')
b.close()
})
@@ -246,6 +247,7 @@ describe('JsonRpcLineTransport', () => {
aToB.emit('error', new Error('input broke'))
await expect(pending).rejects.toThrow('input broke')
+ await expect(b.request('after-error', {})).rejects.toThrow('input broke')
b.close()
})
@@ -256,6 +258,7 @@ describe('JsonRpcLineTransport', () => {
b.close()
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
+ await expect(b.request('after-close', {})).rejects.toThrow('JSON-RPC transport closed')
})
it('rejects a request when writing the frame throws', async () => {
diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml
index 27a2398bee..cf543143de 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: 28aab9ed02805009
- zh: dd7ac790369b7929
+ en: afc4d4b529f334ba
+ zh: 7c7a8d2c6854882a
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring:
en: 47a33e9397e36e15
zh: e6d173820f9ea6e5
diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md
index cae22ab1a5..e94e5cb0b1 100644
--- a/packages/sdk/server/README.md
+++ b/packages/sdk/server/README.md
@@ -25,14 +25,16 @@ English | [中文](README.zh.md)
## Use this package
+Mount this plugin when a runtime must serve SDK clients: add it to a `cordis.yml` that composes the agent service, boot the runtime, and clients connect over stdio. The common path is explicit — the plugin needs the `agents` service; every other capability comes from the surrounding tree.
+
+Set `initialize.approvalRequests` to `true` to send internal approvals as server-to-client `approval/request` calls. The bridge handles SDK-owned Agents and their live in-process descendants, following `agents.isOwnedBy()` runtime ownership rather than persisted `parentSession` lineage; unrelated Agents and clients that omit the flag continue through the composed answerer chain. The approval service still applies `never` policy before dispatch and records the ask/decision audit pair. The bridge accepts only `allowed-once`, `rejected`, or `cancelled`; an invalid response, client error, or transport loss yields the service's fail-closed `unavailable` outcome. Request cancellation abandons the pending wire waiter; shutdown abandons all such waiters before draining Agents. Late replies cannot grant an abandoned operation. See the [protocol](../protocol/README.md) for the payload and the [Python example](../../../python/sdk/README.md#client-approvals) for client handling.
+
`session/resume` accepts a persisted `{ sessionId }` after initialization and returns `{}` once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while recording the initialized model route as the Session selection. An omitted effort preserves an explicit saved effort only for that same route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications.
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/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.
-Mount this plugin when a runtime must serve SDK clients: add it to a `cordis.yml` that composes the agent service, boot the runtime, and clients connect over stdio. The common path is explicit — the plugin needs the `agents` service; every other capability comes from the surrounding tree.
-
### Wiring
The plugin creates one agent per `sessionId` on first use. A registered model adapter wins the route; an unowned `deepseek-official` route mounts the DeepSeek adapter, and any other unowned provider fails initialization. The selected adapter resolves the exact model and optional reasoning effort before initialization succeeds.
diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md
index 6660c55235..390b1858e4 100644
--- a/packages/sdk/server/README.zh.md
+++ b/packages/sdk/server/README.zh.md
@@ -25,14 +25,16 @@ kind: "package-reference"
## 使用本包
+当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。
+
+将 `initialize.approvalRequests` 设为 `true`,即可把内部审批作为服务端到客户端的 `approval/request` 请求发送。桥接通过 `agents.isOwnedBy()` 运行时归属识别 SDK 拥有的 Agent 及其仍在运行的进程内后代,持久化的 `parentSession` 历史关系不决定审批归属;其他 Agent,以及未提供该标志的客户端,继续使用已组合的应答链。审批服务仍在分发前执行 `never` 策略,并记录询问与决定的审计事件。桥接仅接受 `allowed-once`、`rejected` 或 `cancelled`;无效响应、客户端错误或传输断开会产生审批服务的拒绝放行结果 `unavailable`。请求取消时释放待处理的协议等待项;shutdown 在等待 Agent 结束前释放所有此类等待项。迟到响应不能授权已放弃的操作。载荷见[协议](../protocol/README.zh.md),客户端处理见 [Python 示例](../../../python/sdk/README.zh.md#client-approvals)。
+
`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时将初始化时选定的模型路由记录为 Session 选择。省略推理强度时,仅在路由相同的情况下保留已保存的显式强度。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
插件在注册提醒激活处理方后发布 `sdkSessionActivation` 就绪标记;SDK 组合让 Schedule 依赖此标记,而非 Web Session controller。定时提醒也通过同一个 SDK Session 所有者投递。启动时已到期的提醒等待 SDK 成功初始化,再使用初始化指定的提供方、模型、推理强度和输出上限恢复会话。客户端可以首次接管提醒恢复的 Session;并发的提醒与客户端恢复共用一个 handle。关闭时释放等待初始化的提醒,并等待已经接纳的恢复完成。
`session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。
-当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。
-
### 组装
插件在首次使用时为每个 `sessionId` 创建一个 agent。已注册的模型适配器优先用于该路由;尚无适配器负责的 `deepseek-official` 路由会挂载 DeepSeek 适配器,任何其他尚无适配器负责的提供方都会导致初始化失败。初始化成功前,所选适配器会解析确切模型与可选推理强度。
diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
index e6c5c39ca4..e66d359e94 100644
--- a/packages/sdk/server/package.json
+++ b/packages/sdk/server/package.json
@@ -43,8 +43,9 @@
"@deepseek-ai/dsh-sdk-protocol": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
- "@deepseek-ai/dsh-working-directory": "workspace:*",
- "@deepseek-ai/dsh-user-questions": "workspace:*"
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
+ "@deepseek-ai/dsh-user-questions": "workspace:*",
+ "@deepseek-ai/dsh-working-directory": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -62,7 +63,8 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
- "@deepseek-ai/dsh-working-directory": "workspace:*",
- "@deepseek-ai/dsh-user-questions": "workspace:*"
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
+ "@deepseek-ai/dsh-user-questions": "workspace:*",
+ "@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts
index ee0acb739f..040b72b888 100644
--- a/packages/sdk/server/src/server.ts
+++ b/packages/sdk/server/src/server.ts
@@ -15,11 +15,14 @@ import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntim
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-working-directory'
+import type {} from '@deepseek-ai/dsh-user-approval'
+
import type {} from '@deepseek-ai/dsh-schedule'
import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key'
import type {
+ ApprovalRequestParams,
InitializeParams,
InitializeResult,
JsonRpcTransportPeer,
@@ -95,6 +98,8 @@ export class HarnessSdkJsonRpcServer {
private shutdownTask: Promise> | undefined
private shuttingDown = false
private initialized = false
+ private approvalRequests = false
+ private readonly lifetime = new AbortController()
private readonly userQuestions: SdkUserQuestionBridge
private readonly initialization = Promise.withResolvers()
/** Restored by a reminder and available for the client's first resume. */
@@ -105,6 +110,24 @@ export class HarnessSdkJsonRpcServer {
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkJsonRpcServerOptions = {},
) {
+ this.disposers.push(ctx.on('approval/request', async (request, next) => {
+ if (!this.approvalRequests || !this.ownsAgent(request.agent)) return next()
+ const params: ApprovalRequestParams = {
+ sessionId: request.agent.session.id,
+ toolName: request.toolName,
+ ...(request.callId === undefined ? {} : { callId: request.callId }),
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
+ }
+ const signal = request.signal === undefined
+ ? this.lifetime.signal
+ : AbortSignal.any([request.signal, this.lifetime.signal])
+ const result = await this.transport.request('approval/request', params, signal)
+ if (result === null || typeof result !== 'object' || !('outcome' in result)
+ || (result.outcome !== 'allowed-once' && result.outcome !== 'rejected' && result.outcome !== 'cancelled')) {
+ throw new TypeError('approval/request response requires an allowed-once, rejected, or cancelled outcome')
+ }
+ return result.outcome
+ }))
const serverOptions = this.options
this.userQuestions = new SdkUserQuestionBridge(ctx, transport, agent =>
this.sessions.get(agent.session.id)?.handle.agent === agent)
@@ -181,6 +204,9 @@ export class HarnessSdkJsonRpcServer {
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise {
+ if (params.approvalRequests !== undefined && typeof params.approvalRequests !== 'boolean') {
+ throw new TypeError('initialize approvalRequests must be a boolean')
+ }
if (params.reasoningEffort !== undefined
&& (typeof params.reasoningEffort !== 'string' || params.reasoningEffort.length === 0)) {
throw new TypeError('initialize reasoningEffort must be a non-empty string')
@@ -212,6 +238,7 @@ export class HarnessSdkJsonRpcServer {
this.model = model
this.reasoningEffort = reasoningEffort
this.maxTokens = params.maxTokens
+ this.approvalRequests = params.approvalRequests === true
this.userQuestions.enabled = params.capabilities?.userQuestions === true
this.initialized = true
this.initialization.resolve()
@@ -293,6 +320,20 @@ export class HarnessSdkJsonRpcServer {
}
}
+ private ownsAgent(agent: Agent): boolean {
+ const agents = this.ctx.agents
+ if (agents.get(agent.id) !== agent) return false
+ const liveAgents = agents.list()
+ const ancestors = new Set([agent])
+ for (const current of ancestors) {
+ if (this.sessions.get(current.session.id)?.handle.agent === current) return true
+ for (const parent of liveAgents) {
+ if (agents.isOwnedBy(current.id, parent)) ancestors.add(parent)
+ }
+ }
+ return false
+ }
+
private assertLiveAgent(rec: SessionRecord, sessionId: string): void {
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
throw new Error(`session agent was disposed outside the server: ${sessionId}`)
@@ -339,6 +380,7 @@ export class HarnessSdkJsonRpcServer {
private async performShutdown(): Promise> {
this.shuttingDown = true
+ this.lifetime.abort(new Error('SDK server is shutting down'))
this.userQuestions.close()
this.initialization.resolve()
const pendingCreations = [...this.sessionCreations.values()]
diff --git a/packages/sdk/server/tests/approval.spec.ts b/packages/sdk/server/tests/approval.spec.ts
new file mode 100644
index 0000000000..0886592b28
--- /dev/null
+++ b/packages/sdk/server/tests/approval.spec.ts
@@ -0,0 +1,202 @@
+/** SDK approvals use the real bidirectional wire and the internal audit service. */
+import { PassThrough } from 'node:stream'
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import ApprovalService from '@deepseek-ai/dsh-user-approval'
+import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts'
+import { HarnessSdkJsonRpcServer } from '../src/server.ts'
+
+async function setup(enabled?: boolean) {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await mountAgentLoopTestDependencies(ctx)
+ await mountWorkingDirectoryFixture(ctx)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(ApprovalService)
+ ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('done')]))
+ const input = new PassThrough()
+ const output = new PassThrough()
+ const serverWire = new JsonRpcLineTransport(input, output)
+ const client = new JsonRpcLineTransport(output, input)
+ const server = new HarnessSdkJsonRpcServer(ctx, serverWire)
+ onTestFinished(async () => {
+ await server.shutdown()
+ serverWire.close()
+ client.close()
+ input.destroy()
+ output.destroy()
+ })
+ serverWire.onRequest((method, params) => server.handleRequest(method, params))
+ serverWire.start()
+ client.start()
+ await client.request('initialize', {
+ cwd: process.cwd(), provider: 'mock', model: 'mock',
+ ...(enabled === undefined ? {} : { approvalRequests: enabled }),
+ })
+ await client.request('session/working-directory/get', { sessionId: 'main' })
+ const agent = ctx.agents.get(SessionId('main'))!
+ agent.session.append('turn/start', { turn: 1 })
+ return { ctx, server, serverWire, client, agent, input }
+}
+
+describe('SDK approval bridge', () => {
+ it.each(['allowed-once', 'rejected', 'cancelled'] as const)('applies and audits %s', async (outcome) => {
+ const h = await setup(true)
+ h.client.onRequest(async (method, params) => {
+ expect(method).toBe('approval/request')
+ expect(params).toEqual({ sessionId: 'main', toolName: 'bash', callId: 'call-1', reason: 'write outside workspace' })
+ return { outcome }
+ })
+ await expect(h.ctx.approval.request({
+ agent: h.agent, toolName: 'bash', callId: ToolCallId('call-1'), reason: 'write outside workspace',
+ })).resolves.toBe(outcome)
+ expect(h.agent.session.snapshotEvents().slice(-2).map(e => [e.type, e.data])).toEqual([
+ ['approval/asked', expect.objectContaining({ toolName: 'bash', reason: 'write outside workspace' })],
+ ['approval/decided', expect.objectContaining({ outcome })],
+ ])
+ })
+
+ it.each([undefined, false])('delegates when approvalRequests is %s', async (enabled) => {
+ const h = await setup(enabled)
+ h.ctx.on('approval/request', () => Promise.resolve('rejected'))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('rejected')
+ })
+
+ it.each([null, {}, { outcome: 'allow-always' }, { outcome: true }])('fails closed for invalid response %j', async (result) => {
+ const h = await setup(true)
+ h.client.onRequest(() => Promise.resolve(result))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ })
+
+ it('fails closed on client errors and a disconnected transport', async () => {
+ const h = await setup(true)
+ h.client.onRequest(() => Promise.reject(new Error('no decision')))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ h.serverWire.close()
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ })
+
+ it('correlates concurrent requests while another client request is in flight', async () => {
+ const h = await setup(true)
+ const first = Promise.withResolvers