From 5d3dd1bbb012e17fca5a9836a0d89607e7ff29d5 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 19 Oct 2026 13:02:20 +0800 Subject: [PATCH 01/11] feat(sdk): bridge internal approvals to stdio clients --- .../tests/profiles/sdk/fixtures/approval.mjs | 14 ++ docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 + packages/sdk/client/README.zh.md | 2 + packages/sdk/client/src/api.ts | 3 + packages/sdk/client/src/client.ts | 12 ++ packages/sdk/client/src/types.ts | 2 + packages/sdk/client/tests/fake-runtime.ts | 13 +- packages/sdk/client/tests/sdk-client.spec.ts | 33 ++++ packages/sdk/protocol/README.i18n.yaml | 8 +- packages/sdk/protocol/README.md | 4 +- packages/sdk/protocol/README.zh.md | 4 +- packages/sdk/protocol/src/index.ts | 3 + packages/sdk/protocol/src/transport.ts | 6 +- packages/sdk/protocol/src/types.ts | 25 +++ packages/sdk/protocol/tests/transport.spec.ts | 3 + packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 + packages/sdk/server/README.zh.md | 2 + packages/sdk/server/package.json | 2 + packages/sdk/server/src/server.ts | 37 +++++ packages/sdk/server/tests/approval.spec.ts | 150 ++++++++++++++++++ packages/sdk/server/tsconfig.json | 3 + pnpm-lock.yaml | 3 + python/sdk/README.i18n.yaml | 7 +- python/sdk/README.md | 21 +++ python/sdk/README.zh.md | 21 +++ python/sdk/src/deepseek_harness/__init__.py | 4 +- python/sdk/src/deepseek_harness/api.py | 2 + python/sdk/src/deepseek_harness/client.py | 9 ++ python/sdk/src/deepseek_harness/models.py | 17 +- python/sdk/tests/test_approval.py | 49 ++++++ python/sdk/tests/test_approval_runtime.py | 52 ++++++ python/sdk/tests/test_client.py | 2 + snapshots/sdk/approval/runtime.cordis.yml | 5 + snapshots/sdk/approval/session.v4.jsonl | 29 ++++ snapshots/sdk/approval/snapshot.yml | 9 ++ .../approval/workspace.expected/approved.txt | 1 + snapshots/sdk/sdk.snapshot.ts | 19 +++ 41 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 apps/cli/tests/profiles/sdk/fixtures/approval.mjs create mode 100644 packages/sdk/server/tests/approval.spec.ts create mode 100644 python/sdk/tests/test_approval.py create mode 100644 python/sdk/tests/test_approval_runtime.py create mode 100644 snapshots/sdk/approval/runtime.cordis.yml create mode 100644 snapshots/sdk/approval/session.v4.jsonl create mode 100644 snapshots/sdk/approval/snapshot.yml create mode 100644 snapshots/sdk/approval/workspace.expected/approved.txt 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() + const entered = Promise.withResolvers() + h.client.onRequest(async (_method, params) => { + if (params.toolName === 'first') { + entered.resolve(undefined) + return first.promise + } + return { outcome: 'rejected' } + }) + onTestFinished(() => { first.resolve({ outcome: 'cancelled' }) }) + const pending = h.ctx.approval.request({ agent: h.agent, toolName: 'first' }) + await entered.promise + await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'second' })).resolves.toBe('rejected') + await expect(h.client.request('session/working-directory/get', { sessionId: 'main' })).resolves.toHaveProperty('cwd') + first.resolve({ outcome: 'allowed-once' }) + await expect(pending).resolves.toBe('allowed-once') + }) + + it.each(['cancel', 'shutdown', 'disconnect'] as const)('settles a pending approval on %s and ignores a late grant', async (action) => { + const h = await setup(true) + const entered = Promise.withResolvers() + const answer = Promise.withResolvers() + h.client.onRequest(() => { entered.resolve(undefined); return answer.promise }) + onTestFinished(() => { answer.resolve({ outcome: 'cancelled' }) }) + const cancellation = new AbortController() + const pending = h.ctx.approval.request({ agent: h.agent, toolName: 'bash', signal: cancellation.signal }) + await entered.promise + if (action === 'cancel') cancellation.abort() + else if (action === 'shutdown') await h.server.shutdown() + else h.serverWire.close() + await expect(pending).resolves.toBe(action === 'cancel' ? 'cancelled' : 'unavailable') + answer.resolve({ outcome: 'allowed-once' }) + expect(h.agent.session.snapshotEvents().filter(e => e.type === 'approval/decided').map(e => e.data.outcome)) + .toEqual([action === 'cancel' ? 'cancelled' : 'unavailable']) + }) + + it('does not claim an unrelated live Agent', async () => { + const h = await setup(true) + const foreign = await h.ctx.agents.create({ sessionId: SessionId('foreign'), meta: { cwd: process.cwd() } }) + onTestFinished(() => foreign.dispose()) + foreign.agent.session.append('turn/start', { turn: 1 }) + await expect(h.ctx.approval.request({ agent: foreign.agent, toolName: 'bash' })).resolves.toBe('unavailable') + }) + + it('serves in-process descendants and preserves never policy', async () => { + const h = await setup(true) + const child = await h.ctx.agents.create({ sessionId: SessionId('child'), meta: { cwd: process.cwd(), parentSession: h.agent.session.id } }) + onTestFinished(() => child.dispose()) + child.agent.session.append('turn/start', { turn: 1 }) + h.client.onRequest(async (_method, params) => { + expect(params).toEqual({ sessionId: 'child', toolName: 'bash' }) + return { outcome: 'allowed-once' } + }) + await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('allowed-once') + h.ctx.approval.setPolicy(child.agent, 'never') + await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected') + }) + + it('rejects an invalid wire capability flag', async () => { + const h = await setup() + await expect(h.client.request('initialize', { cwd: '.', provider: 'mock', model: 'mock', approvalRequests: 'true' })) + .rejects.toThrow('approvalRequests must be a boolean') + }) +}) diff --git a/packages/sdk/server/tsconfig.json b/packages/sdk/server/tsconfig.json index 5bd5f18692..0a2323acdc 100644 --- a/packages/sdk/server/tsconfig.json +++ b/packages/sdk/server/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../interaction/user-approval" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da43e12301..6882de2112 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12212,6 +12212,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-user-approval': + specifier: workspace:* + version: link:../../interaction/user-approval '@deepseek-ai/dsh-working-directory': specifier: workspace:* version: link:../../session/working-directory diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index b0f9b305c7..fc76b51c9d 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -9,8 +9,11 @@ en: ddf4dabea526cef1 zh: 6e31cab10d2f3ed8 /deepseek-harness-python-sdk/start-a-runtime: - en: 5fceaffcc35039c6 - zh: 1d87e3defe7e875b + en: 1ae09bebf0f9e19b + zh: 55a0d557a2cae4d8 +/deepseek-harness-python-sdk/client-approvals: + en: fc69be1beb8abff1 + zh: 91b56781931a6f68 /deepseek-harness-python-sdk/customize-plugins: en: 69a6a6745c6e89c1 zh: 85e82a4cc31e16e5 diff --git a/python/sdk/README.md b/python/sdk/README.md index 9ed3f8d281..3dc7656ace 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` + +## Client approvals + +This low-level example receives one approval and rejects it. Use a dedicated request-handling thread or loop for a full run: `next_request()` blocks, and the request handler must run concurrently with `session/wait` or a high-level `run()`. Set `approval_requests=True` on `HarnessClient.initialize()` or `DeepSeekHarnessConfig` only when that handler is ready. Omission preserves the runtime's answerer chain. Decisions are one-shot; invalid responses and lost connections fail closed, and cancellation discards late replies. `ApprovalRequestResult` validates response values when constructing decisions. + +```py +from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig + +with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client: + client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official", + model="deepseek-v4-flash", approval_requests=True) + client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}]) + request = client.next_request() + if request.method == "approval/request": + question = ApprovalRequestParams.model_validate(request.payload) + print(question.toolName, question.reason) + client.respond(request.id, {"outcome": "rejected"}) + else: + client.respond_error(request.id, -32601, "Unsupported client request") +``` + ## Customize plugins Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command: diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 7adf0decbd..2982db8e3a 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` + +## 客户端审批 + +此底层示例接收一次审批并拒绝。完整运行应使用专门处理请求的线程或循环:`next_request()` 会阻塞,请求处理器必须与 `session/wait` 或高层 `run()` 并行运行。仅在处理器就绪时,在 `HarnessClient.initialize()` 或 `DeepSeekHarnessConfig` 设置 `approval_requests=True`;省略时保留运行时应答链。决定只作用于单次操作;无效响应和断连会拒绝放行,取消后丢弃迟到响应。构造决定时,可用 `ApprovalRequestResult` 校验响应值。 + +```py +from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig + +with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client: + client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official", + model="deepseek-v4-flash", approval_requests=True) + client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}]) + request = client.next_request() + if request.method == "approval/request": + question = ApprovalRequestParams.model_validate(request.payload) + print(question.toolName, question.reason) + client.respond(request.id, {"outcome": "rejected"}) + else: + client.respond_error(request.id, -32601, "Unsupported client request") +``` + ## 自定义插件 持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle: diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py index 8c098224f3..0be0698559 100644 --- a/python/sdk/src/deepseek_harness/__init__.py +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -1,7 +1,7 @@ from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session from .client import HarnessClient, HarnessConfig from .errors import SdkProtocolError -from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo +from .models import ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo __all__ = [ "DeepSeekHarness", @@ -11,6 +11,8 @@ __all__ = [ "HarnessClient", "HarnessConfig", "SdkProtocolError", + "ApprovalRequestParams", + "ApprovalRequestResult", "IncomingRequest", "InitializeResponse", "JsonObject", diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 0bc5d7211d..1dc8598955 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -23,6 +23,7 @@ class DeepSeekHarnessConfig: model: str = "deepseek-v4-flash" reasoning_effort: str | None = None max_tokens: int | None = None + approval_requests: bool = False cwd: str | None = None runtime_cwd: str | None = None dsh_bin: str | None = None @@ -110,6 +111,7 @@ class DeepSeekHarness: model=self.config.model, reasoning_effort=self.config.reasoning_effort, max_tokens=self.config.max_tokens, + approval_requests=self.config.approval_requests, ) self._initialized = True diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 0428e56b46..46d92fdb87 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -138,12 +138,21 @@ class HarnessClient: model: str, reasoning_effort: str | None = None, max_tokens: int | None = None, + approval_requests: bool = False, ) -> InitializeResponse: + """Initialize the runtime; approval_requests enables approval/request reception. + + When enabled, drain next_request() concurrently with blocking waits and + return one-operation decisions through respond(). Omission preserves the + runtime's composed approval answerers. + """ payload: JsonObject = { "cwd": str(Path(cwd).resolve()), "provider": provider, "model": model, } + if approval_requests: + payload["approvalRequests"] = True if reasoning_effort is not None: payload["reasoningEffort"] = reasoning_effort if max_tokens is not None: diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py index f398bb422d..fa21d8d10b 100644 --- a/python/sdk/src/deepseek_harness/models.py +++ b/python/sdk/src/deepseek_harness/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypeAlias +from typing import Literal, TypeAlias from pydantic import BaseModel @@ -30,3 +30,18 @@ class ServerInfo(BaseModel): class InitializeResponse(BaseModel): serverInfo: ServerInfo | None = None + + +class ApprovalRequestParams(BaseModel): + """Server-to-client approval/request payload after explicit handshake opt-in.""" + + sessionId: str + toolName: str + callId: str | None = None + reason: str | None = None + + +class ApprovalRequestResult(BaseModel): + """One-operation decision; allowed-once is the only grant.""" + + outcome: Literal["allowed-once", "rejected", "cancelled"] diff --git a/python/sdk/tests/test_approval.py b/python/sdk/tests/test_approval.py new file mode 100644 index 0000000000..0ebf631254 --- /dev/null +++ b/python/sdk/tests/test_approval.py @@ -0,0 +1,49 @@ +"""Approval request decoding and explicit wire capability negotiation.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from deepseek_harness import ApprovalRequestParams, ApprovalRequestResult, HarnessClient + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_approval_handshake_and_response(tmp_path: Path, enabled: bool) -> None: + peer = tmp_path / "approval_peer.py" + peer.write_text('''import json +import sys +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "test"}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "capability", "params": msg["params"]}), flush=True) + if msg["params"].get("approvalRequests"): + print(json.dumps({"jsonrpc": "2.0", "id": "ask", "method": "approval/request", "params": {"sessionId": "main", "toolName": "bash", "reason": "write"}}), flush=True) + elif msg.get("id") == "ask": + print(json.dumps({"jsonrpc": "2.0", "method": "decision", "params": msg["result"]}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +''') + with HarnessClient(_launch_args=(sys.executable, str(peer))) as client: + client.initialize(cwd=str(tmp_path), provider="mock", model="mock", approval_requests=enabled) + payload = client.next_notification().payload + assert payload.get("approvalRequests", False) is enabled + if not enabled: + assert "approvalRequests" not in payload + return + request = client.next_request() + assert request.method == "approval/request" + question = ApprovalRequestParams.model_validate(request.payload) + assert question.toolName == "bash" + assert question.callId is None + client.respond(request.id, ApprovalRequestResult(outcome="rejected").model_dump()) + assert client.next_notification().payload == {"outcome": "rejected"} + + +def test_approval_result_rejects_unrecognized_grants() -> None: + with pytest.raises(ValidationError): + ApprovalRequestResult(outcome="allow-always") diff --git a/python/sdk/tests/test_approval_runtime.py b/python/sdk/tests/test_approval_runtime.py new file mode 100644 index 0000000000..44514d128a --- /dev/null +++ b/python/sdk/tests/test_approval_runtime.py @@ -0,0 +1,52 @@ +"""Python decisions control a guarded file operation through the built sdk profile.""" +from __future__ import annotations + +import json +import shutil +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest +from pydantic import BaseModel + +from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig + + +class EmptyResponse(BaseModel): + pass + + +@pytest.mark.parametrize("outcome", ["allowed-once", "rejected"]) +def test_python_approval_controls_runtime_operation(tmp_path: Path, outcome: str) -> None: + repo = Path(__file__).resolve().parents[3] + node = shutil.which("node") + binary = repo / "apps/cli/lib/bin.js" + if node is None or not binary.exists(): + pytest.skip("requires Node and built dsh CLI") + fixture = repo / "apps/cli/tests/profiles/sdk/fixtures/approval.mjs" + marker = tmp_path / "approved.txt" + patch = tmp_path / "approval.patch.yml" + patch.write_text("- insert:\n - id: approval-fixture\n name: " + json.dumps(str(fixture)) + + "\n config:\n stopAfterApproval: true\n path: " + json.dumps(str(marker)) + "\n") + # The client closes inside the executor lifetime, unblocking a reader even + # when a regression prevents the expected request from arriving. + with ThreadPoolExecutor(max_workers=1) as worker: + with HarnessClient( + HarnessConfig(cwd=str(repo), request_timeout_seconds=20, shutdown_timeout_seconds=20, + env={"DSH_HOME": str(tmp_path / "home"), "DSH_PERMISSION_MODE": "workspace-write", + "DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_API_KEY": "keyless-no-model-call"}), + _launch_args=(node, str(binary), "--profile", "sdk", "--patch", str(patch)), + ) as client: + client.initialize(cwd=str(tmp_path), provider="deepseek-official", model="deepseek-v4-flash", + approval_requests=True) + client.session_prompt("main", [{"type": "text", "text": "Request the marker write."}]) + request = worker.submit(client.next_request).result(timeout=20) + assert request.method == "approval/request" + question = ApprovalRequestParams.model_validate(request.payload) + assert question.sessionId == "main" + assert question.toolName == "fixture-write" + client.respond(request.id, {"outcome": outcome}) + client.request("session/wait", {"sessionId": "main"}, response_model=EmptyResponse) + assert marker.exists() is (outcome == "allowed-once") + if marker.exists(): + assert marker.read_text() == "approved\n" diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index a79f560fb4..bdc404cf4d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -165,6 +165,7 @@ for line in sys.stdin: ) with DeepSeekHarness( + approval_requests=True, model="deepseek-v4-flash", reasoning_effort="max", max_tokens=4096, @@ -211,6 +212,7 @@ for line in sys.stdin: assert dumped_env["DSH_SESSION_ROOT"] is None assert dumped_env["DSH_CORDIS_CONFIG"] is None assert json.loads(init_dump.read_text()) == { + "approvalRequests": True, "cwd": str(tmp_path), "provider": "deepseek-official", "model": "deepseek-v4-flash", diff --git a/snapshots/sdk/approval/runtime.cordis.yml b/snapshots/sdk/approval/runtime.cordis.yml new file mode 100644 index 0000000000..daf1554174 --- /dev/null +++ b/snapshots/sdk/approval/runtime.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: sdk-approval-fixture + name: ../../../apps/cli/tests/profiles/sdk/fixtures/approval.mjs + config: + path: !!js "process.getBuiltinModule('node:path').join(process.cwd(), 'approved.txt')" diff --git a/snapshots/sdk/approval/session.v4.jsonl b/snapshots/sdk/approval/session.v4.jsonl new file mode 100644 index 0000000000..8b6bccc5fc --- /dev/null +++ b/snapshots/sdk/approval/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352113760,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No 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":"approval/asked","data":{"id":"{{approval:1}}","toolName":"fixture-write","reason":"write approval marker"}} +{"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"allowed-once"}} +{"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":"Reply with exactly the word: ONE. No 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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:3}}"},"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":"Reply with exactly the word:","messageSeqs":[10],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18},"stream":[{"type":"chunk","time":1792382979881,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792382979881,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792382979881,"index":1,"dt":[],"texts":["ONE"]},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"approval/asked","data":{"id":"{{approval:2}}","toolName":"fixture-write","reason":"write approval marker"}} +{"type":"approval/decided","data":{"id":"{{approval:2}}","outcome":"rejected"}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18},"stream":[{"type":"chunk","time":1792382979900,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792382979900,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792382979900,"index":1,"dt":[0],"texts":["T","WO"]},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/approval/snapshot.yml b/snapshots/sdk/approval/snapshot.yml new file mode 100644 index 0000000000..c9f045ea45 --- /dev/null +++ b/snapshots/sdk/approval/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: approval +profile: sdk +composition: default +recording: authored +header: + class: default +workspace: + final: true diff --git a/snapshots/sdk/approval/workspace.expected/approved.txt b/snapshots/sdk/approval/workspace.expected/approved.txt new file mode 100644 index 0000000000..367ff43054 --- /dev/null +++ b/snapshots/sdk/approval/workspace.expected/approved.txt @@ -0,0 +1 @@ +approved diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 233e4915e5..e800796139 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -101,6 +101,8 @@ function dirOf(url: string): string { } interface SdkAssertions { + /** Client decisions for the internal approval protocol recording. */ + approvalOutcomes?: readonly ('allowed-once' | 'rejected')[] /** Additional profile patches applied after the shared composition. */ patches?: readonly string[] /** Wait for the recorded provider title before sending each subsequent prompt. */ @@ -127,6 +129,11 @@ interface SdkAssertions { } const SDK_ASSERTIONS: Readonly> = { + 'approval': { + approvalOutcomes: ['allowed-once', 'rejected'], + environment: { DSH_PERMISSION_MODE: 'workspace-write' }, + patches: [fileURLToPath(new URL('./approval/runtime.cordis.yml', import.meta.url))], + }, 'minimal-image-compaction': { expectedFinalResponse: 'MINIMAL_IMAGE_OK', expectedTools: { bash: ['command'], read_image: ['file_path'], working_directory: [] }, @@ -648,6 +655,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const createHarness = (): DeepSeekHarness => new DeepSeekHarness({ profile: scenario.manifest.profile, + ...(assertions.approvalOutcomes === undefined ? {} : { approvalRequests: true }), patches, dshHome, processCwd: cwd, @@ -665,6 +673,17 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const results: RunResult[] = [] const sessionId = 'fixture-root-session' const liveSessions: (string | undefined)[] = [sessionId] + if (assertions.approvalOutcomes !== undefined) { + const outcomes = [...assertions.approvalOutcomes] + harness.client.start() + harness.client.onRequest(async (method, params) => { + expect(method).toBe('approval/request') + expect(params).toMatchObject({ sessionId, toolName: 'fixture-write' }) + const outcome = outcomes.shift() + if (outcome === undefined) throw new Error('unexpected approval request') + return { outcome } + }) + } await harness.start() let subscription = harness.client.subscribeSessionTree(sessionId) const titleSubscription = assertions.waitForProviderTitle === true From 4f063cd2f4e8e4650cc62ef1bc79ae35bc9864e2 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 19 Oct 2026 13:07:20 +0800 Subject: [PATCH 02/11] docs(sdk): refresh approval dependency graph --- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index eb1afc4e0f..cb853a1fb9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1408,6 +1408,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_working_directory pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller @@ -1783,7 +1784,7 @@ flowchart TD | [`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), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`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), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`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), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 04677f718a..9ff52b58bb 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1410,6 +1410,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_working_directory pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller @@ -1785,7 +1786,7 @@ flowchart TD | [`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), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`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), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`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), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`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) | From 0ff3d1ddd25a8eedfa6ecfb7992e5837b3d8f9f1 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 19 Oct 2026 13:25:51 +0800 Subject: [PATCH 03/11] fix(sdk): preserve Python config positional arguments --- python/sdk/src/deepseek_harness/api.py | 2 +- python/sdk/tests/test_approval.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 1dc8598955..6a18068cbd 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -23,7 +23,6 @@ class DeepSeekHarnessConfig: model: str = "deepseek-v4-flash" reasoning_effort: str | None = None max_tokens: int | None = None - approval_requests: bool = False cwd: str | None = None runtime_cwd: str | None = None dsh_bin: str | None = None @@ -36,6 +35,7 @@ class DeepSeekHarnessConfig: shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None api_key: str | None = None + approval_requests: bool = False @dataclass(slots=True) diff --git a/python/sdk/tests/test_approval.py b/python/sdk/tests/test_approval.py index 0ebf631254..27767a0fc4 100644 --- a/python/sdk/tests/test_approval.py +++ b/python/sdk/tests/test_approval.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from pydantic import ValidationError -from deepseek_harness import ApprovalRequestParams, ApprovalRequestResult, HarnessClient +from deepseek_harness import ApprovalRequestParams, ApprovalRequestResult, DeepSeekHarnessConfig, HarnessClient @pytest.mark.parametrize("enabled", [False, True]) @@ -47,3 +47,9 @@ for line in sys.stdin: def test_approval_result_rejects_unrecognized_grants() -> None: with pytest.raises(ValidationError): ApprovalRequestResult(outcome="allow-always") + + +def test_approval_opt_in_preserves_existing_config_positional_arguments() -> None: + config = DeepSeekHarnessConfig("mock", "model", None, None, "/workspace") + assert config.cwd == "/workspace" + assert config.approval_requests is False From 50fb069e7e8b3adef7be2e73ce02a4aa940d10e5 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 19 Oct 2026 13:45:50 +0800 Subject: [PATCH 04/11] fix(sdk): address approval interface review findings --- packages/sdk/client/README.i18n.yaml | 4 ++-- packages/sdk/client/README.md | 6 +++--- packages/sdk/client/README.zh.md | 6 +++--- packages/sdk/client/src/client.ts | 5 ++++- packages/sdk/client/tests/fake-runtime.ts | 1 + packages/sdk/client/tests/sdk-client.spec.ts | 4 +--- packages/sdk/protocol/README.i18n.yaml | 8 ++++---- packages/sdk/protocol/README.md | 7 ++++--- packages/sdk/protocol/README.zh.md | 7 ++++--- packages/sdk/protocol/src/transport.ts | 7 +++++-- packages/sdk/server/README.i18n.yaml | 4 ++-- packages/sdk/server/README.md | 4 ++-- packages/sdk/server/README.zh.md | 4 ++-- packages/sdk/server/tests/approval.spec.ts | 14 +++++++++++--- .../sdk/server/tests}/fixtures/approval.mjs | 0 python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/tests/test_approval_runtime.py | 2 +- snapshots/sdk/approval/runtime.cordis.yml | 2 +- 19 files changed, 52 insertions(+), 37 deletions(-) rename {apps/cli/tests/profiles/sdk => packages/sdk/server/tests}/fixtures/approval.mjs (100%) diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 309ae3aeb0..a8d1e2945c 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: 38bb14f9c588dea2 - zh: 3f8218a0eb4f7ec3 + en: 185eaa87c3261f0f + zh: 12ba166f17be15b3 /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 3a6edd9853..594076d6d7 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -25,14 +25,14 @@ 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. +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. Enabling the flag without installing a handler returns `-32601` to server requests 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 375ee1ab37..4f8855b839 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -25,14 +25,14 @@ 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)。 +当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。 + +若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。启用开关但未安装处理器时,服务端请求收到 `-32601`,审批以 `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/client.ts b/packages/sdk/client/src/client.ts index 57b431d6bb..734c8b9d28 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -274,7 +274,10 @@ export class HarnessClient { * @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) { + 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.transport.onRequest(handler) diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 84ef3529aa..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). */ diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 72f82b81db..3baea7687f 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -768,7 +768,6 @@ describe('pure helpers', () => { }) }) - 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' })) @@ -785,11 +784,10 @@ describe('server-to-client SDK requests', () => { 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') + 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 }) diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 54275c2b7b..984205bac3 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: 3b1752b7f9f6c317 - zh: 066b03b904b6448b + 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: 5bd04dbafb8b0fab - zh: de0225a5c9794c34 + en: 06a698d9edab9370 + zh: 19513f1d66b759f0 /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 567d1bb245..8e12b9c28f 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -25,10 +25,10 @@ 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. +`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. @@ -46,12 +46,13 @@ Both wire ends share one method set: client-to-server requests, opt-in server-to | 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 | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) | | server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport. +`HarnessSdkRequestMap`, `HarnessSdkClientRequestMap`, 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 6a9e7a65be..efa5799431 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -25,10 +25,10 @@ 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 方法与通知的类型化结构。 +`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()` 移除监听器并拒绝挂起请求,但不销毁流。 @@ -46,12 +46,13 @@ 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 | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) | | server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。 +`HarnessSdkRequestMap`、`HarnessSdkClientRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。 `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 diff --git a/packages/sdk/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts index 12842e3ef6..382e0c2cf7 100644 --- a/packages/sdk/protocol/src/transport.ts +++ b/packages/sdk/protocol/src/transport.ts @@ -57,7 +57,9 @@ 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 { @@ -84,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) diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 2d695b268d..c96bdd21cb 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: c7db38347b9da536 - zh: 05767011d6b389bf + en: cf1a1511586ed638 + zh: a7e4a9a7be9993f5 /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 69c023ab96..17147d82a3 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -25,14 +25,14 @@ 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; 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. -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 60fe9d4429..01cfd684a7 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -25,14 +25,14 @@ kind: "package-reference" ## 使用本包 +当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 + 将 `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,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 -当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 - ### 组装 插件在首次使用时为每个 `sessionId` 创建一个 agent。已注册的模型适配器优先用于该路由;尚无适配器负责的 `deepseek-official` 路由会挂载 DeepSeek 适配器,任何其他尚无适配器负责的提供方都会导致初始化失败。初始化成功前,所选适配器会解析确切模型与可选推理强度。 diff --git a/packages/sdk/server/tests/approval.spec.ts b/packages/sdk/server/tests/approval.spec.ts index d9d07b53a2..beb3ebcadb 100644 --- a/packages/sdk/server/tests/approval.spec.ts +++ b/packages/sdk/server/tests/approval.spec.ts @@ -1,7 +1,7 @@ /** 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 { 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' @@ -42,7 +42,7 @@ async function setup(enabled?: boolean) { 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 } + return { ctx, server, serverWire, client, agent, input } } describe('SDK approval bridge', () => { @@ -115,7 +115,11 @@ describe('SDK approval bridge', () => { else if (action === 'shutdown') await h.server.shutdown() else h.serverWire.close() await expect(pending).resolves.toBe(action === 'cancel' ? 'cancelled' : 'unavailable') + const delivered = Promise.withResolvers() + h.input.once('data', delivered.resolve) + onTestFinished(() => { h.input.off('data', delivered.resolve) }) answer.resolve({ outcome: 'allowed-once' }) + expect(JSON.parse(String(await delivered.promise))).toMatchObject({ result: { outcome: 'allowed-once' } }) expect(h.agent.session.snapshotEvents().filter(e => e.type === 'approval/decided').map(e => e.data.outcome)) .toEqual([action === 'cancel' ? 'cancelled' : 'unavailable']) }) @@ -125,7 +129,11 @@ describe('SDK approval bridge', () => { const foreign = await h.ctx.agents.create({ sessionId: SessionId('foreign'), meta: { cwd: process.cwd() } }) onTestFinished(() => foreign.dispose()) foreign.agent.session.append('turn/start', { turn: 1 }) - await expect(h.ctx.approval.request({ agent: foreign.agent, toolName: 'bash' })).resolves.toBe('unavailable') + const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' })) + h.client.onRequest(clientDecision) + h.ctx.on('approval/request', () => Promise.resolve('rejected')) + await expect(h.ctx.approval.request({ agent: foreign.agent, toolName: 'bash' })).resolves.toBe('rejected') + expect(clientDecision).not.toHaveBeenCalled() }) it('serves in-process descendants and preserves never policy', async () => { diff --git a/apps/cli/tests/profiles/sdk/fixtures/approval.mjs b/packages/sdk/server/tests/fixtures/approval.mjs similarity index 100% rename from apps/cli/tests/profiles/sdk/fixtures/approval.mjs rename to packages/sdk/server/tests/fixtures/approval.mjs diff --git a/python/sdk/README.md b/python/sdk/README.md index 3dc7656ace..68557b4007 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -76,7 +76,7 @@ with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as clie print(question.toolName, question.reason) client.respond(request.id, {"outcome": "rejected"}) else: - client.respond_error(request.id, -32601, "Unsupported client request") + client.respond_error(request.id, code=-32601, message="Unsupported client request") ``` ## Customize plugins diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 2982db8e3a..dd9adf079d 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -76,7 +76,7 @@ with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as clie print(question.toolName, question.reason) client.respond(request.id, {"outcome": "rejected"}) else: - client.respond_error(request.id, -32601, "Unsupported client request") + client.respond_error(request.id, code=-32601, message="Unsupported client request") ``` ## 自定义插件 diff --git a/python/sdk/tests/test_approval_runtime.py b/python/sdk/tests/test_approval_runtime.py index 44514d128a..f7a9241af9 100644 --- a/python/sdk/tests/test_approval_runtime.py +++ b/python/sdk/tests/test_approval_runtime.py @@ -23,7 +23,7 @@ def test_python_approval_controls_runtime_operation(tmp_path: Path, outcome: str binary = repo / "apps/cli/lib/bin.js" if node is None or not binary.exists(): pytest.skip("requires Node and built dsh CLI") - fixture = repo / "apps/cli/tests/profiles/sdk/fixtures/approval.mjs" + fixture = repo / "packages/sdk/server/tests/fixtures/approval.mjs" marker = tmp_path / "approved.txt" patch = tmp_path / "approval.patch.yml" patch.write_text("- insert:\n - id: approval-fixture\n name: " + json.dumps(str(fixture)) diff --git a/snapshots/sdk/approval/runtime.cordis.yml b/snapshots/sdk/approval/runtime.cordis.yml index daf1554174..aa10a68e39 100644 --- a/snapshots/sdk/approval/runtime.cordis.yml +++ b/snapshots/sdk/approval/runtime.cordis.yml @@ -1,5 +1,5 @@ - insert: - id: sdk-approval-fixture - name: ../../../apps/cli/tests/profiles/sdk/fixtures/approval.mjs + name: ../../../packages/sdk/server/tests/fixtures/approval.mjs config: path: !!js "process.getBuiltinModule('node:path').join(process.cwd(), 'approved.txt')" From 2a871c22013287a4f979da081fb26085159448c7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 15:28:18 +0800 Subject: [PATCH 05/11] fix(web): ignore window-activating click in session title double-click rename Refs #6835 --- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 17 +++++++++- .../ui-workspace/tests/rows.client.spec.tsx | 34 +++++++++++++++++++ .../tests/workspace-browser.client.spec.tsx | 2 ++ 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fb827521a7..cfab3eb462 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -27,8 +27,8 @@ en: 29facc4eae028dae zh: 754ebac4cc294e65 /deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions: - en: e96b714aeb6dcd03 - zh: 75b63aa2cafbb57a + en: 94c922b458859dde + zh: cf3f39b1856b86a6 /deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions: en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 5849927e52..7a779cc27c 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -55,7 +55,7 @@ Collapsed search is one header action beside the view and add actions: activatin The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host. -The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. +The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. When the click that activates an unfocused window is not delivered to the page, as on macOS, the next click opens the conversation without Rename. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a067dd0788..92e9689b37 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。 -Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 +Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。当激活未聚焦窗口的那次点击不会传递给页面时(如在 macOS 上),下一次点击只打开对话,不打开 Rename。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 458a32b113..7e508ddff0 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -591,6 +591,15 @@ export function SessionNodeItem({ const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) const titleRef = useRef(null) + // On macOS the click that activates the window is not delivered, but the + // next click arrives with detail 2 and fires dblclick, so rename requires a + // detail-1 press on this title since the window last lost focus. + const renameArmed = useRef(false) + useEffect(() => { + const disarm = (): void => { renameArmed.current = false } + window.addEventListener('blur', disarm) + return () => { window.removeEventListener('blur', disarm) } + }, []) const marquee = useTitleMarquee(titleRef) useEffect(() => { if (onReveal === undefined) return @@ -659,9 +668,15 @@ export function SessionNodeItem({ { if (e.detail === 1) renameArmed.current = true }} onDoubleClick={row.blank ? undefined - : (e) => { e.stopPropagation(); onRenameRequest(node.id, row.title) }} + : (e) => { + e.stopPropagation() + if (!renameArmed.current) return + renameArmed.current = false + onRenameRequest(node.id, row.title) + }} > {title} diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index cde666f236..40c9e49ee9 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1231,6 +1231,7 @@ describe('workspace browser rows', () => { } const view = render() + fireEvent.mouseDown(screen.getByText('Named'), { detail: 1 }) fireEvent.doubleClick(screen.getByText('Named')) expect(onRenameRequest).toHaveBeenCalledWith(node.id, 'Named') expect(onOpen).not.toHaveBeenCalled() @@ -1238,10 +1239,42 @@ describe('workspace browser rows', () => { // A blank placeholder has no content to rename: double-click is inert. view.rerender() + fireEvent.mouseDown(screen.getByText('新会话'), { detail: 1 }) fireEvent.doubleClick(screen.getByText('新会话')) expect(onRenameRequest).toHaveBeenCalledOnce() }) + it('does not rename when the first click of the pair only activated the window', () => { + const onRenameRequest = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Named', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, + } + render() + const title = screen.getByText('Named') + // macOS delivers only the second press of a window-activating pair. + fireEvent.mouseDown(title, { detail: 2 }) + fireEvent.click(title, { detail: 2 }) + fireEvent.doubleClick(title) + expect(onOpen).toHaveBeenCalledWith(node.id) + expect(onRenameRequest).not.toHaveBeenCalled() + + // A press seen before the window lost focus does not arm the next pair. + fireEvent.mouseDown(title, { detail: 1 }) + fireEvent.blur(window) + fireEvent.mouseDown(title, { detail: 2 }) + fireEvent.doubleClick(title) + expect(onRenameRequest).not.toHaveBeenCalled() + + // One armed press renames once. + fireEvent.mouseDown(title, { detail: 1 }) + fireEvent.doubleClick(title) + fireEvent.doubleClick(title) + expect(onRenameRequest).toHaveBeenCalledOnce() + }) + it('flags an archived content-search result', () => { const result: SearchResultNode = { id: sid('result'), title: 'Old result', workspace: 'Workspace context', @@ -1409,6 +1442,7 @@ it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', ( render( null} onRenameRequest={onRenameRequest} t={translate} />) expect(screen.getByText(label)).toBeTruthy() + fireEvent.mouseDown(screen.getByText(label), { detail: 1 }) fireEvent.doubleClick(screen.getByText(label)) expect(onRenameRequest).toHaveBeenCalledWith(node.id, '') }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 7a481f9eea..297d167c29 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -739,11 +739,13 @@ describe('WorkspaceBrowser', () => { open, }) fireEvent.click(screen.getByText('alpha')) + fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 }) fireEvent.doubleClick(screen.getByText('Alpha session')) expect(requestSessionRename).toHaveBeenCalledWith(sid('alpha-s'), 'Alpha session') expect(open).not.toHaveBeenCalled() // The flat list threads the same request. act(() => { b.store.actions.setGroupBy('flat') }) + fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 }) fireEvent.doubleClick(screen.getByText('Alpha session')) expect(requestSessionRename).toHaveBeenCalledTimes(2) }) From 800805847db3e0b367591acb7b23fbbda3c7a629 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 21 Oct 2026 16:03:18 +0800 Subject: [PATCH 06/11] fix(sdk): route approvals by live runtime ownership --- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/src/server.ts | 12 ++-- packages/sdk/server/tests/approval.spec.ts | 64 ++++++++++++++++++---- 5 files changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 54e93eaca1..574f1fbfdf 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: f37e9b4e845b7c6e - zh: 911c956f830a8b01 + en: 62e246c741d3d115 + zh: a075dc6d120d6df7 /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 239589454c..8a976975bc 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -27,7 +27,7 @@ English | [中文](README.zh.md) 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; 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. +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 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. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 9f13be5926..98f860b826 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 -将 `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)。 +将 `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()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 2447b8bc30..f771150e7e 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -321,11 +321,15 @@ export class HarnessSdkJsonRpcServer { } private ownsAgent(agent: Agent): boolean { - let current: Agent | undefined = agent - while (current !== undefined) { + 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 - const parent: SessionId | undefined = current.session.header.parentSession - current = parent === undefined ? undefined : this.ctx.agents.get(parent) + for (const parent of liveAgents) { + if (agents.isOwnedBy(current.id, parent)) ancestors.add(parent) + } } return false } diff --git a/packages/sdk/server/tests/approval.spec.ts b/packages/sdk/server/tests/approval.spec.ts index beb3ebcadb..0886592b28 100644 --- a/packages/sdk/server/tests/approval.spec.ts +++ b/packages/sdk/server/tests/approval.spec.ts @@ -124,9 +124,12 @@ describe('SDK approval bridge', () => { .toEqual([action === 'cancel' ? 'cancelled' : 'unavailable']) }) - it('does not claim an unrelated live Agent', async () => { + it.each([false, true])('does not claim an unrelated live Agent with SDK session lineage: %s', async (hasLineage) => { const h = await setup(true) - const foreign = await h.ctx.agents.create({ sessionId: SessionId('foreign'), meta: { cwd: process.cwd() } }) + const foreign = await h.ctx.agents.create({ + sessionId: SessionId('foreign'), + meta: { cwd: process.cwd(), ...(hasLineage ? { parentSession: h.agent.session.id } : {}) }, + }) onTestFinished(() => foreign.dispose()) foreign.agent.session.append('turn/start', { turn: 1 }) const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' })) @@ -136,18 +139,59 @@ describe('SDK approval bridge', () => { expect(clientDecision).not.toHaveBeenCalled() }) - it('serves in-process descendants and preserves never policy', async () => { + it.each([false, true])('serves runtime descendants with SDK session lineage: %s and preserves never policy', async (hasLineage) => { const h = await setup(true) - const child = await h.ctx.agents.create({ sessionId: SessionId('child'), meta: { cwd: process.cwd(), parentSession: h.agent.session.id } }) - onTestFinished(() => child.dispose()) - child.agent.session.append('turn/start', { turn: 1 }) - h.client.onRequest(async (_method, params) => { - expect(params).toEqual({ sessionId: 'child', toolName: 'bash' }) - return { outcome: 'allowed-once' } + const child = await h.ctx.agents.create({ + sessionId: SessionId('child'), parentAgent: h.agent, + meta: { cwd: process.cwd(), ...(hasLineage ? { parentSession: h.agent.session.id } : {}) }, }) - await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('allowed-once') + onTestFinished(() => child.dispose()) + const grandchild = await h.ctx.agents.create({ + sessionId: SessionId('grandchild'), parentAgent: child.agent, meta: { cwd: process.cwd() }, + }) + onTestFinished(() => grandchild.dispose()) + child.agent.session.append('turn/start', { turn: 1 }) + grandchild.agent.session.append('turn/start', { turn: 1 }) + const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' })) + h.client.onRequest(clientDecision) + for (const agent of [child.agent, grandchild.agent]) { + await expect(h.ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once') + expect(clientDecision).toHaveBeenLastCalledWith('approval/request', { sessionId: agent.session.id, toolName: 'bash' }) + } h.ctx.approval.setPolicy(child.agent, 'never') await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected') + expect(clientDecision).toHaveBeenCalledTimes(2) + }) + + it('does not claim another runtime owner\'s child despite SDK session lineage', async () => { + const h = await setup(true) + const owner = await h.ctx.agents.create({ sessionId: SessionId('foreign-owner') }) + onTestFinished(() => owner.dispose()) + const child = await h.ctx.agents.create({ + sessionId: SessionId('foreign-child'), parentAgent: owner.agent, + meta: { parentSession: h.agent.session.id }, + }) + onTestFinished(() => child.dispose()) + child.agent.session.append('turn/start', { turn: 1 }) + const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' })) + h.client.onRequest(clientDecision) + h.ctx.on('approval/request', () => Promise.resolve('rejected')) + await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected') + expect(clientDecision).not.toHaveBeenCalled() + }) + + it('does not claim a disposed Agent when an owned replacement reuses its id', async () => { + const h = await setup(true) + const old = await h.ctx.agents.create({ sessionId: SessionId('reused'), parentAgent: h.agent }) + await old.dispose() + const replacement = await h.ctx.agents.create({ sessionId: old.agent.id, parentAgent: h.agent }) + onTestFinished(() => replacement.dispose()) + const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' })) + h.client.onRequest(clientDecision) + // Dispatch at the answerer seam: disposal has already closed the old Agent's turn. + await expect(h.ctx.waterfall('approval/request', { agent: old.agent, toolName: 'bash' }, + () => Promise.resolve('rejected' as const))).resolves.toBe('rejected') + expect(clientDecision).not.toHaveBeenCalled() }) it('rejects an invalid wire capability flag', async () => { From 1e0553ef7083ee43fa541f59387a67c31490574a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 16:04:27 +0800 Subject: [PATCH 07/11] fix(web): register the rename disarm listener only on press Also pin the second press of a real double-click in tests and correct the README and JSDoc. --- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 18 +++++++------ .../ui-workspace/tests/rows.client.spec.tsx | 25 ++++++++++++------- .../tests/workspace-browser.client.spec.tsx | 2 ++ 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index cfab3eb462..542694b356 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -27,8 +27,8 @@ en: 29facc4eae028dae zh: 754ebac4cc294e65 /deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions: - en: 94c922b458859dde - zh: cf3f39b1856b86a6 + en: 353ed9b66fab6486 + zh: e38a4a37627a9426 /deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions: en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 7a779cc27c..40dc2f6328 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -55,7 +55,7 @@ Collapsed search is one header action beside the view and add actions: activatin The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host. -The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. When the click that activates an unfocused window is not delivered to the page, as on macOS, the next click opens the conversation without Rename. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. +The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. When the click that activates an unfocused window is not delivered to the page, as on macOS, the next click does not open Rename. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92e9689b37..fbd9cd5152 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。 -Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。当激活未聚焦窗口的那次点击不会传递给页面时(如在 macOS 上),下一次点击只打开对话,不打开 Rename。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 +Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。当激活未聚焦窗口的那次点击不会传递给页面时(如在 macOS 上),下一次点击不会打开 Rename。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 7e508ddff0..24fa1e1675 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -542,7 +542,8 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: * @param props.currentId - selected session id (row highlight). * @param props.now - epoch ms for relative-time formatting. * @param props.onOpen - open a session by id. - * @param props.onRenameRequest - open the rename dialog from a title double-click (id + current title). + * @param props.onRenameRequest - open the rename dialog from a title double-click (id + current title); + * called only after a single press on the title since the window last lost focus. * @param props.renderSlot - child-seat renderer for the row's action lists * (`sidebar.workspaces.session.menu.item` / `sidebar.workspaces.session.row.action`), * its leading and trailing decorations, and its hover-card section. @@ -559,7 +560,7 @@ export function SessionNodeItem({ currentId: string | undefined now: number onOpen: (id: SessionNode['id']) => void - /** Open the rename dialog from a title double-click (id + current title). */ + /** Open the rename dialog from a title double-click (id + current title); see the function JSDoc for the press precondition. */ onRenameRequest: (id: SessionNode['id'], currentTitle: string) => void /** Scroll this row into view after search navigation, then acknowledge it. */ onReveal?: (() => void) | undefined @@ -595,11 +596,7 @@ export function SessionNodeItem({ // next click arrives with detail 2 and fires dblclick, so rename requires a // detail-1 press on this title since the window last lost focus. const renameArmed = useRef(false) - useEffect(() => { - const disarm = (): void => { renameArmed.current = false } - window.addEventListener('blur', disarm) - return () => { window.removeEventListener('blur', disarm) } - }, []) + const disarmRename = useRef(() => { renameArmed.current = false }).current const marquee = useTitleMarquee(titleRef) useEffect(() => { if (onReveal === undefined) return @@ -668,7 +665,12 @@ export function SessionNodeItem({ { if (e.detail === 1) renameArmed.current = true }} + onMouseDown={(e) => { + if (e.detail !== 1) return + renameArmed.current = true + // One shared function and options: repeated presses add one listener. + window.addEventListener('blur', disarmRename, { once: true }) + }} onDoubleClick={row.blank ? undefined : (e) => { diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 40c9e49ee9..b7ae3ef477 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -38,6 +38,16 @@ type SessionNodeItemProps = ComponentProps const renderNoRowEntries: RowRenderSlot = () => null // Direct row specs use the real required Slot share, empty unless the case supplies entries. +/** + * Browser double-click: the second press carries detail 2 and must not disarm. + * @param el - the double-clicked element. + */ +function realDoubleClick(el: Element): void { + fireEvent.mouseDown(el, { detail: 1 }); fireEvent.mouseUp(el, { detail: 1 }); fireEvent.click(el, { detail: 1 }) + fireEvent.mouseDown(el, { detail: 2 }); fireEvent.mouseUp(el, { detail: 2 }); fireEvent.click(el, { detail: 2 }) + fireEvent.doubleClick(el) +} + function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, ...props }: Omit< SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' > & Partial>) { @@ -1231,16 +1241,14 @@ describe('workspace browser rows', () => { } const view = render() - fireEvent.mouseDown(screen.getByText('Named'), { detail: 1 }) - fireEvent.doubleClick(screen.getByText('Named')) + realDoubleClick(screen.getByText('Named')) expect(onRenameRequest).toHaveBeenCalledWith(node.id, 'Named') - expect(onOpen).not.toHaveBeenCalled() + expect(onOpen).toHaveBeenCalledWith(node.id) // A blank placeholder has no content to rename: double-click is inert. view.rerender() - fireEvent.mouseDown(screen.getByText('新会话'), { detail: 1 }) - fireEvent.doubleClick(screen.getByText('新会话')) + realDoubleClick(screen.getByText('新会话')) expect(onRenameRequest).toHaveBeenCalledOnce() }) @@ -1269,8 +1277,8 @@ describe('workspace browser rows', () => { expect(onRenameRequest).not.toHaveBeenCalled() // One armed press renames once. - fireEvent.mouseDown(title, { detail: 1 }) - fireEvent.doubleClick(title) + realDoubleClick(title) + fireEvent.mouseDown(title, { detail: 2 }) fireEvent.doubleClick(title) expect(onRenameRequest).toHaveBeenCalledOnce() }) @@ -1442,8 +1450,7 @@ it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', ( render( null} onRenameRequest={onRenameRequest} t={translate} />) expect(screen.getByText(label)).toBeTruthy() - fireEvent.mouseDown(screen.getByText(label), { detail: 1 }) - fireEvent.doubleClick(screen.getByText(label)) + realDoubleClick(screen.getByText(label)) expect(onRenameRequest).toHaveBeenCalledWith(node.id, '') }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 297d167c29..cefad07057 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -740,12 +740,14 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 }) + fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 2 }) fireEvent.doubleClick(screen.getByText('Alpha session')) expect(requestSessionRename).toHaveBeenCalledWith(sid('alpha-s'), 'Alpha session') expect(open).not.toHaveBeenCalled() // The flat list threads the same request. act(() => { b.store.actions.setGroupBy('flat') }) fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 }) + fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 2 }) fireEvent.doubleClick(screen.getByText('Alpha session')) expect(requestSessionRename).toHaveBeenCalledTimes(2) }) From e804a6025c4185471cdcba4072f4223be7518da6 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 21 Oct 2026 16:06:47 +0800 Subject: [PATCH 08/11] fix(locale): restore required Japanese quote removal label --- packages/client/ui-conversation/src/client/locales.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', From 68da8d28ef343f596288a628be262593e75e0e11 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 21 Oct 2026 16:12:50 +0800 Subject: [PATCH 09/11] test(sdk): refresh background-wait present tool schema --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From 2efa355f653ee31dbd0d21605c55dd461cd4d80e Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 21 Oct 2026 16:22:21 +0800 Subject: [PATCH 10/11] fix(locale): register Japanese message quote dictionary --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..a712197cd5 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja: Record = { + 'quote.add': 'テキストを引用', +} From dd466092de2f4ad2eec52a45a2ba79a114532686 Mon Sep 17 00:00:00 2001 From: Magolor Date: Wed, 21 Oct 2026 20:03:07 +0800 Subject: [PATCH 11/11] =?UTF-8?q?test(browser-use):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20MCP=20=E7=AD=89=E5=BE=85=E6=9C=9F=E9=99=90=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=A4=B1=E8=B4=A5=E6=B8=85=E7=90=86=20(#6394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(browser-use): 对齐 MCP 等待期限并完成失败清理 * test(browser-use): fail promptly on unexpected process probe errors --- .../browser-use-runtime/tests/mcp.spec.ts | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts index 5feec06e44..b7d8cc05e4 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts @@ -19,7 +19,7 @@ import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepse import Sessions, { SessionId } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import Agents from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -31,13 +31,26 @@ import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '../ const fixture = fileURLToPath(new URL('./mcp-fixture.mjs', import.meta.url)) const roots: string[] = [] const contexts: Context[] = [] +const activations: Promise[] = [] const TOOL = 'mcp__browser-fixture__visit' afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + const disposed = await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(activations.splice(0)) + const removed = await Promise.allSettled(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + const failures: unknown[] = [] + for (const result of [...disposed, ...removed]) { + if (result.status === 'rejected') failures.push(result.reason) + } + if (failures.length > 0) throw new AggregateError(failures, 'MCP fixture cleanup failed') }) +/** Observe pending creation or resume before a readiness assertion can fail; callers retain its original result. */ +function observeActivation(activation: Promise): Promise { + activations.push(activation.then(() => {}, () => {})) + return activation +} + class FixtureModel extends LlmAdapter { requests: GenerateOptions[] = [] override resolveModel(provider: string, model: string): Promise { @@ -256,9 +269,8 @@ describe('Session MCP Loader composition', () => { it('allows another Session to answer while attached-browser discovery is pending', async () => { const { ctx, root, model, browser } = await load(true, 'hold') - const first = ctx.agents.create({ sessionId: SessionId('first') }) - const rejected = expect(first).rejects.toThrow() - await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) }) + const first = observeActivation(ctx.agents.create({ sessionId: SessionId('first') })) + await expect.poll(async () => (await events(root)).some(event => event.event === 'probe')).toBe(true) const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } }) second.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } })) await second.agent.whenIdle() @@ -266,7 +278,7 @@ describe('Session MCP Loader composition', () => { expect(model.requests[0]?.tools ?? []).toEqual([]) expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1) await browser.dispose() - await rejected + await expect(first).rejects.toThrow() expect(ctx.agents.get(SessionId('first'))).toBeUndefined() expect(ctx.sessions.get(SessionId('first'))).toBeUndefined() }) @@ -294,11 +306,10 @@ describe('Session MCP Loader composition', () => { expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' })) } const held = await load(false, 'hold') - const pendingOwner = held.ctx.agents.create({ sessionId: SessionId('pending') }) - const interrupted = expect(pendingOwner).rejects.toThrow() - await vi.waitFor(async () => { expect((await events(held.root)).some(event => event.event === 'probe')).toBe(true) }) + const pendingOwner = observeActivation(held.ctx.agents.create({ sessionId: SessionId('pending') })) + await expect.poll(async () => (await events(held.root)).some(event => event.event === 'probe')).toBe(true) await held.browser.dispose() - await interrupted + await expect(pendingOwner).rejects.toThrow() expect(held.ctx.agents.get(SessionId('pending'))).toBeUndefined() for (const { pid } of (await events(held.root)).filter(event => event.event === 'start')) { expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' })) @@ -311,7 +322,15 @@ describe('Session MCP Loader composition', () => { await warm(ctx, owner.agent) const servingPid = (await events(root)).find(event => event.event === 'initialize')!.pid await execute(ctx, owner.agent, 'mcp__browser-fixture__disconnect') - await vi.waitFor(() => { expect(() => process.kill(servingPid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' })) }) + let exitError: unknown + await expect.poll(() => { + try { process.kill(servingPid, 0) } catch (error) { + exitError = error + return true + } + return false + }).toBe(true) + expect(exitError).toEqual(expect.objectContaining({ code: 'ESRCH' })) await warm(ctx, owner.agent) expect((await events(root)).filter(event => event.event === 'initialize')).toHaveLength(1) expect((await execute(ctx, owner.agent)).isError).toBe(true) @@ -423,7 +442,7 @@ describe('Session MCP Loader composition', () => { await ctx.plugin(PresentationRuntime) let created = false let initializing!: Agent - const creation = ctx.agents.create({ + const creation = observeActivation(ctx.agents.create({ sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' }, setup: (inner, agent) => { initializing = agent @@ -435,8 +454,8 @@ describe('Session MCP Loader composition', () => { }).then((handle) => { created = true return handle - }) - await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) }) + })) + await expect.poll(async () => (await events(root)).some(event => event.event === 'probe')).toBe(true) expect(created).toBe(false) expect(model.requests).toEqual([]) expect((await execute(ctx, initializing)).isError).toBe(true) @@ -452,7 +471,7 @@ describe('Session MCP Loader composition', () => { const { ctx, root, model } = await load(true, 'gate') const controller = new AbortController() let initializing!: Agent - const creation = ctx.agents.create({ + const creation = observeActivation(ctx.agents.create({ sessionId: SessionId('canceled-startup'), signal: controller.signal, agentOptions: { provider: 'fixture', model: 'fixture' }, setup: (inner, agent) => { @@ -461,11 +480,10 @@ describe('Session MCP Loader composition', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } })) }, { prepend: true }) }, - }) - const rejected = expect(creation).rejects.toThrow('cancel browser startup') - await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) }) + })) + await expect.poll(async () => (await events(root)).some(event => event.event === 'probe')).toBe(true) controller.abort(new Error('cancel browser startup')) - await rejected + await expect(creation).rejects.toThrow('cancel browser startup') expect(model.requests).toEqual([]) expect(ctx.agents.get(initializing.id)).toBeUndefined() expect(ctx.sessions.get(initializing.id)).toBeUndefined() @@ -563,7 +581,7 @@ describe('Session MCP Loader composition', () => { await rm(join(root, 'release')) const controller = new AbortController() - const canceledResume = ctx.agents.resume({ + const canceledResume = observeActivation(ctx.agents.resume({ resumeSessionId: sessionId, signal: controller.signal, agentOptions: { provider: 'fixture', model: 'fixture' }, setup: (inner, agent) => { @@ -571,12 +589,11 @@ describe('Session MCP Loader composition', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit again.' }], source: { kind: 'user' } })) }, { prepend: true }) }, - }) - const rejected = expect(canceledResume).rejects.toThrow('cancel browser resume') - await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(2) }) + })) + await expect.poll(async () => (await events(root)).filter(event => event.event === 'probe')).toHaveLength(2) expect(model.requests).toHaveLength(2) controller.abort(new Error('cancel browser resume')) - await rejected + await expect(canceledResume).rejects.toThrow('cancel browser resume') expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() for (const { pid } of (await events(root)).filter(event => event.event === 'start')) { @@ -584,11 +601,11 @@ describe('Session MCP Loader composition', () => { } let resumed = false - const resuming = ctx.agents.resume({ resumeSessionId: sessionId }).then((handle) => { + const resuming = observeActivation(ctx.agents.resume({ resumeSessionId: sessionId }).then((handle) => { resumed = true return handle - }) - await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(3) }) + })) + await expect.poll(async () => (await events(root)).filter(event => event.event === 'probe')).toHaveLength(3) expect(resumed).toBe(false) expect(model.requests).toHaveLength(2) await writeFile(join(root, 'release'), '')