Skip to content

Commit b0c85d3

Browse files
test(workflow): integration tests for canonical scripts (review pipeline / loop-until-dry / resume)
Proves engine compatibility with real Workflow-tool scripts. precheck green: 5426 tests, 0 fail. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 64e2efa commit b0c85d3

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* 集成测试:用忠实 mock adapter 跑「规范 workflow 脚本」(来自 Workflow 工具定义的
3+
* canonical 模式:pipeline 无屏障 + parallel 屏障 + agent(schema) + phase)。
4+
* 验证引擎与真实 workflow 脚本语义兼容。
5+
*/
6+
import { expect, test } from 'bun:test'
7+
import { mkdtemp, rm } from 'node:fs/promises'
8+
import { tmpdir } from 'node:os'
9+
import { join } from 'node:path'
10+
import { runWorkflow } from '../engine/runWorkflow.js'
11+
import { createFileJournalStore } from '../engine/journal.js'
12+
import { createHostHandle, type WorkflowPorts } from '../ports.js'
13+
import { createBufferingEmitter } from '../progress/events.js'
14+
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
15+
16+
function canonicalPorts(runsDir: string): {
17+
ports: WorkflowPorts
18+
events: ProgressEvent[]
19+
agentCalls: AgentRunParams[]
20+
} {
21+
const { emitter, events } = createBufferingEmitter()
22+
const agentCalls: AgentRunParams[] = []
23+
const ports: WorkflowPorts = {
24+
agentRunner: {
25+
runAgentToResult: async (
26+
params: AgentRunParams,
27+
): Promise<AgentRunResult> => {
28+
agentCalls.push(params)
29+
const p = params.prompt
30+
if (p.startsWith('review-')) {
31+
return {
32+
kind: 'ok',
33+
output: { findings: [{ title: `${p}-finding`, file: 'a.ts' }] },
34+
usage: { outputTokens: 5 },
35+
}
36+
}
37+
if (p.startsWith('verify')) {
38+
return {
39+
kind: 'ok',
40+
output: { isReal: true },
41+
usage: { outputTokens: 2 },
42+
}
43+
}
44+
return { kind: 'dead' }
45+
},
46+
},
47+
progressEmitter: emitter,
48+
taskRegistrar: {
49+
register: () => ({ runId: 'r', signal: new AbortController().signal }),
50+
complete: () => {},
51+
fail: () => {},
52+
kill: () => {},
53+
pendingAction: () => null,
54+
},
55+
journalStore: createFileJournalStore(runsDir),
56+
permissionGate: { isAborted: () => false },
57+
logger: { debug: () => {}, event: () => {} },
58+
hostFactory: () => ({
59+
handle: createHostHandle(null),
60+
cwd: runsDir,
61+
budgetTotal: null,
62+
}),
63+
}
64+
return { ports, events, agentCalls }
65+
}
66+
67+
// 规范 review 模式(pipeline→parallel→verify→synthesize),逐字采用 Workflow 工具定义的写法。
68+
const CANONICAL_REVIEW_SCRIPT = `
69+
export const meta = {
70+
name: 'review-changes',
71+
description: 'Review changed files across dimensions, verify each finding',
72+
phases: [{ title: 'Review' }, { title: 'Verify' }],
73+
}
74+
const DIMENSIONS = [
75+
{ key: 'bugs', prompt: 'review-bugs' },
76+
{ key: 'perf', prompt: 'review-perf' },
77+
]
78+
const FINDINGS_SCHEMA = { type: 'object' }
79+
const VERDICT_SCHEMA = { type: 'object' }
80+
81+
phase('Review')
82+
const results = await pipeline(
83+
DIMENSIONS,
84+
d => agent(d.prompt, { label: 'review:' + d.key, phase: 'Review', schema: FINDINGS_SCHEMA }),
85+
review => parallel(
86+
review.findings.map(f => () =>
87+
agent('verify: ' + f.title, { label: 'verify:' + f.file, phase: 'Verify', schema: VERDICT_SCHEMA })
88+
.then(v => ({ ...f, verdict: v }))
89+
)
90+
)
91+
)
92+
const all = results.flat().filter(Boolean)
93+
const confirmed = all.filter(f => f.verdict && f.verdict.isReal)
94+
return { confirmed, total: all.length }
95+
`
96+
97+
test('canonical review 脚本端到端兼容', async () => {
98+
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
99+
try {
100+
const { ports, events, agentCalls } = canonicalPorts(dir)
101+
const result = await runWorkflow({
102+
script: CANONICAL_REVIEW_SCRIPT,
103+
runId: 'int-1',
104+
ports,
105+
host: createHostHandle(null),
106+
signal: new AbortController().signal,
107+
cwd: dir,
108+
budgetTotal: null,
109+
})
110+
111+
expect(result.status).toBe('completed')
112+
const ret = result.returnValue as { confirmed: unknown[]; total: number }
113+
// 2 维度 × 1 finding,全部 isReal=true → confirmed=2, total=2
114+
expect(ret.total).toBe(2)
115+
expect(ret.confirmed).toHaveLength(2)
116+
// 2 个 review agent + 2 个 verify agent = 4
117+
expect(agentCalls).toHaveLength(4)
118+
expect(agentCalls.filter(c => c.prompt.startsWith('review-'))).toHaveLength(
119+
2,
120+
)
121+
expect(agentCalls.filter(c => c.prompt.startsWith('verify'))).toHaveLength(
122+
2,
123+
)
124+
// 进度事件:run_started/done + phase Review/Verify + agent started/done
125+
expect(
126+
events.some(
127+
e => e.type === 'run_started' && e.workflowName === 'review-changes',
128+
),
129+
).toBe(true)
130+
expect(
131+
events.some(e => e.type === 'run_done' && e.status === 'completed'),
132+
).toBe(true)
133+
// 脚本显式调用一次 phase('Review');verify agent 的 phase:'Verify' 是展示标签,不发 phase_started
134+
expect(
135+
events.filter(e => e.type === 'phase_started' && e.phase === 'Review'),
136+
).toHaveLength(1)
137+
expect(events.filter(e => e.type === 'agent_started')).toHaveLength(4)
138+
} finally {
139+
await rm(dir, { recursive: true, force: true })
140+
}
141+
})
142+
143+
test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => {
144+
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
145+
try {
146+
let round = 0
147+
const { emitter, events } = createBufferingEmitter()
148+
const ports: WorkflowPorts = {
149+
agentRunner: {
150+
runAgentToResult: async (
151+
p: AgentRunParams,
152+
): Promise<AgentRunResult> => {
153+
round++
154+
// 第 1-2 轮返回发现,第 3 轮起返回空 → 收敛
155+
const found = round <= 2 ? [{ b: round }] : []
156+
return {
157+
kind: 'ok',
158+
output: { bugs: found },
159+
usage: { outputTokens: 1 },
160+
}
161+
},
162+
},
163+
progressEmitter: emitter,
164+
taskRegistrar: {
165+
register: () => ({ runId: 'r', signal: new AbortController().signal }),
166+
complete: () => {},
167+
fail: () => {},
168+
kill: () => {},
169+
pendingAction: () => null,
170+
},
171+
journalStore: createFileJournalStore(dir),
172+
permissionGate: { isAborted: () => false },
173+
logger: { debug: () => {}, event: () => {} },
174+
hostFactory: () => ({
175+
handle: createHostHandle(null),
176+
cwd: dir,
177+
budgetTotal: null,
178+
}),
179+
}
180+
const script = `
181+
const seen = []
182+
const confirmed = []
183+
let dry = 0
184+
while (dry < 2) {
185+
const found = (await agent('find bugs')).bugs
186+
const fresh = found.filter(b => !seen.includes(b.b))
187+
if (fresh.length === 0) { dry++; continue }
188+
dry = 0
189+
for (const b of fresh) seen.push(b.b)
190+
confirmed.push(...fresh)
191+
}
192+
return { confirmed }
193+
`
194+
const result = await runWorkflow({
195+
script,
196+
runId: 'int-2',
197+
ports,
198+
host: createHostHandle(null),
199+
signal: new AbortController().signal,
200+
cwd: dir,
201+
budgetTotal: null,
202+
})
203+
expect(result.status).toBe('completed')
204+
const ret = result.returnValue as { confirmed: { b: number }[] }
205+
// 第1轮发现{b:1},第2轮发现{b:2}(fresh,因 seen=[1]),第3轮 found{b:3}?
206+
// mock 按 round 计数:round1→{b:1}, round2→{b:2}, round3→[](found空)
207+
// 但 round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0
208+
// round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → 退出
209+
expect(ret.confirmed).toHaveLength(2)
210+
expect(
211+
events.some(e => e.type === 'run_done' && e.status === 'completed'),
212+
).toBe(true)
213+
} finally {
214+
await rm(dir, { recursive: true, force: true })
215+
}
216+
})
217+
218+
test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () => {
219+
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
220+
try {
221+
let calls = 0
222+
const makePorts = (): WorkflowPorts => ({
223+
agentRunner: {
224+
runAgentToResult: async () => {
225+
calls++
226+
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
227+
},
228+
},
229+
progressEmitter: { emit: () => {} },
230+
taskRegistrar: {
231+
register: () => ({ runId: 'r', signal: new AbortController().signal }),
232+
complete: () => {},
233+
fail: () => {},
234+
kill: () => {},
235+
pendingAction: () => null,
236+
},
237+
journalStore: createFileJournalStore(dir),
238+
permissionGate: { isAborted: () => false },
239+
logger: { debug: () => {}, event: () => {} },
240+
hostFactory: () => ({
241+
handle: createHostHandle(null),
242+
cwd: dir,
243+
budgetTotal: null,
244+
}),
245+
})
246+
const script = `
247+
phase('A')
248+
const a = await agent('do-a')
249+
const b = await agent('do-b')
250+
return { a, b }
251+
`
252+
// 第一次运行:2 个 agent 现场跑
253+
const first = await runWorkflow({
254+
script,
255+
runId: 'int-3',
256+
ports: makePorts(),
257+
host: createHostHandle(null),
258+
signal: new AbortController().signal,
259+
cwd: dir,
260+
budgetTotal: null,
261+
})
262+
expect(first.status).toBe('completed')
263+
expect(calls).toBe(2)
264+
265+
// resume 同 runId:journal 命中,不重跑
266+
calls = 0
267+
const resumed = await runWorkflow({
268+
script,
269+
runId: 'int-3',
270+
ports: makePorts(),
271+
host: createHostHandle(null),
272+
signal: new AbortController().signal,
273+
cwd: dir,
274+
budgetTotal: null,
275+
resume: true,
276+
})
277+
expect(resumed.status).toBe('completed')
278+
expect(calls).toBe(0)
279+
} finally {
280+
await rm(dir, { recursive: true, force: true })
281+
}
282+
})

0 commit comments

Comments
 (0)