mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
100
src/workflow/__tests__/WorkflowsPanel.test.tsx
Normal file
100
src/workflow/__tests__/WorkflowsPanel.test.tsx
Normal 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('✗');
|
||||
});
|
||||
|
||||
// 修复 M:useSyncExternalStore / 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');
|
||||
});
|
||||
142
src/workflow/__tests__/claudeCodeBackend.test.ts
Normal file
142
src/workflow/__tests__/claudeCodeBackend.test.ts
Normal 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()
|
||||
})
|
||||
175
src/workflow/__tests__/notifications.test.ts
Normal file
175
src/workflow/__tests__/notifications.test.ts
Normal 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),
|
||||
)
|
||||
|
||||
// 第一次 emit:listener 记录初始 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)
|
||||
})
|
||||
})
|
||||
109
src/workflow/__tests__/ports.test.ts
Normal file
109
src/workflow/__tests__/ports.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。
|
||||
// 原因:mock.module 是进程全局的(last-write-wins),mock 这些公共模块会污染
|
||||
// 同进程其他测试(如 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_started,store 能看到
|
||||
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 对象承载 tasks,registerTask 走真实代码路径。
|
||||
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 回归', () => {
|
||||
// 历史 bug:hostFactory.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())
|
||||
})
|
||||
23
src/workflow/__tests__/progressBus.test.ts
Normal file
23
src/workflow/__tests__/progressBus.test.ts
Normal 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()
|
||||
})
|
||||
175
src/workflow/__tests__/progressStore.test.ts
Normal file
175
src/workflow/__tests__/progressStore.test.ts
Normal 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 落地 outputShape(ok·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()
|
||||
})
|
||||
81
src/workflow/__tests__/selectors.test.ts
Normal file
81
src/workflow/__tests__/selectors.test.ts
Normal 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('filterAgentsByPhase:All / 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('tabLabel:workflow 名 + runId 后 4 位短码', () => {
|
||||
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
|
||||
})
|
||||
335
src/workflow/__tests__/service.test.ts
Normal file
335
src/workflow/__tests__/service.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
// DI 模式:不使用 mock.module(进程全局、last-write-wins,会污染同进程其他测试如
|
||||
// autonomy.test.ts)。改为手工构造 FAKE WorkflowPorts:registry.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 ports:registry.run 返回固定 AgentRunResult,taskRegistrar 带 binding,
|
||||
// journalStore 内存空实现。progressEmitter.emit → bus.emit(store 已在构造时订阅 bus)。
|
||||
// 注意:runWorkflow 自身会发 run_started/run_done;taskRegistrar 只管 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 → completed;store 出现该 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)
|
||||
// 触发 ScriptError:meta 字面量缺 description(validateMeta 要求 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 status,error 透传;
|
||||
// 或透传到 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)
|
||||
})
|
||||
|
||||
// ---- 修复 N:shutdown 清理 ----
|
||||
|
||||
test('shutdown 杀掉所有 running run(taskRegistrar.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()
|
||||
})
|
||||
75
src/workflow/__tests__/status.test.ts
Normal file
75
src/workflow/__tests__/status.test.ts
Normal 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('agentVisual:running → ● warning running', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'running' }
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '●',
|
||||
color: 'warning',
|
||||
suffix: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:done·object → ✓ success object', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'object',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'object',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:done·text → ✓ success text', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'text',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'text',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:dead → ✗ error dead', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
|
||||
})
|
||||
30
src/workflow/__tests__/useWorkflowKeyboard.test.ts
Normal file
30
src/workflow/__tests__/useWorkflowKeyboard.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js'
|
||||
|
||||
test('Tab → nextTab;Shift+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 → kill;r → resume;n → 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()
|
||||
})
|
||||
Reference in New Issue
Block a user