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

fix(sdk): validate closed turn cancellation facts

pku-xht 1 месяц назад
Родитель
Сommit
ba0d7dfdca

+ 2 - 2
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md
-2026-06-22-acp-subagent-backend.md: f89498754e6f9d207282c19d005b26c6c4c2bbeb
-2026-06-22-acp-subagent-backend.zh.md: 2dc9f979998d3e94ef0528528de085142143ba7d
+2026-06-22-acp-subagent-backend.md: 2a93329b5f21e98c3f49c17f62997e07aed3c72d
+2026-06-22-acp-subagent-backend.zh.md: 5be06a47558466f547591eb0fd19d6be11ccfecf

+ 1 - 0
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md

@@ -42,6 +42,7 @@ The child is a separate process, so it inherits an environment. Credential-shape
 - **Keyless Loader composition:** A test-only cordis.yml boots the stdio app through the real Loader with the backend's `cwd` omitted; a scripted model delegates once and the scripted child proves it ran in — and was announced — the parent session's workspace (the cwd-inheritance branch end to end).
 - **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file.
 - **Keyless snapshot:** The ACP example boots the real provider and scripted child through Loader-backed replay, pinning foreground and one-shot background diagnostics while keeping the child process, permission decision, partial output, and cleanup lifecycle deterministic.
+- **Snapshot gap:** Each ACP child still has its own replay session; `TODO(acp-subagent-replay)` continues to track parent replay against a replaying child harness rather than the scripted protocol child used by the diagnostic scenario.
 
 ## Alternatives considered
 

+ 1 - 0
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md

@@ -42,6 +42,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`
 - **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。
 - **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。
 - **无密钥快照:** ACP 示例通过 Loader 支持的回放启动真实提供方与脚本化子进程,固定前台和一次性后台诊断,同时保持子进程、权限决定、部分输出与清理生命周期确定。
+- **快照缺口:** 每个 ACP 子 agent 仍拥有自己的回放会话;`TODO(acp-subagent-replay)` 继续跟踪父进程对回放中子 harness 的回放,而不是诊断场景使用的脚本化协议子进程。
 
 ## 曾考虑的替代方案
 

+ 17 - 2
packages/sdk/client/src/api.ts

@@ -44,8 +44,9 @@ export class DeepSeekHarness implements AsyncDisposable {
 
   /**
    * The underlying JSON-RPC client (exposed for low-level access). A failed
-   * handshake reaps its runtime and swaps in a fresh instance, so do not
-   * cache this across a failed {@link start}.
+   * handshake swaps in a fresh instance only after cleanup proves the runtime
+   * exited; cleanup failure retains this client, so do not cache it across a
+   * failed {@link start}.
    * @returns the client currently owning the runtime subprocess.
    */
   get client(): HarnessClient {
@@ -222,6 +223,20 @@ function validatedTurnEndReason(value: unknown): TurnEndReason {
     if (!isRecord(value.reason) || typeof value.reason.kind !== 'string') {
       throw new SdkProtocolError(`turn/end carried a malformed aborted reason: ${JSON.stringify(value)}`)
     }
+    switch (value.reason.kind) {
+      case 'user':
+      case 'parent':
+      case 'disposed':
+      case 'legacy':
+        break
+      case 'hook':
+        if (typeof value.reason.reason !== 'string') {
+          throw new SdkProtocolError(`turn/end carried a malformed hook abort reason: ${JSON.stringify(value)}`)
+        }
+        break
+      default:
+        throw new SdkProtocolError(`turn/end carried an unknown abort reason: ${JSON.stringify(value)}`)
+    }
   }
   return value as unknown as TurnEndReason
 }

+ 19 - 9
packages/sdk/client/tests/fake-runtime.ts

@@ -26,8 +26,9 @@
  *   the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
  *   array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
  *   member; `FAKE_MALFORMED_REASON`: the `turn/end` carries a bare reason
- *   (`1`), an aborted reason without its cause (`aborted`), or no data member
- *   (`no-data`) for wire-validation probes.
+ *   (`1`), an aborted reason without its cause (`aborted`), an unknown abort
+ *   cause (`abort-unknown`), a hook cause without its reason (`hook`), or no
+ *   data member (`no-data`) for wire-validation probes.
  * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
  *   assistant/message for a usage-only max-tokens step.
  * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
@@ -137,13 +138,22 @@ function runTurn(sessionId: string): void {
     }
     const reason = env.FAKE_MALFORMED_REASON === 'aborted'
       ? { kind: 'aborted' }
-      : env.FAKE_MALFORMED_REASON !== undefined
-        ? 'not-a-reason-envelope'
-        : reasonKind === 'aborted'
-          ? { kind: 'aborted', reason: { kind: env.FAKE_ABORT_REASON_KIND ?? 'user' } }
-          : reasonKind === 'error'
-            ? { kind: 'error', error: { message: 'scripted child error', code: 'UNKNOWN' } }
-            : { kind: reasonKind }
+      : env.FAKE_MALFORMED_REASON === 'abort-unknown'
+        ? { kind: 'aborted', reason: { kind: 'future' } }
+        : env.FAKE_MALFORMED_REASON === 'hook'
+          ? { kind: 'aborted', reason: { kind: 'hook' } }
+          : env.FAKE_MALFORMED_REASON !== undefined
+            ? 'not-a-reason-envelope'
+            : reasonKind === 'aborted'
+              ? {
+                kind: 'aborted',
+                reason: env.FAKE_ABORT_REASON_KIND === 'hook'
+                  ? { kind: 'hook', reason: 'scripted hook abort' }
+                  : { kind: env.FAKE_ABORT_REASON_KIND ?? 'user' },
+              }
+              : reasonKind === 'error'
+                ? { kind: 'error', error: { message: 'scripted child error', code: 'UNKNOWN' } }
+                : { kind: reasonKind }
     event(sessionId, 'turn/end', { turn: 0, reason })
   }
   if (env.FAKE_SUBAGENT !== undefined) {

+ 8 - 1
packages/sdk/client/tests/sdk-client.spec.ts

@@ -533,11 +533,18 @@ describe('wire payload validation', () => {
     await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
   })
 
-  it.each(['1', 'aborted', 'no-data'])('rejects malformed turn/end input %s as a protocol error', async (mode) => {
+  it.each(['1', 'aborted', 'abort-unknown', 'hook', 'no-data'])('rejects malformed turn/end input %s as a protocol error', async (mode) => {
     const harness = harnessWith({ FAKE_MALFORMED_REASON: mode })
     await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
   })
 
+  it('accepts the complete hook cancellation cause', async () => {
+    const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'hook' })
+    const result = await harness.run('hook-abort')
+    const end = result.events.findLast(event => event.type === 'turn/end')
+    expect(end?.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'hook', reason: 'scripted hook abort' } })
+  })
+
 })
 
 describe('stderr tail bound', () => {