mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35: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:
490
packages/workflow-engine/src/__tests__/WorkflowTool.test.ts
Normal file
490
packages/workflow-engine/src/__tests__/WorkflowTool.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createWorkflowTool } from '../tool/WorkflowTool.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
|
||||
|
||||
function mockPorts(
|
||||
runsDir: string,
|
||||
results: Map<string, AgentRunResult>,
|
||||
): {
|
||||
ports: WorkflowPorts
|
||||
events: ProgressEvent[]
|
||||
runStatus: Map<string, string>
|
||||
} {
|
||||
const events: ProgressEvent[] = []
|
||||
const runStatus = new Map<string, string>()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) =>
|
||||
results.get(p.prompt) ?? { kind: 'dead' },
|
||||
},
|
||||
progressEmitter: { emit: e => void events.push(e) },
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'run-x',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete: id => void runStatus.set(id, 'completed'),
|
||||
fail: id => void runStatus.set(id, 'failed'),
|
||||
kill: id => void runStatus.set(id, 'killed'),
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: runsDir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
return { ports, events, runStatus }
|
||||
}
|
||||
|
||||
test('call 返回 launch 消息并在后台完成', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(
|
||||
dir,
|
||||
new Map([
|
||||
['compute', { kind: 'ok', output: '42', usage: { outputTokens: 1 } }],
|
||||
]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ script: `return agent('compute')` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toContain('run_id: run-x')
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('completed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call({}, undefined, undefined, undefined)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本语法错 → 返回校验错误(不进后台)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ script: `return ((` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/校验失败|Error/)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name 解析到 .claude/workflows/<name>.ts', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, '.claude', 'workflows', 'release.ts'),
|
||||
`return agent('compute')`,
|
||||
)
|
||||
const { ports, runStatus } = mockPorts(
|
||||
dir,
|
||||
new Map([
|
||||
['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }],
|
||||
]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ name: 'release' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toContain('run_id')
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('completed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => {
|
||||
const dir = '/tmp'
|
||||
const { ports } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
expect(tool.renderToolUseMessage({ name: 'release' })).toBe(
|
||||
'Workflow: release',
|
||||
)
|
||||
const block = tool.mapToolResultToToolResultBlockParam(
|
||||
{ output: 'hi' },
|
||||
'tu-1',
|
||||
)
|
||||
expect(block.tool_use_id).toBe('tu-1')
|
||||
expect(block.type).toBe('tool_result')
|
||||
expect(block.content[0]!.text).toBe('hi')
|
||||
})
|
||||
|
||||
test('scriptPath 解析到文件内容并后台执行', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const scriptFile = join(dir, 'external.ts')
|
||||
await writeFile(scriptFile, `return agent('compute')`)
|
||||
const { ports, runStatus } = mockPorts(
|
||||
dir,
|
||||
new Map([
|
||||
['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }],
|
||||
]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ scriptPath: scriptFile },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toContain('run_id')
|
||||
expect(res.data.output).toContain('external.ts')
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('completed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本运行时失败 → onFinish 路由到 fail', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{ script: `throw new Error('boom')` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('failed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('元数据方法:description/prompt/renderToolUseMessage', async () => {
|
||||
const { ports } = mockPorts('/tmp', new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
expect(tool.isEnabled()).toBe(true)
|
||||
expect(tool.isReadOnly({})).toBe(false)
|
||||
expect(await tool.description()).toBeTruthy()
|
||||
expect(await tool.prompt()).toContain('Workflow')
|
||||
expect(tool.renderToolUseMessage({})).toBe('Workflow: unknown')
|
||||
expect(tool.renderToolUseMessage({ resumeFromRunId: 'r1' })).toBe(
|
||||
'Workflow resume: r1',
|
||||
)
|
||||
})
|
||||
|
||||
test('name 不存在 → 返回错误(不进后台)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
const { ports, runStatus } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ name: 'nope' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow 被 abort → onFinish 路由 kill', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const runStatus = new Map<string, string>()
|
||||
const ac = new AbortController()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'run-x', signal: ac.signal }),
|
||||
complete: id => void runStatus.set(id, 'completed'),
|
||||
fail: id => void runStatus.set(id, 'failed'),
|
||||
kill: id => void runStatus.set(id, 'killed'),
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
ac.abort()
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{ script: `return agent('x')` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('killed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('args 为 JSON 字符串化的对象时防御性 parse(向后兼容旧 z.string() 契约)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const capturedPrompts: unknown[] = []
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) => {
|
||||
capturedPrompts.push(p.prompt)
|
||||
return { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'run-x',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{
|
||||
script: `return agent(args.commit)`,
|
||||
// 模拟旧契约下模型发送的字符串化 JSON
|
||||
args: '{"commit":"abc123"}',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
// 若 args 未归一化:args.commit === undefined(string 上无 commit 属性)
|
||||
// 若 args 归一化:args.commit === 'abc123'
|
||||
expect(capturedPrompts).toContain('abc123')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('args 为非合法 JSON 字符串时保持原值不抛', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const capturedPrompts: unknown[] = []
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) => {
|
||||
capturedPrompts.push(p.prompt)
|
||||
return { kind: 'ok', output: 'ok', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'run-x',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{
|
||||
// 脚本把 args 当字符串用:agent(args) → agent('hello')
|
||||
script: `return agent(args)`,
|
||||
args: 'hello',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
// 'hello' 不是合法 JSON,应保持为字符串
|
||||
expect(capturedPrompts).toContain('hello')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防任意文件读)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const subDir = join(dir, 'sub')
|
||||
await mkdir(subDir, { recursive: true })
|
||||
// 在 subDir 之外(dir 内)放置一个脚本
|
||||
const outsideScript = join(dir, 'outside.ts')
|
||||
await writeFile(outsideScript, `return agent('x')`)
|
||||
// host.cwd = subDir,scriptPath 是 subDir 外的绝对路径
|
||||
const { ports, runStatus } = mockPorts(subDir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ scriptPath: outsideScript },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
expect(res.data.output).toMatch(/越界|外|outside|contain/i)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)', async () => {
|
||||
const outer = await mkdtemp(join(tmpdir(), 'wf-outer-'))
|
||||
try {
|
||||
// 在 outer 根下放置 evil.ts(在 .claude/workflows 之外)
|
||||
await writeFile(join(outer, 'evil.ts'), `return agent('x')`)
|
||||
await mkdir(join(outer, '.claude', 'workflows'), { recursive: true })
|
||||
const { ports, runStatus } = mockPorts(outer, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
// name = '../../evil' → join 后逃离 workflows 目录到 outer/evil.ts
|
||||
const res = await tool.call(
|
||||
{ name: '../../evil' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(outer, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name 含路径分隔符或为绝对路径 → 拒绝', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
const { ports } = mockPorts(dir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
for (const badName of ['foo/bar', '/etc/passwd', '..', '.']) {
|
||||
const res = await tool.call(
|
||||
{ name: badName },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('returnValue 为对象 → complete(formatValue 走 JSON 分支)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{
|
||||
script: `await agent('x')\nreturn { ok: true, n: 1 }`,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
expect(runStatus.get('run-x')).toBe('completed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
155
packages/workflow-engine/src/__tests__/agentAdapter.test.ts
Normal file
155
packages/workflow-engine/src/__tests__/agentAdapter.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
AgentAdapterRegistry,
|
||||
AdapterNotFoundError,
|
||||
type AgentAdapter,
|
||||
} from '../agentAdapter.js'
|
||||
import { createHostHandle } from '../ports.js'
|
||||
import type { AgentRunParams, AgentRunResult } from '../types.js'
|
||||
|
||||
function makeAdapter(
|
||||
id: string,
|
||||
result: AgentRunResult = {
|
||||
kind: 'ok',
|
||||
output: `out-${id}`,
|
||||
usage: { outputTokens: 1 },
|
||||
},
|
||||
): AgentAdapter {
|
||||
return {
|
||||
id,
|
||||
capabilities: { structuredOutput: true },
|
||||
async run() {
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const P = (over: Partial<AgentRunParams> = {}): AgentRunParams => ({
|
||||
prompt: 'p',
|
||||
...over,
|
||||
})
|
||||
|
||||
const CTX = {
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r',
|
||||
}
|
||||
|
||||
test('resolve 默认走 default adapter,run 返回结果', async () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.register(makeAdapter('b'))
|
||||
.default('a')
|
||||
expect(reg.resolve(P()).id).toBe('a')
|
||||
const r = await reg.resolve(P()).run(P(), CTX)
|
||||
expect(r.kind).toBe('ok')
|
||||
})
|
||||
|
||||
test('route agentType 命中优先于 default', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('default'))
|
||||
.register(makeAdapter('research'))
|
||||
.route({ kind: 'agentType', agentType: 'researcher', adapter: 'research' })
|
||||
.default('default')
|
||||
expect(reg.resolve(P({ agentType: 'researcher' })).id).toBe('research')
|
||||
expect(reg.resolve(P({ agentType: 'other' })).id).toBe('default')
|
||||
})
|
||||
|
||||
test('route model 前缀匹配', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('cheap'))
|
||||
.register(makeAdapter('strong'))
|
||||
.route({ kind: 'model', pattern: 'claude-opus', adapter: 'strong' })
|
||||
.default('cheap')
|
||||
expect(reg.resolve(P({ model: 'claude-opus-4' })).id).toBe('strong')
|
||||
expect(reg.resolve(P({ model: 'claude-sonnet-4' })).id).toBe('cheap')
|
||||
expect(reg.resolve(P()).id).toBe('cheap') // 无 model → default
|
||||
})
|
||||
|
||||
test('route custom 谓词', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('main'))
|
||||
.register(makeAdapter('special'))
|
||||
.route({
|
||||
kind: 'custom',
|
||||
match: p => p.prompt.includes('VIP'),
|
||||
adapter: 'special',
|
||||
})
|
||||
.default('main')
|
||||
expect(reg.resolve(P({ prompt: 'handle VIP case' })).id).toBe('special')
|
||||
expect(reg.resolve(P({ prompt: 'normal' })).id).toBe('main')
|
||||
})
|
||||
|
||||
test('规则按顺序匹配(先命中先用)', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.register(makeAdapter('b'))
|
||||
.route({ kind: 'agentType', agentType: 'x', adapter: 'a' })
|
||||
.route({ kind: 'agentType', agentType: 'x', adapter: 'b' })
|
||||
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('a')
|
||||
})
|
||||
|
||||
test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('real'))
|
||||
.route({ kind: 'agentType', agentType: 'x', adapter: 'ghost' })
|
||||
.route({ kind: 'agentType', agentType: 'x', adapter: 'real' })
|
||||
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('real')
|
||||
})
|
||||
|
||||
test('无匹配且无 default → AdapterNotFoundError', () => {
|
||||
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
|
||||
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
|
||||
})
|
||||
|
||||
test('default 指向未注册的 adapter → 仍抛(不静默回退)', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.default('missing')
|
||||
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
|
||||
})
|
||||
|
||||
test('has / get', () => {
|
||||
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
|
||||
expect(reg.has('a')).toBe(true)
|
||||
expect(reg.has('b')).toBe(false)
|
||||
expect(reg.get('a')?.id).toBe('a')
|
||||
expect(reg.get('b')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async () => {
|
||||
const events: string[] = []
|
||||
const withLifecycle: AgentAdapter = {
|
||||
id: 'a',
|
||||
capabilities: { structuredOutput: false },
|
||||
async run() {
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
async initialize() {
|
||||
events.push('init-a')
|
||||
},
|
||||
async dispose() {
|
||||
events.push('dispose-a')
|
||||
},
|
||||
}
|
||||
const noLifecycle = makeAdapter('b') // 无 initialize/dispose
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(withLifecycle)
|
||||
.register(noLifecycle)
|
||||
await reg.initializeAll()
|
||||
await reg.disposeAll()
|
||||
expect(events).toEqual(['init-a', 'dispose-a'])
|
||||
})
|
||||
|
||||
test('capabilities 声明可读', () => {
|
||||
const adapter: AgentAdapter = {
|
||||
id: 'a',
|
||||
capabilities: { structuredOutput: true, tools: true, stream: false },
|
||||
async run() {
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
}
|
||||
expect(adapter.capabilities.structuredOutput).toBe(true)
|
||||
expect(adapter.capabilities.tools).toBe(true)
|
||||
expect(adapter.capabilities.stream).toBe(false)
|
||||
})
|
||||
94
packages/workflow-engine/src/__tests__/agentId.test.ts
Normal file
94
packages/workflow-engine/src/__tests__/agentId.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { createEngineContext } from '../engine/context.js'
|
||||
import { makeHooks } from '../engine/hooks.js'
|
||||
import { createBufferingEmitter } from '../progress/events.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
import type { AgentRunParams, AgentRunResult } from '../types.js'
|
||||
|
||||
function build(results: Map<string, AgentRunResult>) {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) =>
|
||||
results.get(p.prompt) ?? { kind: 'dead' },
|
||||
},
|
||||
progressEmitter: emitter,
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const ctx = createEngineContext({
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
})
|
||||
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
|
||||
}
|
||||
|
||||
test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () => {
|
||||
const ok = (out: string): AgentRunResult => ({
|
||||
kind: 'ok',
|
||||
output: out,
|
||||
usage: { outputTokens: 1 },
|
||||
})
|
||||
const { ctx, events, hooks } = build(
|
||||
new Map([
|
||||
['a', ok('1')],
|
||||
['b', ok('2')],
|
||||
]),
|
||||
)
|
||||
await hooks.parallel([() => hooks.agent('a'), () => hooks.agent('b')])
|
||||
const started = events.filter(e => e.type === 'agent_started')
|
||||
const done = events.filter(e => e.type === 'agent_done')
|
||||
expect(started).toHaveLength(2)
|
||||
expect(done).toHaveLength(2)
|
||||
const ids = started.map(e => (e as { agentId: number }).agentId)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
for (const d of done as Array<{ agentId: number }>) {
|
||||
expect(ids).toContain(d.agentId)
|
||||
}
|
||||
expect(ctx.resources.agentIdSeq.value).toBe(2)
|
||||
})
|
||||
|
||||
test('agentId 单调递增', async () => {
|
||||
const ok = (out: string): AgentRunResult => ({
|
||||
kind: 'ok',
|
||||
output: out,
|
||||
usage: { outputTokens: 1 },
|
||||
})
|
||||
const { events, hooks } = build(
|
||||
new Map([
|
||||
['a', ok('1')],
|
||||
['b', ok('2')],
|
||||
['c', ok('3')],
|
||||
]),
|
||||
)
|
||||
await hooks.agent('a')
|
||||
await hooks.agent('b')
|
||||
await hooks.agent('c')
|
||||
const ids = events
|
||||
.filter(e => e.type === 'agent_started')
|
||||
.map(e => (e as { agentId: number }).agentId)
|
||||
expect(ids).toEqual([0, 1, 2])
|
||||
})
|
||||
29
packages/workflow-engine/src/__tests__/budget.test.ts
Normal file
29
packages/workflow-engine/src/__tests__/budget.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Budget, BudgetExhaustedError } from '../engine/budget.js'
|
||||
|
||||
test('total=null 时无限制', () => {
|
||||
const b = new Budget(null)
|
||||
expect(b.total).toBeNull()
|
||||
expect(b.remaining()).toBe(Infinity)
|
||||
b.addOutputTokens(999999)
|
||||
expect(b.spent()).toBe(999999)
|
||||
expect(() => b.assertCanSpend()).not.toThrow()
|
||||
})
|
||||
|
||||
test('累加并触顶抛错', () => {
|
||||
const b = new Budget(100)
|
||||
expect(b.remaining()).toBe(100)
|
||||
b.addOutputTokens(40)
|
||||
expect(b.spent()).toBe(40)
|
||||
expect(b.remaining()).toBe(60)
|
||||
expect(() => b.assertCanSpend()).not.toThrow()
|
||||
b.addOutputTokens(60)
|
||||
expect(b.spent()).toBe(100)
|
||||
expect(() => b.assertCanSpend()).toThrow(BudgetExhaustedError)
|
||||
})
|
||||
|
||||
test('addOutputTokens 负值忽略', () => {
|
||||
const b = new Budget(100)
|
||||
b.addOutputTokens(-50)
|
||||
expect(b.spent()).toBe(0)
|
||||
})
|
||||
100
packages/workflow-engine/src/__tests__/concurrency.test.ts
Normal file
100
packages/workflow-engine/src/__tests__/concurrency.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Semaphore, maxConcurrency } from '../engine/concurrency.js'
|
||||
|
||||
test('Semaphore 限制并发,permit 转移不泄漏', async () => {
|
||||
const sem = new Semaphore(2)
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const task = async (): Promise<void> => {
|
||||
const release = await sem.acquire()
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 10)
|
||||
})
|
||||
active--
|
||||
release()
|
||||
}
|
||||
await Promise.all(Array.from({ length: 6 }, () => task()))
|
||||
expect(peak).toBe(2) // 永不超过 permits
|
||||
})
|
||||
|
||||
test('maxConcurrency 落在 [1, 16]', () => {
|
||||
const n = maxConcurrency()
|
||||
expect(n).toBeGreaterThanOrEqual(1)
|
||||
expect(n).toBeLessThanOrEqual(16)
|
||||
})
|
||||
|
||||
test('Semaphore(0) 至少 1 permit,acquire 不阻塞', async () => {
|
||||
const sem = new Semaphore(0)
|
||||
const release = await sem.acquire()
|
||||
expect(release).toBeTypeOf('function')
|
||||
release()
|
||||
})
|
||||
|
||||
test('Semaphore 唤醒按 FIFO 顺序', async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const order: string[] = []
|
||||
const first = await sem.acquire()
|
||||
const p1 = sem.acquire().then(r => {
|
||||
order.push('p1')
|
||||
return r
|
||||
})
|
||||
const p2 = sem.acquire().then(r => {
|
||||
order.push('p2')
|
||||
return r
|
||||
})
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
expect(order).toEqual([])
|
||||
first()
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
expect(order).toEqual(['p1'])
|
||||
;(await p1)()
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
expect(order).toEqual(['p1', 'p2'])
|
||||
;(await p2)()
|
||||
})
|
||||
|
||||
test('Semaphore.acquire 传 aborted signal → 立即 reject,不消耗 permit', async () => {
|
||||
// 修复 L:queued waiter 在 abort 时必须立即 reject 而非等 permit。
|
||||
// 否则一个被取消的 agent 阻塞在 acquire(),permit 被消耗(transfer 给已死的 waiter),
|
||||
// 实际并发能力降低;最坏情况下所有 waiter 都被取消,semaphore 还在排队等死掉的 waiter。
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
|
||||
// 占用唯一 permit
|
||||
const first = await sem.acquire()
|
||||
|
||||
// 排队的 waiter
|
||||
const queued = sem.acquire(ac.signal)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
|
||||
// abort → waiter 应立即 reject
|
||||
ac.abort()
|
||||
await expect(queued).rejects.toThrow()
|
||||
|
||||
// permit 无泄漏:释放 first 后,新 acquire 应能立即拿到(无 stale waiter 抢占)
|
||||
first()
|
||||
const third = await sem.acquire()
|
||||
expect(third).toBeTypeOf('function')
|
||||
third()
|
||||
})
|
||||
|
||||
test('Semaphore.acquire 传已 aborted 的 signal → 同步 reject', async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
// 信号已 aborted,即使有 permit 也不应 acquire(语义:调用者已取消)
|
||||
// 注意:当前实现先看 available,可能直接返回。本测试 lock "先 check abort"。
|
||||
// 若实现选择"permit 可用时优先发放",则此测试改为:acquire 成功,调用者后续检查 abort。
|
||||
// 当前实现选择前者:aborted signal 立即抛错,避免已死 agent 拿 permit。
|
||||
await expect(sem.acquire(ac.signal)).rejects.toThrow()
|
||||
})
|
||||
76
packages/workflow-engine/src/__tests__/context.test.ts
Normal file
76
packages/workflow-engine/src/__tests__/context.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { createBufferingEmitter } from '../progress/events.js'
|
||||
import {
|
||||
createEngineContext,
|
||||
createSharedResources,
|
||||
} from '../engine/context.js'
|
||||
import { WorkflowError } from '../engine/errors.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
|
||||
function mockPorts(): WorkflowPorts {
|
||||
return {
|
||||
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
test('createSharedResources 初始化预算与计数', () => {
|
||||
const r = createSharedResources(100)
|
||||
expect(r.budget.total).toBe(100)
|
||||
expect(r.agentCountBox.value).toBe(0)
|
||||
expect(r.depth).toBe(0)
|
||||
})
|
||||
|
||||
test('createEngineContext 复制 journal 并重置游标', () => {
|
||||
const journal = [
|
||||
{
|
||||
key: 'k',
|
||||
seq: 0,
|
||||
result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } },
|
||||
},
|
||||
]
|
||||
const ctx = createEngineContext({
|
||||
ports: mockPorts(),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r1',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
journal,
|
||||
})
|
||||
expect(ctx.journal).toHaveLength(1)
|
||||
expect(ctx.journalIndex).toBe(0)
|
||||
expect(ctx.journalInvalidated).toBe(false)
|
||||
})
|
||||
|
||||
test('createBufferingEmitter 收集事件', () => {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
emitter.emit({ type: 'log', runId: 'r', message: 'hi' })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('WorkflowError 可识别', () => {
|
||||
const e = new WorkflowError('boom')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('boom')
|
||||
})
|
||||
39
packages/workflow-engine/src/__tests__/errors.test.ts
Normal file
39
packages/workflow-engine/src/__tests__/errors.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js'
|
||||
|
||||
test('WorkflowError 携带消息与 name', () => {
|
||||
const e = new WorkflowError('脚本错误')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('脚本错误')
|
||||
expect(e.name).toBe('WorkflowError')
|
||||
})
|
||||
|
||||
test('WorkflowAbortedError 是可识别的取消错误', () => {
|
||||
const e = new WorkflowAbortedError()
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.name).toBe('WorkflowAbortedError')
|
||||
expect(e.message).toBeTruthy()
|
||||
})
|
||||
|
||||
test('两类错误可被 instanceof 区分(互不混淆)', () => {
|
||||
const a = new WorkflowError('x')
|
||||
const b = new WorkflowAbortedError()
|
||||
expect(a).toBeInstanceOf(WorkflowError)
|
||||
expect(a).not.toBeInstanceOf(WorkflowAbortedError)
|
||||
expect(b).toBeInstanceOf(WorkflowAbortedError)
|
||||
expect(b).not.toBeInstanceOf(WorkflowError)
|
||||
})
|
||||
|
||||
test('可作为普通 Error 在 catch 中捕获', () => {
|
||||
const throwIt = (): never => {
|
||||
throw new WorkflowAbortedError()
|
||||
}
|
||||
let caught: unknown = null
|
||||
try {
|
||||
throwIt()
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
expect(caught).toBeInstanceOf(WorkflowAbortedError)
|
||||
})
|
||||
51
packages/workflow-engine/src/__tests__/events.test.ts
Normal file
51
packages/workflow-engine/src/__tests__/events.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
createBufferingEmitter,
|
||||
createProgressEmitter,
|
||||
} from '../progress/events.js'
|
||||
import type { ProgressEvent } from '../types.js'
|
||||
|
||||
const log = (message: string): ProgressEvent =>
|
||||
({ type: 'log', runId: 'r', message }) as ProgressEvent
|
||||
const phase = (p: string): ProgressEvent =>
|
||||
({ type: 'phase_started', runId: 'r', phase: p }) as ProgressEvent
|
||||
|
||||
test('createBufferingEmitter 按序收集所有事件', () => {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
emitter.emit(log('a'))
|
||||
emitter.emit(phase('P'))
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[0]).toEqual(log('a'))
|
||||
expect(events[1]).toEqual(phase('P'))
|
||||
})
|
||||
|
||||
test('createBufferingEmitter emit 返回 void(无返回值)', () => {
|
||||
const { emitter } = createBufferingEmitter()
|
||||
expect(emitter.emit(log('x'))).toBeUndefined()
|
||||
})
|
||||
|
||||
test('createBufferingEmitter 各自独立(不共享缓冲)', () => {
|
||||
const a = createBufferingEmitter()
|
||||
const b = createBufferingEmitter()
|
||||
a.emitter.emit(log('1'))
|
||||
expect(a.events).toHaveLength(1)
|
||||
expect(b.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('createProgressEmitter 转发事件到回调(按序、不缓冲)', () => {
|
||||
const received: ProgressEvent[] = []
|
||||
const emitter = createProgressEmitter(e => void received.push(e))
|
||||
emitter.emit(log('a'))
|
||||
emitter.emit(log('b'))
|
||||
expect(received).toEqual([log('a'), log('b')])
|
||||
})
|
||||
|
||||
test('createProgressEmitter 回调同步触发', () => {
|
||||
let seen = ''
|
||||
const emitter = createProgressEmitter(e => {
|
||||
seen = (e as { message: string }).message
|
||||
})
|
||||
emitter.emit(log('sync'))
|
||||
// emit 返回前回调已执行
|
||||
expect(seen).toBe('sync')
|
||||
})
|
||||
426
packages/workflow-engine/src/__tests__/hooks.test.ts
Normal file
426
packages/workflow-engine/src/__tests__/hooks.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { AgentAdapterRegistry } from '../agentAdapter.js'
|
||||
import { createEngineContext } from '../engine/context.js'
|
||||
import { maxConcurrency, Semaphore } from '../engine/concurrency.js'
|
||||
import { agentCallKey } from '../engine/journal.js'
|
||||
import { makeHooks, type SubWorkflowRunner } from '../engine/hooks.js'
|
||||
import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js'
|
||||
import { createBufferingEmitter } from '../progress/events.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
import type {
|
||||
AgentRunParams,
|
||||
AgentRunResult,
|
||||
JournalEntry,
|
||||
ProgressEvent,
|
||||
} from '../types.js'
|
||||
|
||||
type CtxOverrides = Partial<{
|
||||
agentResults: Map<string, AgentRunResult>
|
||||
runner: (params: AgentRunParams) => Promise<AgentRunResult>
|
||||
pending: { kind: 'skip' | 'retry' } | null
|
||||
journal: JournalEntry[]
|
||||
budgetTotal: number | null
|
||||
signal: AbortSignal
|
||||
truncated: string[]
|
||||
agentAdapterRegistry: AgentAdapterRegistry
|
||||
loggerWarn: (msg: string) => void
|
||||
}>
|
||||
|
||||
function buildCtx(overrides: CtxOverrides = {}): {
|
||||
ctx: ReturnType<typeof createEngineContext>
|
||||
events: ProgressEvent[]
|
||||
hooks: ReturnType<typeof makeHooks>
|
||||
} {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
const results = overrides.agentResults ?? new Map<string, AgentRunResult>()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: overrides.runner
|
||||
? overrides.runner
|
||||
: async (params: AgentRunParams) =>
|
||||
results.get(params.prompt) ?? { kind: 'dead' },
|
||||
},
|
||||
...(overrides.agentAdapterRegistry
|
||||
? { agentAdapterRegistry: overrides.agentAdapterRegistry }
|
||||
: {}),
|
||||
progressEmitter: emitter,
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => overrides.pending ?? null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async (id: string) => {
|
||||
overrides.truncated?.push(id)
|
||||
},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: {
|
||||
debug: () => {},
|
||||
event: () => {},
|
||||
...(overrides.loggerWarn ? { warn: overrides.loggerWarn } : {}),
|
||||
},
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const ctx = createEngineContext({
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: overrides.signal ?? new AbortController().signal,
|
||||
runId: 'r1',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: overrides.budgetTotal ?? null,
|
||||
journal: overrides.journal,
|
||||
})
|
||||
const noopSub: SubWorkflowRunner = async () => null
|
||||
return { ctx, events, hooks: makeHooks(ctx, noopSub) }
|
||||
}
|
||||
|
||||
test('agent 返回文本结果并计数', async () => {
|
||||
const { ctx, hooks } = buildCtx({
|
||||
agentResults: new Map([
|
||||
['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }],
|
||||
]),
|
||||
})
|
||||
const out = await hooks.agent('hi')
|
||||
expect(out).toBe('hello')
|
||||
expect(ctx.resources.agentCountBox.value).toBe(1)
|
||||
})
|
||||
|
||||
test('agent skipped → null 且不计数', async () => {
|
||||
const { hooks } = buildCtx({
|
||||
agentResults: new Map([['hi', { kind: 'skipped' }]]),
|
||||
})
|
||||
expect(await hooks.agent('hi')).toBeNull()
|
||||
})
|
||||
|
||||
test('agent dead → null', async () => {
|
||||
const { hooks } = buildCtx({
|
||||
agentResults: new Map([['hi', { kind: 'dead' }]]),
|
||||
})
|
||||
expect(await hooks.agent('hi')).toBeNull()
|
||||
})
|
||||
|
||||
test('agent journal 命中时不调用 runner', async () => {
|
||||
let called = 0
|
||||
const { emitter } = createBufferingEmitter()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
called++
|
||||
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: emitter,
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const key = agentCallKey('hi', { prompt: 'hi' })
|
||||
const ctx = createEngineContext({
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r1',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
journal: [
|
||||
{
|
||||
key,
|
||||
seq: 0,
|
||||
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
|
||||
},
|
||||
],
|
||||
})
|
||||
const hooks = makeHooks(ctx, async () => null)
|
||||
expect(await hooks.agent('hi')).toBe('cached')
|
||||
expect(called).toBe(0)
|
||||
})
|
||||
|
||||
test('agent 超过总数上限抛错', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.agentCountBox.value = 1000
|
||||
await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('parallel 单项抛错 → null,其余保留', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
const out = await hooks.parallel([
|
||||
async () => 'a',
|
||||
async () => {
|
||||
throw new Error('x')
|
||||
},
|
||||
async () => 'c',
|
||||
])
|
||||
expect(out).toEqual(['a', null, 'c'])
|
||||
})
|
||||
|
||||
test('parallel 单项抛错 → logger.warn 记录失败原因', async () => {
|
||||
const warns: string[] = []
|
||||
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
|
||||
await hooks.parallel([
|
||||
async () => 'a',
|
||||
async () => {
|
||||
throw new Error('boom-x')
|
||||
},
|
||||
async () => 'c',
|
||||
])
|
||||
expect(warns.length).toBe(1)
|
||||
expect(warns[0]).toMatch(/boom-x/)
|
||||
})
|
||||
|
||||
test('pipeline 逐 stage 链式,stage 抛错 → null', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
const out = await hooks.pipeline(
|
||||
[1, 2],
|
||||
n => Promise.resolve((n as number) + 1),
|
||||
m => Promise.resolve((m as number) * 10),
|
||||
)
|
||||
expect(out).toEqual([20, 30])
|
||||
const out2 = await hooks.pipeline(
|
||||
[1],
|
||||
() => Promise.reject(new Error('boom')),
|
||||
m => Promise.resolve(m),
|
||||
)
|
||||
expect(out2).toEqual([null])
|
||||
})
|
||||
|
||||
test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => {
|
||||
const warns: string[] = []
|
||||
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
|
||||
await hooks.pipeline(
|
||||
[1],
|
||||
() => Promise.reject(new Error('stage-boom')),
|
||||
m => Promise.resolve(m),
|
||||
)
|
||||
expect(warns.length).toBe(1)
|
||||
expect(warns[0]).toMatch(/stage-boom/)
|
||||
})
|
||||
|
||||
test('pipeline 超 4096 抛错', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.pipeline(Array(4097), () => Promise.resolve(1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('phase 切换发射 phase_started/done;log 发射 log', () => {
|
||||
const { hooks, events } = buildCtx()
|
||||
hooks.phase('A')
|
||||
hooks.log('hello')
|
||||
hooks.phase('B')
|
||||
expect(events.some(e => e.type === 'phase_started' && e.phase === 'A')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(events.some(e => e.type === 'phase_done' && e.phase === 'A')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(events.some(e => e.type === 'log' && e.message === 'hello')).toBe(true)
|
||||
expect(events.some(e => e.type === 'phase_started' && e.phase === 'B')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
// ---- 边界与错误路径 ----
|
||||
|
||||
test('agent dead 也计入 agentCountBox', async () => {
|
||||
const { hooks, ctx } = buildCtx({
|
||||
agentResults: new Map([['x', { kind: 'dead' }]]),
|
||||
})
|
||||
await hooks.agent('x')
|
||||
expect(ctx.resources.agentCountBox.value).toBe(1)
|
||||
})
|
||||
|
||||
test('agent pendingAction=skip → null、不调 runner、不计数', async () => {
|
||||
let called = 0
|
||||
const { hooks, ctx } = buildCtx({
|
||||
pending: { kind: 'skip' },
|
||||
runner: async () => {
|
||||
called++
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
expect(await hooks.agent('x')).toBeNull()
|
||||
expect(called).toBe(0)
|
||||
expect(ctx.resources.agentCountBox.value).toBe(0)
|
||||
})
|
||||
|
||||
test('agent journal key 发散 → invalidate 并 truncate', async () => {
|
||||
const truncated: string[] = []
|
||||
const { hooks, ctx } = buildCtx({
|
||||
runner: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'live',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
journal: [
|
||||
{
|
||||
key: 'stale-key',
|
||||
seq: 0,
|
||||
result: { kind: 'ok', output: 'old', usage: { outputTokens: 1 } },
|
||||
},
|
||||
],
|
||||
truncated,
|
||||
})
|
||||
const out = await hooks.agent('different-prompt')
|
||||
expect(out).toBe('live')
|
||||
expect(truncated).toContain('r1')
|
||||
expect(ctx.journalInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
test('agent 预算耗尽时抛错', async () => {
|
||||
const { hooks, ctx } = buildCtx({
|
||||
budgetTotal: 10,
|
||||
runner: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
})
|
||||
ctx.resources.budget.addOutputTokens(10)
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新 spent)', async () => {
|
||||
// 当 semaphore capacity < parallel agent 数时,部分 agent 会排队。
|
||||
// 旧 bug:assertCanSpend 在 acquire 之前,所有 waiter 入队时 spent=0 都过检;
|
||||
// 后续 permit 释放后 waiter 直接跑 runner、扣预算,不再 re-check → 全部超支。
|
||||
// 修复:assertCanSpend 移入临界区,waiter 被唤醒后先看 spent 再决定是否跑。
|
||||
// 强制 capacity=1(serializing semaphore)确保 N>1 个 agent 必须排队。
|
||||
const { hooks, ctx } = buildCtx({
|
||||
budgetTotal: 10,
|
||||
runner: async () => {
|
||||
// 让 runner 慢一点,确保 waiter 真的排队
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 6 }, // 每次 6 token,2 次即超 10
|
||||
}
|
||||
},
|
||||
})
|
||||
// 用单 permit semaphore 替换默认的,强制序列化
|
||||
ctx.resources.semaphore = new Semaphore(1)
|
||||
const results = await hooks.parallel([
|
||||
() => hooks.agent('a'),
|
||||
() => hooks.agent('b'),
|
||||
() => hooks.agent('c'),
|
||||
() => hooks.agent('d'),
|
||||
])
|
||||
// 至少 1 个 agent 被 parallel catch 成 null(assertCanSpend 抛错)
|
||||
expect(results.some(r => r === null)).toBe(true)
|
||||
// 不应 4 个全跑扣 24;上限是 at-most-one-over(前两个扣 12,后两个被拦)
|
||||
expect(ctx.resources.budget.spent()).toBeLessThanOrEqual(12)
|
||||
})
|
||||
|
||||
test('agent signal aborted → WorkflowAbortedError', async () => {
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
const { hooks } = buildCtx({
|
||||
signal: ac.signal,
|
||||
runner: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
})
|
||||
await expect(hooks.agent('x')).rejects.toThrow(WorkflowAbortedError)
|
||||
})
|
||||
|
||||
test('parallel 超过 4096 项抛错', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('workflow() 嵌套超过一层抛错', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.depth = 1
|
||||
await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () => {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
active--
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
await hooks.parallel(Array.from({ length: 32 }, () => () => hooks.agent('p')))
|
||||
expect(peak).toBeLessThanOrEqual(maxConcurrency())
|
||||
})
|
||||
|
||||
test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter)', async () => {
|
||||
const called: string[] = []
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
id: 'ad',
|
||||
capabilities: { structuredOutput: true },
|
||||
async run() {
|
||||
called.push('adapter')
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: 'from-adapter',
|
||||
usage: { outputTokens: 1 },
|
||||
}
|
||||
},
|
||||
})
|
||||
.default('ad')
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
runner: async () => {
|
||||
called.push('runner')
|
||||
return { kind: 'ok', output: 'from-runner', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
expect(await hooks.agent('x')).toBe('from-adapter')
|
||||
expect(called).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)', async () => {
|
||||
const registry = new AgentAdapterRegistry().default('missing') // 未注册
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
runner: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
})
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
88
packages/workflow-engine/src/__tests__/index.test.ts
Normal file
88
packages/workflow-engine/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import * as wf from '../index.js'
|
||||
|
||||
test('引擎核心 API 完整导出', () => {
|
||||
expect(typeof wf.runWorkflow).toBe('function')
|
||||
expect(typeof wf.parseScript).toBe('function')
|
||||
expect(typeof wf.extractMeta).toBe('function')
|
||||
expect(typeof wf.makeHooks).toBe('function')
|
||||
expect(typeof wf.createEngineContext).toBe('function')
|
||||
expect(typeof wf.createSharedResources).toBe('function')
|
||||
})
|
||||
|
||||
test('端口 / host API 完整导出', () => {
|
||||
expect(typeof wf.createHostHandle).toBe('function')
|
||||
expect(typeof wf.isHostHandle).toBe('function')
|
||||
expect(typeof wf.unwrapHostHandle).toBe('function')
|
||||
})
|
||||
|
||||
test('持久化 / 结构化 / 命名 workflow / 进度 API 完整导出', () => {
|
||||
expect(typeof wf.createFileJournalStore).toBe('function')
|
||||
expect(typeof wf.agentCallKey).toBe('function')
|
||||
expect(typeof wf.validateAgainstSchema).toBe('function')
|
||||
expect(typeof wf.resolveNamedWorkflow).toBe('function')
|
||||
expect(typeof wf.listNamedWorkflows).toBe('function')
|
||||
expect(typeof wf.createBufferingEmitter).toBe('function')
|
||||
expect(typeof wf.createProgressEmitter).toBe('function')
|
||||
})
|
||||
|
||||
test('并发 / 预算 / 错误类完整导出', () => {
|
||||
expect(typeof wf.Semaphore).toBe('function')
|
||||
expect(typeof wf.maxConcurrency).toBe('function')
|
||||
expect(typeof wf.Budget).toBe('function')
|
||||
expect(typeof wf.BudgetExhaustedError).toBe('function')
|
||||
expect(typeof wf.WorkflowError).toBe('function')
|
||||
expect(typeof wf.WorkflowAbortedError).toBe('function')
|
||||
expect(typeof wf.ScriptError).toBe('function')
|
||||
})
|
||||
|
||||
test('工具描述符与输入 schema 导出', () => {
|
||||
expect(typeof wf.createWorkflowTool).toBe('function')
|
||||
expect(typeof wf.workflowInputSchema).toBe('object')
|
||||
expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow')
|
||||
})
|
||||
|
||||
test('引擎常量值稳定', () => {
|
||||
expect(wf.WORKFLOW_DIR_NAME).toBe('.claude/workflows')
|
||||
expect(wf.WORKFLOW_RUNS_DIR).toBe('.claude/workflow-runs')
|
||||
expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow')
|
||||
expect(wf.MAX_TOTAL_AGENTS).toBe(1000)
|
||||
expect(wf.MAX_ITEMS_PER_CALL).toBe(4096)
|
||||
expect(wf.MAX_CONCURRENCY_CAP).toBe(16)
|
||||
expect(wf.MAX_CONCURRENCY_OFFSET).toBe(2)
|
||||
expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs'])
|
||||
})
|
||||
|
||||
test('createWorkflowTool 返回完整描述符形状', () => {
|
||||
const tool = wf.createWorkflowTool({
|
||||
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete() {},
|
||||
fail() {},
|
||||
kill() {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: wf.createHostHandle(null),
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
}),
|
||||
})
|
||||
expect(tool.name).toBe('Workflow')
|
||||
expect(tool.isEnabled()).toBe(true)
|
||||
expect(tool.isReadOnly({})).toBe(false)
|
||||
expect(typeof tool.call).toBe('function')
|
||||
expect(typeof tool.description).toBe('function')
|
||||
expect(typeof tool.prompt).toBe('function')
|
||||
expect(typeof tool.renderToolUseMessage).toBe('function')
|
||||
expect(typeof tool.mapToolResultToToolResultBlockParam).toBe('function')
|
||||
})
|
||||
282
packages/workflow-engine/src/__tests__/integration.test.ts
Normal file
282
packages/workflow-engine/src/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 集成测试:用忠实 mock adapter 跑「规范 workflow 脚本」(来自 Workflow 工具定义的
|
||||
* canonical 模式:pipeline 无屏障 + parallel 屏障 + agent(schema) + phase)。
|
||||
* 验证引擎与真实 workflow 脚本语义兼容。
|
||||
*/
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runWorkflow } from '../engine/runWorkflow.js'
|
||||
import { createFileJournalStore } from '../engine/journal.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
import { createBufferingEmitter } from '../progress/events.js'
|
||||
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
|
||||
|
||||
function canonicalPorts(runsDir: string): {
|
||||
ports: WorkflowPorts
|
||||
events: ProgressEvent[]
|
||||
agentCalls: AgentRunParams[]
|
||||
} {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
const agentCalls: AgentRunParams[] = []
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (
|
||||
params: AgentRunParams,
|
||||
): Promise<AgentRunResult> => {
|
||||
agentCalls.push(params)
|
||||
const p = params.prompt
|
||||
if (p.startsWith('review-')) {
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: { findings: [{ title: `${p}-finding`, file: 'a.ts' }] },
|
||||
usage: { outputTokens: 5 },
|
||||
}
|
||||
}
|
||||
if (p.startsWith('verify')) {
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: { isReal: true },
|
||||
usage: { outputTokens: 2 },
|
||||
}
|
||||
}
|
||||
return { kind: 'dead' }
|
||||
},
|
||||
},
|
||||
progressEmitter: emitter,
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: runsDir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
return { ports, events, agentCalls }
|
||||
}
|
||||
|
||||
// 规范 review 模式(pipeline→parallel→verify→synthesize),逐字采用 Workflow 工具定义的写法。
|
||||
const CANONICAL_REVIEW_SCRIPT = `
|
||||
export const meta = {
|
||||
name: 'review-changes',
|
||||
description: 'Review changed files across dimensions, verify each finding',
|
||||
phases: [{ title: 'Review' }, { title: 'Verify' }],
|
||||
}
|
||||
const DIMENSIONS = [
|
||||
{ key: 'bugs', prompt: 'review-bugs' },
|
||||
{ key: 'perf', prompt: 'review-perf' },
|
||||
]
|
||||
const FINDINGS_SCHEMA = { type: 'object' }
|
||||
const VERDICT_SCHEMA = { type: 'object' }
|
||||
|
||||
phase('Review')
|
||||
const results = await pipeline(
|
||||
DIMENSIONS,
|
||||
d => agent(d.prompt, { label: 'review:' + d.key, phase: 'Review', schema: FINDINGS_SCHEMA }),
|
||||
review => parallel(
|
||||
review.findings.map(f => () =>
|
||||
agent('verify: ' + f.title, { label: 'verify:' + f.file, phase: 'Verify', schema: VERDICT_SCHEMA })
|
||||
.then(v => ({ ...f, verdict: v }))
|
||||
)
|
||||
)
|
||||
)
|
||||
const all = results.flat().filter(Boolean)
|
||||
const confirmed = all.filter(f => f.verdict && f.verdict.isReal)
|
||||
return { confirmed, total: all.length }
|
||||
`
|
||||
|
||||
test('canonical review 脚本端到端兼容', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
|
||||
try {
|
||||
const { ports, events, agentCalls } = canonicalPorts(dir)
|
||||
const result = await runWorkflow({
|
||||
script: CANONICAL_REVIEW_SCRIPT,
|
||||
runId: 'int-1',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
const ret = result.returnValue as { confirmed: unknown[]; total: number }
|
||||
// 2 维度 × 1 finding,全部 isReal=true → confirmed=2, total=2
|
||||
expect(ret.total).toBe(2)
|
||||
expect(ret.confirmed).toHaveLength(2)
|
||||
// 2 个 review agent + 2 个 verify agent = 4
|
||||
expect(agentCalls).toHaveLength(4)
|
||||
expect(agentCalls.filter(c => c.prompt.startsWith('review-'))).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(agentCalls.filter(c => c.prompt.startsWith('verify'))).toHaveLength(
|
||||
2,
|
||||
)
|
||||
// 进度事件:run_started/done + phase Review/Verify + agent started/done
|
||||
expect(
|
||||
events.some(
|
||||
e => e.type === 'run_started' && e.workflowName === 'review-changes',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'completed'),
|
||||
).toBe(true)
|
||||
// 脚本显式调用一次 phase('Review');verify agent 的 phase:'Verify' 是展示标签,不发 phase_started
|
||||
expect(
|
||||
events.filter(e => e.type === 'phase_started' && e.phase === 'Review'),
|
||||
).toHaveLength(1)
|
||||
expect(events.filter(e => e.type === 'agent_started')).toHaveLength(4)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
|
||||
try {
|
||||
let round = 0
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (
|
||||
p: AgentRunParams,
|
||||
): Promise<AgentRunResult> => {
|
||||
round++
|
||||
// 第 1-2 轮返回发现,第 3 轮起返回空 → 收敛
|
||||
const found = round <= 2 ? [{ b: round }] : []
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: { bugs: found },
|
||||
usage: { outputTokens: 1 },
|
||||
}
|
||||
},
|
||||
},
|
||||
progressEmitter: emitter,
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(dir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const script = `
|
||||
const seen = []
|
||||
const confirmed = []
|
||||
let dry = 0
|
||||
while (dry < 2) {
|
||||
const found = (await agent('find bugs')).bugs
|
||||
const fresh = found.filter(b => !seen.includes(b.b))
|
||||
if (fresh.length === 0) { dry++; continue }
|
||||
dry = 0
|
||||
for (const b of fresh) seen.push(b.b)
|
||||
confirmed.push(...fresh)
|
||||
}
|
||||
return { confirmed }
|
||||
`
|
||||
const result = await runWorkflow({
|
||||
script,
|
||||
runId: 'int-2',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
const ret = result.returnValue as { confirmed: { b: number }[] }
|
||||
// 第1轮发现{b:1},第2轮发现{b:2}(fresh,因 seen=[1]),第3轮 found{b:3}?
|
||||
// mock 按 round 计数:round1→{b:1}, round2→{b:2}, round3→[](found空)
|
||||
// 但 round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0
|
||||
// round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → 退出
|
||||
expect(ret.confirmed).toHaveLength(2)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'completed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
|
||||
try {
|
||||
let calls = 0
|
||||
const makePorts = (): WorkflowPorts => ({
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
calls++
|
||||
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(dir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
})
|
||||
const script = `
|
||||
phase('A')
|
||||
const a = await agent('do-a')
|
||||
const b = await agent('do-b')
|
||||
return { a, b }
|
||||
`
|
||||
// 第一次运行:2 个 agent 现场跑
|
||||
const first = await runWorkflow({
|
||||
script,
|
||||
runId: 'int-3',
|
||||
ports: makePorts(),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(first.status).toBe('completed')
|
||||
expect(calls).toBe(2)
|
||||
|
||||
// resume 同 runId:journal 命中,不重跑
|
||||
calls = 0
|
||||
const resumed = await runWorkflow({
|
||||
script,
|
||||
runId: 'int-3',
|
||||
ports: makePorts(),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
resume: true,
|
||||
})
|
||||
expect(resumed.status).toBe('completed')
|
||||
expect(calls).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
113
packages/workflow-engine/src/__tests__/journal.test.ts
Normal file
113
packages/workflow-engine/src/__tests__/journal.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { agentCallKey, createFileJournalStore } from '../engine/journal.js'
|
||||
import type { AgentRunParams } from '../types.js'
|
||||
|
||||
const base: AgentRunParams = { prompt: 'do something' }
|
||||
|
||||
test('agentCallKey 对相同 prompt+params 稳定', () => {
|
||||
expect(agentCallKey('p', base)).toBe(agentCallKey('p', base))
|
||||
})
|
||||
|
||||
test('agentCallKey 随 prompt 变化', () => {
|
||||
expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base))
|
||||
})
|
||||
|
||||
test('agentCallKey 忽略纯展示字段 label/phase', () => {
|
||||
const a = agentCallKey('p', { ...base, label: 'A', phase: 'ph1' })
|
||||
const b = agentCallKey('p', { ...base, label: 'B', phase: 'ph2' })
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
test('FileJournalStore append → read 保序,truncate 清空', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-'))
|
||||
try {
|
||||
const store = createFileJournalStore(dir)
|
||||
const e1 = {
|
||||
key: 'k1',
|
||||
seq: 0,
|
||||
result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } },
|
||||
}
|
||||
const e2 = { key: 'k2', seq: 1, result: { kind: 'dead' as const } }
|
||||
await store.append('run-1', e1)
|
||||
await store.append('run-1', e2)
|
||||
const got = await store.read('run-1')
|
||||
expect(got).toHaveLength(2)
|
||||
expect(got[0]!.key).toBe('k1')
|
||||
expect(got[1]!.result.kind).toBe('dead')
|
||||
await store.truncate('run-1')
|
||||
expect(await store.read('run-1')).toEqual([])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('FileJournalStore read 按 seq 排序——parallel 完成顺序≠调用顺序时 resume 稳定', async () => {
|
||||
// 并发完成顺序不确定:append 落盘 = completion 顺序;resume 时按调用顺序
|
||||
// 匹配 key。无 seq 排序 → 不同 run 的 key 顺序不同 → 几乎所有 key mismatch →
|
||||
// 全重跑,journal 失效。修复:read() 按 seq 升序整理后再返回。
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-sort-'))
|
||||
try {
|
||||
const store = createFileJournalStore(dir)
|
||||
await store.append('r1', {
|
||||
key: 'late',
|
||||
seq: 2,
|
||||
result: { kind: 'ok', output: 'late', usage: { outputTokens: 1 } },
|
||||
})
|
||||
await store.append('r1', {
|
||||
key: 'first',
|
||||
seq: 0,
|
||||
result: { kind: 'ok', output: 'first', usage: { outputTokens: 1 } },
|
||||
})
|
||||
await store.append('r1', {
|
||||
key: 'mid',
|
||||
seq: 1,
|
||||
result: { kind: 'ok', output: 'mid', usage: { outputTokens: 1 } },
|
||||
})
|
||||
const got = await store.read('r1')
|
||||
expect(got.map(e => e.key)).toEqual(['first', 'mid', 'late'])
|
||||
expect(got.map(e => e.seq)).toEqual([0, 1, 2])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('agentCallKey 随 schema 变化', () => {
|
||||
const k0 = agentCallKey('p', { prompt: 'p' })
|
||||
const k1 = agentCallKey('p', { prompt: 'p', schema: { type: 'object' } })
|
||||
const k2 = agentCallKey('p', { prompt: 'p', schema: { type: 'array' } })
|
||||
expect(k1).not.toBe(k0)
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
test('agentCallKey 随 model 变化', () => {
|
||||
expect(agentCallKey('p', { prompt: 'p', model: 'sonnet' })).not.toBe(
|
||||
agentCallKey('p', { prompt: 'p', model: 'opus' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('agentCallKey 对 params 字段顺序稳定(canonical 排序)', () => {
|
||||
const a = agentCallKey('p', {
|
||||
prompt: 'p',
|
||||
model: 'm',
|
||||
schema: { type: 'object' },
|
||||
})
|
||||
const b = agentCallKey('p', {
|
||||
schema: { type: 'object' },
|
||||
prompt: 'p',
|
||||
model: 'm',
|
||||
})
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
test('FileJournalStore read 不存在的 run → []', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-'))
|
||||
try {
|
||||
const store = createFileJournalStore(dir)
|
||||
expect(await store.read('never-existed')).toEqual([])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
listNamedWorkflows,
|
||||
resolveNamedWorkflow,
|
||||
} from '../engine/namedWorkflows.js'
|
||||
|
||||
test('按扩展名优先级解析命名 workflow', async () => {
|
||||
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')
|
||||
await writeFile(join(dir, 'c.mjs'), 'return 3')
|
||||
await writeFile(join(dir, 'ignore.md'), '# not a workflow')
|
||||
|
||||
const a = await resolveNamedWorkflow(dir, 'a')
|
||||
expect(a?.path.endsWith('a.ts')).toBe(true)
|
||||
expect(a?.content).toContain('meta')
|
||||
|
||||
expect(await resolveNamedWorkflow(dir, 'missing')).toBeNull()
|
||||
|
||||
const names = await listNamedWorkflows(dir)
|
||||
expect(names).toEqual(['a', 'b', 'c']) // 不含 .md
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listNamedWorkflows 不存在目录返回空数组', async () => {
|
||||
expect(
|
||||
await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now())),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('resolveNamedWorkflow 在 .ts 缺失时降级到 .js/.mjs', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
|
||||
try {
|
||||
await writeFile(join(dir, 'onlyjs.js'), 'return 1')
|
||||
await writeFile(join(dir, 'onlymjs.mjs'), 'return 2')
|
||||
expect(
|
||||
(await resolveNamedWorkflow(dir, 'onlyjs'))?.path.endsWith('onlyjs.js'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
(await resolveNamedWorkflow(dir, 'onlymjs'))?.path.endsWith(
|
||||
'onlymjs.mjs',
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listNamedWorkflows 返回排序后的名字', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
|
||||
try {
|
||||
await writeFile(join(dir, 'zeta.ts'), 'return 1')
|
||||
await writeFile(join(dir, 'alpha.js'), 'return 2')
|
||||
await writeFile(join(dir, 'mid.mjs'), 'return 3')
|
||||
expect(await listNamedWorkflows(dir)).toEqual(['alpha', 'mid', 'zeta'])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
56
packages/workflow-engine/src/__tests__/paths.test.ts
Normal file
56
packages/workflow-engine/src/__tests__/paths.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { containsPath, sanitizeWorkflowName } from '../engine/paths.js'
|
||||
|
||||
test('containsPath: target 等于 base → true', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
expect(containsPath(base, base)).toBe(true)
|
||||
})
|
||||
|
||||
test('containsPath: target 在 base 内 → true', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
const target = join(base, 'b', 'c.ts')
|
||||
expect(containsPath(base, target)).toBe(true)
|
||||
})
|
||||
|
||||
test('containsPath: target 在 base 之外(前缀假阳)→ false', () => {
|
||||
// /tmp/foobar 不应被认为是 /tmp/foo 的子路径
|
||||
const base = join(tmpdir(), 'foo')
|
||||
const target = join(tmpdir(), 'foobar', 'x.ts')
|
||||
expect(containsPath(base, target)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsPath: target 用 .. 越界 → false', () => {
|
||||
const base = join(tmpdir(), 'a', 'b')
|
||||
const target = join(base, '..', 'outside.ts')
|
||||
expect(containsPath(base, target)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsPath: 相对 target 相对 base 解析', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
expect(containsPath(base, 'sub/file.ts')).toBe(true)
|
||||
expect(containsPath(base, '../b/file.ts')).toBe(false)
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: 合法标识符 → 原值', () => {
|
||||
expect(sanitizeWorkflowName('release')).toBe('release')
|
||||
expect(sanitizeWorkflowName('my-workflow')).toBe('my-workflow')
|
||||
expect(sanitizeWorkflowName('my_workflow_2')).toBe('my_workflow_2')
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: 含路径分隔符 → null', () => {
|
||||
expect(sanitizeWorkflowName('foo/bar')).toBeNull()
|
||||
expect(sanitizeWorkflowName('foo\\bar')).toBeNull()
|
||||
expect(sanitizeWorkflowName('/abs/path')).toBeNull()
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: . / .. / 空 → null', () => {
|
||||
expect(sanitizeWorkflowName('.')).toBeNull()
|
||||
expect(sanitizeWorkflowName('..')).toBeNull()
|
||||
expect(sanitizeWorkflowName('')).toBeNull()
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: 含 null 字节 → null', () => {
|
||||
expect(sanitizeWorkflowName('evil\0.ts')).toBeNull()
|
||||
})
|
||||
61
packages/workflow-engine/src/__tests__/ports.test.ts
Normal file
61
packages/workflow-engine/src/__tests__/ports.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { createHostHandle, isHostHandle, unwrapHostHandle } from '../ports.js'
|
||||
|
||||
test('createHostHandle 包装任意 bundle 且对外不透明', () => {
|
||||
const bundle = { secret: 'ctx', nested: { a: 1 } }
|
||||
const handle = createHostHandle(bundle)
|
||||
expect(isHostHandle(handle)).toBe(true)
|
||||
// 包内不暴露 bundle —— handle 只有符号标记
|
||||
expect(Object.keys(handle)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('普通对象不是 HostHandle', () => {
|
||||
expect(isHostHandle({} as unknown)).toBe(false)
|
||||
expect(isHostHandle(null)).toBe(false)
|
||||
})
|
||||
|
||||
test('端口对象满足最小形状', () => {
|
||||
// 编译期形状校验:以下赋值通过即说明端口契约自洽
|
||||
const noop = (): void => {}
|
||||
const ports = {
|
||||
agentRunner: { runAgentToResult: noop },
|
||||
progressEmitter: { emit: noop },
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete: noop,
|
||||
fail: noop,
|
||||
kill: noop,
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
append: async () => {},
|
||||
truncate: async () => {},
|
||||
},
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: noop, event: noop },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
toolUseId: 'tu-1',
|
||||
}),
|
||||
}
|
||||
expect(ports.taskRegistrar.register().runId).toBe('run-1')
|
||||
expect(ports.hostFactory().toolUseId).toBe('tu-1')
|
||||
})
|
||||
|
||||
test('unwrapHostHandle 取回原始 bundle(同引用)', () => {
|
||||
const bundle = { secret: 'ctx', nested: { a: 1 } }
|
||||
const handle = createHostHandle(bundle)
|
||||
expect(unwrapHostHandle(handle)).toBe(bundle)
|
||||
})
|
||||
|
||||
test('createHostHandle(null) 不透明且解包为 null', () => {
|
||||
const handle = createHostHandle(null)
|
||||
expect(isHostHandle(handle)).toBe(true)
|
||||
expect(unwrapHostHandle(handle)).toBeNull()
|
||||
})
|
||||
423
packages/workflow-engine/src/__tests__/runWorkflow.test.ts
Normal file
423
packages/workflow-engine/src/__tests__/runWorkflow.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runWorkflow } from '../engine/runWorkflow.js'
|
||||
import { agentCallKey, createFileJournalStore } from '../engine/journal.js'
|
||||
import { createHostHandle, type WorkflowPorts } from '../ports.js'
|
||||
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
|
||||
|
||||
function portsWith(
|
||||
runsDir: string,
|
||||
results: Map<string, AgentRunResult>,
|
||||
): WorkflowPorts {
|
||||
return {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) =>
|
||||
results.get(p.prompt) ?? { kind: 'dead' },
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: runsDir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function portsWithEvents(
|
||||
runsDir: string,
|
||||
results: Map<string, AgentRunResult>,
|
||||
): { ports: WorkflowPorts; events: ProgressEvent[] } {
|
||||
const events: ProgressEvent[] = []
|
||||
return {
|
||||
events,
|
||||
ports: {
|
||||
agentRunner: {
|
||||
runAgentToResult: async (p: AgentRunParams) =>
|
||||
results.get(p.prompt) ?? { kind: 'dead' },
|
||||
},
|
||||
progressEmitter: { emit: e => void events.push(e) },
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'r',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: runsDir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('端到端:脚本返回 agent 结果,状态 completed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(
|
||||
dir,
|
||||
new Map([
|
||||
['compute', { kind: 'ok', output: '42', usage: { outputTokens: 3 } }],
|
||||
]),
|
||||
)
|
||||
const result = await runWorkflow({
|
||||
script: `export const meta = { name: 't', description: 'd' }\nreturn agent('compute')`,
|
||||
runId: 'run-1',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.returnValue).toBe('42')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本语法错误 → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
const result = await runWorkflow({
|
||||
script: `export const meta = { name: 't', description: 'd' }\nreturn ((`,
|
||||
runId: 'run-2',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toBeTruthy()
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resume:journal 命中则不调用 runner', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let called = 0
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
called++
|
||||
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(dir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const key = agentCallKey('compute', { prompt: 'compute' })
|
||||
await ports.journalStore.append('run-3', {
|
||||
key,
|
||||
seq: 0,
|
||||
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
|
||||
})
|
||||
|
||||
const result = await runWorkflow({
|
||||
script: `return agent('compute')`,
|
||||
runId: 'run-3',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
resume: true,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.returnValue).toBe('cached')
|
||||
expect(called).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('abort → killed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
const result = await runWorkflow({
|
||||
script: `return agent('x')`,
|
||||
runId: 'run-4',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: ac.signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('killed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 嵌套(一层)共享计数', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, '.claude', 'workflows', 'child.ts'),
|
||||
`return agent('child')\n// child workflow`,
|
||||
)
|
||||
const ports = portsWith(
|
||||
dir,
|
||||
new Map([
|
||||
[
|
||||
'child',
|
||||
{ kind: 'ok', output: 'child-out', usage: { outputTokens: 1 } },
|
||||
],
|
||||
]),
|
||||
)
|
||||
const result = await runWorkflow({
|
||||
script: `return workflow('child')`,
|
||||
runId: 'run-5',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.returnValue).toBe('child-out')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 边界与事件 ----
|
||||
|
||||
test('scriptChanged=true → truncate journal 并全量现场跑', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let called = 0
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
called++
|
||||
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(dir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const key = agentCallKey('compute', { prompt: 'compute' })
|
||||
await ports.journalStore.append('run-chg', {
|
||||
key,
|
||||
seq: 0,
|
||||
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
|
||||
})
|
||||
const result = await runWorkflow({
|
||||
script: `return agent('compute')`,
|
||||
runId: 'run-chg',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
resume: true,
|
||||
scriptChanged: true,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.returnValue).toBe('live')
|
||||
expect(called).toBe(1)
|
||||
// truncate 清空了旧 cached journal,现场 agent append 新 entry(live)
|
||||
const final = await ports.journalStore.read('run-chg')
|
||||
expect(final).toHaveLength(1)
|
||||
expect((final[0]!.result as { output: string }).output).toBe('live')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本运行时抛错(非语法错)→ failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
const result = await runWorkflow({
|
||||
script: `throw new Error('boom at runtime')`,
|
||||
runId: 'run-throw',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/boom/)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('发射 run_started(含 workflowName)与 run_done 事件', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
await runWorkflow({
|
||||
script: `return agent('x')`,
|
||||
runId: 'run-ev',
|
||||
workflowName: 'my-wf',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(
|
||||
events.some(e => e.type === 'run_started' && e.workflowName === 'my-wf'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'completed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('未传 workflowName 时从 meta.name 推导', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(dir, new Map())
|
||||
await runWorkflow({
|
||||
script: `export const meta = { name: 'from-meta', description: 'd' }\nreturn 1`,
|
||||
runId: 'run-meta',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(
|
||||
events.some(
|
||||
e => e.type === 'run_started' && e.workflowName === 'from-meta',
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('budgetTotal 耗尽 → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(
|
||||
dir,
|
||||
new Map([
|
||||
['a', { kind: 'ok', output: '1', usage: { outputTokens: 5 } }],
|
||||
['b', { kind: 'ok', output: '2', usage: { outputTokens: 5 } }],
|
||||
]),
|
||||
)
|
||||
const result = await runWorkflow({
|
||||
script: `await agent('a')\nreturn agent('b')`,
|
||||
runId: 'run-budget',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: 5,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 引用语法错的子脚本 → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
await writeFile(join(dir, '.claude', 'workflows', 'broken.ts'), `return ((`)
|
||||
const ports = portsWith(dir, new Map())
|
||||
const result = await runWorkflow({
|
||||
script: `return workflow('broken')`,
|
||||
runId: 'run-sub-err',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/子 workflow|脚本错误/)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 引用不存在的 name → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
const result = await runWorkflow({
|
||||
script: `return workflow('ghost')`,
|
||||
runId: 'run-sub-missing',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/子 workflow|未找到/)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
44
packages/workflow-engine/src/__tests__/schema.test.ts
Normal file
44
packages/workflow-engine/src/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { workflowInputSchema } from '../tool/schema.js'
|
||||
|
||||
test('空对象通过(所有字段 optional)', () => {
|
||||
expect(workflowInputSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
test('全部已知字段可填', () => {
|
||||
const r = workflowInputSchema.safeParse({
|
||||
script: 'return 1',
|
||||
name: 'release',
|
||||
scriptPath: '/abs/x.ts',
|
||||
args: { n: 1 },
|
||||
resumeFromRunId: 'run-1',
|
||||
description: 'do thing',
|
||||
title: 'T',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('args 接受任意 JSON 值(对象/数组/字符串/数字/布尔/null)', () => {
|
||||
for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) {
|
||||
expect(workflowInputSchema.safeParse({ args }).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('类型错误被拒(script/name/scriptPath 非字符串)', () => {
|
||||
expect(workflowInputSchema.safeParse({ script: 123 }).success).toBe(false)
|
||||
expect(workflowInputSchema.safeParse({ name: 42 }).success).toBe(false)
|
||||
expect(workflowInputSchema.safeParse({ scriptPath: {} }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('resumeFromRunId/description/title 必须为字符串', () => {
|
||||
expect(workflowInputSchema.safeParse({ resumeFromRunId: 1 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
expect(workflowInputSchema.safeParse({ description: 1 }).success).toBe(false)
|
||||
expect(workflowInputSchema.safeParse({ title: 1 }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('未知字段被 strip(zod 默认非 strict,safeParse 成功)', () => {
|
||||
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
168
packages/workflow-engine/src/__tests__/script.test.ts
Normal file
168
packages/workflow-engine/src/__tests__/script.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
ScriptError,
|
||||
extractMeta,
|
||||
parseScript,
|
||||
type WorkflowHooks,
|
||||
} from '../engine/script.js'
|
||||
|
||||
const stubHooks: WorkflowHooks = {
|
||||
agent: async () => 'agent-result',
|
||||
parallel: async thunks =>
|
||||
Promise.all(
|
||||
thunks.map(async t => {
|
||||
try {
|
||||
return await t()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
),
|
||||
pipeline: async () => [],
|
||||
phase: () => {},
|
||||
log: () => {},
|
||||
workflow: async () => null,
|
||||
}
|
||||
|
||||
test('extractMeta 提取纯字面量并剥离语句', () => {
|
||||
const src = `export const meta = { name: 'x', description: 'y' }\nreturn 1`
|
||||
const { meta, body } = extractMeta(src)
|
||||
expect(meta?.name).toBe('x')
|
||||
expect(meta?.description).toBe('y')
|
||||
expect(body).not.toContain('export const meta')
|
||||
expect(body).toContain('return 1')
|
||||
})
|
||||
|
||||
test('extractMeta 无 meta 返回 null 且 body 不变', () => {
|
||||
const src = `return 42`
|
||||
const { meta, body } = extractMeta(src)
|
||||
expect(meta).toBeNull()
|
||||
expect(body).toBe(src)
|
||||
})
|
||||
|
||||
test('extractMeta 拒绝非纯字面量(引用变量)', () => {
|
||||
const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1`
|
||||
expect(() => extractMeta(src)).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('parseScript 执行 body 顶层 return', async () => {
|
||||
const { execute } = parseScript(`return args.n + 1`)
|
||||
const out = await execute(stubHooks, { n: 41 }, { total: null })
|
||||
expect(out).toBe(42)
|
||||
})
|
||||
|
||||
test('脚本中 Date.now() 抛非确定性错误', async () => {
|
||||
const { execute } = parseScript(`return Date.now()`)
|
||||
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
|
||||
/Date\.now/,
|
||||
)
|
||||
})
|
||||
|
||||
test('脚本中 Math.random() 抛非确定性错误', async () => {
|
||||
const { execute } = parseScript(`return Math.random()`)
|
||||
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
|
||||
/Math\.random/,
|
||||
)
|
||||
})
|
||||
|
||||
test('无参 new Date() 抛,有参 new Date() 可用', async () => {
|
||||
const bad = parseScript(`return new Date()`)
|
||||
await expect(bad.execute(stubHooks, {}, { total: null })).rejects.toThrow(
|
||||
/new Date/,
|
||||
)
|
||||
const good = parseScript(
|
||||
`return new Date('2020-06-12T00:00:00Z').getUTCFullYear()`,
|
||||
)
|
||||
await expect(good.execute(stubHooks, {}, { total: null })).resolves.toBe(2020)
|
||||
})
|
||||
|
||||
// ---- meta 校验错误分支与嵌套 ----
|
||||
|
||||
test('extractMeta meta 为数组 → ScriptError', () => {
|
||||
expect(() => extractMeta('export const meta = [1, 2]\nreturn 1')).toThrow(
|
||||
ScriptError,
|
||||
)
|
||||
})
|
||||
|
||||
test('extractMeta meta 缺 name → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { description: "d" }\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta meta 缺 description → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { name: "n" }\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta meta 大括号未闭合 → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { name: "n", description: "d"\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta 支持嵌套对象(phases 数组)', () => {
|
||||
const src = `export const meta = { name: 'x', description: 'y', phases: [{ title: 'A' }, { title: 'B' }] }\nreturn 1`
|
||||
const { meta } = extractMeta(src)
|
||||
expect(meta?.name).toBe('x')
|
||||
expect(meta?.phases).toHaveLength(2)
|
||||
expect(meta?.phases?.[0]?.title).toBe('A')
|
||||
expect(meta?.phases?.[1]?.title).toBe('B')
|
||||
})
|
||||
|
||||
test('parseScript 语法错 → ScriptError', () => {
|
||||
expect(() => parseScript('return ((')).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('parseScript 检测 import → 带指引的 ScriptError(不落泛化语法错)', () => {
|
||||
expect(() =>
|
||||
parseScript(
|
||||
`import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`,
|
||||
),
|
||||
).toThrow(ScriptError)
|
||||
expect(() =>
|
||||
parseScript(
|
||||
`import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`,
|
||||
),
|
||||
).toThrow(/不支持 import/)
|
||||
})
|
||||
|
||||
test('parseScript 检测 meta 之外的多余 export → 带指引的 ScriptError', () => {
|
||||
expect(() =>
|
||||
parseScript(
|
||||
`export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`,
|
||||
),
|
||||
).toThrow(ScriptError)
|
||||
expect(() =>
|
||||
parseScript(
|
||||
`export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`,
|
||||
),
|
||||
).toThrow(/只允许一处 export const meta/)
|
||||
})
|
||||
|
||||
test('parseScript 正常纯 JS 脚本(无 import/无多余 export)不被误拦', () => {
|
||||
const { execute } = parseScript(
|
||||
`export const meta = { name: 'n', description: 'd' }\nconst r = await agent('hi')\nreturn r`,
|
||||
)
|
||||
expect(typeof execute).toBe('function')
|
||||
})
|
||||
|
||||
test('parseScript 检测动态 import(...) → 带指引的 ScriptError(沙箱防逃逸)', () => {
|
||||
expect(() =>
|
||||
parseScript(
|
||||
`const cp = await import('node:child_process')\nreturn cp.execSync('id').toString()`,
|
||||
),
|
||||
).toThrow(ScriptError)
|
||||
expect(() =>
|
||||
parseScript(`const cp = await import('node:child_process')\nreturn cp`),
|
||||
).toThrow(/import/)
|
||||
})
|
||||
|
||||
test('parseScript 检测行中含 import 字符串字面量时不误拦(如 prompt 里出现 "import")', () => {
|
||||
// 字符串里的 import 不应被静态 regex 拦——允许 prompt 包含 "import" 词
|
||||
const { execute } = parseScript(
|
||||
`export const meta = { name: 'n', description: 'd' }\nconst r = await agent('please import this module')\nreturn r`,
|
||||
)
|
||||
expect(typeof execute).toBe('function')
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { validateAgainstSchema } from '../engine/structuredOutput.js'
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
required: ['name', 'count'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
test('合法对象通过', () => {
|
||||
const { valid, errors } = validateAgainstSchema(
|
||||
{ name: 'a', count: 1 },
|
||||
schema,
|
||||
)
|
||||
expect(valid).toBe(true)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('缺字段失败', () => {
|
||||
const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema)
|
||||
expect(valid).toBe(false)
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('类型错误失败', () => {
|
||||
const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema)
|
||||
expect(valid).toBe(false)
|
||||
})
|
||||
|
||||
test('同一 schema 复用缓存', () => {
|
||||
validateAgainstSchema({ name: 'a', count: 1 }, schema)
|
||||
// 第二次用同一 schema 对象应命中缓存(不抛错即可)
|
||||
expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
30
packages/workflow-engine/src/__tests__/types.test.ts
Normal file
30
packages/workflow-engine/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
// 直接构造类型形状,验证 JSON 往返(resume 持久化的核心要求)。
|
||||
test('AgentRunResult ok 分支可 JSON 往返', () => {
|
||||
const result = {
|
||||
kind: 'ok' as const,
|
||||
output: { confirmed: true },
|
||||
usage: { outputTokens: 42 },
|
||||
}
|
||||
const round = JSON.parse(JSON.stringify(result))
|
||||
expect(round).toEqual(result)
|
||||
expect(round.kind).toBe('ok')
|
||||
})
|
||||
|
||||
test('AgentRunResult skipped/dead 分支可 JSON 往返', () => {
|
||||
for (const kind of ['skipped', 'dead'] as const) {
|
||||
const round = JSON.parse(JSON.stringify({ kind }))
|
||||
expect(round.kind).toBe(kind)
|
||||
}
|
||||
})
|
||||
|
||||
test('JournalEntry 形状稳定', () => {
|
||||
const entry = {
|
||||
key: 'abc123',
|
||||
result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } },
|
||||
}
|
||||
const round = JSON.parse(JSON.stringify(entry))
|
||||
expect(round.key).toBe('abc123')
|
||||
expect(round.result.kind).toBe('ok')
|
||||
})
|
||||
Reference in New Issue
Block a user