diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 006aaf9b79..ecf279b614 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index f89498754e..2a93329b5f 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 2dc9f97999..5be06a4755 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.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 的回放,而不是诊断场景使用的脚本化协议子进程。 ## 曾考虑的替代方案 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 666efc651a..2f90a306c5 100644 --- a/packages/sdk/client/src/api.ts +++ b/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 } diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index b916afb64d..cb090df239 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/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) { diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index e7624d1b9e..2e618bc276 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/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', () => {