fix(sdk): validate closed turn cancellation facts

This commit is contained in:
pku-xht
2026-08-21 07:23:43 +08:00
parent fe88976b6a
commit ba0d7dfdca
6 changed files with 48 additions and 14 deletions
@@ -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
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {