feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)

- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch)
- AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill)
- ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断
- 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触
- 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 10:38:15 +08:00
parent 5aa39c36b7
commit bcd6771c48
14 changed files with 592 additions and 25 deletions

View File

@@ -33,6 +33,7 @@ const CTX = {
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
agentId: 1,
}
test('resolve 默认走 default adapterrun 返回结果', async () => {

View File

@@ -24,6 +24,14 @@ type CtxOverrides = Partial<{
truncated: string[]
agentAdapterRegistry: AgentAdapterRegistry
loggerWarn: (msg: string) => void
// taskRegistrar 的 agent 级 abort 绑定agent kill 桥接)。
// 提供后 buildCtx 注入到 ports.taskRegistrarhooks.agent 把闭包塞进 adapterCtx。
registerAgentAbort: (
runId: string,
agentId: number,
ac: AbortController,
) => void
unregisterAgentAbort: (runId: string, agentId: number) => void
}>
function buildCtx(overrides: CtxOverrides = {}): {
@@ -50,6 +58,12 @@ function buildCtx(overrides: CtxOverrides = {}): {
fail: () => {},
kill: () => {},
pendingAction: () => overrides.pending ?? null,
...(overrides.registerAgentAbort
? { registerAgentAbort: overrides.registerAgentAbort }
: {}),
...(overrides.unregisterAgentAbort
? { unregisterAgentAbort: overrides.unregisterAgentAbort }
: {}),
},
journalStore: {
read: async () => [],
@@ -424,3 +438,73 @@ test('agentAdapterRegistry resolve 抛错 → agent 上抛workflow failed'
})
await expect(hooks.agent('x')).rejects.toThrow()
})
// service.kill(runId, agentId) 桥接hooks.agent 必须把 taskRegistrar 的
// registerAgentAbort/unregisterAgentAbort 注入 adapterCtx绑定当前 runId
// backend 据此把 agentAbort controller 塞进 Mapservice.kill 据 agentId 精确 abort。
test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort绑定 runId 转发 taskRegistrar', async () => {
const registered: Array<{
runId: string
agentId: number
controller: AbortController
}> = []
const unregistered: Array<{ runId: string; agentId: number }> = []
// 捕获 hooks 传给 adapter 的 ctx验证 register/unregister 已注入且绑定 runId
let capturedCtx: {
registerAgentAbort?: (id: number, ac: AbortController) => void
unregisterAgentAbort?: (id: number) => void
agentId: number
runId: string
} | null = null
const registry = new AgentAdapterRegistry()
.register({
id: 'ad',
capabilities: { structuredOutput: true },
async run(_params, ctx) {
capturedCtx = ctx
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
.default('ad')
const { hooks } = buildCtx({
agentAdapterRegistry: registry,
registerAgentAbort: (runId, agentId, controller) =>
registered.push({ runId, agentId, controller }),
unregisterAgentAbort: (runId, agentId) =>
unregistered.push({ runId, agentId }),
})
await hooks.agent('x')
// ctx 含 register/unregister闭包绑定 runId='r1'
expect(capturedCtx).not.toBeNull()
expect(typeof capturedCtx!.registerAgentAbort).toBe('function')
expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function')
// 模拟 backend 调用:注入的闭包把 (agentId, controller) 转发到 taskRegistrar
// 并自动补 runId='r1'backend 不需要知道 runId
const ac = new AbortController()
capturedCtx!.registerAgentAbort!(7, ac)
capturedCtx!.unregisterAgentAbort!(7)
expect(registered).toEqual([{ runId: 'r1', agentId: 7, controller: ac }])
expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }])
})
test('taskRegistrar 未提供 registerAgentAbort → adapterCtx 也不含hooks 不报错)', async () => {
// 不传 registerAgentAbort/unregisterAgentAbort overrides → buildCtx 也不注入 taskRegistrar
// hooks 用 optional chaining 跳过adapterCtx 不含这两个字段
let capturedCtx: object | null = null
const registry = new AgentAdapterRegistry()
.register({
id: 'ad',
capabilities: { structuredOutput: true },
async run(_params, ctx) {
capturedCtx = ctx
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
.default('ad')
const { hooks } = buildCtx({ agentAdapterRegistry: registry })
await hooks.agent('x')
expect(capturedCtx).not.toBeNull()
expect(
(capturedCtx! as Record<string, unknown>).registerAgentAbort,
).toBeUndefined()
})