瀏覽代碼

fix(subagent): preserve actionable ACP failure facts

pku-xht 1 月之前
父節點
當前提交
5c27df5ed7
共有 23 個文件被更改,包括 1665 次插入 和 118 次删除
  1. 6 0
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml
  2. 73 0
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
  3. 73 0
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md
  4. 41 0
      examples/acp-agent/subagent-acp-diagnostic.cordis.snapshot.yml
  5. 31 0
      examples/acp-agent/subagent-acp-diagnostic.cordis.yml
  6. 18 0
      examples/acp-agent/tests/acp.snapshot.ts
  7. 6 7
      examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml
  8. 7 0
      examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/input.json
  9. 42 0
      examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/replay.override.json
  10. 48 0
      examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/session.jsonl
  11. 4 0
      examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/stdout.expected.jsonl
  12. 440 0
      examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/tool-schemas.expected.json
  13. 2 2
      packages/subagent/subagent-acp/README.i18n.yaml
  14. 23 10
      packages/subagent/subagent-acp/README.md
  15. 23 10
      packages/subagent/subagent-acp/README.zh.md
  16. 13 2
      packages/subagent/subagent-acp/src/index.ts
  17. 272 56
      packages/subagent/subagent-acp/src/run.ts
  18. 42 13
      packages/subagent/subagent-acp/tests/loader-composition.e2e.ts
  19. 33 2
      packages/subagent/subagent-acp/tests/mock-acp-server.ts
  20. 377 11
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  21. 15 2
      packages/subagent/subagent/src/out-of-process.ts
  22. 7 3
      packages/subagent/subagent/src/run-settlement.ts
  23. 69 0
      packages/subagent/subagent/tests/run-settlement.spec.ts

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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

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

@@ -0,0 +1,73 @@
+# Agent Note: Out-of-process subagents expose minimal actionable diagnostics
+
+Status: implemented
+
+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.
+
+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.
+
+### Safe failure text
+
+The first line has this fixed field order:
+
+```text
+Subagent failure (provider: <provider>; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
+```
+
+Unavailable optional fields are omitted. The complete result is limited to 4096 UTF-8 bytes by the shared settlement boundary. Successful results and local cancellation carry no failure diagnostic. Partial assistant output remains in `SubagentResult.output` and is presented separately.
+
+When an ACP permission request contributes to a non-completed result, a second fixed line records `policy`, the closed ACP tool `request` kind, and `decision`. Tool titles, raw input, locations, option names, and metadata are excluded. A diagnostic-bearing remote `aborted` result keeps its public stop reason; the one-shot Job adapter treats it as failed, while diagnostic-free local cancellation remains killed.
+
+### ACP facts
+
+| Stage | Owned operation | Safe categories and facts |
+| --- | --- | --- |
+| `initialize` | Parent workspace resolution, spawn, and ACP initialize | `configuration`, `transport`, `process-start`, or `process-exit` |
+| `new-session` | ACP `session/new` and returned session-id validation | `protocol`, `transport`, or `process-exit` |
+| `prompt` | ACP prompt request, remote stop reason, and permission callback | `remote-limit`, `remote-refusal`, `permission`, `transport`, or `unknown` |
+| `process` | Managed child exits before a prompt terminal response | `process-exit` plus independently observed exit code and signal |
+| `teardown` | EOF quiescence and managed process-tree termination | Fixed teardown facts; the original cleanup failure remains internal |
+
+`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.
+
+### 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 |
+| 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.
+
+## 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.
+
+## Alternatives considered
+
+**Return raw exceptions, stderr, or protocol payloads.** These values can contain task content, tool input, paths, environment values, credentials, and upstream prose. Fixed allowlisted facts preserve the actionable distinction without expanding the model-visible trust boundary.
+
+**Add a shared structured error enum.** ACP and other process-backed providers own different lifecycle points and closed termination vocabularies. A shared enum would invent false equivalence and force unrelated consumers to track provider releases.
+
+**Parse exception messages or stderr into categories.** Free-form text is neither stable nor safe. Only closed protocol values, typed errors, current call sites, and managed process outcomes qualify as diagnostic inputs.
+
+**Change existing stop reasons.** The stop reason remains the provider-neutral terminal result. The optional diagnostic explains why a non-completed result needs a different next action without adding new public result states.
+
+**Add retries, recovery state, or interactive approval.** Diagnostics report a failure; they do not own remediation. Retry policy, session recovery, and human interaction require separate user contracts and lifecycle owners.
+
+## 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 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.

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

@@ -0,0 +1,73 @@
+# Agent Note: 进程外 subagent 公开最小可行动诊断
+
+Status: implemented
+
+[English](2026-08-21-out-of-process-subagent-minimal-diagnostics.md) | 中文
+
+## Problem
+
+ACP 子进程可能因为达到远端限制、拒绝必需权限、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
+
+若把异常、stderr、任务内容、工具输入、路径、环境值、凭证或协议 payload 复制进 `SubagentResult.diagnostic`,不受信任的子进程文本就会变成模型可见内容。若复用完整的产品专属错误联合,又会在提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.zh.md) 中复制彼此独立版本化的权威。
+
+## Decision
+
+每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来实现该规则。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
+
+### 安全失败文本
+
+首行采用以下固定字段顺序:
+
+```text
+Subagent failure (provider: <provider>; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
+```
+
+不可用的可选字段会被省略。共享结算边界会把完整结果限制在 4096 个 UTF-8 字节以内。成功结果和本地取消不携带失败诊断。部分 assistant 输出继续保留在 `SubagentResult.output` 中,并与诊断分开呈现。
+
+当 ACP 权限请求参与非完成结果时,第二个固定行会记录 `policy`、ACP 闭集工具 `request` 种类和 `decision`。工具标题、raw input、位置、选项名称与 metadata 均被排除。带诊断的远端 `aborted` 结果仍保持公共结束原因;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
+
+### ACP 事实
+
+| Stage | 归属操作 | 安全 category 与事实 |
+| --- | --- | --- |
+| `initialize` | 父工作区解析、spawn 与 ACP initialize | `configuration`、`transport`、`process-start` 或 `process-exit` |
+| `new-session` | ACP `session/new` 与返回 session id 校验 | `protocol`、`transport` 或 `process-exit` |
+| `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit`、`remote-refusal`、`permission`、`transport` 或 `unknown` |
+| `process` | 受管子进程先于 prompt 终态响应退出 | `process-exit`,以及分别观测到的退出码与信号 |
+| `teardown` | EOF 停稳与受管进程树终止 | 固定 teardown 事实;原始清理失败仍留在内部 |
+
+`max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。
+
+### 所有权与生命周期
+
+| 事实或资源 | Owner | 消费方行为 |
+| --- | --- | --- |
+| ACP 结束原因与工具种类 | ACP server 与 SDK | 提供方只映射闭集值,并对闭集外值使用固定 unknown 回退 |
+| 当前失败 stage 与最新权限决定 | 单次 ACP 运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
+| 退出码与信号 | `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
+| 诊断字节与呈现 | `dsh-subagent`、前台工具与 Job 运行时 | 前台和一次性后台模式都把同一份有界文本与 assistant 输出分开 |
+| 原始失败 | 子运行时、Error cause 链与 Host logger | 只供 Host 排障,绝不复制进父模型结果 |
+
+启动只有在 initialize 与 new-session 成功后才发布运行。启动失败会先把私有子进程回滚到完全停稳,再以安全事实拒绝。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 失败,并继续使用后端既有的整棵进程树清理阶梯。
+
+## Verification
+
+ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。Loader 组合证明真实配置的提供方会到达模型可见前台结果。无密钥 ACP snapshot 会在前台错误输出与一次性后台 `job_output` detail 中固定同一份诊断与权限事实。
+
+## Alternatives considered
+
+**返回原始异常、stderr 或协议 payload。** 这些值可能包含任务内容、工具输入、路径、环境值、凭证和上游文本。固定白名单事实能够保留可行动差异,而不扩大模型可见信任边界。
+
+**增加共享结构化错误 enum。** ACP 与其他进程外提供方拥有不同生命周期位置和闭集终止词汇。共享 enum 会制造虚假的统一,并迫使无关消费方跟随提供方版本。
+
+**解析异常消息或 stderr 来分类。** 自由文本既不稳定也不安全。只有闭集协议值、typed 错误、当前调用位置与受管进程结果可以成为诊断输入。
+
+**修改既有结束原因。** 结束原因继续表示提供方无关的终态结果。可选诊断说明非完成结果为何要求不同的下一步,而不增加新的公共结果状态。
+
+**增加重试、恢复状态或交互审批。** 诊断只负责报告失败,不拥有修复动作。重试策略、会话恢复与人工交互需要独立用户约定和生命周期责任方。
+
+## Consequences
+
+父 agent 可以区分 ACP 远端限制、权限参与、协议或传输失败、部署/进程失败与 teardown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
+
+诊断仍是展示文本,不是公共协议。消费方可以呈现它,但不得按格式分支。本决策不增加重试策略、恢复控制器、共享提供方错误 enum、stderr 分类器、认证分类、会话持久化、进度流或新的 ACP 能力。

+ 41 - 0
examples/acp-agent/subagent-acp-diagnostic.cordis.snapshot.yml

@@ -0,0 +1,41 @@
+# Keyless twin of subagent-acp-diagnostic.cordis.yml: keep the real ACP child
+# process/provider/tool and replace only the external parent model adapter.
+- id: base
+  name: '@deepseek-ai/cordis-plugin-include'
+  config:
+    path: ./cordis.yml
+    patches:
+      - id: llm-deepseek
+        name: '@deepseek-ai/dsh-llm-deepseek'
+        disabled: true
+      - insert:
+          - id: llm-replay
+            name: '@deepseek-ai/dsh-llm-replay'
+            config:
+              providers:
+                - id: deepseek-official
+                  name: DeepSeek
+                  models:
+                    - id: deepseek-v4-flash
+                    - id: deepseek-v4-pro
+          - id: subagent-acp-diagnostic
+            name: '@deepseek-ai/dsh-subagent-acp'
+            config:
+              providerName: acp-diagnostic
+              command: !!js process.execPath
+              args:
+                - !!js process.env.DSH_TEST_MOCK_ACP_SERVER
+              permission: reject
+              env:
+                MOCK_TEXT: partial ACP assistant text
+                MOCK_STOP: max_turn_requests
+                MOCK_PERMISSION: '1'
+                MOCK_PERMISSION_IGNORE_DECISION: '1'
+                MOCK_TOOL_KIND: execute
+          - id: tool-subagent-acp-diagnostic
+            name: '@deepseek-ai/dsh-tool-subagent'
+            config:
+              provider: acp-diagnostic
+              toolName: subagent_acp
+              backgroundMode: one-shot
+              maxDepth: provider-managed

+ 31 - 0
examples/acp-agent/subagent-acp-diagnostic.cordis.yml

@@ -0,0 +1,31 @@
+# Add the real ACP provider behind a one-shot delegation tool. The snapshot
+# scenario supplies the absolute protocol fixture path through
+# DSH_TEST_MOCK_ACP_SERVER; the child returns a remote limit after a denied
+# execute permission and streams partial assistant output first.
+- id: base
+  name: '@deepseek-ai/cordis-plugin-include'
+  config:
+    path: ./cordis.yml
+    patches:
+      - insert:
+          - id: subagent-acp-diagnostic
+            name: '@deepseek-ai/dsh-subagent-acp'
+            config:
+              providerName: acp-diagnostic
+              command: !!js process.execPath
+              args:
+                - !!js process.env.DSH_TEST_MOCK_ACP_SERVER
+              permission: reject
+              env:
+                MOCK_TEXT: partial ACP assistant text
+                MOCK_STOP: max_turn_requests
+                MOCK_PERMISSION: '1'
+                MOCK_PERMISSION_IGNORE_DECISION: '1'
+                MOCK_TOOL_KIND: execute
+          - id: tool-subagent-acp-diagnostic
+            name: '@deepseek-ai/dsh-tool-subagent'
+            config:
+              provider: acp-diagnostic
+              toolName: subagent_acp
+              backgroundMode: one-shot
+              maxDepth: provider-managed

+ 18 - 0
examples/acp-agent/tests/acp.snapshot.ts

@@ -84,6 +84,13 @@ const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-
 const PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG = fileURLToPath(
   new URL('../subagent-result-diagnostic.cordis.yml', import.meta.url),
 )
+const SUBAGENT_ACP_DIAGNOSTIC_CONFIG = fileURLToPath(
+  new URL('../subagent-acp-diagnostic.cordis.yml', import.meta.url),
+)
+const SUBAGENT_ACP_MOCK_SERVER = fileURLToPath(new URL(
+  '../../../packages/subagent/subagent-acp/tests/mock-acp-server.ts',
+  import.meta.url,
+))
 const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url))
 const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
 const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
@@ -192,6 +199,17 @@ const SCENARIOS: Scenario[] = [
     systemPromptSource: 'product-subagent-codex',
     configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG,
   },
+  {
+    name: 'subagent-acp-diagnostic',
+    hasModelTurn: true,
+    recorded: false,
+    overridden: true,
+    pinsHeader: true,
+    headerClass: 'subagent-acp-diagnostic',
+    systemPromptSource: 'product-subagent-codex',
+    configPath: SUBAGENT_ACP_DIAGNOSTIC_CONFIG,
+    env: { DSH_TEST_MOCK_ACP_SERVER: SUBAGENT_ACP_MOCK_SERVER },
+  },
   {
     name: 'session-title-after-turn',
     hasModelTurn: true,

+ 6 - 7
examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml

@@ -1,9 +1,9 @@
 # Test-only composition: the ACP subagent backend on the real Loader/app path.
-# The scripted model delegates once; the scripted mock ACP child (MOCK_ECHO_CWD)
-# echoes its process cwd and announced session cwd, so parent-session cwd
-# inheritance is asserted keylessly end to end. `cwd` is deliberately omitted —
-# the inheritance branch under test. The child command path is machine-absolute,
-# so the driving e2e supplies it via DSH_TEST_MOCK_ACP_SERVER.
+# The scripted model delegates once. The driving e2e selects either the cwd
+# echo or a remote-limit diagnostic through DSH_TEST_ACP_MODE. `cwd` is
+# deliberately omitted so both paths exercise parent-session inheritance. The
+# child command path is machine-absolute and arrives through
+# DSH_TEST_MOCK_ACP_SERVER.
 - id: mock-llm
   name: './mock-delegating-llm.ts'
 
@@ -22,8 +22,7 @@
     args:
       - !!js process.env.DSH_TEST_MOCK_ACP_SERVER
     permission: reject
-    env:
-      MOCK_ECHO_CWD: '1'
+    env: !!js "process.env.DSH_TEST_ACP_MODE === 'diagnostic' ? { MOCK_TEXT: 'partial loader answer', MOCK_STOP: 'max_turn_requests' } : { MOCK_ECHO_CWD: '1' }"
 
 - id: tool-subagent
   name: '@deepseek-ai/dsh-tool-subagent'

+ 7 - 0
examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/input.json

@@ -0,0 +1,7 @@
+{
+  "steps": [
+    { "op": "initialize" },
+    { "op": "newSession" },
+    { "op": "prompt", "text": "Observe the ACP diagnostic twice with subagent_acp. 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_ACP_DIAGNOSTIC. Do not call any other tools." }
+  ]
+}

+ 42 - 0
examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/replay.override.json

@@ -0,0 +1,42 @@
+[
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "tool-call" },
+      { "type": "tool-call-delta", "index": 0, "id": "call_acp_foreground", "name": "subagent_acp", "argumentsDelta": "{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}" },
+      { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_foreground", "name": "subagent_acp", "arguments": "{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}" } },
+      { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
+      { "type": "finish", "reason": { "kind": "tool-calls" } }
+    ]
+  },
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "tool-call" },
+      { "type": "tool-call-delta", "index": 0, "id": "call_acp_background", "name": "subagent_acp", "argumentsDelta": "{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}" },
+      { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_background", "name": "subagent_acp", "arguments": "{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}" } },
+      { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
+      { "type": "finish", "reason": { "kind": "tool-calls" } }
+    ]
+  },
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "tool-call" },
+      { "type": "tool-call-delta", "index": 0, "id": "call_acp_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
+      { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
+      { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
+      { "type": "finish", "reason": { "kind": "tool-calls" } }
+    ]
+  },
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "text" },
+      { "type": "text-delta", "index": 0, "text": "PARENT_OBSERVED_ACP_DIAGNOSTIC" },
+      { "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_OBSERVED_ACP_DIAGNOSTIC" } },
+      { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } },
+      { "type": "finish", "reason": { "kind": "stop" } }
+    ]
+  }
+]

+ 48 - 0
examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/session.jsonl

@@ -0,0 +1,48 @@
+{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"agent/inbox/spliced","seq":0,"time":1787254574854,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe the ACP diagnostic twice with subagent_acp. 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_ACP_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"a93f593d-0716-4eb9-9c7d-3f7c77ae796f"}]}}
+{"type":"turn/start","seq":1,"time":1787254574854,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":2,"time":1787254574855,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":3,"time":1787254574883,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":4,"time":1787254574883,"data":{"content":[{"type":"text","text":"Observe the ACP diagnostic twice with subagent_acp. 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_ACP_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"a93f593d-0716-4eb9-9c7d-3f7c77ae796f"},"surfaceOp":"append"}
+{"type":"user/message","seq":5,"time":1787254574883,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"b0d1e3fd-067c-4aec-9432-a91c750afbf2"},"surfaceOp":"append"}
+{"type":"session/title","seq":6,"time":1787254574883,"data":{"title":"Observe the ACP diagnostic twice","messageSeqs":[4],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":7,"time":1787254574884,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1787254574884,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
+{"type":"assistant/chunk","seq":9,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":10,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_foreground","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}}
+{"type":"assistant/chunk","seq":11,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}}}
+{"type":"assistant/chunk","seq":12,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":13,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":14,"time":1787254574889,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"ef8e9ff0-886c-4d55-bbdb-8e878258fb53"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
+{"type":"tool/call","seq":15,"time":1787254574890,"data":{"turn":1,"step":1,"callId":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}
+{"type":"tool/result","seq":16,"time":1787254574996,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_acp_foreground"},"content":[{"type":"tool-result","toolCallId":"call_acp_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Subagent failure (provider: ACP; stage: prompt; category: remote-limit; stop reason: max_turn_requests)\nACP unattended decision (policy: reject; request: execute; decision: denied)\nPartial output before the run ended:\npartial ACP assistant text"}],"isError":true}],"role":"user","id":"9a82d328-e8dc-43c6-94c5-cfaf93b64c5d"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
+{"type":"step/end","seq":17,"time":1787254574996,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":18,"time":1787254575002,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":19,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":20,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_background","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}}
+{"type":"assistant/chunk","seq":21,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}}}
+{"type":"assistant/chunk","seq":22,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":23,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":24,"time":1787254575006,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"ed38f844-a1b6-45d5-9ee9-a3b2fb280b48"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
+{"type":"tool/call","seq":25,"time":1787254575007,"data":{"turn":1,"step":2,"callId":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}
+{"type":"tool/result","seq":26,"time":1787254575011,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_acp_background"},"content":[{"type":"tool-result","toolCallId":"call_acp_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"5f00fc5b-7460-4122-a613-720e1749bdf9"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
+{"type":"step/end","seq":27,"time":1787254575011,"data":{"turn":1,"step":2}}
+{"type":"step/start","seq":28,"time":1787254575017,"data":{"turn":1,"step":3}}
+{"type":"assistant/chunk","seq":29,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":30,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
+{"type":"assistant/chunk","seq":31,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
+{"type":"assistant/chunk","seq":32,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":33,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":34,"time":1787254575021,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b20d64f1-7fcf-498d-84ec-afe518983863"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}
+{"type":"tool/call","seq":35,"time":1787254575021,"data":{"turn":1,"step":3,"callId":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
+{"type":"tool/result","seq":36,"time":1787254575110,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_acp_output"},"content":[{"type":"tool-result","toolCallId":"call_acp_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Subagent failure (provider: ACP; stage: prompt; category: remote-limit; stop reason: max_turn_requests)\nACP unattended decision (policy: reject; request: execute; decision: denied)]"}],"isError":false}],"role":"user","id":"26ecb040-cc32-474c-9db2-a27ffa7fe9fe"}},"sourceEventSeqs":[35],"surfaceOp":"append"}
+{"type":"step/end","seq":37,"time":1787254575110,"data":{"turn":1,"step":3}}
+{"type":"step/start","seq":38,"time":1787254575116,"data":{"turn":1,"step":4}}
+{"type":"assistant/chunk","seq":39,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":40,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}}}
+{"type":"assistant/chunk","seq":41,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}}}}
+{"type":"assistant/chunk","seq":42,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
+{"type":"assistant/chunk","seq":43,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":44,"time":1787254575121,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"3fdb6493-585b-44c6-9faa-88e954401eeb"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"}
+{"type":"step/end","seq":45,"time":1787254575121,"data":{"turn":1,"step":4}}
+{"type":"turn/end","seq":46,"time":1787254575121,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 0
examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/stdout.expected.jsonl

@@ -0,0 +1,4 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

文件差異過大導致無法顯示
+ 440 - 0
examples/acp-agent/tests/snapshots/subagent-acp-diagnostic/tool-schemas.expected.json


+ 2 - 2
packages/subagent/subagent-acp/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-acp/README.md
-README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669
-README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88
+README.md: e01785a7a8cd5406fa545cc5e97b0a09d4f57fa1
+README.zh.md: 9082ab4d57b4393a07dc2e02cf6ce95ca259ae8d

+ 23 - 10
packages/subagent/subagent-acp/README.md

@@ -6,13 +6,13 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
 
 ## Start and ownership
 
-`start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, 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, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, 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 failure remains on the internal cause chain and in Host diagnostics.
 
 The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
 
 The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
 
-After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
+After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport or early-process failure resolves with `stopReason: 'error'` and a safe `SubagentResult.diagnostic`; local cancellation resolves as `aborted` without failure detail. Partial assistant text remains in `output`, separate from the diagnostic.
 
 `dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented.
 
@@ -47,13 +47,26 @@ ACP advertises no start-time capabilities because this process cannot enforce th
 
 ## Stop-reason mapping
 
-| ACP | Harness |
-|---|---|
-| `end_turn` | `completed` |
-| `max_tokens` | `max-tokens` |
-| `refusal` | `refusal` |
-| `cancelled` | `aborted` |
-| `max_turn_requests` or unknown | `error` |
+| ACP | Harness | Additional diagnostic |
+|---|---|---|
+| `end_turn` | `completed` | None. |
+| `max_tokens` | `max-tokens` | Only a contributing permission decision. |
+| `refusal` | `refusal` | Only a contributing permission decision. |
+| `cancelled` | `aborted` | Only a contributing permission decision; local cancellation never adds one. |
+| `max_turn_requests` | `error` | `remote-limit` with the closed stop reason. |
+| unknown | `error` | Fixed `unknown`; the wire value is not copied. |
+
+## Failure diagnostics
+
+The first line has a fixed field order:
+
+```text
+Subagent failure (provider: ACP; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
+```
+
+Unavailable optional fields are omitted. The provider derives `initialize`, `new-session`, `prompt`, `process`, or `teardown` at the operation that owns the failure. Categories distinguish configuration, protocol or transport failure, process start/exit, remote limits or refusal, permission-related cancellation, and the fixed unknown fallback. Exit code and signal come only from the managed subprocess outcome; stderr, exception messages, task text, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. The shared result boundary limits the complete text to 4096 UTF-8 bytes.
+
+When a run requested permission and did not complete, a second fixed line records the configured policy, the ACP closed tool kind, and whether the provider allowed or denied it. Tool titles, raw input, locations, and option text are excluded. Successful results and local cancellation omit both lines. A permission-diagnosed remote `aborted` result remains `aborted`; foreground presentation includes its diagnostic, while the one-shot Job adapter classifies that diagnostic-bearing remote abort as failed instead of conflating it with local cancellation.
 
 ## Process boundary
 
@@ -81,7 +94,7 @@ Independent of the parent request cache. Each ACP child can reuse only prefixes
 
 #### What the model sees
 
-Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: <message>`.
+Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. Non-completed results present the safe diagnostic before separately preserved partial assistant output. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; another start failure contains only the fixed `Subagent failure (...)` line.
 
 #### Token effect
 

+ 23 - 10
packages/subagent/subagent-acp/README.zh.md

@@ -6,13 +6,13 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s
 
 ## 启动与所有权
 
-`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn 失败、初始化失败、新建会话失败或因发布前取消而失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在尚未 spawn 任何进程时拒绝。
+`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn 失败、初始化失败、新建会话失败或因发布前取消而失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在尚未 spawn 任何进程时拒绝。非取消拒绝的 Error 消息只公开固定的 provider、stage 与 category 事实;原始失败仍保留在内部 cause 链和 Host 诊断中。
 
 工作目录优先使用已配置的 `cwd` 覆盖值,否则使用执行委派的父会话 cwd,绝不使用服务器进程自身的 cwd,因为同一个服务器进程会服务来自多个工作区的会话。从父级取得的值必须是绝对路径,指向 harness 可以进入的目录(具备搜索权限,这是子进程 cwd 的要求);解析后的同一路径同时作为子进程 cwd 和 ACP `session/new` 工作区。
 
 返回的运行 id 在父级命名空间中生成。子服务器的会话 id 只用于 ACP 协议调用,因为 ACP 只保证它在该全新子进程中唯一;若将其用作父级生命周期 id,可能与另一个远程运行或本地 agent 冲突。
 
-发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。
+发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败或进程提前退出会以 `stopReason: 'error'` 和安全的 `SubagentResult.diagnostic` 兑现;本地取消以 `aborted` 兑现,且不携带失败细节。部分 assistant 文本继续保留在 `output` 中,与诊断分开。
 
 `dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
 
@@ -47,13 +47,26 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
 
 ## 结束原因映射
 
-| ACP | Harness |
-|---|---|
-| `end_turn` | `completed` |
-| `max_tokens` | `max-tokens` |
-| `refusal` | `refusal` |
-| `cancelled` | `aborted` |
-| `max_turn_requests` 或未知值 | `error` |
+| ACP | Harness | 附加诊断 |
+|---|---|---|
+| `end_turn` | `completed` | 无。 |
+| `max_tokens` | `max-tokens` | 仅记录参与失败的权限决定。 |
+| `refusal` | `refusal` | 仅记录参与失败的权限决定。 |
+| `cancelled` | `aborted` | 仅记录参与失败的权限决定;本地取消绝不附加。 |
+| `max_turn_requests` | `error` | `remote-limit` 与闭集结束原因。 |
+| 未知值 | `error` | 固定 `unknown`,不复制 wire 原值。 |
+
+## 失败诊断
+
+首行采用固定字段顺序:
+
+```text
+Subagent failure (provider: ACP; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
+```
+
+不可用的可选字段会被省略。提供方从实际拥有失败的操作派生 `initialize`、`new-session`、`prompt`、`process` 或 `teardown`。category 区分配置、协议或传输失败、进程启动/退出、远端限制或拒绝、权限相关取消以及固定 unknown 回退。退出码与信号只来自受管子进程结果;stderr、异常消息、任务文本、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。
+
+当运行请求过权限且最终未完成时,第二个固定行会记录已配置策略、ACP 闭集工具种类以及提供方允许还是拒绝。工具标题、raw input、位置与选项文本均被排除。成功结果和本地取消会省略两行。带权限诊断的远端 `aborted` 结果仍保持 `aborted`;前台会呈现该诊断,而一次性 Job adapter 会把这种带诊断的远端取消判为 failed,避免与本地取消混淆。
 
 ## 进程边界
 
@@ -81,7 +94,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
 
 #### 模型看到的内容
 
-通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败按原样传递为 `Error: <message>`。
+通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。非完成结果会先呈现安全诊断,再单独呈现保留的部分 assistant 输出。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败只包含固定的 `Subagent failure (...)` 行。
 
 #### Token 影响
 

+ 13 - 2
packages/subagent/subagent-acp/src/index.ts

@@ -18,7 +18,7 @@ import type {
   SubagentStartRequest,
 } from '@deepseek-ai/dsh-subagent'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
+import { acpConfigurationFailure, type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
 
 export const name = 'subagent-acp'
 export const inject = ['subagents', 'subprocess']
@@ -151,10 +151,21 @@ class AcpProvider implements SubagentProvider {
   constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
 
   start(request: ResolvedSubagentStartRequest) {
+    if (request.signal.aborted) {
+      throw new Error('subagent request was aborted before the ACP child started')
+    }
+    let cwd: string
+    try {
+      cwd = resolveCwd(this.config.cwd, request)
+    } catch (error: unknown) {
+      const failure = acpConfigurationFailure(error)
+      this.ctx.logger.warn(`subagent-acp "${this.name}": child start failed: %o`, error)
+      throw failure
+    }
     const spec: AcpRunSpec = {
       command: this.config.command,
       args: this.config.args,
-      cwd: resolveCwd(this.config.cwd, request),
+      cwd,
       permission: this.config.permission,
       env: this.config.env,
       disposeEofGraceMs: this.config.disposeEofGraceMs,

+ 272 - 56
packages/subagent/subagent-acp/src/run.ts

@@ -2,9 +2,6 @@
  * Fresh-process ACP subagent client. Drives one child session and owns cancellation and
  * quiescent disposal.
  *
- * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
- * sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
- * with-key coverage drives the real ACP example.
  * @module @deepseek-ai/dsh-subagent-acp/run
  */
 
@@ -21,12 +18,13 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
   type StopReason,
+  type ToolKind,
 } from '@agentclientprotocol/sdk'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
-import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent'
+import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
 import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
-import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 
 /** Fixed response to child permission requests: reject by default, or select the first allow option. */
 export type PermissionPolicy = 'allow' | 'reject'
@@ -75,12 +73,9 @@ export interface AcpRunSpec {
    */
   spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
   /**
-   * Sink for a child-level failure that the run flattened into a stop reason
-   * (the seam contract forbids `result` rejecting). The driver calls this with
-   * the original error and the chosen stop reason so the fault is preserved
-   * rather than silently lost; the provider wires it to `ctx.logger.warn`.
-   * A throw from the sink itself is contained — it cannot reject `result`.
-   * Optional — omitted in a unit test that asserts the stop reason directly.
+   * Host sink for startup, published-run, or teardown failures. Model-visible
+   * text uses fixed safe facts, while this callback retains the original Error
+   * when one exists. A throw from the sink itself is contained.
    */
   onError?: (error: Error, stopReason: SubagentStopReason) => void
 }
@@ -91,6 +86,92 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
 export const DEFAULT_DISPOSE_GRACE_MS = 3_000
 
+type AcpFailureStage = 'initialize' | 'new-session' | 'prompt' | 'process' | 'teardown'
+
+type AcpFailureCategory =
+  | 'protocol'
+  | 'configuration'
+  | 'transport'
+  | 'process-start'
+  | 'process-exit'
+  | 'remote-limit'
+  | 'remote-refusal'
+  | 'permission'
+  | 'unknown'
+
+interface AcpFailureFacts {
+  readonly stage: AcpFailureStage
+  readonly category: AcpFailureCategory
+  readonly stopReason?: StopReason | 'unknown'
+  readonly outcome?: SubprocessOutcome | undefined
+}
+
+interface AcpPermissionDecision {
+  readonly policy: PermissionPolicy
+  readonly request: ToolKind | 'unknown'
+  readonly decision: 'allowed' | 'denied'
+}
+
+const ACP_TOOL_KINDS: ReadonlySet<string> = new Set([
+  'read', 'edit', 'delete', 'move', 'search',
+  'execute', 'think', 'fetch', 'switch_mode', 'other',
+])
+
+/** Fixed safe failure text derived only from provider-owned structured facts. */
+function failureDiagnostic(facts: AcpFailureFacts): string {
+  const fields = [
+    'provider: ACP',
+    `stage: ${facts.stage}`,
+    `category: ${facts.category}`,
+  ]
+  if (facts.stopReason !== undefined) fields.push(`stop reason: ${facts.stopReason}`)
+  if (facts.outcome?.exitCode !== null && facts.outcome?.exitCode !== undefined) {
+    fields.push(`exit code: ${facts.outcome.exitCode}`)
+  }
+  if (facts.outcome?.signal !== null && facts.outcome?.signal !== undefined) {
+    fields.push(`signal: ${facts.outcome.signal}`)
+  }
+  return `Subagent failure (${fields.join('; ')})`
+}
+
+/** Fixed permission fact; ACP tool titles and option text never enter it. */
+function permissionDiagnostic(permission: AcpPermissionDecision): string {
+  return `ACP unattended decision (policy: ${permission.policy}; request: ${permission.request}; decision: ${permission.decision})`
+}
+
+/** Put the operation failure first, followed by the latest contributing permission fact. */
+function diagnosticText(facts: AcpFailureFacts, permission?: AcpPermissionDecision): string {
+  const failure = failureDiagnostic(facts)
+  return permission === undefined ? failure : `${failure}\n${permissionDiagnostic(permission)}`
+}
+
+class AcpRunFailure extends Error {
+  constructor(readonly facts: AcpFailureFacts, cause: unknown) {
+    super(
+      `subagent-acp: ${failureDiagnostic(facts)}`,
+      { cause },
+    )
+    this.name = 'AcpRunFailure'
+  }
+}
+
+/**
+ * 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 ACP failure line.
+ */
+export function acpConfigurationFailure(cause: unknown): Error {
+  return new AcpRunFailure({ stage: 'initialize', category: 'configuration' }, cause)
+}
+
+/** Keep only the closed ACP tool-kind vocabulary; future values use a fixed fallback. */
+function permissionRequestKind(kind: ToolKind | null | undefined): ToolKind | 'unknown' {
+  const candidate = kind ?? 'unknown'
+  return ACP_TOOL_KINDS.has(candidate)
+    ? candidate
+    : 'unknown'
+}
+
 /** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
 async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
   const controller = new AbortController()
@@ -187,10 +268,70 @@ 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: AcpRunSpec, error: unknown): void {
+  try {
+    spec.onError?.(toError(error), 'error')
+  } catch {
+    // Host diagnostic logging cannot replace the child failure.
+  }
+}
+
+/** Classify an unpublished failure from the active protocol operation and observed process facts. */
+function startupFailure(
+  error: unknown,
+  stage: Extract<AcpFailureStage, 'initialize' | 'new-session'>,
+  child: SubprocessHandle,
+  outcome: SubprocessOutcome | undefined,
+): AcpRunFailure {
+  if (error instanceof AcpRunFailure) return error
+  if (child.pid <= 0) {
+    return new AcpRunFailure({ stage: 'process', category: 'process-start' }, error)
+  }
+  return new AcpRunFailure(
+    outcome === undefined
+      ? { stage, category: 'transport' }
+      : { stage, category: 'process-exit', outcome },
+    error,
+  )
+}
+
+/** Map one remote terminal reason to the optional safe failure line it needs. */
+function terminalFailure(
+  reason: StopReason,
+  permission: AcpPermissionDecision | undefined,
+): string | undefined {
+  switch (reason) {
+    case 'end_turn':
+      return undefined
+    case 'max_turn_requests':
+      return diagnosticText({
+        stage: 'prompt',
+        category: 'remote-limit',
+        stopReason: 'max_turn_requests',
+      }, permission)
+    case 'max_tokens':
+      return permission === undefined
+        ? undefined
+        : diagnosticText({ stage: 'prompt', category: 'remote-limit', stopReason: reason }, permission)
+    case 'refusal':
+      return permission === undefined
+        ? undefined
+        : diagnosticText({ stage: 'prompt', category: 'remote-refusal', stopReason: reason }, permission)
+    case 'cancelled':
+      return permission === undefined
+        ? undefined
+        : diagnosticText({ stage: 'prompt', category: 'permission', stopReason: reason }, permission)
+    default:
+      return diagnosticText({ stage: 'prompt', category: 'unknown', stopReason: 'unknown' }, permission)
+  }
+}
+
 /**
  * Start and publish one ACP child after initialization and session creation.
- * Child failures resolve through the run result; startup failures reject after
- * process reap. Disposal cancels, kills, and reaps the child.
+ * Child failures resolve through the run result; startup and teardown failures
+ * reject with fixed safe facts after process reap, retaining original causes
+ * for Host observation. Disposal cancels, kills, and reaps the child.
  * @param request - the start request; its signal is the cancellation channel.
  * @param spec - the resolved spawn spec: command/args/cwd, env, permission
  * policy, dispose graces, and the optional error sink.
@@ -218,17 +359,36 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
     throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream')
   }
   /* v8 ignore stop */
+  let processOutcome: SubprocessOutcome | undefined
+  const processDone = child.done.then((outcome) => {
+    processOutcome = outcome
+    return outcome
+  })
+
   // Spawn-level failure surfaces as `done` rejecting into the startup race; a
   // clean exit must never win it, so the success arm parks forever. (The ACP
   // connection observing its streams closing bounds a child that exits
   // without speaking the protocol.)
-  const spawnFailed: Promise<never> = child.done.then(
+  const spawnFailed: Promise<never> = processDone.then(
     /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
     () => new Promise<never>(() => {}),
     (err: unknown) => Promise.reject(toError(err)),
   )
   spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ })
 
+  const observeProcessOutcome = async (): Promise<SubprocessOutcome | undefined> => {
+    if (processOutcome !== undefined || child.pid <= 0) return processOutcome
+    try {
+      const exited = await child.waitForExit(
+        AbortSignal.timeout(Math.min(spec.disposeGraceMs, 100)),
+      )
+      if (exited) return await processDone
+    } catch {
+      // The active protocol failure remains authoritative when exit observation fails.
+    }
+    return processOutcome
+  }
+
   // Startup rollback and the published handle share one process teardown.
   let processDisposal: Promise<void> | undefined
   const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
@@ -238,6 +398,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
   const fold = new AssistantOutputFold()
   // Shared mutable state keeps cancellation visible across async closures.
   const flags = { cancelled: false }
+  let latestPermission: AcpPermissionDecision | undefined
 
   const makeClient = (_agent: AcpAgent): Client => ({
     sessionUpdate(params: SessionNotification): Promise<void> {
@@ -256,9 +417,19 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
       if (spec.permission === 'allow') {
         const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always')
         if (allow !== undefined) {
+          latestPermission = {
+            policy: 'allow',
+            request: permissionRequestKind(params.toolCall.kind),
+            decision: 'allowed',
+          }
           return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } })
         }
       }
+      latestPermission = {
+        policy: spec.permission,
+        request: permissionRequestKind(params.toolCall.kind),
+        decision: 'denied',
+      }
       return Promise.resolve({ outcome: { outcome: 'cancelled' } })
     },
   })
@@ -272,6 +443,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
   )
 
   let sessionId: string | undefined
+  let startupStage: Extract<AcpFailureStage, 'initialize' | 'new-session'> = 'initialize'
   // Cancellation settles the result without waiting for a cooperative child.
   let signalCancelSettled!: () => void
   const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
@@ -300,9 +472,15 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
           // child self-serves in its own process.
           clientCapabilities: {},
         })
+        startupStage = 'new-session'
         const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
         const returnedSessionId: unknown = Reflect.get(session, 'sessionId')
-        if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id')
+        if (typeof returnedSessionId !== 'string') {
+          throw new AcpRunFailure(
+            { stage: 'new-session', category: 'protocol' },
+            new Error('ACP child published without a session id'),
+          )
+        }
         sessionId = returnedSessionId
         if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
       })(),
@@ -311,9 +489,36 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
     ])
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
-    await disposeProcess()
-    if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
-    throw toError(error)
+    const cancelledBeforeCleanup = flags.cancelled
+    // A child closing its protocol stream can precede whole-tree exit
+    // observation. Wait briefly for an already-ending process, but do not let a
+    // still-live transport failure delay rollback by a full teardown grace.
+    const startupOutcome = await observeProcessOutcome()
+    const failure = startupFailure(error, startupStage, child, startupOutcome)
+    if (!cancelledBeforeCleanup) {
+      reportFailure(spec, error instanceof AcpRunFailure
+        ? error.cause
+        : error)
+    }
+    try {
+      await disposeProcess()
+    } catch (cleanupError: unknown) {
+      reportFailure(spec, cleanupError)
+      const cleanupFailure = new AcpRunFailure({
+        stage: 'teardown',
+        category: processOutcome === undefined ? 'unknown' : 'process-exit',
+        ...(processOutcome === undefined ? {} : { outcome: processOutcome }),
+      }, cleanupError)
+      if (cancelledBeforeCleanup) throw cleanupFailure
+      throw new AggregateError(
+        [failure, cleanupFailure],
+        `${failure.message}; ${cleanupFailure.message}`,
+      )
+    }
+    if (cancelledBeforeCleanup) {
+      throw new Error('subagent request was aborted before the ACP child started')
+    }
+    throw failure
   }
   // The startup transaction validates the returned id before it can fulfill.
   // This assertion carries that cross-closure invariant into TypeScript.
@@ -321,48 +526,59 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
   if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
   const remoteSessionId = sessionId
 
-  const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
-    try {
-      // Race the remote turn against local cancellation.
-      const prompt = async (): Promise<SubagentResult> => {
-        // The startup phase cannot fulfill without assigning the session id.
-        const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) })
-        return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
-      }
-      return await Promise.race([
-        prompt(),
-        cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
-      ])
-    } catch (error: unknown) {
-      // Cover a process rejection already queued when cancellation arrives.
-      /* v8 ignore next */
-      if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
-      // Flatten post-publication transport failures while preserving diagnostics.
+  let diagnostic: string | undefined
+  const result: Promise<SubagentResult> = settleRunResult({
+    attempt: async (): Promise<SubagentResult> => {
       try {
-        spec.onError?.(toError(error), 'error')
-      } catch {
-        // The diagnostic sink cannot reject the run result.
+        const promptResult = await Promise.race([
+          conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }),
+          cancelSettled.then((): never => { throw new Error('subagent cancelled while the ACP prompt was running') }),
+        ])
+        const stopReason = acpStopReason(promptResult.stopReason)
+        diagnostic = terminalFailure(promptResult.stopReason, latestPermission)
+        return {
+          output: collectOutput(),
+          ...(diagnostic === undefined ? {} : { diagnostic }),
+          stopReason,
+        }
+      } catch (error: unknown) {
+        if (!flags.cancelled) {
+          const outcome = await observeProcessOutcome()
+          const facts = outcome === undefined
+            ? { stage: 'prompt', category: 'transport' } as const
+            : { stage: 'process', category: 'process-exit', outcome } as const
+          diagnostic = diagnosticText(facts, latestPermission)
+        }
+        throw error
       }
-      return { output: collectOutput(), stopReason: 'error' }
-    } finally {
-      request.signal.removeEventListener('abort', onAbort)
-    }
-  })()
+    },
+    collectOutput,
+    collectDiagnostic: () => diagnostic,
+    cancelled: () => flags.cancelled,
+    onError: spec.onError,
+    signal: request.signal,
+    onAbort,
+  })
 
-  let disposal: Promise<void> | undefined
-  return {
+  return subprocessRunHandle({
     id,
-    localAgent: undefined,
     result,
-    dispose(): Promise<void> {
-      if (disposal !== undefined) return disposal
-      request.signal.removeEventListener('abort', onAbort)
-      requestCancel()
-      // The shared platform-aware ladder awaits exit. ACP normally quiesces from
-      // stdin EOF, including the final flush, so this backend uses a wider EOF
-      // grace before process termination escalates.
-      disposal = disposeProcess()
-      return disposal
+    signal: request.signal,
+    onAbort,
+    requestCancel,
+    teardown: async () => {
+      try {
+        // ACP normally quiesces from stdin EOF, including the final flush, so
+        // this backend uses a wider EOF grace before process termination.
+        await disposeProcess()
+      } catch (error: unknown) {
+        reportFailure(spec, error)
+        throw new AcpRunFailure({
+          stage: 'teardown',
+          category: processOutcome === undefined ? 'unknown' : 'process-exit',
+          ...(processOutcome === undefined ? {} : { outcome: processOutcome }),
+        }, error)
+      }
     },
-  }
+  })
 }

+ 42 - 13
packages/subagent/subagent-acp/tests/loader-composition.e2e.ts

@@ -7,12 +7,10 @@ import { type SessionEvent } from '@deepseek-ai/dsh-session'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless REAL-composition coverage for parent-session cwd inheritance: a
- * test-only cordis.yml boots the headless app through the Loader with the ACP
- * backend's `cwd` omitted, a scripted model delegates once, and the scripted
- * mock ACP child echoes where it actually ran plus the workspace it was
- * announced — both must be the parent session's cwd. Mock-only composition, so
- * only this keyless tier applies (the with-key tier lives in subagent-acp.e2e.ts).
+ * Keyless REAL-composition coverage for the ACP provider through a test-only
+ * cordis.yml: parent-session cwd inheritance and model-visible failure detail
+ * both cross the Loader, subprocess, ACP, tool, and persisted-session paths.
+ * The with-key tier lives in subagent-acp.e2e.ts.
  */
 
 const driver = fileURLToPath(new URL(
@@ -36,6 +34,15 @@ async function jsonlFiles(dir: string): Promise<string[]> {
   return paths.flat()
 }
 
+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('')
+}
+
 describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
   it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => {
     let events: SessionEvent[] = []
@@ -62,12 +69,34 @@ describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
     // The tool result carries the child's two-line echo: its real process.cwd()
     // and the cwd the backend announced in `session/new` — both 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(`${workspace}\n${workspace}`)
+    expect(toolResultText(events)).toBe(`${workspace}\n${workspace}`)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
+  it('presents the ACP remote-limit diagnostic separately from partial output', async () => {
+    let events: SessionEvent[] = []
+    const { stderr } = await runLoaderSmoke({
+      label: 'acp-subagent diagnostic composition smoke',
+      tempDirPrefix: 'acp-subagent-diagnostic-e2e-',
+      binScript: driver,
+      libBinScript: driver,
+      configPath,
+      tsconfigPath: repoTsconfig,
+      env: {
+        DSH_TEST_MOCK_ACP_SERVER: mockServer,
+        DSH_TEST_ACP_MODE: 'diagnostic',
+      },
+      inspect: async (cwd) => {
+        const logs = await jsonlFiles(join(cwd, '.sessions'))
+        expect(logs).toHaveLength(1)
+        const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
+        events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
+      },
+    })
+    expect(stderr).not.toContain('UNHANDLED')
+    expect(toolResultText(events)).toBe(
+      'Error: subagent run failed\n'
+      + 'Diagnostic: Subagent failure (provider: ACP; stage: prompt; category: remote-limit; stop reason: max_turn_requests)\n'
+      + 'Partial output before the run ended:\npartial loader answer',
+    )
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 33 - 2
packages/subagent/subagent-acp/tests/mock-acp-server.ts

@@ -18,6 +18,15 @@
  *                        `dispose()` must still kill the process.
  * - `MOCK_PERMISSION`  — if `1`, the agent calls `session/request_permission`
  *                        before answering, to exercise the client's auto-answer.
+ * - `MOCK_PERMISSION_IGNORE_DECISION` — if `1`, continue after a denied
+ *                        permission so the terminal failure can carry the
+ *                        provider's fixed permission fact.
+ * - `MOCK_CRASH_ON_INITIALIZE` / `MOCK_CRASH_ON_NEW_SESSION` — exit while the
+ *                        named unpublished protocol operation is active.
+ * - `MOCK_CLOSE_PROTOCOL_ON_PROMPT` — close stdout while keeping the process
+ *                        alive, producing a prompt-stage transport failure.
+ * - `MOCK_CRASH_AFTER_CHUNK` — exit after streaming the assistant chunk, so
+ *                        the parent preserves partial output with process facts.
  * - `MOCK_ECHO_CWD`    — if `1`, ignore MOCK_TEXT and stream two lines instead:
  *                        the agent PROCESS's `process.cwd()` and the `cwd` the
  *                        client announced in `session/new` — so a test can assert
@@ -68,6 +77,7 @@ import {
   type PromptRequest,
   type PromptResponse,
   type StopReason,
+  type ToolKind,
 } from '@agentclientprotocol/sdk'
 
 // When MOCK_ECHO_ENV names a variable, stream that variable's value in place
@@ -80,11 +90,17 @@ const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
 const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
 const HANG = process.env.MOCK_HANG === '1'
 const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
+const IGNORE_PERMISSION_DECISION = process.env.MOCK_PERMISSION_IGNORE_DECISION === '1'
 const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
 const THOUGHT = process.env.MOCK_THOUGHT === '1'
+const CRASH_ON_INITIALIZE = process.env.MOCK_CRASH_ON_INITIALIZE === '1'
+const CRASH_ON_NEW_SESSION = process.env.MOCK_CRASH_ON_NEW_SESSION === '1'
 const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
 const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
+const CLOSE_PROTOCOL_ON_PROMPT = process.env.MOCK_CLOSE_PROTOCOL_ON_PROMPT === '1'
+const CRASH_AFTER_CHUNK = process.env.MOCK_CRASH_AFTER_CHUNK === '1'
 const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
+const TOOL_KIND = process.env.MOCK_TOOL_KIND as ToolKind | undefined
 const READY_FILE = process.env.MOCK_READY_FILE
 const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
 // When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
@@ -102,6 +118,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
 
   return {
     initialize(_params: InitializeRequest): Promise<InitializeResponse> {
+      if (CRASH_ON_INITIALIZE) process.exit(11)
       return Promise.resolve({
         protocolVersion: PROTOCOL_VERSION,
         agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
@@ -109,6 +126,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
       })
     },
     async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
+      if (CRASH_ON_NEW_SESSION) process.exit(12)
       sessionCwd = params.cwd
       // Optionally signal "newSession reached" and block until released, so a
       // test can cancel DURING newSession (the early-cancel race window) on a
@@ -126,6 +144,11 @@ function makeAgent(conn: AgentSideConnection): Agent {
     },
     async prompt(params: PromptRequest): Promise<PromptResponse> {
       if (CRASH_ON_PROMPT) process.exit(1)
+      if (CLOSE_PROTOCOL_ON_PROMPT) {
+        process.stdout.end()
+        setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000)
+        return new Promise<PromptResponse>(() => {})
+      }
       if (WANT_PERMISSION) {
         // Ask the client to approve before answering; honor its decision. Under
         // MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
@@ -138,10 +161,14 @@ function makeAgent(conn: AgentSideConnection): Agent {
           ]
         const decision = await conn.requestPermission({
           sessionId: params.sessionId,
-          toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
+          toolCall: {
+            toolCallId: 'mock-call',
+            title: 'mock side effect',
+            ...(TOOL_KIND === undefined ? {} : { kind: TOOL_KIND }),
+          },
           options,
         })
-        if (decision.outcome.outcome === 'cancelled') {
+        if (decision.outcome.outcome === 'cancelled' && !IGNORE_PERMISSION_DECISION) {
           return { stopReason: 'cancelled' }
         }
       }
@@ -162,6 +189,10 @@ function makeAgent(conn: AgentSideConnection): Agent {
           content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT },
         },
       })
+      if (CRASH_AFTER_CHUNK) {
+        await new Promise<void>((resolve) => { setImmediate(resolve) })
+        process.exit(17)
+      }
       // Signal "prompt is in flight" by touching the readiness file, so a test
       // can wait on a CONDITION (file exists) rather than an arbitrary timeout
       // before cancelling — deterministic regardless of subprocess cold-start.

+ 377 - 11
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 import * as acp from '../src/index.ts'
 import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -59,6 +59,14 @@ 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: ACP; ${fields})`
+}
+
+function expectedPermission(policy: 'allow' | 'reject', requestKind: string, decision: 'allowed' | 'denied'): string {
+  return `ACP unattended decision (policy: ${policy}; request: ${requestKind}; decision: ${decision})`
+}
+
 /**
  * Poll until `file` exists (the mock touches it once its prompt is in flight),
  * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
@@ -73,6 +81,30 @@ async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
   }
 }
 
+function rejectFinalExitWait(child: SubprocessHandle, message: string): SubprocessHandle {
+  return {
+    pid: child.pid,
+    stdin: child.stdin,
+    stdout: child.stdout,
+    stderr: child.stderr,
+    collected: child.collected,
+    done: child.done,
+    terminate: () => { child.terminate() },
+    waitForExit: (signal?: AbortSignal) => signal === undefined
+      ? Promise.reject(new Error(message))
+      : Promise.resolve(false),
+  }
+}
+
+function rejectFinalExitWaitAfterExit(child: SubprocessHandle, message: string): SubprocessHandle {
+  return {
+    ...rejectFinalExitWait(child, message),
+    waitForExit: (signal?: AbortSignal) => signal === undefined
+      ? child.done.then(() => Promise.reject(new Error(message)))
+      : Promise.resolve(false),
+  }
+}
+
 describe('acpStopReason', () => {
   it('maps each ACP stop reason to the harness vocabulary', () => {
     expect(acpStopReason('end_turn')).toBe('completed')
@@ -228,7 +260,7 @@ describe('cwd resolution', () => {
       await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
       const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
       await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
-        .rejects.toThrow('no working directory')
+        .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
       // Resolution failed BEFORE the process boundary — nothing was launched.
       expect(existsSync(sentinel)).toBe(false)
     } finally {
@@ -349,7 +381,7 @@ describe('cwd resolution', () => {
     const ctx = await setup({})
     const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
     await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
-      .rejects.toThrow('must be an absolute path')
+      .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
   })
 
   it('rejects a parent session cwd that names a FILE, not a directory', async () => {
@@ -360,7 +392,7 @@ describe('cwd resolution', () => {
       const ctx = await setup({})
       const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
       await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
-        .rejects.toThrow('not an accessible directory')
+        .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
     } finally {
       rmSync(tmp, { recursive: true, force: true })
     }
@@ -376,7 +408,7 @@ describe('cwd resolution', () => {
       await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
       const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
       await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
-        .rejects.toThrow('not an accessible directory')
+        .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
       expect(existsSync(sentinel)).toBe(false)
     } finally {
       rmSync(tmp, { recursive: true, force: true })
@@ -391,6 +423,7 @@ describe('dsh-subagent-acp', () => {
     expect(run.id).not.toBe('acp-child-session')
     const result = await run.result
     expect(result.stopReason).toBe('completed')
+    expect(result.diagnostic).toBeUndefined()
     expect(text(result.output)).toBe('hello from acp child')
     const disposal = run.dispose()
     expect(run.dispose()).toBe(disposal)
@@ -408,6 +441,7 @@ describe('dsh-subagent-acp', () => {
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     expect(result.stopReason).toBe('max-tokens')
+    expect(result.diagnostic).toBeUndefined()
     await run.dispose()
   })
 
@@ -416,6 +450,59 @@ describe('dsh-subagent-acp', () => {
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     expect(result.stopReason).toBe('refusal')
+    expect(result.diagnostic).toBeUndefined()
+    await run.dispose()
+  })
+
+  it.each([
+    ['max_tokens', 'max-tokens', 'remote-limit'],
+    ['refusal', 'refusal', 'remote-refusal'],
+  ] as const)('adds a permission fact to %s without changing its stop reason', async (remote, stopReason, category) => {
+    const ctx = await setup({
+      MOCK_PERMISSION: '1',
+      MOCK_PERMISSION_IGNORE_DECISION: '1',
+      MOCK_TOOL_KIND: 'read',
+      MOCK_STOP: remote,
+    }, 'reject')
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result.stopReason).toBe(stopReason)
+    expect(result.diagnostic).toBe(
+      `${expectedFailure(`stage: prompt; category: ${category}; stop reason: ${remote}`)}\n`
+      + expectedPermission('reject', 'read', 'denied'),
+    )
+    await run.dispose()
+  })
+
+  it('keeps an ordinary remote cancelled stop diagnostic-free', async () => {
+    const ctx = await setup({ MOCK_STOP: 'cancelled' })
+    const run = await ctx.subagents.start('acp', request())
+    await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'mock child answer' }], stopReason: 'aborted' })
+    await run.dispose()
+  })
+
+  it('preserves max_turn_requests as an actionable remote limit', async () => {
+    const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: 'max_turn_requests' })
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result).toEqual({
+      output: [{ type: 'text', text: 'partial' }],
+      diagnostic: expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests'),
+      stopReason: 'error',
+    })
+    await run.dispose()
+  })
+
+  it('uses a fixed fallback for an unknown remote stop reason', async () => {
+    const rawReason = 'private/path/SECRET_TOKEN'
+    const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: rawReason })
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: prompt; category: unknown; stop reason: unknown'),
+    )
+    expect(result.diagnostic).not.toContain(rawReason)
     await run.dispose()
   })
 
@@ -432,6 +519,7 @@ describe('dsh-subagent-acp', () => {
       controller.abort('test')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
+      expect(result.diagnostic).toBeUndefined()
       await run.dispose()
     } finally {
       rmSync(tmp, { recursive: true, force: true })
@@ -459,6 +547,35 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
+  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('acp', {
+      prompt: [{ type: 'text' as const, text: 'p' }],
+      parent,
+      signal: controller.signal,
+    })).rejects.toThrow('subagent request was aborted before the ACP child started')
+  })
+
+  it('reports an initialize-stage process exit without copying the transport error', async () => {
+    const error = await startAcpRun(request(), {
+      command: process.execPath,
+      args: [mockServer],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: { MOCK_CRASH_ON_INITIALIZE: '1' },
+      disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
+      disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
+      spawn: spawnSubprocess,
+    }).catch((cause: unknown) => cause)
+    expect(error).toBeInstanceOf(Error)
+    expect((error as Error).message).toBe(
+      `subagent-acp: ${expectedFailure('stage: initialize; category: process-exit; exit code: 11')}`,
+    )
+  })
+
   it('reaps a child whose session/new response omits the session id', async () => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
     const flushed = join(tmp, 'flushed')
@@ -476,7 +593,9 @@ describe('dsh-subagent-acp', () => {
         disposeEofGraceMs: 1000,
         disposeGraceMs: 100,
         spawn: spawnSubprocess,
-      })).rejects.toThrow('ACP child published without a session id')
+      })).rejects.toThrow(
+        `subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}`,
+      )
       // Startup rejects only after its private child reaches quiescence. The
       // marker proves rollback closed stdin and allowed the child's EOF flush.
       expect(existsSync(flushed)).toBe(true)
@@ -485,6 +604,71 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
+  it('aggregates safe startup and teardown facts when rollback itself fails', async () => {
+    const rawCleanup = 'rollback leaked /private/path SECRET_TOKEN'
+    let realChild: SubprocessHandle | undefined
+    const errors: string[] = []
+    const error = await startAcpRun(request(), {
+      command: process.execPath,
+      args: [mockServer],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: { MOCK_MISSING_SESSION_ID: '1' },
+      disposeEofGraceMs: 10,
+      disposeGraceMs: 10,
+      spawn: (spec) => {
+        realChild = spawnSubprocess(spec)
+        return rejectFinalExitWaitAfterExit(realChild, rawCleanup)
+      },
+      onError: (failure) => { errors.push(failure.message) },
+    }).catch((cause: unknown) => cause)
+    expect(error).toBeInstanceOf(AggregateError)
+    expect((error as Error).message).toContain(
+      `subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}; `
+      + 'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
+    )
+    expect((error as Error).message).not.toContain(rawCleanup)
+    expect(errors).toContain('ACP child published without a session id')
+    expect(errors).toContain(rawCleanup)
+    await realChild?.done
+  })
+
+  it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
+    const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
+    const ready = join(tmp, 'ready')
+    const go = join(tmp, 'go')
+    const rawCleanup = 'cancel rollback leaked SECRET_TOKEN'
+    let realChild: SubprocessHandle | undefined
+    try {
+      const controller = new AbortController()
+      const starting = startAcpRun(request('p', controller.signal), {
+        command: process.execPath,
+        args: [mockServer],
+        cwd: process.cwd(),
+        permission: 'reject',
+        env: { MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go },
+        disposeEofGraceMs: 10,
+        disposeGraceMs: 10,
+        spawn: (spec) => {
+          realChild = spawnSubprocess(spec)
+          return rejectFinalExitWait(realChild, rawCleanup)
+        },
+      })
+      await waitForFile(ready)
+      controller.abort()
+      writeFileSync(go, 'go')
+      const error = await starting.catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(Error)
+      expect((error as Error).message).toBe(
+        `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+      await realChild?.done
+    } finally {
+      rmSync(tmp, { recursive: true, force: true })
+    }
+  })
+
   it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
     // The child traps SIGTERM and keeps its event loop alive, so a graceful
     // term alone would hang dispose forever. With a short grace, dispose must
@@ -632,6 +816,7 @@ describe('dsh-subagent-acp', () => {
       controller.abort()
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
+      expect(result.diagnostic).toBeUndefined()
       await run.dispose()
     } finally {
       rmSync(tmp, { recursive: true, force: true })
@@ -639,11 +824,15 @@ describe('dsh-subagent-acp', () => {
   })
 
   it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
-    const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
+    const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1', MOCK_TOOL_KIND: 'execute' }, 'reject')
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     // The child asked permission, the backend rejected, the child returned cancelled.
     expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBe(
+      `${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
+      + expectedPermission('reject', 'execute', 'denied'),
+    )
     await run.dispose()
   })
 
@@ -652,6 +841,7 @@ describe('dsh-subagent-acp', () => {
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     expect(result.stopReason).toBe('completed')
+    expect(result.diagnostic).toBeUndefined()
     expect(text(result.output)).toBe('approved answer')
     await run.dispose()
   })
@@ -663,6 +853,44 @@ describe('dsh-subagent-acp', () => {
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBe(
+      `${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
+      + expectedPermission('allow', 'unknown', 'denied'),
+    )
+    await run.dispose()
+  })
+
+  it('appends a rejected permission fact to a later remote failure', async () => {
+    const ctx = await setup({
+      MOCK_PERMISSION: '1',
+      MOCK_PERMISSION_IGNORE_DECISION: '1',
+      MOCK_TOOL_KIND: 'edit',
+      MOCK_STOP: 'max_turn_requests',
+    }, 'reject')
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      `${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
+      + expectedPermission('reject', 'edit', 'denied'),
+    )
+    await run.dispose()
+  })
+
+  it('appends an allowed permission fact only when the run later fails', async () => {
+    const ctx = await setup({
+      MOCK_PERMISSION: '1',
+      MOCK_PERMISSION_IGNORE_DECISION: '1',
+      MOCK_TOOL_KIND: 'execute',
+      MOCK_STOP: 'max_turn_requests',
+    }, 'allow')
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      `${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
+      + expectedPermission('allow', 'execute', 'allowed'),
+    )
     await run.dispose()
   })
 
@@ -678,11 +906,50 @@ describe('dsh-subagent-acp', () => {
     await run.dispose()
   })
 
+  it('classifies a prompt transport failure without copying SDK text', async () => {
+    const run = await startAcpRun(request('private prompt text'), {
+      command: process.execPath,
+      args: [mockServer],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
+      disposeEofGraceMs: 100,
+      disposeGraceMs: 100,
+      spawn: spawnSubprocess,
+    })
+    const result = await run.result
+    expect(result).toEqual({
+      output: [],
+      diagnostic: expectedFailure('stage: prompt; category: transport'),
+      stopReason: 'error',
+    })
+    expect(result.diagnostic).not.toContain('private prompt text')
+    await run.dispose()
+  })
+
+  it('preserves partial output and structured process facts when the child exits', async () => {
+    const ctx = await setup({ MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' })
+    const run = await ctx.subagents.start('acp', request())
+    const result = await run.result
+    expect(result).toEqual({
+      output: [{ type: 'text', text: 'partial answer' }],
+      diagnostic: expectedFailure('stage: process; category: process-exit; exit code: 17'),
+      stopReason: 'error',
+    })
+    await run.dispose()
+  })
+
   it('rejects a spawn failure after provider-owned cleanup', async () => {
-    await expect(startAcpRun(
+    const privateCommand = '/nonexistent/private/SECRET_TOKEN/acp-agent'
+    const error = await startAcpRun(
       request(),
-      { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
-    )).rejects.toThrow()
+      { command: privateCommand, args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
+    ).catch((cause: unknown) => cause)
+    expect(error).toBeInstanceOf(Error)
+    expect((error as Error).message).toBe(
+      `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
+    )
+    expect((error as Error).message).not.toContain(privateCommand)
   })
 
   it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
@@ -746,7 +1013,95 @@ describe('dsh-subagent-acp', () => {
       permission: 'reject',
       env: {},
     })
-    await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
+    await expect(ctx.subagents.start('acp', request())).rejects.toThrow(
+      `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
+    )
+  })
+
+  it('keeps permission diagnostics isolated across concurrent runs', async () => {
+    const start = (permission: 'allow' | 'reject', kind: 'edit' | 'execute') => startAcpRun(
+      request(),
+      {
+        command: process.execPath,
+        args: [mockServer],
+        cwd: process.cwd(),
+        permission,
+        env: {
+          MOCK_PERMISSION: '1',
+          MOCK_PERMISSION_IGNORE_DECISION: '1',
+          MOCK_TOOL_KIND: kind,
+          MOCK_STOP: 'max_turn_requests',
+        },
+        disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
+        disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
+        spawn: spawnSubprocess,
+      },
+    )
+    const [allowed, denied] = await Promise.all([
+      start('allow', 'execute'),
+      start('reject', 'edit'),
+    ])
+    const [allowedResult, deniedResult] = await Promise.all([allowed.result, denied.result])
+    expect(allowedResult.diagnostic).toContain(expectedPermission('allow', 'execute', 'allowed'))
+    expect(allowedResult.diagnostic).not.toContain('policy: reject')
+    expect(deniedResult.diagnostic).toContain(expectedPermission('reject', 'edit', 'denied'))
+    expect(deniedResult.diagnostic).not.toContain('policy: allow')
+    await Promise.all([allowed.dispose(), denied.dispose()])
+  })
+
+  it('wraps a teardown rejection with safe facts and keeps the raw cause in Host diagnostics', async () => {
+    const rawMessage = 'teardown leaked /private/path SECRET_TOKEN'
+    const errors: string[] = []
+    let realChild: SubprocessHandle | undefined
+    const run = await startAcpRun(request(), {
+      command: process.execPath,
+      args: [mockServer],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
+      disposeEofGraceMs: 10,
+      disposeGraceMs: 10,
+      spawn: (spec) => {
+        const child = spawnSubprocess(spec)
+        realChild = child
+        return rejectFinalExitWait(child, rawMessage)
+      },
+      onError: (error) => { errors.push(error.message) },
+    })
+    const error = await run.dispose().catch((cause: unknown) => cause)
+    expect(error).toBeInstanceOf(Error)
+    expect((error as Error).message).toBe(
+      `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
+    )
+    expect((error as Error).message).not.toContain(rawMessage)
+    expect(errors).toContain(rawMessage)
+    await realChild?.done
+    await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
+  })
+
+  it('adds an observed process outcome to a teardown failure', async () => {
+    let realChild: SubprocessHandle | undefined
+    const run = await startAcpRun(request(), {
+      command: process.execPath,
+      args: [mockServer],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
+      disposeEofGraceMs: 10,
+      disposeGraceMs: 10,
+      spawn: (spec) => {
+        const child = spawnSubprocess(spec)
+        realChild = child
+        return rejectFinalExitWaitAfterExit(child, 'post-exit wait failed')
+      },
+    })
+    const error = await run.dispose().catch((cause: unknown) => cause)
+    expect(error).toBeInstanceOf(Error)
+    expect((error as Error).message).toContain(
+      'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
+    )
+    expect((error as Error).message).toMatch(/(?:exit code|signal): /)
+    await realChild?.done
   })
 
   it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
@@ -771,6 +1126,9 @@ describe('dsh-subagent-acp', () => {
     )
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: process; category: process-exit; exit code: 1'),
+    )
     expect(errors).toHaveLength(1)
     expect(errors[0]!.stopReason).toBe('error')
     expect(errors[0]!.message.length).toBeGreaterThan(0)
@@ -784,6 +1142,9 @@ describe('dsh-subagent-acp', () => {
     const run = await ctx.subagents.start('acp', request())
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: process; category: process-exit; exit code: 1'),
+    )
     expect(warnings).toEqual([
       expect.stringContaining('subagent-acp "acp": child run failed (error):'),
     ])
@@ -810,6 +1171,9 @@ describe('dsh-subagent-acp', () => {
     )
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: process; category: process-exit; exit code: 1'),
+    )
     await run.dispose()
   })
 
@@ -828,6 +1192,7 @@ describe('dsh-subagent-acp', () => {
       controller.abort('crash it')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
+      expect(result.diagnostic).toBeUndefined()
       await run.dispose()
     } finally {
       rmSync(tmp, { recursive: true, force: true })
@@ -854,6 +1219,7 @@ describe('dsh-subagent-acp', () => {
         new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
       ])
       expect(result.stopReason).toBe('aborted')
+      expect(result.diagnostic).toBeUndefined()
       await run.dispose()
     } finally {
       rmSync(tmp, { recursive: true, force: true })

+ 15 - 2
packages/subagent/subagent/src/out-of-process.ts

@@ -41,6 +41,18 @@ function limitSubagentDiagnostic(diagnostic: string): string {
     + DIAGNOSTIC_TRUNCATION_SUFFIX
 }
 
+/** Enforce success omission and the byte limit on a provider-returned result. */
+function normalizeSubagentDiagnostic(result: SubagentResult): SubagentResult {
+  if (result.stopReason === 'completed') {
+    const normalized = { ...result }
+    Reflect.deleteProperty(normalized, 'diagnostic')
+    return normalized
+  }
+  return result.diagnostic === undefined
+    ? result
+    : { ...result, diagnostic: limitSubagentDiagnostic(result.diagnostic) }
+}
+
 /**
  * The capability advertisement of an out-of-process backend: NONE. A child in
  * another process cannot honor parent-enforced start features
@@ -176,7 +188,8 @@ export interface RunResultSettlement {
  * rejects after publication. A normally completed or rejected attempt resolves
  * as `aborted` when cancellation already settled locally; another rejection is
  * flattened to `stopReason: 'error'` through the contained diagnostic sink.
- * The abort listener is removed on every path.
+ * Provider-returned diagnostics use the same byte limit, and completed results
+ * omit them. The abort listener is removed on every path.
  * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
  * @returns the terminal result (never a rejection).
  */
@@ -185,7 +198,7 @@ export async function settleRunResult(parts: RunResultSettlement): Promise<Subag
     const result = await parts.attempt()
     return parts.cancelled()
       ? { output: parts.collectOutput(), stopReason: 'aborted' }
-      : result
+      : normalizeSubagentDiagnostic(result)
   } catch (error: unknown) {
     // Cover a rejection already queued when cancellation arrives.
     if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' }

+ 7 - 3
packages/subagent/subagent/src/run-settlement.ts

@@ -27,8 +27,10 @@ function failureDetail(result: SubagentResult): string {
 }
 
 /**
- * Map a child result to the task outcome: completed carries final text,
- * aborted is killed, and every other reason is failed without partial output.
+ * Map a child result to the task outcome: completed carries final text, local
+ * cancellation (`aborted` without a diagnostic) is killed, and provider-
+ * diagnosed remote aborts plus every other reason are failed without partial
+ * output.
  * @param result - child terminal result.
  * @returns outcome for the `ctx.jobs` registration.
  */
@@ -37,7 +39,9 @@ function runOutcome(result: SubagentResult): JobOutcome {
     case 'completed':
       return { status: 'completed', output: finalText(result.output) }
     case 'aborted':
-      return { status: 'killed' }
+      return result.diagnostic === undefined
+        ? { status: 'killed' }
+        : { status: 'failed', detail: failureDetail(result) }
     case 'error':
     case 'max-tokens':
     case 'refusal':

+ 69 - 0
packages/subagent/subagent/tests/run-settlement.spec.ts

@@ -84,6 +84,22 @@ describe('outcome mapping helpers', () => {
     })
   })
 
+  it('treats a diagnostic-bearing remote abort as failed without changing local cancellation', async () => {
+    await expect(settleRun({
+      id: SessionId('child-remote-abort'),
+      localAgent: undefined,
+      result: Promise.resolve({
+        output: [],
+        diagnostic: 'ACP permission was denied',
+        stopReason: 'aborted',
+      }),
+      dispose: () => Promise.resolve(),
+    })).resolves.toEqual({
+      status: 'failed',
+      detail: 'aborted; diagnostic: ACP permission was denied',
+    })
+  })
+
   it('bounds multibyte diagnostics and marks truncation', async () => {
     const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
     const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
@@ -114,4 +130,57 @@ describe('outcome mapping helpers', () => {
     expect(result.stopReason).toBe('error')
     expect(result.diagnostic).toBe(limited)
   })
+
+  it('applies the same diagnostic rules to provider-returned results', async () => {
+    const controller = new AbortController()
+    const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
+    const failed = await settleRunResult({
+      attempt: () => Promise.resolve({
+        output: [],
+        diagnostic: oversized,
+        stopReason: 'error',
+      }),
+      collectOutput: () => [],
+      cancelled: () => false,
+      signal: controller.signal,
+      onAbort: () => {},
+    })
+    expect(Buffer.byteLength(failed.diagnostic ?? '', 'utf8'))
+      .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
+    expect(failed.diagnostic).toMatch(/\[diagnostic truncated\]$/)
+
+    const completed = await settleRunResult({
+      attempt: () => Promise.resolve({
+        output: [],
+        diagnostic: 'must not survive success',
+        stopReason: 'completed',
+      }),
+      collectOutput: () => [],
+      cancelled: () => false,
+      signal: controller.signal,
+      onAbort: () => {},
+    })
+    expect(completed).toEqual({ output: [], stopReason: 'completed' })
+
+    const plainFailure = await settleRunResult({
+      attempt: () => Promise.resolve({ output: [], stopReason: 'error' }),
+      collectOutput: () => [],
+      cancelled: () => false,
+      signal: controller.signal,
+      onAbort: () => {},
+    })
+    expect(plainFailure).toEqual({ output: [], stopReason: 'error' })
+
+    const cancelledAfterAttempt = await settleRunResult({
+      attempt: () => Promise.resolve({ output: [], stopReason: 'completed' }),
+      collectOutput: () => [{ type: 'text', text: 'partial' }],
+      cancelled: () => true,
+      signal: controller.signal,
+      onAbort: () => {},
+    })
+    expect(cancelledAfterAttempt).toEqual({
+      output: [{ type: 'text', text: 'partial' }],
+      stopReason: 'aborted',
+    })
+  })
 })

部分文件因文件數量過多而無法顯示