diff --git a/apps/cli/tests/profiles/sdk/fixtures/approval.mjs b/apps/cli/tests/profiles/sdk/fixtures/approval.mjs
new file mode 100644
index 0000000000..1f71f23adf
--- /dev/null
+++ b/apps/cli/tests/profiles/sdk/fixtures/approval.mjs
@@ -0,0 +1,14 @@
+/** Exercises the composed internal approval service. */
+import { appendFile } from 'node:fs/promises'
+
+export const name = 'sdk-approval-fixture'
+export const inject = ['approval']
+
+/** Ask once per turn and expose whether the guarded operation ran. */
+export function apply(ctx, config) {
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
+ const outcome = await ctx.approval.request({ agent, signal, toolName: 'fixture-write', reason: 'write approval marker' })
+ if (outcome === 'allowed-once') await appendFile(config.path, 'approved\n')
+ return config.stopAfterApproval ? { kind: 'reject' } : next()
+ })
+}
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 9661237f21..c41f93cbbf 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -28,7 +28,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:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../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 77f6a926d4..e1f0c76409 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -30,7 +30,7 @@
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../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/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml
index db75bc1878..309ae3aeb0 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: 38bb14f9c588dea2
+ zh: 3f8218a0eb4f7ec3
/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 2b64ba7417..3a6edd9853 100644
--- a/packages/sdk/client/README.md
+++ b/packages/sdk/client/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+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. 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.
diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md
index 2dd38f0251..375ee1ab37 100644
--- a/packages/sdk/client/README.zh.md
+++ b/packages/sdk/client/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-library"
## 使用本包
+若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。未启用时保留现有审批应答链。请求字段与取消规则见[协议](../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` 作为首个参数。
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 632e06d872..57b431d6bb 100644
--- a/packages/sdk/client/src/client.ts
+++ b/packages/sdk/client/src/client.ts
@@ -268,6 +268,18 @@ export class HarnessClient {
this.transport = transport
}
+ /**
+ * Install the server-request handler on a started client, replacing the prior handler.
+ * @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.transport === undefined || this.closeTask !== undefined) {
+ throw new TransportClosedError('start the SDK client before installing a request handler')
+ }
+ this.transport.onRequest(handler)
+ }
+
/**
* 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 9e91eb1036..3f8f85175b 100644
--- a/packages/sdk/client/src/types.ts
+++ b/packages/sdk/client/src/types.ts
@@ -64,6 +64,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..84ef3529aa 100644
--- a/packages/sdk/client/tests/fake-runtime.ts
+++ b/packages/sdk/client/tests/fake-runtime.ts
@@ -245,7 +245,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 +292,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..72f82b81db 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,34 @@ 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('start the SDK 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/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml
index aa6e00701a..54275c2b7b 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: 3b1752b7f9f6c317
+ zh: 066b03b904b6448b
/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: 8057053746fc2755
- zh: 36d5989488c1a351
+ en: 5bd04dbafb8b0fab
+ zh: de0225a5c9794c34
/deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics:
en: 3cf5d4ffbaf37f69
zh: 23c4450e31c91d67
diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md
index 2b9ff7eacf..567d1bb245 100644
--- a/packages/sdk/protocol/README.md
+++ b/packages/sdk/protocol/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+`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.
+
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.
### Framing and transport
@@ -33,7 +35,7 @@ Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own
### The SDK methods
-Both wire ends share one method set: seven client-to-server requests and four 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 |
|---|---|---|
diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md
index ebafcdccb0..6a9e7a65be 100644
--- a/packages/sdk/protocol/README.zh.md
+++ b/packages/sdk/protocol/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-library"
## 使用本包
+`InitializeParams.approvalRequests: true` 启用由 `HarnessSdkClientRequestMap` 索引的服务端请求 `approval/request`。`ApprovalRequestParams` 包含 `sessionId`、`toolName`,以及可选的 `callId` 和 `reason`;`ApprovalRequestResult` 为 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`。省略或设为 `false` 时保留现有应答链。只有 `allowed-once` 批准单次操作。取消会丢弃待处理请求及迟到响应;shutdown 和传输断开使待处理决定拒绝放行。客户端等待 Agent 完成期间,必须继续读取并回答请求。
+
当你构建或调试 SDK 协议端——服务插件、客户端库或使用该协议的自定义工具——时使用本包。它为你提供一个在调用方持有的字节流上承载 JSON-RPC 2.0 的传输,以及每个 SDK 方法与通知的类型化结构。
### 分帧与传输
@@ -33,7 +35,7 @@ kind: "package-library"
### SDK 方法
-两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。
+两个协议端共享同一套方法:客户端到服务端请求、显式启用的服务端到客户端请求,以及服务端通知。
| 方向 | 方法 | 载荷类型 |
|---|---|---|
diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts
index 698ecb6360..f98d84f88a 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,
InitializeParams,
diff --git a/packages/sdk/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts
index 36574f46bf..12842e3ef6 100644
--- a/packages/sdk/protocol/src/transport.ts
+++ b/packages/sdk/protocol/src/transport.ts
@@ -36,10 +36,11 @@ 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 waiter and rejects on abort.
* @returns the result; rejects with {@link JsonRpcResponseError} on an error
* response, and with a plain `Error` on a write failure or closure.
*/
- request(method: string, params: object): Promise
+ request(method: string, params: object, signal?: AbortSignal): Promise
/**
* Send a notification; omitted params produce no `params` member.
* @param method - the JSON-RPC method name.
@@ -61,6 +62,7 @@ interface PendingRequest {
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
+ private failure: Error | undefined
private readonly decoder = new StringDecoder('utf8')
private started = false
private requestHandler: RequestHandler | undefined
@@ -119,6 +121,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) => {
@@ -262,6 +265,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 25891197e2..7b2fe33df5 100644
--- a/packages/sdk/protocol/src/types.ts
+++ b/packages/sdk/protocol/src/types.ts
@@ -16,6 +16,8 @@ import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
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`). */
@@ -151,3 +153,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 2f62e76afe..2d695b268d 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: e136ff3a0b42cc22
- zh: f6c4df5bbafa335f
+ en: c7db38347b9da536
+ zh: 05767011d6b389bf
/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 bc78c02cdb..69c023ab96 100644
--- a/packages/sdk/server/README.md
+++ b/packages/sdk/server/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+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; 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 applying the initialized model 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.
`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.
diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md
index 772ae98e6f..60fe9d4429 100644
--- a/packages/sdk/server/README.zh.md
+++ b/packages/sdk/server/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-reference"
## 使用本包
+将 `initialize.approvalRequests` 设为 `true`,即可把内部审批作为服务端到客户端的 `approval/request` 请求发送。桥接处理 SDK 拥有的 Agent 及其仍在运行的进程内后代;其他 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()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
`session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。
diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
index 4628de64fb..aaa09e4708 100644
--- a/packages/sdk/server/package.json
+++ b/packages/sdk/server/package.json
@@ -40,6 +40,7 @@
"@deepseek-ai/dsh-sdk-protocol": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"devDependencies": {
@@ -57,6 +58,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts
index 726f430f15..850a24ac7c 100644
--- a/packages/sdk/server/src/server.ts
+++ b/packages/sdk/server/src/server.ts
@@ -14,10 +14,12 @@ 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 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,
@@ -93,12 +95,32 @@ export class HarnessSdkJsonRpcServer {
private shutdownTask: Promise> | undefined
private shuttingDown = false
private initialized = false
+ private approvalRequests = false
+ private readonly lifetime = new AbortController()
constructor(
private readonly ctx: Context,
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.disposers.push(ctx.on('session/event', (session, event) => {
const record = this.sessions.get(session.id)
@@ -153,6 +175,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')
@@ -184,6 +209,7 @@ export class HarnessSdkJsonRpcServer {
this.model = model
this.reasoningEffort = reasoningEffort
this.maxTokens = params.maxTokens
+ this.approvalRequests = params.approvalRequests === true
this.initialized = true
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}
@@ -256,6 +282,16 @@ export class HarnessSdkJsonRpcServer {
}
}
+ private ownsAgent(agent: Agent): boolean {
+ let current: Agent | undefined = agent
+ while (current !== undefined) {
+ if (this.sessions.get(current.session.id)?.handle.agent === current) return true
+ const parent: SessionId | undefined = current.session.header.parentSession
+ current = parent === undefined ? undefined : this.ctx.agents.get(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}`)
@@ -302,6 +338,7 @@ export class HarnessSdkJsonRpcServer {
private async performShutdown(): Promise> {
this.shuttingDown = true
+ this.lifetime.abort(new Error('SDK server is shutting down'))
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
diff --git a/packages/sdk/server/tests/approval.spec.ts b/packages/sdk/server/tests/approval.spec.ts
new file mode 100644
index 0000000000..d9d07b53a2
--- /dev/null
+++ b/packages/sdk/server/tests/approval.spec.ts
@@ -0,0 +1,150 @@
+/** 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 } 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 }
+}
+
+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