mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
fix(sdk): validate closed turn cancellation facts
This commit is contained in:
@@ -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 的回放,而不是诊断场景使用的脚本化协议子进程。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user