Просмотр исходного кода

fix(subagent): preserve actionable DSH SDK failure facts

pku-xht 1 месяц назад
Родитель
Сommit
b88a35d506
22 измененных файлов с 816 добавлено и 105 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml
  2. 5 5
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
  3. 5 5
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml
  5. 22 8
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
  6. 22 8
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md
  7. 1 1
      docs/config-catalog.md
  8. 31 0
      examples/jsonrpc-agent/subagent-dsh-sdk-diagnostic.cordis.yml
  9. 30 0
      examples/jsonrpc-agent/subagent-dsh-sdk-diagnostic.snapshot.cordis.yml
  10. 8 6
      examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts
  11. 11 0
      examples/jsonrpc-agent/tests/sdk.snapshot.ts
  12. 48 0
      examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl
  13. 1 0
      examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/result.expected.json
  14. 47 0
      examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/session.jsonl
  15. 30 1
      packages/sdk/client/tests/fake-runtime.ts
  16. 2 2
      packages/subagent/subagent-dsh-sdk/README.i18n.yaml
  17. 27 4
      packages/subagent/subagent-dsh-sdk/README.md
  18. 27 4
      packages/subagent/subagent-dsh-sdk/README.zh.md
  19. 13 1
      packages/subagent/subagent-dsh-sdk/src/index.ts
  20. 152 20
      packages/subagent/subagent-dsh-sdk/src/run.ts
  21. 56 28
      packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts
  22. 274 8
      packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
-2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a
-2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a
+2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: c0fe606ee02447221f2b4727264a7baa93fd9558
+2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 866cda3c607b00c103cba796f1cba3e8a111f620

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md

@@ -14,7 +14,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service
 
 - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
 - **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
-- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
+- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Non-completed child reasons and SDK failures add the bounded safe diagnostic defined by the [out-of-process diagnostics decision](2026-08-21-out-of-process-subagent-minimal-diagnostics.md), using only the child reason, current provider stage, and exported SDK error class. Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
 - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
 
 `dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line.
@@ -23,9 +23,9 @@ Three packages, layered exactly like the existing Python stack, plus one Service
 
 Four tiers, per [testing policy](../../../../docs/testing.md):
 
-- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages.
-- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold.
-- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
+- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider, including child-reason, typed-error, startup, process, and shutdown diagnostics. 100% per-file coverage on all three packages.
+- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts cwd inheritance and the model-visible child-error diagnostic with separate partial output. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold.
+- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind `cordis.snapshot.yml` overlays passed explicitly through `DSH_CORDIS_CONFIG`. Text, bash, in-process subagent, persistent-tool, and DSH SDK diagnostic scenarios pin the normalized notification stream, SDK result, persisted logs, and the provider's foreground/background failure text.
 - **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.
 
 ## Alternatives considered
@@ -44,6 +44,6 @@ Four tiers, per [testing policy](../../../../docs/testing.md):
 
 ## Consequences
 
-**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.
+**Bought**: the SDK runtime protocol has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam has a harness-native out-of-process backend whose children are full peers (own config, persistence, tools), and parent agents receive minimal safe child/SDK failure facts through the same SDK path; the jsonrpc example pins both successful and failed delegation behavior.
 
 **Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus.

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md

@@ -14,7 +14,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
 
 - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
 - **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
-- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
+- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。非完成子原因与 SDK 失败会附加[进程外诊断决策](2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md)定义的有界安全诊断,只使用子原因、当前提供方 stage 与导出的 SDK 错误 class。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
 - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。
 
 `dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。
@@ -23,9 +23,9 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
 
 四层,依[测试政策](../../../../docs/testing.zh.md):
 
-- **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。
-- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。
-- **免密钥快照**——`examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制 fixture(测试前置数据)(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本轮次、bash 工具、spawn subagent——各自钉住规范化通知流、SDK 轮次结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
+- **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时,包括子原因、typed 错误、启动、进程与 shutdown 诊断。三个包全部 100% 逐文件覆盖。
+- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言 cwd 继承,以及模型可见的子错误诊断与分离的部分输出。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。
+- **免密钥快照**——`examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的 snapshot 套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,并经由 `DSH_CORDIS_CONFIG` 显式传入的 `cordis.snapshot.yml` 覆盖层回放已录制 fixture。文本、bash、进程内 subagent、持久工具与 DSH SDK 诊断场景会固定规范化通知流、SDK 结果、持久日志,以及提供方前台/后台失败文本。
 - **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。
 
 ## 考虑过的替代方案
@@ -44,6 +44,6 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
 
 ## 后果
 
-**收益**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是 seam Agent Note 所设想的递归组合方式;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。
+**收益**:SDK 运行时协议拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 拥有一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具),父 agent 还能经同一 SDK 路径收到最小安全的子轮次/SDK 失败事实;jsonrpc 示例同时固定成功与失败委派行为。
 
 **代价**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);协议仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地结算、服务器侧轮次会继续运行到进程清理为止;快照 fixture 录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
-2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 38cf32dc3de3fe157f73e1546a827df9b3622fa6
-2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: 386f85e6b5665c8006e10a0ed0aa49845b6ffed0
+2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 58ac32c7a3869e8acb18a130660095c8fcd8a671
+2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: 9b36c45594931303e6582f995c442a6c804dedc4

+ 22 - 8
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md

@@ -6,13 +6,13 @@ English | [中文](2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md)
 
 ## Problem
 
-An ACP child can stop because it reached a remote limit, denied a required permission, lost its protocol transport, or exited as a process. The shared result historically reduced these outcomes to a stop reason such as `error`, while startup and cleanup rejection messages could expose the original exception. A parent could not choose between narrowing the task, adjusting permission policy, or repairing the child deployment without Host logs.
+An ACP or DSH SDK child can stop because it reached a remote limit, denied a required permission, ended with a non-completed child turn, lost its protocol transport, or exited as a process. The shared result historically reduced these outcomes to a stop reason such as `error`, while startup and cleanup rejection messages could expose the original exception. A parent could not choose between narrowing the task, adjusting permission policy, or repairing the child deployment without Host logs.
 
 Copying exceptions, stderr, task content, tool input, paths, environment values, credentials, or protocol payloads into `SubagentResult.diagnostic` would make untrusted child text model-visible. Reusing a complete product-specific error union would also duplicate independently versioned authorities in the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md).
 
 ## Decision
 
-Each out-of-process provider owns a small mapping from facts it already receives at its protocol and process lifecycle points to fixed safe display text. The ACP provider implements that rule from its closed stop reasons, current operation, closed tool kind, configured permission policy, selected permission outcome, and the managed subprocess exit code or signal. Consumers continue to use the existing optional `SubagentResult.diagnostic`; they do not parse its punctuation or provider-private category names.
+Each out-of-process provider owns a small mapping from facts it already receives at its protocol and process lifecycle points to fixed safe display text. The ACP provider derives it from closed stop reasons, current operation, closed tool kind, configured permission policy, selected permission outcome, and the managed subprocess exit code or signal. The DSH SDK provider derives it from the child `turn/end` reason, current SDK operation, and exported SDK error class. Consumers continue to use the existing optional `SubagentResult.diagnostic`; they do not parse its punctuation or provider-private category names.
 
 ### Safe failure text
 
@@ -38,21 +38,35 @@ When an ACP permission request contributes to a non-completed result, a second f
 
 `max_turn_requests` remains the shared `error` stop reason and adds `remote-limit`. An unknown stop reason remains `error` and becomes the fixed `unknown` category without copying the value. `max_tokens`, `refusal`, and `cancelled` keep their existing shared stop reasons; they add a diagnostic only when a permission decision must be explained.
 
+### DSH SDK facts
+
+| Stage | Owned operation | Safe categories and facts |
+| --- | --- | --- |
+| `initialize` | Parent workspace resolution, SDK runtime spawn, and initialize handshake | `configuration`, `protocol`, `timeout`, `transport`, or `unknown` |
+| `session-run` | Prompt acceptance, session notifications, and final child reason | `child-error`, `child-interrupted`, `child-disposed`, `child-blocked`, `missing-terminal`, `protocol`, `timeout`, or `unknown` |
+| `process` | SDK transport closes during a published child run | `transport`; the Error message and its stderr tail stay internal |
+| `shutdown` | Bounded SDK shutdown and runtime process release | The same typed SDK categories with shutdown stage |
+
+Child `completed`, `max-tokens`, and ordinary `aborted` results keep their existing shared stop reasons without extra text. An `aborted` turn whose closed cause is `disposed` keeps `aborted` and adds `child-disposed`. `blocked`, `error`, and `interrupted` remain `error` and add their fixed categories. A missing terminal event adds `missing-terminal`; an unknown reason uses `unknown` without copying the value or the child's structured failure message.
+
+`SdkProtocolError` and JSON-RPC error responses map to `protocol`, `RequestTimeoutError` maps to `timeout`, and `TransportClosedError` maps to `transport`; the provider never reads their messages. Other exceptions use `unknown`.
+
 ### Ownership and lifecycle
 
 | Fact or resource | Owner | Consumer behavior |
 | --- | --- | --- |
-| ACP stop reason and tool kind | ACP server and SDK | The provider maps only closed values and uses fixed unknown fallbacks |
-| Current failure stage and latest permission decision | One ACP run | Derived at the failure point and discarded with the run; concurrent runs share no diagnostic state |
-| Exit code and signal | `dsh-subprocess` handle | Displayed only after the managed outcome is observed; stderr is never parsed |
+| Protocol terminal fact | ACP server or child Harness Session | Each provider maps only its owned closed values and uses fixed unknown fallbacks |
+| Current failure stage and operation-local detail | One provider run | Derived at the failure point and discarded with the run; concurrent runs share no diagnostic state |
+| Exit code and signal | ACP's `dsh-subprocess` handle | Displayed only after the managed outcome is observed; stderr is never parsed |
+| SDK error category | TypeScript SDK client error class | Classified with `instanceof`; the Error message and stderr tail remain internal |
 | Diagnostic bytes and presentation | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text stays separate from assistant output in foreground and one-shot background modes |
 | Raw failure | Child runtime, Error cause chain, and Host logger | Available for Host diagnosis only, never copied into the parent model result |
 
-Startup publishes no run until initialize and new-session succeed. A startup failure rolls the private child back to quiescence before rejecting with safe facts. A published run settles its result without rejection, and `dispose()` independently reports a safe teardown failure while still using the backend's existing whole-tree cleanup ladder.
+Startup publishes no run until the provider's handshake completes. A startup failure rolls the private child back to quiescence before rejecting with safe facts. A published run settles its result without rejection, and `dispose()` independently reports safe teardown or shutdown facts while still using the backend's existing process cleanup ladder.
 
 ## Verification
 
-ACP package tests drive a real stdio protocol child and pin every stop-reason mapping, remote-limit and unknown fallbacks, permission allow/deny facts, configuration, initialize, new-session, prompt, process, and teardown stages, startup rollback, successful-result and local-cancellation omission, partial output, concurrent-run isolation, Host-only raw errors, process quiescence, and the shared multibyte diagnostic limit. A Loader composition proves the real configured provider reaches the model-visible foreground result. The keyless ACP snapshot pins the same diagnostic and permission fact in foreground error output and one-shot background `job_output` detail.
+ACP package tests drive a real stdio protocol child and pin every stop-reason mapping, remote-limit and unknown fallbacks, permission allow/deny facts, configuration, initialize, new-session, prompt, process, and teardown stages, startup rollback, successful-result and local-cancellation omission, partial output, concurrent-run isolation, Host-only raw errors, process quiescence, and the shared multibyte diagnostic limit. DSH SDK package tests drive the real SDK client against its stdio fake runtime and pin every child reason, typed SDK category, all four stages, startup and shutdown aggregation, partial output, cancellation omission, concurrency, sanitization, and quiescence. Loader compositions prove each real configured provider reaches the model-visible foreground result. Keyless ACP and JSON-RPC snapshots pin each provider's exact foreground and one-shot background diagnostic text.
 
 ## Alternatives considered
 
@@ -68,6 +82,6 @@ ACP package tests drive a real stdio protocol child and pin every stop-reason ma
 
 ## Consequences
 
-The parent can distinguish an ACP remote limit, permission involvement, protocol or transport failure, deployment/process failure, and teardown failure without receiving child-controlled text. Startup and cleanup errors use the same safe facts as published results, while Host observation retains the original cause.
+The parent can distinguish an ACP remote limit or permission decision and a DSH child-turn, protocol, timeout, transport/process, or shutdown failure without receiving child-controlled text. Startup and cleanup errors use the same safe facts as published results, while Host observation retains the original cause.
 
 The diagnostic remains display text rather than a public protocol. Consumers may present it but must not branch on its format. This decision adds no retry policy, recovery controller, shared provider-error enum, stderr classifier, authentication taxonomy, session persistence, progress stream, or new ACP capability.

+ 22 - 8
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## Problem
 
-ACP 子进程可能因为达到远端限制、拒绝必需权限、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
+ACP 或 DSH SDK 子进程可能因为达到远端限制、拒绝必需权限、以非完成子轮次结束、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
 
 若把异常、stderr、任务内容、工具输入、路径、环境值、凭证或协议 payload 复制进 `SubagentResult.diagnostic`,不受信任的子进程文本就会变成模型可见内容。若复用完整的产品专属错误联合,又会在提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.zh.md) 中复制彼此独立版本化的权威。
 
 ## Decision
 
-每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来实现该规则。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
+每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来派生。DSH SDK 提供方使用子 `turn/end` 原因、当前 SDK 操作与导出的 SDK 错误 class 来派生。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
 
 ### 安全失败文本
 
@@ -38,21 +38,35 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
 
 `max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。
 
+### DSH SDK 事实
+
+| Stage | 归属操作 | 安全 category 与事实 |
+| --- | --- | --- |
+| `initialize` | 父工作区解析、SDK 运行时 spawn 与 initialize 握手 | `configuration`、`protocol`、`timeout`、`transport` 或 `unknown` |
+| `session-run` | prompt 接受、会话通知与最终子轮次原因 | `child-error`、`child-interrupted`、`child-disposed`、`child-blocked`、`missing-terminal`、`protocol`、`timeout` 或 `unknown` |
+| `process` | 已发布子运行期间 SDK 传输关闭 | `transport`;Error 消息及其中的 stderr tail 仍留在内部 |
+| `shutdown` | 有界 SDK shutdown 与运行时进程释放 | 使用 shutdown stage 的同一套 typed SDK category |
+
+子 `completed`、`max-tokens` 与普通 `aborted` 结果保持既有共享结束原因,不附加文本。闭集原因是 `disposed` 的 `aborted` 轮次仍保持 `aborted`,并附加 `child-disposed`。`blocked`、`error` 与 `interrupted` 继续映射到 `error`,并附加各自固定 category。缺失终态事件会附加 `missing-terminal`;未知原因使用 `unknown`,且不复制原值或子进程结构化失败消息。
+
+`SdkProtocolError` 与 JSON-RPC 错误响应映射为 `protocol`,`RequestTimeoutError` 映射为 `timeout`,`TransportClosedError` 映射为 `transport`;提供方绝不读取其消息。其他异常使用 `unknown`。
+
 ### 所有权与生命周期
 
 | 事实或资源 | Owner | 消费方行为 |
 | --- | --- | --- |
-| ACP 结束原因与工具种类 | ACP server 与 SDK | 提供方只映射闭集值,并对闭集外值使用固定 unknown 回退 |
-| 当前失败 stage 与最新权限决定 | 单次 ACP 运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
-| 退出码与信号 | `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
+| 协议终态事实 | ACP server 或子 Harness Session | 每个提供方只映射自身拥有的闭集值,并使用固定 unknown 回退 |
+| 当前失败 stage 与 operation-local 细节 | 单次提供方运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
+| 退出码与信号 | ACP 的 `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
+| SDK 错误 category | TypeScript SDK 客户端错误 class | 仅通过 `instanceof` 分类;Error 消息和 stderr tail 留在内部 |
 | 诊断字节与呈现 | `dsh-subagent`、前台工具与 Job 运行时 | 前台和一次性后台模式都把同一份有界文本与 assistant 输出分开 |
 | 原始失败 | 子运行时、Error cause 链与 Host logger | 只供 Host 排障,绝不复制进父模型结果 |
 
-启动只有在 initialize 与 new-session 成功后才发布运行。启动失败会先把私有子进程回滚到完全停稳,再以安全事实拒绝。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 失败,并继续使用后端既有的整棵进程树清理阶梯。
+启动只有在提供方握手完成后才发布运行。启动失败会先把私有子进程回滚到完全停稳,再以安全事实拒绝。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 或 shutdown 事实,并继续使用后端既有的进程清理阶梯。
 
 ## Verification
 
-ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。Loader 组合证明真实配置的提供方会到达模型可见前台结果。无密钥 ACP snapshot 会在前台错误输出与一次性后台 `job_output` detail 中固定同一份诊断与权限事实。
+ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。DSH SDK 包测试通过真实 SDK 客户端驱动其 stdio 伪运行时,固定全部子轮次原因、typed SDK category、四个 stage、启动与 shutdown 聚合、部分输出、取消省略、并发、脱敏与停稳。Loader 组合证明两个真实配置的提供方都能到达模型可见前台结果。无密钥 ACP 与 JSON-RPC snapshot 会固定各自提供方的准确前台与一次性后台诊断文本。
 
 ## Alternatives considered
 
@@ -68,6 +82,6 @@ ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、
 
 ## Consequences
 
-父 agent 可以区分 ACP 远端限制、权限参与、协议或传输失败、部署/进程失败与 teardown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
+父 agent 可以区分 ACP 远端限制或权限决定,以及 DSH 子轮次、协议、超时、传输/进程或 shutdown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
 
 诊断仍是展示文本,不是公共协议。消费方可以呈现它,但不得按格式分支。本决策不增加重试策略、恢复控制器、共享提供方错误 enum、stderr 分类器、认证分类、会话持久化、进度流或新的 ACP 能力。

+ 1 - 1
docs/config-catalog.md

@@ -2300,7 +2300,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:29`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
+Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:30`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
 
 <a id="deepseek-aidsh-subagent-fork-in-process"></a>
 

+ 31 - 0
examples/jsonrpc-agent/subagent-dsh-sdk-diagnostic.cordis.yml

@@ -0,0 +1,31 @@
+# Add the real DSH SDK provider behind a one-shot delegation tool. The SDK
+# snapshot supplies the protocol-only child runtime path through
+# DSH_TEST_FAKE_SDK_RUNTIME; that child returns a structured turn error after
+# streaming partial assistant output.
+- id: base
+  name: '@deepseek-ai/cordis-plugin-include'
+  config:
+    path: ./cordis.yml
+    patches:
+      - insert:
+          - id: subagent-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-subagent-dsh-sdk'
+            config:
+              providerName: dsh-sdk-diagnostic
+              command: !!js process.execPath
+              args:
+                - !!js process.env.DSH_TEST_FAKE_SDK_RUNTIME
+              provider: fake-provider
+              model: fake-model
+              env:
+                FAKE_TEXT: partial DSH SDK assistant text
+                FAKE_REASON_KIND: error
+          - id: tool-subagent-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-tool-subagent'
+            config:
+              provider: dsh-sdk-diagnostic
+              toolName: subagent_dsh_sdk
+              backgroundMode: one-shot
+              maxDepth: provider-managed
+          - id: tool-jobs-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-tool-jobs'

+ 30 - 0
examples/jsonrpc-agent/subagent-dsh-sdk-diagnostic.snapshot.cordis.yml

@@ -0,0 +1,30 @@
+# Keyless twin of subagent-dsh-sdk-diagnostic.cordis.yml: include the normal
+# JSON-RPC replay composition, then keep the real DSH SDK child process,
+# provider, and delegation tool.
+- id: base
+  name: '@deepseek-ai/cordis-plugin-include'
+  config:
+    path: ./cordis.snapshot.yml
+    patches:
+      - insert:
+          - id: subagent-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-subagent-dsh-sdk'
+            config:
+              providerName: dsh-sdk-diagnostic
+              command: !!js process.execPath
+              args:
+                - !!js process.env.DSH_TEST_FAKE_SDK_RUNTIME
+              provider: fake-provider
+              model: fake-model
+              env:
+                FAKE_TEXT: partial DSH SDK assistant text
+                FAKE_REASON_KIND: error
+          - id: tool-subagent-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-tool-subagent'
+            config:
+              provider: dsh-sdk-diagnostic
+              toolName: subagent_dsh_sdk
+              backgroundMode: one-shot
+              maxDepth: provider-managed
+          - id: tool-jobs-dsh-sdk-diagnostic
+            name: '@deepseek-ai/dsh-tool-jobs'

+ 8 - 6
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts

@@ -3,20 +3,22 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 
 /**
- * Scripted model for the CHILD runtime: answers every request with its own
- * process cwd, so the driving e2e can prove the parent session's workspace
- * reached the child process across the SDK wire. `options` carries the
- * request; the reply depends only on process state.
+ * Scripted model for the CHILD runtime: normally answers with its process cwd;
+ * under DSH_TEST_CHILD_FAILURE it streams partial text and ends with a fixed
+ * provider failure so the parent can assert DSH SDK diagnostics.
  */
 class CwdEchoAdapter extends LlmAdapter {
   async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
     void options
-    const reply = `child cwd: ${process.cwd()}`
+    const failure = process.env.DSH_TEST_CHILD_FAILURE === '1'
+    const reply = failure ? 'partial child loader answer' : `child cwd: ${process.cwd()}`
     yield { type: 'block-start', index: 0, blockType: 'text' }
     yield { type: 'text-delta', index: 0, text: reply }
     yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
     yield { type: 'usage', usage: { inputTokens: 3, outputTokens: reply.length } }
-    yield { type: 'finish', reason: { kind: 'stop' } }
+    yield failure
+      ? { type: 'finish', reason: { kind: 'error', failure: { code: 'CHILD_TEST_FAILURE', message: 'child loader failure' } } }
+      : { type: 'finish', reason: { kind: 'stop' } }
   }
 }
 

+ 11 - 0
examples/jsonrpc-agent/tests/sdk.snapshot.ts

@@ -35,7 +35,10 @@ const liveConfig = join(testsDir, '..', 'cordis.yml')
 const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml')
 const minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml')
 const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml')
+const diagnosticLiveConfig = join(testsDir, '..', 'subagent-dsh-sdk-diagnostic.cordis.yml')
+const diagnosticReplayConfig = join(testsDir, '..', 'subagent-dsh-sdk-diagnostic.snapshot.cordis.yml')
 const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
+const fakeSdkRuntime = fileURLToPath(new URL('../../../packages/sdk/client/tests/fake-runtime.ts', import.meta.url))
 const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 const MINIMAL_SYSTEM_PROMPT = 'You are the environment-selected minimal software engineer.'
@@ -100,6 +103,14 @@ const SCENARIOS: SdkScenario[] = [
     sessionId: 'sdk-snapshot-subagent',
     children: 1,
   },
+  {
+    name: 'subagent-dsh-sdk-diagnostic',
+    prompt: 'Observe the DSH SDK diagnostic twice with subagent_dsh_sdk. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC. Do not call any other tools.',
+    sessionId: 'sdk-snapshot-dsh-sdk-diagnostic',
+    children: 0,
+    configs: { live: diagnosticLiveConfig, replay: diagnosticReplayConfig },
+    environment: { DSH_TEST_FAKE_SDK_RUNTIME: fakeSdkRuntime },
+  },
   {
     name: 'persistent-tools',
     prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.',

+ 48 - 0
examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl

@@ -0,0 +1,48 @@
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe the DSH SDK diagnostic twice with subagent_dsh_sdk. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
+{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Observe the DSH SDK diagnostic twice with subagent_dsh_sdk. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Observe the DSH SDK diagnostic","messageSeqs":[4],"source":{"kind":"fallback"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","argumentsDelta":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_foreground"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error; child reason: error)\nPartial output before the run ended:\npartial DSH SDK assistant text"}],"isError":true}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","argumentsDelta":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_background"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_output"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error; child reason: error)]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":45,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
+{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}

+ 1 - 0
examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/result.expected.json

@@ -0,0 +1 @@
+{"sessionId":"{{sessionId}}","finalResponse":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}

+ 47 - 0
examples/jsonrpc-agent/tests/snapshots/subagent-dsh-sdk-diagnostic/session.jsonl

@@ -0,0 +1,47 @@
+{"type":"session","version":0,"id":"sdk-snapshot-dsh-sdk-diagnostic","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"agent/inbox/spliced","seq":0,"time":1787257517557,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe the DSH SDK diagnostic twice with subagent_dsh_sdk. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"fb50a593-f729-430e-a0b4-591fd05c9a80"}]}}
+{"type":"turn/start","seq":1,"time":1787257517557,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":2,"time":1787257517557,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":3,"time":1787257517607,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":4,"time":1787257517607,"data":{"content":[{"type":"text","text":"Observe the DSH SDK diagnostic twice with subagent_dsh_sdk. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"fb50a593-f729-430e-a0b4-591fd05c9a80"},"surfaceOp":"append"}
+{"type":"session/title","seq":5,"time":1787257517607,"data":{"title":"Observe the DSH SDK diagnostic","messageSeqs":[4],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":6,"time":1787257517609,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":7,"time":1787257517609,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","argumentsDelta":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}}
+{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}}}
+{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":13,"time":1787257517614,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fb422889-0cdd-4a7f-8228-b94db8ae58ca"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}
+{"type":"tool/call","seq":14,"time":1787257517615,"data":{"turn":1,"step":1,"callId":"call_dsh_sdk_foreground","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK foreground failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":false}"}}
+{"type":"tool/result","seq":15,"time":1787257517679,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_foreground"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error; child reason: error)\nPartial output before the run ended:\npartial DSH SDK assistant text"}],"isError":true}],"role":"user","id":"d3fb3f99-1bab-432b-b79e-009e6a8e2891"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"step/end","seq":16,"time":1787257517679,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":17,"time":1787257517683,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","argumentsDelta":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}
+{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}
+{"type":"assistant/chunk","seq":21,"time":1787257517687,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":22,"time":1787257517687,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":23,"time":1787257517687,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2c0e8318-c5f2-4885-be54-8716b21005e0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}
+{"type":"tool/call","seq":24,"time":1787257517688,"data":{"turn":1,"step":2,"callId":"call_dsh_sdk_background","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}
+{"type":"tool/result","seq":25,"time":1787257517691,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_background"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"092ae9df-ff5f-4add-8533-3e800d54d4e9"}},"sourceEventSeqs":[24],"surfaceOp":"append"}
+{"type":"step/end","seq":26,"time":1787257517692,"data":{"turn":1,"step":2}}
+{"type":"step/start","seq":27,"time":1787257517695,"data":{"turn":1,"step":3}}
+{"type":"assistant/chunk","seq":28,"time":1787257517699,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":29,"time":1787257517699,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
+{"type":"assistant/chunk","seq":30,"time":1787257517699,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
+{"type":"assistant/chunk","seq":31,"time":1787257517699,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":32,"time":1787257517699,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":33,"time":1787257517699,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1233bddb-7034-4298-b30d-4edc9cb541e6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}
+{"type":"tool/call","seq":34,"time":1787257517700,"data":{"turn":1,"step":3,"callId":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
+{"type":"tool/result","seq":35,"time":1787257517703,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_output"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error; child reason: error)]"}],"isError":false}],"role":"user","id":"2d58158d-6d53-4b87-8423-9a64e567bf68"}},"sourceEventSeqs":[34],"surfaceOp":"append"}
+{"type":"step/end","seq":36,"time":1787257517703,"data":{"turn":1,"step":3}}
+{"type":"step/start","seq":37,"time":1787257517707,"data":{"turn":1,"step":4}}
+{"type":"assistant/chunk","seq":38,"time":1787257517711,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":39,"time":1787257517711,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}
+{"type":"assistant/chunk","seq":40,"time":1787257517711,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}}
+{"type":"assistant/chunk","seq":41,"time":1787257517711,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
+{"type":"assistant/chunk","seq":42,"time":1787257517711,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":43,"time":1787257517711,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bdd016d-cb60-4922-a239-65958f5e9910"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
+{"type":"step/end","seq":44,"time":1787257517711,"data":{"turn":1,"step":4}}
+{"type":"turn/end","seq":45,"time":1787257517711,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 30 - 1
packages/sdk/client/tests/fake-runtime.ts

@@ -10,6 +10,7 @@
  * - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
  * - `FAKE_STATUS`: the `session.finished` status (default `ok`).
  * - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
+ * - `FAKE_ABORT_REASON_KIND`: nested cause for an `aborted` turn (default `user`).
  * - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
  * - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
  * - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
@@ -33,6 +34,8 @@
  *   arrives, then poll for the GO file before answering (deterministic
  *   cancel-during-handshake window).
  * - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
+ * - `FAKE_EXIT_DURING_PROMPT`: stream one partial chunk, then exit 17 while
+ *   the owned session run is waiting for its terminal state.
  * - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
  *   answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
  *   before the protocol failure (partial-output retention probe).
@@ -126,7 +129,12 @@ function runTurn(sessionId: string): void {
     },
   })
   const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
-  event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
+  if (reasonKind !== 'none') {
+    const reason = reasonKind === 'aborted'
+      ? { kind: 'aborted', reason: { kind: env.FAKE_ABORT_REASON_KIND ?? 'user' } }
+      : { kind: reasonKind }
+    event(sessionId, 'turn/end', { turn: 0, reason })
+  }
   if (env.FAKE_SUBAGENT !== undefined) {
     const childId = `${sessionId}-child`
     notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
@@ -212,6 +220,27 @@ reader.on('line', (line) => {
         respond({})
         return
       }
+      if (env.FAKE_EXIT_DURING_PROMPT !== undefined) {
+        const partial = env.FAKE_TEXT ?? 'partial before exit'
+        respond({ messageId })
+        event(sessionId, 'assistant/chunk', {
+          turn: 0,
+          step: 0,
+          chunk: { type: 'text-delta', index: 0, text: partial },
+        })
+        event(sessionId, 'assistant/message', {
+          turn: 0,
+          step: 0,
+          message: {
+            id: `fake-partial-${seq}`,
+            role: 'assistant',
+            content: [{ type: 'text', text: partial }],
+            source: { kind: 'model', provider: 'fake', model: 'fake' },
+          },
+        })
+        setImmediate(() => { process.exit(17) })
+        return
+      }
       if (env.FAKE_HANG_PROMPT !== undefined) return
       if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
         respond({})

+ 2 - 2
packages/subagent/subagent-dsh-sdk/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md
-README.md: 8d20b44dacd773345986e2a2fc0e6aca9470bf2c
-README.zh.md: 3937724c28a693e3e74cca2e1c3f1ae61766a14e
+README.md: df7953eebe7a4239e9346136a78928ae7eb2bb6e
+README.zh.md: f422e9486d03abee5649d37d33ac665d8d4f9d95

+ 27 - 4
packages/subagent/subagent-dsh-sdk/README.md

@@ -6,17 +6,40 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
 
 ## Start and ownership
 
-`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route and optional `maxTokens` output cap) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
+`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route and optional `maxTokens` output cap) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned. Non-cancellation rejections expose only fixed provider, stage, and category facts in their Error message; the original SDK failure remains on the internal cause chain and in Host diagnostics.
 
 The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
 
-The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error.
+The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error, separate from any `SubagentResult.diagnostic`.
 
 `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
 
 ## Stop-reason mapping
 
-The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and maps it into the seam vocabulary: `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or an activity with no turn — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting.
+The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and preserves the existing seam stop reason while adding detail only where it changes the next action.
+
+| Child turn reason | Harness | Additional diagnostic |
+|---|---|---|
+| `completed` | `completed` | None. |
+| `max-tokens` | `max-tokens` | None; the stop reason is already actionable. |
+| `aborted` | `aborted` | `child-disposed` only for the closed `disposed` cause; local parent cancellation never adds one. |
+| `blocked` | `error` | `child-blocked`. |
+| `error` | `error` | `child-error`; the child failure message/code is excluded. |
+| `interrupted` | `error` | `child-interrupted`. |
+| no `turn/end` | `error` | `missing-terminal`. |
+| unknown variant | `error` | Fixed `unknown`; the value is not copied. |
+
+## Failure diagnostics
+
+The first line follows the shared fixed form:
+
+```text
+Subagent failure (provider: DSH SDK; stage: <stage>; category: <category>; child reason: <reason>)
+```
+
+Unavailable optional fields are omitted, and the shared result boundary limits the complete text to 4096 UTF-8 bytes. The provider derives `initialize`, `session-run`, `process`, or `shutdown` at the operation that owns the failure. `SdkProtocolError` and JSON-RPC error responses map to `protocol`, `RequestTimeoutError` maps to `timeout`, `TransportClosedError` maps to `transport` (with `process` stage during a published child run), and other exceptions use `unknown`. Classification never reads an error message, so the stderr tail carried by `TransportClosedError`, paths, task content, environment values, credentials, and protocol payloads remain Host-only.
+
+Successful results and local cancellation omit diagnostics. Startup and shutdown rejections use the same safe line in their Error message while retaining the original cause internally. A diagnostic-bearing child `aborted` result remains `aborted`; the one-shot Job adapter classifies it as failed, while diagnostic-free local cancellation remains killed.
 
 ## Capabilities and context
 
@@ -79,7 +102,7 @@ Independent of the parent request cache. Each SDK child can reuse only prefixes
 
 #### What the model sees
 
-Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic.
+Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A non-completed result presents the safe diagnostic before separately preserved partial assistant output; startup and shutdown errors expose the same fixed facts without raw SDK text.
 
 #### Token effect
 

+ 27 - 4
packages/subagent/subagent-dsh-sdk/README.zh.md

@@ -6,17 +6,40 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
 
 ## 启动与所有权
 
-`start(request)` 先解析子进程工作目录,通过 `DeepSeekHarness` spawn 运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此,履行意味着子运行时已就绪、所有权已移交给调用方。spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
+`start(request)` 先解析子进程工作目录,通过 `DeepSeekHarness` spawn 运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此,履行意味着子运行时已就绪、所有权已移交给调用方。spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。非取消拒绝的 Error 消息只公开固定的 provider、stage 与 category 事实;原始 SDK 失败仍保留在内部 cause 链和 Host 诊断中。
 
 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
 
-返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。
+返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用,并与 `SubagentResult.diagnostic` 分开。
 
 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
 
 ## 停止原因映射
 
-SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个已持久化的 `turn/end`,并将其映射为 seam 词汇:`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或不含轮次的活动,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 约定禁止 `result` 被拒绝。
+SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个已持久化的 `turn/end`,保留既有 seam 结束原因,并只在会改变下一步动作时附加细节。
+
+| 子轮次原因 | Harness | 附加诊断 |
+|---|---|---|
+| `completed` | `completed` | 无。 |
+| `max-tokens` | `max-tokens` | 无;结束原因本身已经可行动。 |
+| `aborted` | `aborted` | 只有闭集 `disposed` 原因会附加 `child-disposed`;父级本地取消绝不附加。 |
+| `blocked` | `error` | `child-blocked`。 |
+| `error` | `error` | `child-error`;不包含子失败消息或 code。 |
+| `interrupted` | `error` | `child-interrupted`。 |
+| 缺少 `turn/end` | `error` | `missing-terminal`。 |
+| 未知 variant | `error` | 固定 `unknown`,不复制原值。 |
+
+## 失败诊断
+
+首行遵循共享固定格式:
+
+```text
+Subagent failure (provider: DSH SDK; stage: <stage>; category: <category>; child reason: <reason>)
+```
+
+不可用的可选字段会被省略,共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。提供方从实际拥有失败的操作派生 `initialize`、`session-run`、`process` 或 `shutdown`。`SdkProtocolError` 与 JSON-RPC 错误响应映射为 `protocol`,`RequestTimeoutError` 映射为 `timeout`,`TransportClosedError` 映射为 `transport`(已发布子运行期间使用 `process` stage),其他异常使用 `unknown`。分类绝不读取错误消息,因此 `TransportClosedError` 携带的 stderr tail、路径、任务内容、环境值、凭证与协议 payload 都只留在 Host。
+
+成功结果与本地取消会省略诊断。启动和 shutdown 拒绝会在 Error 消息中使用同一安全行,同时把原始 cause 留在内部。带诊断的子 `aborted` 结果仍保持 `aborted`;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
 
 ## 能力与上下文
 
@@ -79,7 +102,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
 
 #### 模型看到的内容
 
-经由 `dsh-tool-subagent`,父级只会收到子运行时最终的 assistant 文本(或累积的部分文本),或该消费方给出的精确停止原因错误;不会收到中间消息或工具流量。
+经由 `dsh-tool-subagent`,父级只会收到子运行时最终的 assistant 文本(或累积的部分文本),或该消费方给出的精确停止原因错误;不会收到中间消息或工具流量。非完成结果会先呈现安全诊断,再单独呈现保留的部分 assistant 输出;启动与 shutdown 错误使用同一固定事实,不公开原始 SDK 文本。
 
 #### Token 影响
 

+ 13 - 1
packages/subagent/subagent-dsh-sdk/src/index.ts

@@ -18,6 +18,7 @@ import {
   DEFAULT_DISPOSE_EOF_GRACE_MS,
   DEFAULT_DISPOSE_GRACE_MS,
   DEFAULT_SHUTDOWN_TIMEOUT_MS,
+  sdkConfigurationFailure,
   startSdkRun,
   type SdkRunSpec,
 } from './run.ts'
@@ -98,10 +99,21 @@ class SdkSubagentProvider implements SubagentProvider {
   constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
 
   start(request: SubagentStartRequest) {
+    if (request.signal.aborted) {
+      throw new Error('subagent request was aborted before the SDK child started')
+    }
+    let cwd: string
+    try {
+      cwd = resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd)
+    } catch (error: unknown) {
+      const failure = sdkConfigurationFailure(error)
+      this.ctx.logger.warn(`subagent-dsh-sdk "${this.name}": child start failed: %o`, error)
+      throw failure
+    }
     const spec: SdkRunSpec = {
       command: this.config.command,
       args: this.config.args,
-      cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
+      cwd,
       provider: this.config.provider,
       model: this.config.model,
       ...this.config.maxTokens === undefined ? {} : { maxTokens: this.config.maxTokens },

+ 152 - 20
packages/subagent/subagent-dsh-sdk/src/run.ts

@@ -12,7 +12,14 @@
  */
 
 import { randomUUID } from 'node:crypto'
-import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client'
+import {
+  DeepSeekHarness,
+  type HarnessNotification,
+  JsonRpcResponseError,
+  RequestTimeoutError,
+  SdkProtocolError,
+  TransportClosedError,
+} from '@deepseek-ai/dsh-sdk-client'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
@@ -51,10 +58,9 @@ export interface SdkRunSpec {
   /** Termination confirmation window (ms), including forced exit on every platform. */
   disposeGraceMs: number
   /**
-   * Sink for a child-level failure that the run flattened into a stop reason
-   * (the seam contract forbids `result` rejecting). A throw from the sink
-   * itself is contained. Optional — omitted in unit tests that assert the
-   * stop reason directly.
+   * Host sink for startup, published-run, or shutdown failures. Model-visible
+   * text uses fixed safe facts, while this callback retains the original Error.
+   * A throw from the sink itself is contained.
    */
   onError?: (error: Error, stopReason: SubagentStopReason) => void
 }
@@ -68,6 +74,88 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000
 /** Default bound on the protocol `shutdown` exchange during dispose. */
 export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000
 
+type SdkFailureStage = 'initialize' | 'session-run' | 'process' | 'shutdown'
+
+type SdkFailureCategory =
+  | 'configuration'
+  | 'protocol'
+  | 'timeout'
+  | 'transport'
+  | 'child-error'
+  | 'child-interrupted'
+  | 'child-disposed'
+  | 'child-blocked'
+  | 'missing-terminal'
+  | 'unknown'
+
+interface SdkFailureFacts {
+  readonly stage: SdkFailureStage
+  readonly category: SdkFailureCategory
+  readonly childReason?: 'error' | 'interrupted' | 'disposed' | 'blocked' | 'missing' | 'unknown'
+}
+
+/** Fixed safe failure text derived only from provider-owned structured facts. */
+function failureDiagnostic(facts: SdkFailureFacts): string {
+  const fields = [
+    'provider: DSH SDK',
+    `stage: ${facts.stage}`,
+    `category: ${facts.category}`,
+  ]
+  if (facts.childReason !== undefined) fields.push(`child reason: ${facts.childReason}`)
+  return `Subagent failure (${fields.join('; ')})`
+}
+
+class SdkRunFailure extends Error {
+  constructor(readonly facts: SdkFailureFacts, cause: unknown) {
+    super(`subagent-dsh-sdk: ${failureDiagnostic(facts)}`, { cause })
+    this.name = 'SdkRunFailure'
+  }
+}
+
+/**
+ * Hide a pre-spawn workspace/configuration failure behind fixed safe facts.
+ * @param cause - original Host failure retained on the Error cause chain.
+ * @returns an Error whose message contains only the fixed DSH SDK failure line.
+ */
+export function sdkConfigurationFailure(cause: unknown): Error {
+  return new SdkRunFailure({ stage: 'initialize', category: 'configuration' }, cause)
+}
+
+/** Classify one SDK rejection without reading its message or stderr tail. */
+function sdkFailure(error: unknown, stage: SdkFailureStage): SdkRunFailure {
+  const facts: SdkFailureFacts = error instanceof TransportClosedError
+    ? { stage: stage === 'session-run' ? 'process' : stage, category: 'transport' }
+    : error instanceof RequestTimeoutError
+      ? { stage, category: 'timeout' }
+      : error instanceof SdkProtocolError || error instanceof JsonRpcResponseError
+        ? { stage, category: 'protocol' }
+        : { stage, category: 'unknown' }
+  return new SdkRunFailure(facts, error)
+}
+
+/** Map a child terminal reason to the optional diagnostic it needs. */
+function childDiagnostic(reason: TurnEndReason | undefined): string | undefined {
+  switch (reason?.kind) {
+    case 'completed':
+    case 'max-tokens':
+      return undefined
+    case 'aborted':
+      return reason.reason.kind === 'disposed'
+        ? failureDiagnostic({ stage: 'session-run', category: 'child-disposed', childReason: 'disposed' })
+        : undefined
+    case 'blocked':
+      return failureDiagnostic({ stage: 'session-run', category: 'child-blocked', childReason: 'blocked' })
+    case 'error':
+      return failureDiagnostic({ stage: 'session-run', category: 'child-error', childReason: 'error' })
+    case 'interrupted':
+      return failureDiagnostic({ stage: 'session-run', category: 'child-interrupted', childReason: 'interrupted' })
+    case undefined:
+      return failureDiagnostic({ stage: 'session-run', category: 'missing-terminal', childReason: 'missing' })
+    default:
+      return failureDiagnostic({ stage: 'session-run', category: 'unknown', childReason: 'unknown' })
+  }
+}
+
 /**
  * Map a child turn-end reason to a harness {@link SubagentStopReason}.
  * @param reason - the owned child run's final durable turn reason, or
@@ -100,10 +188,20 @@ function toError(value: unknown): Error {
   return value instanceof Error ? value : new Error(String(value))
 }
 
+/** Report an original Host failure without letting the observation sink replace it. */
+function reportFailure(spec: SdkRunSpec, error: unknown): void {
+  try {
+    spec.onError?.(toError(error), 'error')
+  } catch {
+    // Host diagnostic logging cannot replace the child failure.
+  }
+}
+
 /**
  * Start and publish one SDK runtime child after its `initialize` handshake.
- * Child failures resolve through the run result; startup failures reject
- * after process reap. Disposal shuts the runtime down and reaps it.
+ * Child failures resolve through the run result; startup and shutdown failures
+ * reject with fixed safe facts after process reap, retaining original causes
+ * for Host observation. Disposal shuts the runtime down and reaps it.
  * @param request - the start request; its signal is the cancellation channel.
  * @param spec - the resolved spawn spec: command/args/cwd, the child's
  * provider/model route, env, timeouts, and the optional error sink.
@@ -157,9 +255,24 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
     if (flags.cancelled) throw new Error('subagent cancelled before the SDK child initialized')
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
-    await harness.close()
-    if (flags.cancelled) throw new Error('subagent request was aborted before the SDK child started')
-    throw toError(error)
+    const cancelledBeforeCleanup = flags.cancelled
+    const failure = sdkFailure(error, 'initialize')
+    if (!cancelledBeforeCleanup) reportFailure(spec, error)
+    try {
+      await harness.close()
+    } catch (cleanupError: unknown) {
+      reportFailure(spec, cleanupError)
+      const cleanupFailure = sdkFailure(cleanupError, 'shutdown')
+      if (cancelledBeforeCleanup) throw cleanupFailure
+      throw new AggregateError(
+        [failure, cleanupFailure],
+        `${failure.message}; ${cleanupFailure.message}`,
+      )
+    }
+    if (cancelledBeforeCleanup) {
+      throw new Error('subagent request was aborted before the SDK child started')
+    }
+    throw failure
   }
 
   const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
@@ -174,19 +287,31 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
 
   // Race the child turn against local cancellation; the shared settlement
   // flattens failures under the seam's never-reject contract.
+  let diagnostic: string | undefined
   const result: Promise<SubagentResult> = settleRunResult({
     attempt: async () => {
-      const turn = await Promise.race([
-        harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
-        cancelSettled.then(() => 'cancelled' as const),
-      ])
-      if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
-      const lastEnd = turn.events.findLast(
-        (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
-      )
-      return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) }
+      try {
+        const turn = await Promise.race([
+          harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
+          cancelSettled.then(() => 'cancelled' as const),
+        ])
+        if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
+        const lastEnd = turn.events.findLast(
+          (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
+        )
+        diagnostic = childDiagnostic(lastEnd?.data.reason)
+        return {
+          output: collectOutput(),
+          ...(diagnostic === undefined ? {} : { diagnostic }),
+          stopReason: sdkStopReason(lastEnd?.data.reason),
+        }
+      } catch (error: unknown) {
+        diagnostic = failureDiagnostic(sdkFailure(error, 'session-run').facts)
+        throw error
+      }
     },
     collectOutput,
+    collectDiagnostic: () => diagnostic,
     cancelled: () => flags.cancelled,
     onError: spec.onError,
     signal: request.signal,
@@ -201,6 +326,13 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
     signal: request.signal,
     onAbort,
     requestCancel,
-    teardown: () => harness.close(),
+    teardown: async () => {
+      try {
+        await harness.close()
+      } catch (error: unknown) {
+        reportFailure(spec, error)
+        throw sdkFailure(error, 'shutdown')
+      }
+    },
   })
 }

+ 56 - 28
packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts

@@ -1,12 +1,8 @@
 /**
- * Keyless REAL-composition coverage for parent-session cwd inheritance across
- * the SDK wire: a test-only cordis.yml boots the headless app through the
- * Loader with the SDK backend's `cwd` omitted, a scripted model delegates
- * once, and the child — a COMPLETE second harness runtime booted from its own
- * cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran.
- * Both the parent's tool result and the child's own persisted session log
- * must carry the parent session's cwd. Mock-only composition, so only this
- * keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
+ * Keyless REAL-composition coverage across the SDK wire: a test-only
+ * cordis.yml boots the headless app through the Loader, delegates to a
+ * complete second harness runtime, and verifies cwd inheritance plus
+ * model-visible child-failure diagnostics.
  */
 
 import { realpathSync } from 'node:fs'
@@ -39,17 +35,36 @@ async function sessionEvents(log: string): Promise<SessionEvent[]> {
   return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
 }
 
+function toolResultText(events: SessionEvent[]): string {
+  const results = events.filter(event => event.type === 'tool/result')
+  expect(results).toHaveLength(1)
+  return results[0]!.data.message.content[0].content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')
+}
+
+function childLaunchEnv(failure = false): Record<string, string> {
+  const launch = resolveExampleLaunch({
+    srcBin: runtimeBin,
+    configArgs: [childConfigPath],
+    tsconfigPath: repoTsconfig,
+  })
+  return {
+    DSH_TEST_CHILD_COMMAND: launch.command,
+    DSH_TEST_CHILD_ARGS: JSON.stringify(launch.args),
+    DSH_TEST_CHILD_ENV: JSON.stringify({
+      ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)),
+      ...(failure ? { DSH_TEST_CHILD_FAILURE: '1' } : {}),
+    }),
+  }
+}
+
 describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
   it('runs the child runtime in the parent session workspace', async () => {
     // The child launch honors the same src/lib mode as the driving harness,
     // per the shared example-launch resolver (testing policy forbids
     // hand-written `--import tsx` argv for example subprocesses).
-    const childLaunch = resolveExampleLaunch({
-      srcBin: runtimeBin,
-      configArgs: [childConfigPath],
-      tsconfigPath: repoTsconfig,
-    })
-
     let events: SessionEvent[] = []
     let childEvents: SessionEvent[] = []
     let workspace = ''
@@ -64,13 +79,7 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
       // child); from-source tsx boots under load need more than the default
       // 30s window.
       processTimeoutMs: 120_000,
-      env: {
-        DSH_TEST_CHILD_COMMAND: childLaunch.command,
-        DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args),
-        DSH_TEST_CHILD_ENV: JSON.stringify({
-          ...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)),
-        }),
-      },
+      env: childLaunchEnv(),
       inspect: async (cwd) => {
         // The child reports realpaths; canonicalize the temp workspace to match.
         workspace = realpathSync(cwd)
@@ -89,13 +98,7 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
     // The parent's tool result carries the child model's echo of its real
     // process.cwd() — the parent session's workspace, never the harness
     // process's launch directory.
-    const results = events.filter(event => event.type === 'tool/result')
-    expect(results).toHaveLength(1)
-    const resultText = results[0]!.data.message.content[0].content
-      .filter(block => block.type === 'text')
-      .map(block => block.text)
-      .join('')
-    expect(resultText).toBe(`child cwd: ${workspace}`)
+    expect(toolResultText(events)).toBe(`child cwd: ${workspace}`)
 
     // The child ran a real turn of its own: user message in, assistant out.
     expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
@@ -104,4 +107,29 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
     // 15s of vitest headroom past the subprocess deadline, mirroring
     // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
   }, 135_000)
+
+  it('presents the child error diagnostic separately from partial output', async () => {
+    let events: SessionEvent[] = []
+    const { stderr } = await runLoaderSmoke({
+      label: 'dsh-sdk-subagent diagnostic composition smoke',
+      tempDirPrefix: 'dsh-sdk-subagent-diagnostic-e2e-',
+      binScript: driver,
+      libBinScript: driver,
+      configPath,
+      tsconfigPath: repoTsconfig,
+      processTimeoutMs: 120_000,
+      env: childLaunchEnv(true),
+      inspect: async (cwd) => {
+        const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
+        expect(parentLogs).toHaveLength(1)
+        events = await sessionEvents(parentLogs[0] as string)
+      },
+    })
+    expect(stderr).not.toContain('UNHANDLED')
+    expect(toolResultText(events)).toBe(
+      'Error: subagent run failed\n'
+      + 'Diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error; child reason: error)\n'
+      + 'Partial output before the run ended:\npartial child loader answer',
+    )
+  }, 135_000)
 })

+ 274 - 8
packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

@@ -6,7 +6,7 @@
  * quiescent disposal are all exercised end to end. No model, no key.
  */
 
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { existsSync, mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -14,6 +14,11 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import {
+  DeepSeekHarness,
+  HarnessSession,
+  RequestTimeoutError,
+} from '@deepseek-ai/dsh-sdk-client'
 import * as sdk from '../src/index.ts'
 import {
   DEFAULT_DISPOSE_EOF_GRACE_MS,
@@ -56,6 +61,10 @@ function text(blocks: { type: string; text?: string }[]): string {
   return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
 }
 
+function expectedFailure(fields: string): string {
+  return `Subagent failure (provider: DSH SDK; ${fields})`
+}
+
 /**
  * Poll until `file` exists (the fake touches it once the probed state is
  * reached), so cancel tests wait on a CONDITION rather than an arbitrary
@@ -92,6 +101,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     expect(run.localAgent).toBeUndefined()
     const result = await run.result
     expect(result.stopReason).toBe('completed')
+    expect(result.diagnostic).toBeUndefined()
     expect(text(result.output)).toBe('hello from sdk child')
     // dispose is idempotent (one memoized teardown).
     const disposal = run.dispose()
@@ -150,7 +160,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
   it('maps a max-tokens child turn end', async () => {
     const ctx = await setup({ FAKE_REASON_KIND: 'max-tokens', FAKE_STATUS: 'error' })
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('max-tokens')
+    const result = await run.result
+    expect(result.stopReason).toBe('max-tokens')
+    expect(result.diagnostic).toBeUndefined()
     await run.dispose()
     await ctx.fiber.dispose()
   })
@@ -160,6 +172,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await ctx.subagents.start('dsh-sdk', request())
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: child-error; child reason: error'),
+    )
     expect(text(result.output)).toBe('partial answer')
     await run.dispose()
     await ctx.fiber.dispose()
@@ -193,7 +208,115 @@ describe('dsh-subagent-dsh-sdk provider', () => {
   it('reports a settled-without-turn child as an error', async () => {
     const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('error')
+    expect(await run.result).toMatchObject({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: missing-terminal; child reason: missing'),
+    })
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it.each([
+    ['interrupted', 'child-interrupted', 'interrupted'],
+    ['blocked', 'child-blocked', 'blocked'],
+  ] as const)('preserves the %s child terminal fact', async (reason, category, safeReason) => {
+    const ctx = await setup({ FAKE_REASON_KIND: reason })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure(`stage: session-run; category: ${category}; child reason: ${safeReason}`),
+    )
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('aggregates safe initialize and shutdown facts when startup rollback fails', async () => {
+    const rawCleanup = 'shutdown leaked /private/path SECRET_TOKEN'
+    const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const ctx = await setup({ FAKE_MALFORMED: '1' })
+      const error = await ctx.subagents.start('dsh-sdk', request()).catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(AggregateError)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: protocol')}; `
+        + `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+      await ctx.fiber.dispose()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('reports only safe shutdown facts when cancelled startup rollback fails', async () => {
+    const rawCleanup = 'cancelled shutdown leaked SECRET_TOKEN'
+    const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const controller = new AbortController()
+      const pending = startSdkRun(request('p', controller.signal), {
+        command: process.execPath,
+        args: [fakeRuntime],
+        cwd: process.cwd(),
+        provider: 'p',
+        model: 'm',
+        env: { FAKE_HANG_INIT: '1' },
+        shutdownTimeoutMs: 100,
+        disposeEofGraceMs: 100,
+        disposeGraceMs: 100,
+      })
+      controller.abort()
+      const error = await pending.catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(Error)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('preserves a disposed child cancellation without treating it as local cancellation', async () => {
+    const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'disposed' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: child-disposed; child reason: disposed'),
+    )
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps an ordinary child abort diagnostic-free', async () => {
+    const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'user' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBeUndefined()
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('uses a fixed fallback for an unknown child terminal reason', async () => {
+    const rawReason = 'private/path/SECRET_TOKEN'
+    const ctx = await setup({ FAKE_REASON_KIND: rawReason })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: unknown; child reason: unknown'),
+    )
+    expect(result.diagnostic).not.toContain(rawReason)
     await run.dispose()
     await ctx.fiber.dispose()
   })
@@ -205,6 +328,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     controller.abort('test')
     const result = await run.result
     expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBeUndefined()
     // The hung child streamed nothing, so the aborted result has no output.
     expect(result.output).toEqual([])
     await run.dispose()
@@ -251,11 +375,94 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await ctx.subagents.start('dsh-sdk', request())
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: protocol'),
+    )
     expect(result.output).toEqual([])
     await run.dispose()
     await ctx.fiber.dispose()
   })
 
+  it('preserves partial output while hiding a transport error stderr tail', async () => {
+    const stderr = 'private/path SECRET_TOKEN must remain Host-only'
+    const ctx = await setup({
+      FAKE_EXIT_DURING_PROMPT: '1',
+      FAKE_TEXT: 'partial before transport exit',
+      FAKE_STDERR: stderr,
+    })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.output).toEqual([{ type: 'text', text: 'partial before transport exit' }])
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: process; category: transport'),
+    )
+    expect(result.diagnostic).not.toContain(stderr)
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('classifies a typed SDK request timeout without copying its message', async () => {
+    const rawMessage = 'session path SECRET_TOKEN timed out'
+    const spy = vi.spyOn(HarnessSession.prototype, 'run')
+      .mockRejectedValue(new RequestTimeoutError(rawMessage))
+    try {
+      const ctx = await setup()
+      const run = await ctx.subagents.start('dsh-sdk', request())
+      const result = await run.result
+      expect(result).toEqual({
+        output: [],
+        diagnostic: expectedFailure('stage: session-run; category: timeout'),
+        stopReason: 'error',
+      })
+      expect(result.diagnostic).not.toContain(rawMessage)
+      await run.dispose()
+      await ctx.fiber.dispose()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('uses a fixed unknown category for an untyped SDK exception', async () => {
+    const rawMessage = 'unknown SDK failure at /private/path SECRET_TOKEN'
+    const spy = vi.spyOn(HarnessSession.prototype, 'run')
+      .mockRejectedValue(new Error(rawMessage))
+    try {
+      const ctx = await setup()
+      const run = await ctx.subagents.start('dsh-sdk', request())
+      const result = await run.result
+      expect(result.diagnostic).toBe(
+        expectedFailure('stage: session-run; category: unknown'),
+      )
+      expect(result.diagnostic).not.toContain(rawMessage)
+      await run.dispose()
+      await ctx.fiber.dispose()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('keeps child diagnostics isolated across concurrent runs', async () => {
+    const start = (reason: 'error' | 'interrupted') => startSdkRun(request(), {
+      command: process.execPath,
+      args: [fakeRuntime],
+      cwd: process.cwd(),
+      provider: 'p',
+      model: 'm',
+      env: { FAKE_REASON_KIND: reason },
+      shutdownTimeoutMs: 100,
+      disposeEofGraceMs: 200,
+      disposeGraceMs: 200,
+    })
+    const [errored, interrupted] = await Promise.all([start('error'), start('interrupted')])
+    const [errorResult, interruptedResult] = await Promise.all([errored.result, interrupted.result])
+    expect(errorResult.diagnostic).toContain('category: child-error')
+    expect(errorResult.diagnostic).not.toContain('child-interrupted')
+    expect(interruptedResult.diagnostic).toContain('category: child-interrupted')
+    expect(interruptedResult.diagnostic).not.toContain('child-error')
+    await Promise.all([errored.dispose(), interrupted.dispose()])
+  })
+
   it('dispose cancels a hung child locally and reaps it', async () => {
     const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
     const run = await ctx.subagents.start('dsh-sdk', request())
@@ -291,14 +498,42 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     }
   })
 
+  it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => {
+    const ctx = await setup()
+    const controller = new AbortController()
+    controller.abort()
+    const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
+    await expect(ctx.subagents.start('dsh-sdk', {
+      label: 'p',
+      prompt: [{ type: 'text' as const, text: 'p' }],
+      parent,
+      signal: controller.signal,
+    })).rejects.toThrow('subagent request was aborted before the SDK child started')
+    await ctx.fiber.dispose()
+  })
+
   it('rejects after reaping when the child dies before the handshake', async () => {
-    const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'scripted boot failure' })
+    const rawStderr = 'scripted boot failure at /private/path SECRET_TOKEN'
+    const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: rawStderr })
     const failure = await ctx.subagents.start('dsh-sdk', request()).then(
       () => { throw new Error('start unexpectedly succeeded') },
       (error: unknown) => error,
     )
-    expect(String(failure)).toContain('exit code: 3')
-    expect(String(failure)).toContain('scripted boot failure')
+    expect(String(failure)).toBe(
+      `SdkRunFailure: subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: transport')}`,
+    )
+    expect(String(failure)).not.toContain(rawStderr)
+    await ctx.fiber.dispose()
+  })
+
+  it.each([
+    [{ FAKE_MALFORMED: '1' }, 'protocol'],
+    [{ FAKE_INIT_ERROR: '1' }, 'protocol'],
+  ] as const)('rejects an initialize failure with safe %s facts', async (env, category) => {
+    const ctx = await setup({ ...env })
+    await expect(ctx.subagents.start('dsh-sdk', request())).rejects.toThrow(
+      `subagent-dsh-sdk: ${expectedFailure(`stage: initialize; category: ${category}`)}`,
+    )
     await ctx.fiber.dispose()
   })
 
@@ -343,6 +578,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await startSdkRun(request(), spec)
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: protocol'),
+    )
     expect(seen).toHaveLength(1)
     await run.dispose()
   })
@@ -352,13 +590,39 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const warnings: string[] = []
     ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('error')
+    expect(await run.result).toMatchObject({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: protocol'),
+    })
     expect(warnings).toHaveLength(1)
     expect(warnings[0]).toContain('subagent-dsh-sdk "dsh-sdk": child run failed (error)')
     await run.dispose()
     await ctx.fiber.dispose()
   })
 
+  it('wraps a shutdown rejection with safe facts after the runtime is reaped', async () => {
+    const rawCleanup = 'shutdown failed at /private/path SECRET_TOKEN'
+    const ctx = await setup()
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    await run.result
+    const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const error = await run.dispose().catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(Error)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+    } finally {
+      spy.mockRestore()
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentRuntime)
@@ -468,7 +732,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     await expect(ctx.subagents.start('dsh-sdk', {
       label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
     }))
-      .rejects.toThrow('no working directory for the child')
+      .rejects.toThrow(
+        `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: configuration')}`,
+      )
     await ctx.fiber.dispose()
   })