|
| 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