mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
@@ -76,6 +76,7 @@ mock.module('src/utils/worktree.js', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import { WorkflowAbortedError } from '@claude-code-best/workflow-engine'
|
||||
import {
|
||||
claudeCodeBackend,
|
||||
resolveAgentDefinition,
|
||||
@@ -108,6 +109,7 @@ function ctx() {
|
||||
}),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r1',
|
||||
agentId: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +190,85 @@ test('runAgent 抛错 → dead', async () => {
|
||||
expect(res.kind).toBe('dead')
|
||||
})
|
||||
|
||||
// 下面三组测试覆盖 'x' 无效修复:backend 必须把 ctx.signal 桥接到 runAgent.override
|
||||
// .abortController,并把 AbortError 识别为 abort(throw WorkflowAbortedError,而非吞成 dead)。
|
||||
// 还要验证 registerAgentAbort 注入,让 service.kill(runId, agentId) 能精确中断单个 agent。
|
||||
|
||||
test('ctx.signal 预 abort → backend 桥接:override.abortController.signal.aborted=true', async () => {
|
||||
// 用 capturedOverride 暴露 backend 创建的 agentAbort(mock 收到的 override.abortController)
|
||||
let capturedController: AbortController | undefined
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
runAgent: async function* (opts: {
|
||||
override?: { abortController?: AbortController }
|
||||
}) {
|
||||
capturedController = opts.override?.abortController
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: 'x' }] },
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
const parentAbort = new AbortController()
|
||||
parentAbort.abort()
|
||||
// mock 不抛 → backend 走正常返回路径;但桥接 `if (ctx.signal.aborted) agentAbort.abort()`
|
||||
// 已同步触发,capturedController.signal.aborted 必为 true(kill 桥接根因)
|
||||
await claudeCodeBackend.run(
|
||||
{ prompt: 'pre-aborted' },
|
||||
{ ...ctx(), signal: parentAbort.signal },
|
||||
)
|
||||
expect(capturedController?.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test('runAgent 抛 AbortError → backend throw WorkflowAbortedError(不吞成 dead)', async () => {
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
// biome-ignore lint/correctness/useYield: 故意抛 AbortError 测识别分支
|
||||
runAgent: async function* () {
|
||||
const e = new Error('aborted by parent')
|
||||
e.name = 'AbortError'
|
||||
throw e
|
||||
},
|
||||
}),
|
||||
)
|
||||
await expect(
|
||||
claudeCodeBackend.run({ prompt: 'abort' }, ctx()),
|
||||
).rejects.toBeInstanceOf(WorkflowAbortedError)
|
||||
})
|
||||
|
||||
test('registerAgentAbort/unregisterAgentAbort 注入:key=ctx.agentId(数字),controller 来自桥接', async () => {
|
||||
// 恢复默认 mock(上一个测试把它改成抛 AbortError 了)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
runAgent: async function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: 'agent-text' }] },
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
const registered: Array<{ id: number; controller: AbortController }> = []
|
||||
const unregistered: number[] = []
|
||||
await claudeCodeBackend.run(
|
||||
{ prompt: 'wiring' },
|
||||
{
|
||||
...ctx(),
|
||||
agentId: 42,
|
||||
registerAgentAbort: (id, ac) => registered.push({ id, controller: ac }),
|
||||
unregisterAgentAbort: id => unregistered.push(id),
|
||||
},
|
||||
)
|
||||
expect(registered).toHaveLength(1)
|
||||
expect(registered[0]?.id).toBe(42) // 引擎数字 agentId(非 coreAgentId 字符串)
|
||||
expect(registered[0]?.controller).toBeInstanceOf(AbortController)
|
||||
expect(unregistered).toEqual([42]) // finally 清理幂等
|
||||
})
|
||||
|
||||
test('id 与 capabilities 形状', () => {
|
||||
expect(claudeCodeBackend.id).toBe('claude-code')
|
||||
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)
|
||||
|
||||
@@ -93,6 +93,95 @@ test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppSta
|
||||
ports.taskRegistrar.kill(runId)
|
||||
})
|
||||
|
||||
// agent 级 kill 桥接:register → killAgent 精确中断;kill(runId) 顺带 abort 所有 agent。
|
||||
test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;kill(runId) 批量 abort', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
// 实现always provides these — cast 把 optional 拍平为 required(避免每行 ! 断言)
|
||||
const tr = ports.taskRegistrar as Required<typeof ports.taskRegistrar>
|
||||
|
||||
const state = { tasks: {} } as unknown as AppState
|
||||
const setAppState: SetAppState = f => {
|
||||
Object.assign(state, f(state))
|
||||
}
|
||||
const hostCtx = ports.hostFactory({
|
||||
context: { agentId: 'a-1', toolUseId: 'tu-1', setAppState },
|
||||
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
|
||||
parentMessage: {} as never,
|
||||
})
|
||||
const { runId } = tr.register(
|
||||
{
|
||||
workflowName: 'wf',
|
||||
summary: 'summary',
|
||||
workflowFile: 'wf.ts',
|
||||
toolUseId: 'tu-1',
|
||||
},
|
||||
hostCtx.handle,
|
||||
)
|
||||
|
||||
// 注册两个 agent 的 AbortController(模拟 backend 在启动 agent 时调用)
|
||||
const ac1 = new AbortController()
|
||||
const ac2 = new AbortController()
|
||||
tr.registerAgentAbort(runId, 1, ac1)
|
||||
tr.registerAgentAbort(runId, 2, ac2)
|
||||
expect(ac1.signal.aborted).toBe(false)
|
||||
expect(ac2.signal.aborted).toBe(false)
|
||||
|
||||
// killAgent 精确中断 agent #1:仅 ac1 abort,ac2 不受影响
|
||||
expect(tr.killAgent(runId, 1)).toBe(true)
|
||||
expect(ac1.signal.aborted).toBe(true)
|
||||
expect(ac2.signal.aborted).toBe(false)
|
||||
// 重复 kill 同 agent:controller 已 delete,返回 false(幂等)
|
||||
expect(tr.killAgent(runId, 1)).toBe(false)
|
||||
|
||||
// 未知 agentId / 未知 runId 安全返回 false
|
||||
expect(tr.killAgent(runId, 999)).toBe(false)
|
||||
expect(tr.killAgent('nope', 1)).toBe(false)
|
||||
|
||||
// kill(runId) 批量 abort 剩余 agent(ac2)
|
||||
tr.kill(runId)
|
||||
expect(ac2.signal.aborted).toBe(true)
|
||||
|
||||
// run 终态后 binding 已回收:再 killAgent 返回 false
|
||||
expect(tr.killAgent(runId, 2)).toBe(false)
|
||||
})
|
||||
|
||||
test('unregisterAgentAbort 从 Map 删除(backend finally 清理幂等)', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
const tr = ports.taskRegistrar as Required<typeof ports.taskRegistrar>
|
||||
|
||||
const state = { tasks: {} } as unknown as AppState
|
||||
const setAppState: SetAppState = f => {
|
||||
Object.assign(state, f(state))
|
||||
}
|
||||
const hostCtx = ports.hostFactory({
|
||||
context: { agentId: 'a-1', toolUseId: 'tu-1', setAppState },
|
||||
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
|
||||
parentMessage: {} as never,
|
||||
})
|
||||
const { runId } = tr.register(
|
||||
{
|
||||
workflowName: 'wf',
|
||||
summary: 'summary',
|
||||
workflowFile: 'wf.ts',
|
||||
toolUseId: 'tu-1',
|
||||
},
|
||||
hostCtx.handle,
|
||||
)
|
||||
const ac = new AbortController()
|
||||
tr.registerAgentAbort(runId, 5, ac)
|
||||
// 注销后 killAgent 无目标,返 false(不抛)
|
||||
tr.unregisterAgentAbort(runId, 5)
|
||||
expect(tr.killAgent(runId, 5)).toBe(false)
|
||||
// 重复注销幂等(backend finally 不抛)
|
||||
expect(() => tr.unregisterAgentAbort(runId, 5)).not.toThrow()
|
||||
// 未知 runId 安全 no-op
|
||||
expect(() => tr.unregisterAgentAbort('nope', 5)).not.toThrow()
|
||||
})
|
||||
|
||||
test('hostFactory.cwd 与 journalStore 同根(getProjectRoot)—— 修复 K 回归', () => {
|
||||
// 历史 bug:hostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot(),
|
||||
// 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。
|
||||
|
||||
@@ -27,6 +27,14 @@ type RegistrarCall =
|
||||
| { kind: 'complete'; runId: string; summary?: string }
|
||||
| { kind: 'fail'; runId: string; error?: string }
|
||||
| { kind: 'kill'; runId: string }
|
||||
| {
|
||||
kind: 'registerAgentAbort'
|
||||
runId: string
|
||||
agentId: number
|
||||
controller: AbortController
|
||||
}
|
||||
| { kind: 'unregisterAgentAbort'; runId: string; agentId: number }
|
||||
| { kind: 'killAgent'; runId: string; agentId: number }
|
||||
|
||||
function fakePorts(
|
||||
opts: {
|
||||
@@ -41,14 +49,18 @@ function fakePorts(
|
||||
ports: WorkflowPorts
|
||||
store: ReturnType<typeof createProgressStoreFromBus>
|
||||
killed: string[]
|
||||
/** taskRegistrar 调用记录(complete/fail/kill)。 */
|
||||
/** taskRegistrar 调用记录(complete/fail/kill/registerAgentAbort/...)。 */
|
||||
calls: RegistrarCall[]
|
||||
/** runId → (agentId → AbortController)。测试模拟 backend 注册用。 */
|
||||
agentBindings: Map<string, Map<number, AbortController>>
|
||||
} {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const killed: string[] = []
|
||||
const calls: RegistrarCall[] = []
|
||||
const bindings = new Map<string, { abort: AbortController }>()
|
||||
// agentId → AbortController(每个 runId 独立)。killAgent 据此精确中断。
|
||||
const agentBindings = new Map<string, Map<number, AbortController>>()
|
||||
let seq = 0
|
||||
const ports = {
|
||||
// hostFactory 实际不被 service.launch 路径调用(service 自建 host handle),
|
||||
@@ -93,6 +105,7 @@ function fakePorts(
|
||||
seq += 1
|
||||
const runId = `run-${seq}`
|
||||
bindings.set(runId, { abort })
|
||||
agentBindings.set(runId, new Map())
|
||||
return { runId, signal: abort.signal }
|
||||
},
|
||||
complete: (runId: string, summary?: string) => {
|
||||
@@ -106,6 +119,31 @@ function fakePorts(
|
||||
calls.push({ kind: 'kill', runId })
|
||||
bindings.get(runId)?.abort.abort()
|
||||
},
|
||||
registerAgentAbort: (
|
||||
runId: string,
|
||||
agentId: number,
|
||||
controller: AbortController,
|
||||
) => {
|
||||
calls.push({
|
||||
kind: 'registerAgentAbort',
|
||||
runId,
|
||||
agentId,
|
||||
controller,
|
||||
})
|
||||
agentBindings.get(runId)?.set(agentId, controller)
|
||||
},
|
||||
unregisterAgentAbort: (runId: string, agentId: number) => {
|
||||
calls.push({ kind: 'unregisterAgentAbort', runId, agentId })
|
||||
agentBindings.get(runId)?.delete(agentId)
|
||||
},
|
||||
killAgent: (runId: string, agentId: number) => {
|
||||
calls.push({ kind: 'killAgent', runId, agentId })
|
||||
const ac = agentBindings.get(runId)?.get(agentId)
|
||||
if (!ac) return false
|
||||
ac.abort()
|
||||
agentBindings.get(runId)!.delete(agentId)
|
||||
return true
|
||||
},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
@@ -120,7 +158,7 @@ function fakePorts(
|
||||
warn: () => {},
|
||||
},
|
||||
} as unknown as WorkflowPorts
|
||||
return { ports, store, killed, calls }
|
||||
return { ports, store, killed, calls, agentBindings }
|
||||
}
|
||||
|
||||
const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never
|
||||
@@ -184,6 +222,33 @@ test('kill 走 taskRegistrar.kill', async () => {
|
||||
expect(killed).toContain(runId)
|
||||
})
|
||||
|
||||
test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, calls, agentBindings } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
const { runId } = await svc.launch(
|
||||
{ script: `return agent('x')` },
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
)
|
||||
// 模拟 backend 启动 agent 时注册 AbortController
|
||||
const ac = new AbortController()
|
||||
agentBindings.get(runId)!.set(7, ac)
|
||||
// service.killAgent 路由到 taskRegistrar.killAgent,后者真 abort 对应 controller
|
||||
expect(svc.killAgent(runId, 7)).toBe(true)
|
||||
expect(ac.signal.aborted).toBe(true)
|
||||
expect(
|
||||
calls.some(
|
||||
c => c.kind === 'killAgent' && c.runId === runId && c.agentId === 7,
|
||||
),
|
||||
).toBe(true)
|
||||
// 已 abort 后 controller 从 Map 删除:再次 killAgent 同 agent 返 false(幂等)
|
||||
expect(svc.killAgent(runId, 7)).toBe(false)
|
||||
// 未知 agentId / 未知 runId 安全返 false
|
||||
expect(svc.killAgent(runId, 999)).toBe(false)
|
||||
expect(svc.killAgent('nope', 1)).toBe(false)
|
||||
})
|
||||
|
||||
test('listRuns/subscribe 来自 store', () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
|
||||
@@ -11,12 +11,27 @@ test('q / Esc → quit', () => {
|
||||
expect(routeWorkflowKey('', { escape: true })).toBe('quit')
|
||||
})
|
||||
|
||||
test('x → kill;r → resume;n → newRun', () => {
|
||||
expect(routeWorkflowKey('x', {})).toBe('kill')
|
||||
test('x → killAgent;K → killWorkflow;r → resume;n → newRun', () => {
|
||||
expect(routeWorkflowKey('x', {})).toBe('killAgent')
|
||||
expect(routeWorkflowKey('K', {})).toBe('killWorkflow')
|
||||
expect(routeWorkflowKey('r', {})).toBe('resume')
|
||||
expect(routeWorkflowKey('n', {})).toBe('newRun')
|
||||
})
|
||||
|
||||
test('confirm 模式:y/Enter → confirmYes;n/Esc/q → confirmNo;其他键 → null', () => {
|
||||
expect(routeWorkflowKey('y', {}, 'confirm')).toBe('confirmYes')
|
||||
expect(routeWorkflowKey('Y', {}, 'confirm')).toBe('confirmYes')
|
||||
expect(routeWorkflowKey('', { return: true }, 'confirm')).toBe('confirmYes')
|
||||
expect(routeWorkflowKey('n', {}, 'confirm')).toBe('confirmNo')
|
||||
expect(routeWorkflowKey('N', {}, 'confirm')).toBe('confirmNo')
|
||||
expect(routeWorkflowKey('', { escape: true }, 'confirm')).toBe('confirmNo')
|
||||
expect(routeWorkflowKey('q', {}, 'confirm')).toBe('confirmNo')
|
||||
// confirm 模式吞掉导航/编辑键,防误触
|
||||
expect(routeWorkflowKey('x', {}, 'confirm')).toBeNull()
|
||||
expect(routeWorkflowKey('', { tab: true }, 'confirm')).toBeNull()
|
||||
expect(routeWorkflowKey('', { upArrow: true }, 'confirm')).toBeNull()
|
||||
})
|
||||
|
||||
test('←/→ 切焦点列;↑/↓ 列内移动', () => {
|
||||
expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft')
|
||||
expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight')
|
||||
|
||||
Reference in New Issue
Block a user