dsh-knj-workflow 0.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * dsh-knj-workflow 图编排器测试(node:test)
3
+ * 用 vm 沙箱执行 orchestrator.js,mock 引擎的 agent/phase/log,验证图遍历顺序。
4
+ * 运行:node --test lib/orchestrator.test.js
5
+ */
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { readFileSync } from 'node:fs';
9
+ import vm from 'node:vm';
10
+
11
+ const code = readFileSync(new URL('./orchestrator.js', import.meta.url), 'utf8');
12
+
13
+ /**
14
+ * 在 vm 沙箱里执行编排器脚本,返回 { result, agentCalls, phaseCalls }。
15
+ * agentImpl(label, prompt) 按节点 title 返回该 task 节点的输出。
16
+ */
17
+ async function runOrchestrator(workflow, agentImpl, extraArgs = {}) {
18
+ const agentCalls = [];
19
+ const phaseCalls = [];
20
+ const logCalls = [];
21
+ const sandbox = {
22
+ args: { config: workflow, task: { id: 't1', title: 'T', taskDir: '.tasks/t1' }, ...extraArgs },
23
+ agent: async (prompt, opts) => {
24
+ agentCalls.push(opts?.label ?? '?');
25
+ return agentImpl ? agentImpl(opts?.label, prompt) : { ok: true };
26
+ },
27
+ parallel: async (thunks) => Promise.all(thunks.map((t) => t())),
28
+ phase: (title) => { phaseCalls.push(title); },
29
+ log: (message) => { logCalls.push(message); },
30
+ console,
31
+ };
32
+ const wrapped = `(async () => {\n${code}\n})()`;
33
+ try {
34
+ const result = await vm.runInNewContext(wrapped, sandbox, { timeout: 5000 });
35
+ return { result, agentCalls, phaseCalls, logCalls };
36
+ } catch (e) {
37
+ // orchestrator 执行失败(节点 subagent 无输出 / 并行分支失败 / 循环超限)→ 返回失败结果
38
+ return { result: { ok: false, error: e instanceof Error ? e.message : String(e) }, agentCalls, phaseCalls, logCalls };
39
+ }
40
+ }
41
+
42
+ test('orchestrator: 串行图按序执行 task 节点', async () => {
43
+ const wf = {
44
+ id: 'wf', nodes: [
45
+ { id: 'start', type: 'start' },
46
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
47
+ { id: 'b', type: 'task', title: 'B', inputs: [{ from: 'a', field: '*' }], body: { prompt: 'B', mode: 'single', output: {} } },
48
+ { id: 'end', type: 'end' },
49
+ ],
50
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
51
+ };
52
+ const { result, agentCalls } = await runOrchestrator(wf, (label) => ({ label, ok: true }));
53
+ assert.deepEqual(agentCalls, ['A', 'B']);
54
+ assert.equal(result.current, 'end');
55
+ assert.ok(result.results.a);
56
+ assert.ok(result.results.b);
57
+ });
58
+
59
+ test('orchestrator: prompt 用 ${ctx.a.x} 显式引用上游输出', async () => {
60
+ const wf = {
61
+ id: 'wf', nodes: [
62
+ { id: 'start', type: 'start' },
63
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
64
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: '上游 x=${ctx.a.x},y=${ctx.a.y}', mode: 'single', output: {} } },
65
+ { id: 'end', type: 'end' },
66
+ ],
67
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
68
+ };
69
+ let capturedPrompt = null;
70
+ await runOrchestrator(wf, (label, prompt) => {
71
+ if (label === 'A') return { x: 42, y: 99 };
72
+ if (label === 'B') { capturedPrompt = prompt; return { ok: true }; }
73
+ return { ok: true };
74
+ });
75
+ // 显式引用 ${ctx.a.x} / ${ctx.a.y} 生效;未引用的内容不注入
76
+ assert.ok(capturedPrompt.includes('上游 x=42'), capturedPrompt);
77
+ assert.ok(capturedPrompt.includes('y=99'), capturedPrompt);
78
+ });
79
+
80
+ test('orchestrator: XOR 网关走 high 分支(跳过 low 分支)', async () => {
81
+ const wf = {
82
+ id: 'wf', nodes: [
83
+ { id: 'start', type: 'start' },
84
+ { id: 'analyze', type: 'task', title: '分析', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
85
+ { id: 'gw', type: 'gateway-xor', title: '复杂度' },
86
+ { id: 'design', type: 'task', title: '设计', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
87
+ { id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
88
+ { id: 'end', type: 'end' },
89
+ ],
90
+ edges: [
91
+ { from: 'start', to: 'analyze' },
92
+ { from: 'analyze', to: 'gw' },
93
+ { from: 'gw', to: 'design', when: { field: 'complexity', op: 'eq', value: 'high' } },
94
+ { from: 'gw', to: 'implement', when: { field: 'complexity', op: 'eq', value: 'low' } },
95
+ { from: 'gw', to: 'design', default: true },
96
+ { from: 'design', to: 'end' },
97
+ { from: 'implement', to: 'end' },
98
+ ],
99
+ };
100
+ const { result, agentCalls } = await runOrchestrator(wf, (label) => {
101
+ if (label === '分析') return { complexity: 'high' };
102
+ return { ok: true };
103
+ });
104
+ assert.ok(agentCalls.includes('设计'), JSON.stringify(agentCalls));
105
+ assert.ok(!agentCalls.includes('编码'), JSON.stringify(agentCalls));
106
+ assert.equal(result.current, 'end');
107
+ });
108
+
109
+ test('orchestrator: XOR 网关条件不命中走 default', async () => {
110
+ const wf = {
111
+ id: 'wf', nodes: [
112
+ { id: 'start', type: 'start' },
113
+ { id: 'analyze', type: 'task', title: '分析', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
114
+ { id: 'gw', type: 'gateway-xor', title: '复杂度' },
115
+ { id: 'design', type: 'task', title: '设计', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
116
+ { id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
117
+ { id: 'end', type: 'end' },
118
+ ],
119
+ edges: [
120
+ { from: 'start', to: 'analyze' },
121
+ { from: 'analyze', to: 'gw' },
122
+ { from: 'gw', to: 'design', when: { field: 'complexity', op: 'eq', value: 'high' } },
123
+ { from: 'gw', to: 'implement', when: { field: 'complexity', op: 'eq', value: 'low' } },
124
+ { from: 'gw', to: 'design', default: true },
125
+ { from: 'design', to: 'end' },
126
+ { from: 'implement', to: 'end' },
127
+ ],
128
+ };
129
+ const { agentCalls } = await runOrchestrator(wf, (label) => {
130
+ if (label === '分析') return { complexity: 'medium' }; // 不命中
131
+ return { ok: true };
132
+ });
133
+ assert.ok(agentCalls.includes('设计'), JSON.stringify(agentCalls)); // default 指向 design
134
+ assert.ok(!agentCalls.includes('编码'));
135
+ });
136
+
137
+ test('orchestrator: default 边带条件也参与匹配;兜底优先无条件 default', async () => {
138
+ const wf = {
139
+ id: 'wf', nodes: [
140
+ { id: 'start', type: 'start' },
141
+ { id: 'analyze', type: 'task', title: '分析', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
142
+ { id: 'gw', type: 'gateway-xor', title: '复杂度' },
143
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
144
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
145
+ { id: 'c', type: 'task', title: 'C', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
146
+ { id: 'end', type: 'end' },
147
+ ],
148
+ edges: [
149
+ { from: 'start', to: 'analyze' },
150
+ { from: 'analyze', to: 'gw' },
151
+ { from: 'gw', to: 'a', when: { field: 'level', op: 'eq', value: 'high' } },
152
+ { from: 'gw', to: 'b', when: { field: 'level', op: 'eq', value: 'medium' }, default: true }, // default 带条件
153
+ { from: 'gw', to: 'c', default: true }, // 无条件兜底
154
+ { from: 'a', to: 'end' }, { from: 'b', to: 'end' }, { from: 'c', to: 'end' },
155
+ ],
156
+ };
157
+ // level=medium → default 边的条件命中 → B
158
+ const r1 = await runOrchestrator(wf, (label) => (label === '分析' ? { level: 'medium' } : { ok: true }));
159
+ assert.ok(r1.agentCalls.includes('B'), JSON.stringify(r1.agentCalls));
160
+ assert.ok(!r1.agentCalls.includes('C'), JSON.stringify(r1.agentCalls));
161
+ // level=other → 无条件 default 兜底 → C(不会被带条件的 default 抢走)
162
+ const r2 = await runOrchestrator(wf, (label) => (label === '分析' ? { level: 'other' } : { ok: true }));
163
+ assert.ok(r2.agentCalls.includes('C'), JSON.stringify(r2.agentCalls));
164
+ assert.ok(!r2.agentCalls.includes('B'), JSON.stringify(r2.agentCalls));
165
+ });
166
+
167
+ // 构造 AND split/join 图
168
+ function andWorkflow() {
169
+ return {
170
+ id: 'wf-and', nodes: [
171
+ { id: 'start', type: 'start' },
172
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
173
+ { id: 'split', type: 'gateway-and', title: '分叉' },
174
+ { id: 'b1', type: 'task', title: 'B1', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
175
+ { id: 'b2', type: 'task', title: 'B2', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
176
+ { id: 'join', type: 'gateway-and', title: '汇合' },
177
+ { id: 'c', type: 'task', title: 'C', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
178
+ { id: 'end', type: 'end' },
179
+ ],
180
+ edges: [
181
+ { from: 'start', to: 'a' }, { from: 'a', to: 'split' },
182
+ { from: 'split', to: 'b1' }, { from: 'split', to: 'b2' },
183
+ { from: 'b1', to: 'join' }, { from: 'b2', to: 'join' },
184
+ { from: 'join', to: 'c' }, { from: 'c', to: 'end' },
185
+ ],
186
+ };
187
+ }
188
+
189
+ test('orchestrator: AND split 并行执行分支,join 后继续', async () => {
190
+ const { result, agentCalls } = await runOrchestrator(andWorkflow(), (label) => ({ label, ok: true }));
191
+ assert.ok(agentCalls.includes('A'), JSON.stringify(agentCalls));
192
+ assert.ok(agentCalls.includes('B1'), JSON.stringify(agentCalls));
193
+ assert.ok(agentCalls.includes('B2'), JSON.stringify(agentCalls));
194
+ assert.ok(agentCalls.includes('C'), JSON.stringify(agentCalls));
195
+ const idx = (l) => agentCalls.indexOf(l);
196
+ assert.ok(idx('C') > idx('B1') && idx('C') > idx('B2'), JSON.stringify(agentCalls));
197
+ assert.equal(result.current, 'end');
198
+ });
199
+
200
+ test('orchestrator: AND split 某分支失败 → 整体失败(严格)', async () => {
201
+ const { result, agentCalls } = await runOrchestrator(andWorkflow(), (label) => {
202
+ if (label === 'B1') return null; // B1 失败
203
+ return { ok: true };
204
+ });
205
+ assert.ok(!agentCalls.includes('C'), JSON.stringify(agentCalls)); // 严格失败,不执行 C
206
+ assert.equal(result.ok, false);
207
+ });
208
+
209
+ // 构造循环图:review 不通过 → 回到 implement → 再 review。死循环由节点级 maxRuns(默认 3)保护
210
+ function loopWorkflow() {
211
+ return {
212
+ id: 'wf-loop', nodes: [
213
+ { id: 'start', type: 'start' },
214
+ { id: 'review', type: 'task', title: '评审', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
215
+ { id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
216
+ { id: 'end', type: 'end' },
217
+ ],
218
+ edges: [
219
+ { from: 'start', to: 'review' },
220
+ { from: 'review', to: 'implement', when: { field: 'passed', op: 'eq', value: false } },
221
+ { from: 'review', to: 'end', default: true },
222
+ { from: 'implement', to: 'review' },
223
+ ],
224
+ };
225
+ }
226
+
227
+ test('orchestrator: 环边触发回退后通过(默认 maxRuns=3 内)', async () => {
228
+ let reviewCount = 0;
229
+ const { result, agentCalls } = await runOrchestrator(loopWorkflow(), (label) => {
230
+ if (label === '评审') {
231
+ reviewCount++;
232
+ return reviewCount === 1 ? { passed: false } : { passed: true };
233
+ }
234
+ return { ok: true };
235
+ });
236
+ assert.ok(agentCalls.includes('编码'), JSON.stringify(agentCalls)); // 回退执行了 implement
237
+ assert.equal(reviewCount, 2); // 第一次 false 回退,第二次 true 通过
238
+ assert.equal(result.current, 'end');
239
+ assert.equal(result.ok, true);
240
+ });
241
+
242
+ test('orchestrator: 节点默认最多执行 3 次,超限失败(自动防死循环)', async () => {
243
+ const { result, agentCalls } = await runOrchestrator(loopWorkflow(), (label) => {
244
+ if (label === '评审') return { passed: false }; // 一直不通过
245
+ return { ok: true };
246
+ });
247
+ assert.equal(result.ok, false);
248
+ // review 第 1/2/3 次执行了 subagent,第 4 次尝试在派 agent 前被拦下
249
+ assert.equal(agentCalls.filter((l) => l === '评审').length, 3);
250
+ });
251
+
252
+ test('orchestrator: maxRuns 可配置——=1 时重跑即失败且不浪费 subagent', async () => {
253
+ const wf = loopWorkflow();
254
+ wf.nodes.find((n) => n.id === 'review').maxRuns = 1;
255
+ const { result, agentCalls } = await runOrchestrator(wf, (label) => {
256
+ if (label === '评审') return { passed: false };
257
+ return { ok: true };
258
+ });
259
+ assert.equal(result.ok, false);
260
+ assert.equal(agentCalls.filter((l) => l === '评审').length, 1); // 第二次尝试直接失败,未派 agent
261
+ });
262
+
263
+ test('orchestrator: maxRuns 可配置——=2 时允许 1 次重跑', async () => {
264
+ const wf = loopWorkflow();
265
+ wf.nodes.find((n) => n.id === 'review').maxRuns = 2;
266
+ let reviewCount = 0;
267
+ const { result } = await runOrchestrator(wf, (label) => {
268
+ if (label === '评审') {
269
+ reviewCount++;
270
+ return reviewCount === 1 ? { passed: false } : { passed: true };
271
+ }
272
+ return { ok: true };
273
+ });
274
+ assert.equal(result.ok, true);
275
+ assert.equal(result.current, 'end');
276
+ });
277
+
278
+ // 构造人工节点图:review → approve(human) → approveTo:end / rejectTo:implement
279
+ function humanWorkflow() {
280
+ return {
281
+ id: 'wf-human', nodes: [
282
+ { id: 'start', type: 'start' },
283
+ { id: 'review', type: 'task', title: '评审', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
284
+ { id: 'approve', type: 'human', title: '审批', displayFrom: 'review', approveTo: 'end', rejectTo: 'implement' },
285
+ { id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
286
+ { id: 'end', type: 'end' },
287
+ ],
288
+ edges: [
289
+ { from: 'start', to: 'review' },
290
+ { from: 'review', to: 'approve' },
291
+ { from: 'implement', to: 'end' },
292
+ ],
293
+ };
294
+ }
295
+
296
+ test('orchestrator: human 节点暂停返回 paused', async () => {
297
+ const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label) => ({ ok: true }));
298
+ assert.equal(result.paused, true);
299
+ assert.equal(result.pausedAt, 'approve');
300
+ assert.ok(agentCalls.includes('评审'), JSON.stringify(agentCalls));
301
+ assert.ok(!agentCalls.includes('编码'), JSON.stringify(agentCalls)); // 暂停,不继续
302
+ });
303
+
304
+ test('orchestrator: human 决策 approve 走 approveTo', async () => {
305
+ const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label) => ({ ok: true }), {
306
+ decision: { humanId: 'approve', value: 'approve' },
307
+ });
308
+ assert.equal(result.current, 'end');
309
+ assert.ok(!agentCalls.includes('编码'), JSON.stringify(agentCalls)); // approveTo=end,跳过 implement
310
+ });
311
+
312
+ test('orchestrator: human 决策 reject 走 rejectTo', async () => {
313
+ const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label) => ({ ok: true }), {
314
+ decision: { humanId: 'approve', value: 'reject' },
315
+ });
316
+ assert.ok(agentCalls.includes('编码'), JSON.stringify(agentCalls)); // rejectTo=implement
317
+ assert.equal(result.current, 'end');
318
+ });
319
+
320
+ test('orchestrator: human 未配置驳回去向 → 决策 reject 抛错而非静默完成', async () => {
321
+ const wf = humanWorkflow();
322
+ wf.nodes.find((n) => n.id === 'approve').rejectTo = ''; // 未配置驳回
323
+ const { result } = await runOrchestrator(wf, (label) => ({ ok: true }), {
324
+ decision: { humanId: 'approve', value: 'reject' },
325
+ });
326
+ assert.equal(result.ok, false); // 不能悄悄判成功
327
+ });
328
+
329
+ test('orchestrator: human 未配置通过去向 → 决策 approve 抛错而非静默完成', async () => {
330
+ const wf = humanWorkflow();
331
+ wf.nodes.find((n) => n.id === 'approve').approveTo = '';
332
+ const { result } = await runOrchestrator(wf, (label) => ({ ok: true }), {
333
+ decision: { humanId: 'approve', value: 'approve' },
334
+ });
335
+ assert.equal(result.ok, false);
336
+ });
337
+
338
+ // 多去向(routes):通过 → end / 驳回 → implement / 小改 → fix
339
+ function multiRouteWorkflow() {
340
+ return {
341
+ id: 'wf-multi', nodes: [
342
+ { id: 'start', type: 'start' },
343
+ { id: 'review', type: 'task', title: '评审', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
344
+ { id: 'approve', type: 'human', title: '设计评审', displayFrom: 'review', routes: [
345
+ { label: '通过', to: 'end', tone: 'success' },
346
+ { label: '驳回', to: 'implement', tone: 'danger' },
347
+ { label: '小改', to: 'fix', tone: 'warning' },
348
+ ] },
349
+ { id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
350
+ { id: 'fix', type: 'task', title: '小修', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
351
+ { id: 'end', type: 'end' },
352
+ ],
353
+ edges: [
354
+ { from: 'start', to: 'review' },
355
+ { from: 'review', to: 'approve' },
356
+ { from: 'implement', to: 'end' },
357
+ { from: 'fix', to: 'end' },
358
+ ],
359
+ };
360
+ }
361
+
362
+ test('orchestrator: human 多去向(routes)决策走对应目标', async () => {
363
+ const { result, agentCalls } = await runOrchestrator(multiRouteWorkflow(), (label) => ({ ok: true }), {
364
+ decision: { humanId: 'approve', value: '小改' },
365
+ });
366
+ assert.ok(agentCalls.includes('小修'), JSON.stringify(agentCalls)); // 小改 → fix
367
+ assert.ok(!agentCalls.includes('编码'), JSON.stringify(agentCalls));
368
+ assert.equal(result.current, 'end');
369
+ assert.equal(result.ok, true);
370
+ });
371
+
372
+ test('orchestrator: human 决策不在去向列表 → 抛错', async () => {
373
+ const { result } = await runOrchestrator(multiRouteWorkflow(), (label) => ({ ok: true }), {
374
+ decision: { humanId: 'approve', value: '随便' },
375
+ });
376
+ assert.equal(result.ok, false);
377
+ });
378
+
379
+ test('orchestrator: human 多去向任意决策带意见 → 注入目标节点', async () => {
380
+ const seen = [];
381
+ const { result } = await runOrchestrator(multiRouteWorkflow(), (label, prompt) => {
382
+ if (label === '小修') seen.push(prompt);
383
+ return { ok: true };
384
+ }, {
385
+ decision: { humanId: 'approve', value: '小改', feedback: '文案要更简洁' },
386
+ });
387
+ assert.equal(result.ok, true);
388
+ assert.ok(seen.length === 1 && seen[0].includes('[人工意见]') && seen[0].includes('文案要更简洁'), JSON.stringify(seen));
389
+ });
390
+
391
+ test('orchestrator: human 多去向兼容 approve/reject 旧决策值', async () => {
392
+ const { result, agentCalls } = await runOrchestrator(multiRouteWorkflow(), (label) => ({ ok: true }), {
393
+ decision: { humanId: 'approve', value: 'reject' }, // 旧值 → 驳回 → implement
394
+ });
395
+ assert.ok(agentCalls.includes('编码'), JSON.stringify(agentCalls));
396
+ assert.equal(result.ok, true);
397
+ });
398
+
399
+ test('orchestrator: 断点恢复跳过已执行节点(initialResults + decision)', async () => {
400
+ const initialResults = { review: { ok: true } };
401
+ const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label) => ({ ok: true }), {
402
+ decision: { humanId: 'approve', value: 'approve' },
403
+ initialResults,
404
+ });
405
+ assert.ok(!agentCalls.includes('评审'), JSON.stringify(agentCalls)); // review 跳过,不重新执行
406
+ assert.equal(result.current, 'end');
407
+ });
408
+
409
+ // 构造 hook 图:task 有 prehook(exec) + posthook(builtin)
410
+ function hookWorkflow() {
411
+ return {
412
+ id: 'wf-hook', nodes: [
413
+ { id: 'start', type: 'start' },
414
+ {
415
+ id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '主体', mode: 'single', output: {} },
416
+ prehook: [
417
+ { type: 'exec', prompt: '查工单拿分支', output: { type: 'object', properties: { branch: { type: 'string' } } }, as: 'branchInfo' },
418
+ ],
419
+ posthook: [
420
+ { type: 'builtin', name: 'write-product', params: { path: 'docs/x.md' } },
421
+ ],
422
+ },
423
+ { id: 'end', type: 'end' },
424
+ ],
425
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
426
+ };
427
+ }
428
+
429
+ test('orchestrator: hook 执行 prehook(exec) + 主体 + posthook(builtin),动作输出进 ctx', async () => {
430
+ const { result, agentCalls } = await runOrchestrator(hookWorkflow(), (label) => {
431
+ if (label === 'A:exec') return { branch: 'feature/x' };
432
+ return { ok: true };
433
+ });
434
+ assert.ok(agentCalls.includes('A:exec'), JSON.stringify(agentCalls));
435
+ assert.ok(agentCalls.includes('A'), JSON.stringify(agentCalls));
436
+ assert.ok(agentCalls.includes('A:write-product'), JSON.stringify(agentCalls));
437
+ assert.deepEqual(result.results['a.branchInfo'], { branch: 'feature/x' });
438
+ });
439
+
440
+ test('orchestrator: 主体 prompt 解析 ${ctx} 引用(上游输出 + 任务输入)', async () => {
441
+ const wf = {
442
+ id: 'wf-ref', nodes: [
443
+ { id: 'start', type: 'start' },
444
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
445
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: '处理工单 ${ctx.inputs.ticketNo},分支 ${ctx.a.branch}', mode: 'single', output: {} } },
446
+ { id: 'end', type: 'end' },
447
+ ],
448
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
449
+ };
450
+ let capturedPrompt = null;
451
+ await runOrchestrator(wf, (label, prompt) => {
452
+ if (label === 'A') return { branch: 'feature/x' };
453
+ if (label === 'B') { capturedPrompt = prompt; return { ok: true }; }
454
+ return { ok: true };
455
+ }, { inputs: { ticketNo: 'JIRA-123' } });
456
+ assert.ok(capturedPrompt.includes('JIRA-123'), capturedPrompt);
457
+ assert.ok(capturedPrompt.includes('feature/x'), capturedPrompt);
458
+ assert.ok(!capturedPrompt.includes('${ctx'), capturedPrompt); // 无残留占位符
459
+ });
460
+
461
+ test('orchestrator: 任务输入 inputs 进入 ctx,可在下游 prompt 引用', async () => {
462
+ const wf = {
463
+ id: 'wf-in', nodes: [
464
+ { id: 'start', type: 'start' },
465
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '查工单 ${ctx.inputs.ticketNo}', mode: 'single', output: {} } },
466
+ { id: 'end', type: 'end' },
467
+ ],
468
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
469
+ };
470
+ let capturedPrompt = null;
471
+ await runOrchestrator(wf, (label, prompt) => {
472
+ if (label === 'A') { capturedPrompt = prompt; return { ok: true }; }
473
+ return { ok: true };
474
+ }, { inputs: { ticketNo: 'TICKET-42' } });
475
+ assert.ok(capturedPrompt.includes('TICKET-42'), capturedPrompt);
476
+ });
477
+
478
+ test('orchestrator: human reject 带 feedback → rejectTo 节点 prompt 含人工意见', async () => {
479
+ let capturedPrompt = null;
480
+ const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label, prompt) => {
481
+ if (label === '编码') { capturedPrompt = prompt; return { ok: true }; }
482
+ return { ok: true };
483
+ }, {
484
+ decision: { humanId: 'approve', value: 'reject', feedback: '缺少单元测试,请补充边界用例' },
485
+ });
486
+ assert.ok(agentCalls.includes('编码'), JSON.stringify(agentCalls));
487
+ assert.ok(capturedPrompt.includes('缺少单元测试'), capturedPrompt);
488
+ assert.ok(capturedPrompt.includes('人工意见'), capturedPrompt);
489
+ assert.equal(result.current, 'end');
490
+ });
491
+
492
+ test('orchestrator: 节点级 parallel 派 N 个 subagent,结果合并成数组', async () => {
493
+ const wf = {
494
+ id: 'wf-par', nodes: [
495
+ { id: 'start', type: 'start' },
496
+ { id: 'a', type: 'task', title: '评审', inputs: [], body: { prompt: '评审代码', mode: 'parallel', parallelItems: 3, output: {} } },
497
+ { id: 'end', type: 'end' },
498
+ ],
499
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
500
+ };
501
+ const { result, agentCalls } = await runOrchestrator(wf, (label) => ({ label, ok: true }));
502
+ assert.equal(agentCalls.filter((l) => l.startsWith('评审 #')).length, 3, JSON.stringify(agentCalls));
503
+ assert.ok(Array.isArray(result.results.a), JSON.stringify(result.results));
504
+ assert.equal(result.results.a.length, 3);
505
+ assert.equal(result.current, 'end');
506
+ });
507
+
508
+ test('orchestrator: builtin 动作 params 支持 ${ctx} 动态引用', async () => {
509
+ const wf = {
510
+ id: 'wf-bp', nodes: [
511
+ { id: 'start', type: 'start' },
512
+ {
513
+ id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '主体', mode: 'single', output: {} },
514
+ posthook: [
515
+ { type: 'builtin', name: 'write-product', params: { path: '${ctx.inputs.dir}/x.md' } },
516
+ ],
517
+ },
518
+ { id: 'end', type: 'end' },
519
+ ],
520
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
521
+ };
522
+ let capturedPrompt = null;
523
+ await runOrchestrator(wf, (label, prompt) => {
524
+ if (label === 'A:write-product') { capturedPrompt = prompt; return { ok: true }; }
525
+ return { ok: true };
526
+ }, { inputs: { dir: 'docs' } });
527
+ assert.ok(capturedPrompt.includes('docs/x.md'), capturedPrompt);
528
+ assert.ok(!capturedPrompt.includes('${ctx'), capturedPrompt);
529
+ });
530
+
531
+ test('orchestrator: reject 时 rejectTo 目标即使已在 initialResults 也重新执行', async () => {
532
+ let implementCount = 0;
533
+ let capturedPrompt = null;
534
+ const initialResults = { review: { ok: true }, implement: { ok: true } }; // implement 上一轮已执行过
535
+ const { result } = await runOrchestrator(humanWorkflow(), (label, prompt) => {
536
+ if (label === '编码') { implementCount++; capturedPrompt = prompt; return { ok: true }; }
537
+ return { ok: true };
538
+ }, {
539
+ decision: { humanId: 'approve', value: 'reject', feedback: '再次驳回:补充边界用例' },
540
+ initialResults,
541
+ });
542
+ assert.equal(implementCount, 1); // 修复节点应重新执行,不被 skipSet 跳过
543
+ assert.ok(capturedPrompt.includes('再次驳回'), capturedPrompt);
544
+ assert.equal(result.current, 'end');
545
+ });
546
+
547
+ test('orchestrator: 每节点成功后上报 checkpoint([knj-checkpoint] 含 node 与 output)', async () => {
548
+ const wf = {
549
+ id: 'wf', nodes: [
550
+ { id: 'start', type: 'start' },
551
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
552
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: 'B', mode: 'single', output: {} } },
553
+ { id: 'end', type: 'end' },
554
+ ],
555
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
556
+ };
557
+ const { result, logCalls } = await runOrchestrator(wf, (label) => ({ ok: true, value: label }));
558
+ const cps = logCalls.filter((m) => typeof m === 'string' && m.startsWith('[knj-checkpoint]'))
559
+ .map((m) => JSON.parse(m.slice('[knj-checkpoint]'.length)));
560
+ assert.equal(cps.length, 2);
561
+ assert.equal(cps[0].node, 'a');
562
+ assert.deepEqual(cps[0].output, { ok: true, value: 'A' });
563
+ assert.equal(cps[1].node, 'b');
564
+ assert.equal(result.current, 'end');
565
+ });
566
+
567
+ test('orchestrator: 节点 subagent 无输出(返回 null)→ 整体失败(throw)', async () => {
568
+ const wf = {
569
+ id: 'wf', nodes: [
570
+ { id: 'start', type: 'start' },
571
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
572
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: 'B', mode: 'single', output: {} } },
573
+ { id: 'end', type: 'end' },
574
+ ],
575
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
576
+ };
577
+ const { result, agentCalls } = await runOrchestrator(wf, (label) => (label === 'B' ? null : { ok: true }));
578
+ assert.equal(result.ok, false); // 必须失败(throw),不能误判成功
579
+ assert.ok(agentCalls.includes('A'));
580
+ assert.ok(!(result.results && result.results.b)); // 失败节点结果不存在(throw 中断,results 可能缺失)
581
+ });
582
+
583
+ test('orchestrator: 不自动注入需求/工作目录,prompt 干净(显式 ${inputDescription} 生效)', async () => {
584
+ const wf = {
585
+ id: 'wf', nodes: [
586
+ { id: 'start', type: 'start' },
587
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '按需求做:${inputDescription}', mode: 'single', output: {} } },
588
+ { id: 'end', type: 'end' },
589
+ ],
590
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
591
+ };
592
+ let capturedPrompt = null;
593
+ const { result } = await runOrchestrator(wf, (label, prompt) => {
594
+ if (label === 'A') capturedPrompt = prompt;
595
+ return { ok: true };
596
+ }, { task: { id: 't1', title: 'T', taskDir: '.tasks/t1', description: '做一个 url 解码工具,保存到 ~/story 下', cwd: '/tmp' } });
597
+ // 不再自动注入 [任务需求]/[工作目录]/[上游节点输出];显式 ${inputDescription} 生效
598
+ assert.ok(!capturedPrompt.includes('[任务需求]'), capturedPrompt);
599
+ assert.ok(!capturedPrompt.includes('[工作目录]'), capturedPrompt);
600
+ assert.ok(!capturedPrompt.includes('[上游节点输出]'), capturedPrompt);
601
+ assert.ok(capturedPrompt.includes('按需求做:做一个 url 解码工具,保存到 ~/story 下'), capturedPrompt);
602
+ assert.equal(result.current, 'end');
603
+ });
604
+
605
+ test('orchestrator: 空 initialResults 时全部节点重新执行(重跑语义)', async () => {
606
+ const wf = {
607
+ id: 'wf', nodes: [
608
+ { id: 'start', type: 'start' },
609
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
610
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: 'B', mode: 'single', output: {} } },
611
+ { id: 'end', type: 'end' },
612
+ ],
613
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
614
+ };
615
+ const { agentCalls, result } = await runOrchestrator(wf, (label) => ({ ok: true }), { initialResults: {} });
616
+ assert.deepEqual(agentCalls, ['A', 'B']);
617
+ assert.equal(result.current, 'end');
618
+ });
619
+
620
+ test('orchestrator: prompt 可引用 ${inputDescription}/${inputTitle}/${ctx.inputDescription}', async () => {
621
+ const wf = {
622
+ id: 'wf', nodes: [
623
+ { id: 'start', type: 'start' },
624
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '需求:${inputDescription},标题:${inputTitle},完整:${ctx.inputDescription}', mode: 'single', output: {} } },
625
+ { id: 'end', type: 'end' },
626
+ ],
627
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
628
+ };
629
+ let capturedPrompt = null;
630
+ const { result } = await runOrchestrator(wf, (label, prompt) => {
631
+ if (label === 'A') capturedPrompt = prompt;
632
+ return { ok: true };
633
+ }, { task: { id: 't1', title: '测试标题', taskDir: '.tasks/t1', description: '做一个工具' } });
634
+ assert.ok(capturedPrompt.includes('需求:做一个工具'), capturedPrompt);
635
+ assert.ok(capturedPrompt.includes('标题:测试标题'), capturedPrompt);
636
+ assert.ok(capturedPrompt.includes('完整:做一个工具'), capturedPrompt);
637
+ assert.equal(result.current, 'end');
638
+ });
639
+
640
+ test('orchestrator: XOR 条件值可引用 ${ctx.inputs.x} 分流', async () => {
641
+ const wf = {
642
+ id: 'wf', nodes: [
643
+ { id: 'start', type: 'start' },
644
+ { id: 'c', type: 'task', title: 'C', inputs: [], body: { prompt: 'C', mode: 'single', output: {} } },
645
+ { id: 'gw', type: 'gateway-xor', title: '分流', inputs: [] },
646
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
647
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: 'B', mode: 'single', output: {} } },
648
+ { id: 'end', type: 'end' },
649
+ ],
650
+ edges: [
651
+ { from: 'start', to: 'c' },
652
+ { from: 'c', to: 'gw' },
653
+ { from: 'gw', to: 'a', when: { field: 'level', op: 'eq', value: '${ctx.inputs.level}' } },
654
+ { from: 'gw', to: 'b', default: true },
655
+ { from: 'a', to: 'end' },
656
+ { from: 'b', to: 'end' },
657
+ ],
658
+ };
659
+ const { agentCalls } = await runOrchestrator(wf, (label) => ({ ok: true, level: label === 'C' ? 'high' : 'low' }), { inputs: { level: 'high' } });
660
+ assert.ok(agentCalls.includes('A'), JSON.stringify(agentCalls)); // 条件值 ${ctx.inputs.level} 解析为 high,匹配 C 输出的 level,走 A
661
+ assert.ok(!agentCalls.includes('B'), JSON.stringify(agentCalls));
662
+ });
663
+
664
+ test('orchestrator: 无前缀 ${节点id.字段} 引用(${ctx.前缀} 兼容)', async () => {
665
+ const wf = {
666
+ id: 'wf', nodes: [
667
+ { id: 'start', type: 'start' },
668
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
669
+ { id: 'b', type: 'task', title: 'B', inputs: [], body: { prompt: 'x=${a.x},旧写法=${ctx.a.x}', mode: 'single', output: {} } },
670
+ { id: 'end', type: 'end' },
671
+ ],
672
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'end' }],
673
+ };
674
+ let capturedPrompt = null;
675
+ await runOrchestrator(wf, (label, prompt) => {
676
+ if (label === 'A') return { x: 42 };
677
+ if (label === 'B') { capturedPrompt = prompt; return { ok: true }; }
678
+ return { ok: true };
679
+ });
680
+ assert.ok(capturedPrompt.includes('x=42'), capturedPrompt); // 无前缀 ${a.x}
681
+ assert.ok(capturedPrompt.includes('旧写法=42'), capturedPrompt); // ${ctx.a.x} 兼容
682
+ });