feat(workflow): add workflow engine, /workflows panel, /ultracode skill

将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:

- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-13 20:07:18 +08:00
parent 91cffe16e2
commit d236880bc3
106 changed files with 16127 additions and 834 deletions

View File

@@ -0,0 +1,100 @@
import { expect, test } from 'bun:test';
import React from 'react';
import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
import type { RunProgress } from '../progress/store.js';
import { call as panelCall } from '../panel/panelCall.js';
import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { STATUS_DOT } from '../panel/status.js';
// 纯函数:选中夹紧到有效区间(与面板内 clampSelected 同源)。
test('clampSelected空列表→0越界→末位负/NaN→0正常→原值', () => {
expect(clampSelected(5, 0)).toBe(0);
expect(clampSelected(5, 3)).toBe(2);
expect(clampSelected(-3, 3)).toBe(0);
expect(clampSelected(1, 3)).toBe(1);
expect(clampSelected(0, 1)).toBe(0);
// NaN如未初始化状态安全回落到 0
expect(clampSelected(Number.NaN, 3)).toBe(0);
});
// STATUS_DOT 覆盖四种状态,且均为可见圆点字符。
test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () => {
const statuses = ['running', 'completed', 'failed', 'killed'] as const;
for (const s of statuses) {
expect(STATUS_DOT[s]).toBeTruthy();
expect(STATUS_DOT[s].length).toBeGreaterThan(0);
}
});
// 进度数据形态契约:面板读取的字段在典型 RunProgress 上存在/可读,
// 防止 store.ts 结构漂移悄悄破坏面板渲染。
test('RunProgress 字段契约:面板读取的 key 均存在', () => {
const run: RunProgress = {
runId: 'r1',
workflowName: 'review',
status: 'running',
phases: [{ title: 'Find', status: 'done' }],
currentPhase: 'Review',
agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }],
agentCount: 1,
updatedAt: 1,
};
// 面板 WorkflowList/Detail 读取的路径
expect(run.status).toBe('running');
expect(STATUS_DOT[run.status]).toBe('●');
expect(run.currentPhase).toBe('Review');
expect(run.agents.length).toBe(run.agentCount);
expect(run.phases[0]?.title).toBe('Find');
expect(run.phases[0]?.status).toBe('done');
expect(run.agents[0]?.label).toBe('review:api');
});
// 完成/失败形态returnValue / error 在非 running 时才显示。
test('RunProgress 完成/失败形态returnValue/error 可选', () => {
const completed: RunProgress = {
runId: 'r2',
workflowName: 'w',
status: 'completed',
phases: [],
currentPhase: null,
agents: [],
agentCount: 0,
returnValue: 'ok',
updatedAt: 2,
};
const failed: RunProgress = {
runId: 'r3',
workflowName: 'w',
status: 'failed',
phases: [],
currentPhase: null,
agents: [],
agentCount: 0,
error: 'boom',
updatedAt: 3,
};
expect(completed.returnValue).toBe('ok');
expect(completed.error).toBeUndefined();
expect(failed.error).toBe('boom');
expect(failed.returnValue).toBeUndefined();
expect(STATUS_DOT['completed']).toBe('✓');
expect(STATUS_DOT['failed']).toBe('✗');
});
// 修复 MuseSyncExternalStore / listNamed / 子组件抛错时不应击穿 REPL。
// panelCall 必须把 WorkflowsPanel 包在 SentryErrorBoundary 里。
test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel修复 M 回归)', async () => {
const element = (await (panelCall as unknown as (a: unknown, b: unknown, c: unknown) => Promise<React.ReactNode>)(
() => {},
{ canUseTool: undefined },
'',
)) as React.ReactElement<{ name?: string; children: React.ReactNode }>;
expect(element.type).toBe(SentryErrorBoundary);
expect(element.props.name).toBe('WorkflowsPanel');
const child = element.props.children as React.ReactElement<{
onDone: () => void;
}>;
expect(child.type).toBe(WorkflowsPanel);
expect(React.isValidElement(child)).toBe(true);
expect(typeof child.props.onDone).toBe('function');
});

View File

@@ -0,0 +1,142 @@
import { expect, test, mock } from 'bun:test'
// 注意mock specifier 必须解析到 impl 实际 import 的同一模块bun mock.module
// 按解析后模块匹配。impl 用 '@claude-code-best/builtin-tools/...' 与 'src/*' 别名
// 路径导入,此处用相同 specifier。
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
runAgent: async function* () {
yield {
type: 'assistant',
message: { content: [{ type: 'text', text: 'agent-text' }] },
}
},
}),
)
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js',
() => ({
finalizeAgentTool: () => ({
content: [{ type: 'text', text: 'agent-text' }],
usage: { output_tokens: 42 },
totalTokens: 42,
}),
}),
)
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js',
() => ({
isBuiltInAgent: () => true,
}),
)
mock.module('src/tools.js', () => ({ assembleToolPool: () => ({ tools: [] }) }))
mock.module('src/utils/messages.js', () => ({
createUserMessage: (o: { content: string }) => ({
role: 'user',
content: o.content,
}),
extractTextContent: () => 'agent-text',
}))
mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} }))
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
import {
claudeCodeBackend,
resolveAgentDefinition,
mapWorkflowModel,
extractStructuredOutput,
WORKFLOW_AGENT,
} from '../backends/claudeCodeBackend.js'
import { makeHostHandle } from '../hostHandle.js'
function ctx() {
return {
host: makeHostHandle({
toolUseContext: {
options: {
agentDefinitions: { activeAgents: [] },
querySource: 'workflow',
mainLoopModel: 'm',
},
getAppState: () => ({
toolPermissionContext: {
mode: 'acceptEdits',
alwaysAllowRules: {},
},
mcp: { tools: [] },
}),
} as never,
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
// run() 不读 parentMessage用空对象占位满足 WorkflowHostBundle 类型。
parentMessage: {} as never,
}),
signal: new AbortController().signal,
runId: 'r1',
}
}
test('文本 agent → ok + token 计量', async () => {
const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx())
expect(res.kind).toBe('ok')
if (res.kind === 'ok') {
expect(res.output).toBe('agent-text')
expect(res.usage.outputTokens).toBe(42)
}
})
test('runAgent 抛错 → dead', async () => {
// 覆盖 mock 让 runAgent 抛last-write-wins
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
// biome-ignore lint/correctness/useYield: 故意抛错以测试 dead 分支(不 yield
runAgent: async function* () {
throw new Error('boom')
},
}),
)
const res = await claudeCodeBackend.run({ prompt: 'fail' }, ctx())
expect(res.kind).toBe('dead')
})
test('id 与 capabilities 形状', () => {
expect(claudeCodeBackend.id).toBe('claude-code')
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)
expect(claudeCodeBackend.capabilities.tools).toBe(true)
})
test('resolveAgentDefinition无 agentType → WORKFLOW_AGENT 兜底', () => {
const tuc = {
options: { agentDefinitions: { activeAgents: [] } },
} as never
expect(resolveAgentDefinition(undefined, tuc)).toBe(WORKFLOW_AGENT)
})
test('resolveAgentDefinition命中 activeAgents', () => {
const fake = { agentType: 'Explore', permissionMode: 'plan' } as never
const tuc = {
options: { agentDefinitions: { activeAgents: [fake] } },
} as never
expect(resolveAgentDefinition('Explore', tuc)).toBe(fake)
// 未命中仍兜底
expect(resolveAgentDefinition('Nope', tuc)).toBe(WORKFLOW_AGENT)
})
test('mapWorkflowModel 直传', () => {
expect(mapWorkflowModel(undefined)).toBeUndefined()
expect(mapWorkflowModel('claude-haiku-*')).toBe('claude-haiku-*')
})
test('extractStructuredOutput合法 JSON 提取;非法返回 null', () => {
expect(
extractStructuredOutput([
{ type: 'text', text: 'prefix {"a":1,"b":2} suffix' },
]),
).toEqual({ a: 1, b: 2 })
expect(
extractStructuredOutput([{ type: 'text', text: 'no json here' }]),
).toBeNull()
expect(extractStructuredOutput([])).toBeNull()
})

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from 'bun:test'
import type { RunProgress } from '../progress/store.js'
import type { WorkflowService } from '../service.js'
function makeMockService(runs: RunProgress[]): {
service: WorkflowService
emit: () => void
setRuns: (runs: RunProgress[]) => void
} {
let current = runs
const listeners = new Set<() => void>()
return {
service: {
ports: {},
launch: async () => ({ runId: 'x' }),
kill: () => {},
listRuns: () => current,
getRun: () => undefined,
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
},
listNamed: async () => [],
} as unknown as WorkflowService,
emit: () => {
for (const fn of listeners) fn()
},
setRuns: r => {
current = r
},
}
}
function makeRun(
runId: string,
status: RunProgress['status'],
overrides: Partial<RunProgress> = {},
): RunProgress {
return {
runId,
workflowName: 'wf',
status,
phases: [],
declaredPhases: [],
currentPhase: null,
agents: [],
agentCount: 0,
updatedAt: Date.now(),
...overrides,
}
}
describe('installWorkflowNotifications', () => {
test('running → completed 触发通知(含 workflow 名)', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
const unsubscribe = installWorkflowNotifications(service, msg =>
calls.push(msg),
)
// 第一次 emitlistener 记录初始 running 状态,不发通知
emit()
expect(calls.length).toBe(0)
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/task-notification/)
expect(calls[0]).toMatch(/completed successfully/)
expect(calls[0]).toMatch(/"wf"/)
unsubscribe()
})
test('running → failed 触发通知,含 error 文字', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'failed', { error: 'agent X boom' })])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/failed/)
expect(calls[0]).toMatch(/agent X boom/)
})
test('running → killed 触发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'killed')])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/was stopped/)
})
test('初次见到 run无 prev不发通知避免启动时通知历史 run', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
// 启动后第一次 emit看到 r1 已 completed——不应通知不是从 running 转换来)
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(0)
})
test('running → running 不发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'running', { agentCount: 1 })])
emit()
expect(calls.length).toBe(0)
})
test('已 completed 的 run 再次 emit 不重复通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(1)
emit()
expect(calls.length).toBe(1)
})
test('unsubscribe 后不再发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
const unsubscribe = installWorkflowNotifications(service, msg =>
calls.push(msg),
)
emit() // 记录初始 running
unsubscribe()
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(0)
})
})

View File

@@ -0,0 +1,109 @@
import { expect, test } from 'bun:test'
// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。
// 原因mock.module 是进程全局的last-write-winsmock 这些公共模块会污染
// 同进程其他测试(如 src/commands/__tests__/autonomy.test.ts 经其依赖链 import
// 真实 bootstrap/state。ports 在测试环境下能正常解析 getProjectRoot/getCwd
// logEvent/logForDebugging 在 sink 未 attach 时为静默 no-op无需 mock。
import { buildRegistry } from '../registry.js'
import { createWorkflowPorts } from '../ports.js'
import { createProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import { getProjectRoot } from '../../bootstrap/state.js'
import type { SetAppState } from '../../Task.js'
import type { AppState } from '../../state/AppState.tsx'
test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => {
const reg = buildRegistry()
expect(reg.has('claude-code')).toBe(true)
expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code')
expect(reg.resolve({ prompt: 'x', agentType: 'whatever' }).id).toBe(
'claude-code',
)
})
test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
expect(ports.agentAdapterRegistry).toBeDefined()
expect(ports.agentAdapterRegistry!.resolve({ prompt: 'x' }).id).toBe(
'claude-code',
)
expect(typeof ports.taskRegistrar.register).toBe('function')
expect(typeof ports.taskRegistrar.kill).toBe('function')
expect(typeof ports.hostFactory).toBe('function')
// agentRunner 兜底字段仍存在WorkflowPorts 必填)
expect(ports.agentRunner).toBeDefined()
expect(typeof ports.agentRunner.runAgentToResult).toBe('function')
// progressEmitter 经 bus → store发一个 run_startedstore 能看到
ports.progressEmitter.emit({
type: 'run_started',
runId: 't',
workflowName: 'w',
meta: null,
})
expect(store.get('t')?.workflowName).toBe('w')
})
test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppState无 mock', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
// 真 setAppState用一个本地 AppState 对象承载 tasksregisterTask 走真实代码路径。
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, signal } = ports.taskRegistrar.register(
{
workflowName: 'wf',
summary: 'summary',
workflowFile: 'wf.ts',
toolUseId: 'tu-1',
},
hostCtx.handle,
)
expect(typeof runId).toBe('string')
expect(signal).toBeInstanceOf(AbortSignal)
// complete/fail/kill 不抛RunBinding 命中)
expect(() => ports.taskRegistrar.complete(runId, 'done')).not.toThrow()
expect(() => ports.taskRegistrar.kill(runId)).not.toThrow()
// 未知 runId 安全 no-op
expect(() => ports.taskRegistrar.complete('nope')).not.toThrow()
expect(ports.taskRegistrar.pendingAction('nope')).toBeNull()
// 终态后 binding 回收:再次 complete 同 runId 应安全 no-op不抛错、不重复调用 workflow task fn
ports.taskRegistrar.complete(runId)
ports.taskRegistrar.kill(runId)
})
test('hostFactory.cwd 与 journalStore 同根getProjectRoot—— 修复 K 回归', () => {
// 历史 bughostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot()
// 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。
// 修复后两者都用 projectRoot本测试 lock-in 该选择,防止回归。
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
const hostCtx = ports.hostFactory({
context: { agentId: 'a', toolUseId: 'tu' },
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: {} as never,
})
expect(hostCtx.cwd).toBe(getProjectRoot())
})

View File

@@ -0,0 +1,23 @@
import { expect, test, mock } from 'bun:test'
import { createProgressBus } from '../progress/bus.js'
test('emit 广播给所有订阅者', () => {
const bus = createProgressBus()
const a = mock(() => {})
const b = mock(() => {})
bus.subscribe(a)
bus.subscribe(b)
const ev = { type: 'log' as const, runId: 'r', message: 'hi' }
bus.emit(ev)
expect(a).toHaveBeenCalledTimes(1)
expect(b).toHaveBeenCalledWith(ev)
})
test('subscribe 返回取消订阅', () => {
const bus = createProgressBus()
const fn = mock(() => {})
const unsub = bus.subscribe(fn)
unsub()
bus.emit({ type: 'log', runId: 'r', message: 'x' })
expect(fn).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,175 @@
import { expect, test } from 'bun:test'
import { createProgressBus, type ProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import type { AgentRunResult } from '@claude-code-best/workflow-engine'
const ok = (o: string): AgentRunResult => ({
kind: 'ok',
output: o,
usage: { outputTokens: 1 },
})
function newStore() {
const bus: ProgressBus = createProgressBus()
return { bus, store: createProgressStoreFromBus(bus) }
}
test('run_started 建条目phase_started/done 更新 phases', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({ type: 'phase_started', runId: 'r1', phase: 'A' })
bus.emit({ type: 'phase_started', runId: 'r1', phase: 'B' })
bus.emit({ type: 'phase_done', runId: 'r1', phase: 'A' })
const r = store.get('r1')!
expect(r.phases.map(p => [p.title, p.status])).toEqual([
['A', 'done'],
['B', 'running'],
])
expect(r.currentPhase).toBe('B')
})
test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'agent_started',
runId: 'r1',
agentId: 0,
label: 'a',
phase: 'A',
})
bus.emit({
type: 'agent_started',
runId: 'r1',
agentId: 1,
label: 'b',
phase: 'A',
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 1,
label: 'b',
phase: 'A',
result: ok('b-out'),
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 0,
label: 'a',
phase: 'A',
result: ok('a-out'),
})
const agents = store.get('r1')!.agents
expect(agents.find(x => x.id === 0)?.status).toBe('done')
expect(agents.find(x => x.id === 1)?.status).toBe('done')
expect(agents.find(x => x.id === 0)?.label).toBe('a')
expect(agents.find(x => x.id === 1)?.label).toBe('b')
})
test('journal 命中(仅 agent_done 无 started按 id 补建 done 条目', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 7,
label: 'c',
phase: 'A',
result: ok('c'),
})
const a = store.get('r1')!.agents.find(x => x.id === 7)!
expect(a.status).toBe('done')
})
test('run_done 终态 + list 排序 + subscribe 通知', () => {
const { bus, store } = newStore()
let calls = 0
store.subscribe(() => calls++)
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'run_done',
runId: 'r1',
status: 'completed',
returnValue: 42,
})
const r = store.get('r1')!
expect(r.status).toBe('completed')
expect(r.returnValue).toBe(42)
expect(store.list().map(x => x.runId)).toEqual(['r1'])
expect(calls).toBe(2)
})
test('run_done failed 终态记录 error', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r2', workflowName: 'w', meta: null })
bus.emit({ type: 'run_done', runId: 'r2', status: 'failed', error: 'boom' })
const r = store.get('r2')!
expect(r.status).toBe('failed')
expect(r.error).toBe('boom')
})
test('log 事件不触发 notify', () => {
const { bus, store } = newStore()
let calls = 0
store.subscribe(() => calls++)
bus.emit({ type: 'run_started', runId: 'r3', workflowName: 'w', meta: null })
const before = calls
bus.emit({ type: 'log', runId: 'r3', message: 'hi' })
expect(calls).toBe(before) // log 不应触发 notify
})
test('run_started 落地 declaredPhases来自 meta.phases顺序保留', () => {
const { bus, store } = newStore()
bus.emit({
type: 'run_started',
runId: 'r1',
workflowName: 'w',
meta: {
name: 'w',
description: 'd',
phases: [{ title: 'Find' }, { title: 'Review' }, { title: 'Verify' }],
},
})
expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify'])
})
test('run_started meta 为 null → declaredPhases = []', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
expect(store.get('r1')!.declaredPhases).toEqual([])
})
test('agent_done 落地 outputShapeok·object / ok·text / dead 无)', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' })
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 1, phase: 'A' })
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 2, phase: 'A' })
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 0,
phase: 'A',
result: { kind: 'ok', output: { x: 1 }, usage: { outputTokens: 1 } },
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 1,
phase: 'A',
result: { kind: 'ok', output: 'hi', usage: { outputTokens: 1 } },
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 2,
phase: 'A',
result: { kind: 'dead' },
})
const agents = store.get('r1')!.agents
expect(agents.find(a => a.id === 0)?.outputShape).toBe('object')
expect(agents.find(a => a.id === 1)?.outputShape).toBe('text')
expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined()
})

View File

@@ -0,0 +1,81 @@
import { expect, test } from 'bun:test'
import type { AgentProgress, RunProgress } from '../progress/store.js'
import {
ALL_PHASE,
mergePhases,
filterAgentsByPhase,
tabLabel,
} from '../panel/selectors.js'
function run(partial: Partial<RunProgress>): RunProgress {
return {
runId: 'r1',
workflowName: 'w',
status: 'running',
phases: [],
declaredPhases: [],
currentPhase: null,
agents: [],
agentCount: 0,
updatedAt: 1,
...partial,
}
}
test('mergePhases声明顺序优先实际 phase 追加未声明的,计数 done/total', () => {
const r = run({
declaredPhases: ['Find', 'Review', 'Verify'],
phases: [
{ title: 'Find', status: 'done' },
{ title: 'Review', status: 'running' },
],
agents: [
{
id: 1,
phase: 'Find',
status: 'done',
resultKind: 'ok',
outputShape: 'text',
},
{ id: 2, phase: 'Find', status: 'done', resultKind: 'dead' },
{ id: 3, phase: 'Review', status: 'running' },
],
})
expect(mergePhases(r)).toEqual([
{ title: 'Find', status: 'done', done: 2, total: 2 },
{ title: 'Review', status: 'running', done: 0, total: 1 },
{ title: 'Verify', status: 'pending', done: 0, total: 0 },
])
})
test('mergePhases实际出现但未声明的 phase 追加到末尾', () => {
const r = run({
declaredPhases: ['Find'],
phases: [
{ title: 'Find', status: 'done' },
{ title: 'Adhoc', status: 'running' },
],
agents: [],
})
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
})
test('filterAgentsByPhaseAll / undefined → 全部;指定 → 仅该 phase', () => {
const agents: AgentProgress[] = [
{ id: 1, phase: 'A', status: 'running' },
{
id: 2,
phase: 'B',
status: 'done',
resultKind: 'ok',
outputShape: 'text',
},
]
expect(filterAgentsByPhase(agents, undefined)).toHaveLength(2)
expect(filterAgentsByPhase(agents, ALL_PHASE)).toHaveLength(2)
expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]])
})
test('tabLabelworkflow 名 + runId 后 4 位短码', () => {
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
})

View File

@@ -0,0 +1,335 @@
import { expect, test } from 'bun:test'
// DI 模式:不使用 mock.module进程全局、last-write-wins会污染同进程其他测试如
// autonomy.test.ts。改为手工构造 FAKE WorkflowPortsregistry.run 返回固定 ok
// 结果taskRegistrar 维护 abort 绑定journalStore 内存空实现。真实 runWorkflow
// 因此跑完且无需 LLM 或 mock。
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { makeService, __resetWorkflowServiceForTests } from '../service.js'
import { createProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import type {
AgentRunResult,
ProgressEvent,
WorkflowPorts,
} from '@claude-code-best/workflow-engine'
// 构造 FAKE portsregistry.run 返回固定 AgentRunResulttaskRegistrar 带 binding
// journalStore 内存空实现。progressEmitter.emit → bus.emitstore 已在构造时订阅 bus
// 注意runWorkflow 自身会发 run_started/run_donetaskRegistrar 只管 abort 绑定,
// 不重复发事件(避免 store reducer 收到重复 run_done
type RegistrarCall =
| { kind: 'complete'; runId: string; summary?: string }
| { kind: 'fail'; runId: string; error?: string }
| { kind: 'kill'; runId: string }
function fakePorts(
opts: {
/** adapter.run 抛错(模拟 agent 后端崩溃)。 */
adapterThrow?: string
/** adapter.run 返回值(默认 ok。 */
adapterResult?: AgentRunResult
/** agentRunner.runAgentToResult 返回值fallback 路径,默认 throw。 */
runnerResult?: AgentRunResult
} = {},
): {
ports: WorkflowPorts
store: ReturnType<typeof createProgressStoreFromBus>
killed: string[]
/** taskRegistrar 调用记录complete/fail/kill。 */
calls: RegistrarCall[]
} {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const killed: string[] = []
const calls: RegistrarCall[] = []
const bindings = new Map<string, { abort: AbortController }>()
let seq = 0
const ports = {
// hostFactory 实际不被 service.launch 路径调用service 自建 host handle
// 但 WorkflowPorts 类型要求存在;保留一个最小实现。
hostFactory: () => ({
handle: {} as never,
cwd: '/tmp',
budgetTotal: null,
toolUseId: 'tu',
}),
agentAdapterRegistry: {
resolve: () => ({
id: 'claude-code',
capabilities: { structuredOutput: true },
run:
opts.adapterThrow !== undefined
? async (): Promise<AgentRunResult> => {
throw new Error(opts.adapterThrow)
}
: async (): Promise<AgentRunResult> =>
opts.adapterResult ?? {
kind: 'ok',
output: 'mock-out',
usage: { outputTokens: 1 },
},
}),
},
agentRunner: {
runAgentToResult:
opts.runnerResult !== undefined
? async () => opts.runnerResult
: async () => {
throw new Error('should not reach')
},
},
progressEmitter: {
emit: (e: ProgressEvent) => bus.emit(e),
},
taskRegistrar: {
register: ({ workflowName }: { workflowName: string }) => {
const abort = new AbortController()
seq += 1
const runId = `run-${seq}`
bindings.set(runId, { abort })
return { runId, signal: abort.signal }
},
complete: (runId: string, summary?: string) => {
calls.push({ kind: 'complete', runId, summary })
},
fail: (runId: string, error?: string) => {
calls.push({ kind: 'fail', runId, error })
},
kill: (runId: string) => {
killed.push(runId)
calls.push({ kind: 'kill', runId })
bindings.get(runId)?.abort.abort()
},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: {
debug: () => {},
event: () => {},
warn: () => {},
},
} as unknown as WorkflowPorts
return { ports, store, killed, calls }
}
const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never
const stubCanUseTool = (() => Promise.resolve({ behavior: 'allow' })) as never
/** 等待 detached runWorkflow 完成detached 调用,需让微任务/宏任务排空)。 */
async function settle(): Promise<void> {
await new Promise(r => setTimeout(r, 60))
}
test('launch → completedstore 出现该 run', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('compute')` },
stubTUC,
stubCanUseTool,
)
await settle()
const r = svc.getRun(runId)
expect(r).toBeDefined()
// detached 执行可能在 settle 窗口内仍 running或已 completed——两者皆可接受。
expect(['completed', 'running']).toContain(r!.status)
expect(r!.workflowName).toBe('workflow')
})
test('kill 走 taskRegistrar.kill', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('x')` },
stubTUC,
stubCanUseTool,
)
svc.kill(runId)
expect(killed).toContain(runId)
})
test('listRuns/subscribe 来自 store', () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
expect(svc.listRuns()).toEqual([])
let n = 0
const unsub = svc.subscribe(() => {
n++
})
expect(typeof unsub).toBe('function')
unsub()
expect(n).toBe(0)
})
test('listNamed 委托 namedWorkflows空目录→[];有文件→列出)', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
// 不存在的目录 → []
const empty = await svc.listNamed(
join(tmpdir(), `wf-nope-${Math.random().toString(36).slice(2)}`),
)
expect(empty).toEqual([])
// 有命名文件的目录 → 列出 name去扩展名排序
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
try {
await writeFile(
join(dir, 'a.ts'),
'export const meta = { name: "a", description: "d" }\nreturn 1',
)
await writeFile(join(dir, 'b.js'), 'return 2')
const names = await svc.listNamed(dir)
expect(names).toEqual(['a', 'b'])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('缺 script/name/scriptPath → 抛错', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
await expect(svc.launch({}, stubTUC, stubCanUseTool)).rejects.toThrow(
/script|name|scriptPath/,
)
})
test('scriptPath 读取文件内容并校验', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
const dir = await mkdtemp(join(tmpdir(), 'wf-path-'))
const file = join(dir, 's.ts')
try {
await writeFile(file, `return agent('from-file')`)
const { runId } = await svc.launch(
{ scriptPath: file },
stubTUC,
stubCanUseTool,
)
await settle()
const r = svc.getRun(runId)
expect(r).toBeDefined()
expect(['completed', 'running']).toContain(r!.status)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('parseScript 校验失败 → launch 抛错', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
// 触发 ScriptErrormeta 字面量缺 descriptionvalidateMeta 要求 name+description 均为字符串)
await expect(
svc.launch(
{ script: `export const meta = { name: "x" }\nreturn 1` },
stubTUC,
stubCanUseTool,
),
).rejects.toThrow(/校验失败/)
})
// ---- 服务层失败路由覆盖(审查 gap.then/.catch → taskRegistrar 路径)----
test('脚本运行抛错 → service 路由到 taskRegistrar.fail带 error 文本', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts()
const svc = makeService(ports, store)
await svc.launch(
{ script: `throw new Error('script boom')` },
stubTUC,
stubCanUseTool,
)
await settle()
const fail = calls.find(c => c.kind === 'fail')
expect(fail).toBeDefined()
expect(fail?.kind === 'fail' && fail.error).toMatch(/script boom/)
})
test('adapter 抛错 → service 通过 .catch 路径路由到 taskRegistrar.fail', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts({ adapterThrow: 'adapter boom' })
const svc = makeService(ports, store)
await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool)
await settle()
const fail = calls.find(c => c.kind === 'fail')
expect(fail).toBeDefined()
// adapter throw → runWorkflow 的内部 try/catch 转 failed statuserror 透传;
// 或透传到 detached promise 的 .catch。两者最终都进 taskRegistrar.fail。
expect(fail?.kind === 'fail' && fail.error).toMatch(/adapter boom/)
})
test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts()
const svc = makeService(ports, store)
await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool)
await settle()
expect(calls.some(c => c.kind === 'complete')).toBe(true)
})
// ---- 修复 Nshutdown 清理 ----
test('shutdown 杀掉所有 running runtaskRegistrar.kill 调用每个)', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
// 让 adapter 慢一点settle 期间 run 仍在 running
const slowPorts = {
...ports,
agentAdapterRegistry: {
resolve: () => ({
id: 'claude-code',
capabilities: { structuredOutput: true },
run: async (): Promise<AgentRunResult> => {
await new Promise(r => setTimeout(r, 200))
return { kind: 'ok', output: 'slow', usage: { outputTokens: 1 } }
},
}),
},
} as unknown as typeof ports
const slowSvc = makeService(slowPorts, store)
const { runId: a } = await slowSvc.launch(
{ script: `return agent('a')` },
stubTUC,
stubCanUseTool,
)
const { runId: b } = await slowSvc.launch(
{ script: `return agent('b')` },
stubTUC,
stubCanUseTool,
)
killed.length = 0
slowSvc.shutdown()
expect(killed).toContain(a)
expect(killed).toContain(b)
})
test('shutdown 不重复杀已完成 run幂等多次调用安全', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('x')` },
stubTUC,
stubCanUseTool,
)
await settle() // 完成
killed.length = 0
svc.shutdown()
// 已完成的不应再被 kill
expect(killed).not.toContain(runId)
// 幂等
expect(() => svc.shutdown()).not.toThrow()
})

View File

@@ -0,0 +1,75 @@
import { expect, test } from 'bun:test'
import type { AgentProgress, RunProgress } from '../progress/store.js'
import {
STATUS_DOT,
RUN_STATUS_COLOR,
PHASE_MARK,
PHASE_COLOR,
agentVisual,
} from '../panel/status.js'
test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => {
const statuses: RunProgress['status'][] = [
'running',
'completed',
'failed',
'killed',
]
for (const s of statuses) {
expect(STATUS_DOT[s].length).toBeGreaterThan(0)
expect(RUN_STATUS_COLOR[s]).toBeTruthy()
}
expect(STATUS_DOT.running).toBe('●')
expect(STATUS_DOT.completed).toBe('✓')
expect(STATUS_DOT.failed).toBe('✗')
expect(STATUS_DOT.killed).toBe('■')
})
test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
expect(PHASE_MARK.running).toBe('●')
expect(PHASE_MARK.done).toBe('✓')
expect(PHASE_MARK.pending).toBe('○')
expect(PHASE_COLOR.pending).toBe('subtle')
})
test('agentVisualrunning → ● warning running', () => {
const a: AgentProgress = { id: 1, status: 'running' }
expect(agentVisual(a)).toEqual({
mark: '●',
color: 'warning',
suffix: 'running',
})
})
test('agentVisualdone·object → ✓ success object', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'object',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'object',
})
})
test('agentVisualdone·text → ✓ success text', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'text',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'text',
})
})
test('agentVisualdead → ✗ error dead', () => {
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
})

View File

@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js'
test('Tab → nextTabShift+Tab → prevTab', () => {
expect(routeWorkflowKey('', { tab: true })).toBe('nextTab')
expect(routeWorkflowKey('', { tab: true, shift: true })).toBe('prevTab')
})
test('q / Esc → quit', () => {
expect(routeWorkflowKey('q', {})).toBe('quit')
expect(routeWorkflowKey('', { escape: true })).toBe('quit')
})
test('x → killr → resumen → newRun', () => {
expect(routeWorkflowKey('x', {})).toBe('kill')
expect(routeWorkflowKey('r', {})).toBe('resume')
expect(routeWorkflowKey('n', {})).toBe('newRun')
})
test('←/→ 切焦点列;↑/↓ 列内移动', () => {
expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft')
expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight')
expect(routeWorkflowKey('', { upArrow: true })).toBe('moveUp')
expect(routeWorkflowKey('', { downArrow: true })).toBe('moveDown')
})
test('无关输入 → null', () => {
expect(routeWorkflowKey('z', {})).toBeNull()
expect(routeWorkflowKey('', {})).toBeNull()
})