minimal-agent 0.12.0 → 0.13.0

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.
Files changed (74) hide show
  1. package/package.json +3 -2
  2. package/plugins/workflow-runner/src/expressions.js +29 -8
  3. package/plugins/workflow-runner/src/expressions.ts +28 -10
  4. package/plugins/workflow-runner/src/index.js +45 -17
  5. package/plugins/workflow-runner/src/index.ts +44 -18
  6. package/plugins/workflow-runner/src/recovery.js +9 -2
  7. package/plugins/workflow-runner/src/recovery.ts +9 -2
  8. package/plugins/workflow-runner/src/runner.js +31 -5
  9. package/plugins/workflow-runner/src/runner.ts +33 -5
  10. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  11. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
  14. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  15. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  16. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  17. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  18. package/plugins/workflow-runner/src/types.js +113 -7
  19. package/plugins/workflow-runner/src/types.ts +111 -7
  20. package/plugins/workflow-runner/src/workflowState.js +455 -54
  21. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  22. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  23. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  24. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  25. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  26. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  27. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  28. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  29. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  30. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  31. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  32. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  33. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  34. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  35. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  36. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  37. package/skills/skill-creator/scripts/run_eval.py +3 -2
  38. package/src/cli/print.js +17 -0
  39. package/src/cli/print.ts +25 -0
  40. package/src/config/configService.js +24 -0
  41. package/src/config/configService.ts +23 -0
  42. package/src/infra/verification.js +25 -18
  43. package/src/infra/verification.ts +50 -18
  44. package/src/plugins/pluginRunner.js +18 -7
  45. package/src/plugins/pluginRunner.ts +20 -6
  46. package/src/prompts/policyRegistry.js +28 -1
  47. package/src/prompts/policyRegistry.ts +28 -1
  48. package/src/tools/advisor/advisor.js +76 -9
  49. package/src/tools/advisor/advisor.ts +79 -9
  50. package/src/tools/bash/bash.js +51 -6
  51. package/src/tools/bash/bash.ts +56 -7
  52. package/src/tools/powershell/powershell.js +18 -1
  53. package/src/tools/powershell/powershell.ts +18 -2
  54. package/src/tools/read/read.js +9 -4
  55. package/src/tools/read/read.ts +9 -4
  56. package/src/tools/shared/permanentDeleteGuard.js +34 -3
  57. package/src/tools/shared/permanentDeleteGuard.ts +43 -6
  58. package/src/tools/webbrowser/browserControl.js +5 -1
  59. package/src/tools/webbrowser/browserControl.ts +7 -1
  60. package/src/tools/webbrowser/webbrowser.js +8 -4
  61. package/src/tools/webbrowser/webbrowser.ts +8 -4
  62. package/src/types.ts +10 -4
  63. package/workflows/adaptive-research.yaml +5 -5
  64. package/workflows/book-review-short.yaml +5 -5
  65. package/workflows/e2e-write-greet.yaml +2 -2
  66. package/workflows/illustrate.yaml +12 -12
  67. package/workflows/plan.yaml +18 -18
  68. package/workflows/ppt.yaml +201 -201
  69. package/workflows/ralph-loop.yaml +3 -1
  70. package/workflows/research.md +449 -0
  71. package/workflows/research.yaml +873 -0
  72. package/workflows/webppt.yaml +47 -47
  73. package/workflows/word.yaml +80 -80
  74. package/workflows/youtube-shorts.yaml +25 -25
@@ -0,0 +1,328 @@
1
+ /**
2
+ * test/research.load.test.ts
3
+ * ------------------------------------------------------------
4
+ * 锁定 workflows/research.yaml(技术研究工作流:定义问题→测绘地图→方案对比→
5
+ * 深度调研→动手验证→综合升华→补洞→报告拼装)的「可解析 + 关键骨架 + 所有表达式合法」。
6
+ */
7
+
8
+ import { describe, expect, it } from 'bun:test';
9
+ import { join } from 'node:path';
10
+
11
+ import { evalExpr, interpolate } from '../src/expressions.js';
12
+ import { parseWorkflowFile } from '../src/loader.js';
13
+ import { VarStack } from '../src/types.js';
14
+ import type { StepDef } from '../src/types.js';
15
+
16
+ const wfPath = join(
17
+ import.meta.dir.replace(/[/\\]test$/, ''),
18
+ '..',
19
+ '..',
20
+ 'workflows',
21
+ 'research.yaml',
22
+ );
23
+
24
+ function allStrings(def: unknown): string[] {
25
+ const out: string[] = [];
26
+ const walk = (v: unknown): void => {
27
+ if (typeof v === 'string') out.push(v);
28
+ else if (Array.isArray(v)) for (const x of v) walk(x);
29
+ else if (v && typeof v === 'object')
30
+ for (const x of Object.values(v as Record<string, unknown>)) walk(x);
31
+ };
32
+ walk(def);
33
+ return out;
34
+ }
35
+
36
+ function allStepsRec(steps: StepDef[]): StepDef[] {
37
+ const out: StepDef[] = [];
38
+ const go = (s: StepDef) => {
39
+ out.push(s);
40
+ if (Array.isArray(s.do)) s.do.forEach(go);
41
+ if (Array.isArray(s.then)) s.then.forEach(go);
42
+ if (Array.isArray(s.else)) s.else.forEach(go);
43
+ };
44
+ steps.forEach(go);
45
+ return out;
46
+ }
47
+
48
+ describe('workflows/research.yaml(技术研究工作流)', () => {
49
+ it('解析通过、name=research、inputs 正确', async () => {
50
+ const def = await parseWorkflowFile(wfPath);
51
+ expect(def.name).toBe('research');
52
+ expect(typeof def.description).toBe('string');
53
+ expect(def.description.length).toBeGreaterThan(10);
54
+
55
+ const inputs = def.inputs ?? [];
56
+ const names = inputs.map((i) => i.name);
57
+ expect(names).toEqual(['topic', 'depth', 'max_iterations']);
58
+ expect(inputs[0].greedy).toBe(true);
59
+ expect(inputs[0].required).toBe(true);
60
+ expect(inputs[1].default).toBe('standard');
61
+ expect(inputs[2].default).toBe(2);
62
+ });
63
+
64
+ it('骨架齐全:init -> s0(goal) -> s0_pause -> s1(map) -> [early-exit 判定] -> s2(options) -> s3(per-branch 深挖 loop) -> s4(verify) -> s5(synthesis) -> s5b(补洞 loop) -> s6(拼装) -> done', async () => {
65
+ const def = await parseWorkflowFile(wfPath);
66
+ const ids = def.steps.map((s) => s.id);
67
+ const expectOrder = [
68
+ 'init',
69
+ 's0_goal',
70
+ 's0_pause',
71
+ 's1_map',
72
+ 's1_load_exit',
73
+ 's1_exit_report',
74
+ 's2_options',
75
+ 's2_load_branches',
76
+ 's2_assert_branches',
77
+ 's3_deep_dive',
78
+ 's4_verify',
79
+ 's5_synthesis',
80
+ 's5b_loop',
81
+ 's5c_check_synthesis',
82
+ 's5c_assert_synthesis',
83
+ 's6_build_report',
84
+ 's6_package',
85
+ 's6_assert_report',
86
+ 'done',
87
+ ];
88
+ for (const id of expectOrder) expect(ids).toContain(id);
89
+ // 顺序
90
+ for (let i = 1; i < expectOrder.length; i++) {
91
+ expect(ids.indexOf(expectOrder[i - 1])).toBeLessThan(ids.indexOf(expectOrder[i]));
92
+ }
93
+ });
94
+
95
+ it('early-exit 双路径:s1_exit_report 在 _early_exit==true 时补齐全套文件;主阶段以 when=_early_exit != true 守卫跳过', async () => {
96
+ const def = await parseWorkflowFile(wfPath);
97
+ const byId = new Map(def.steps.map((s) => [s.id, s]));
98
+
99
+ const exitReport = byId.get('s1_exit_report');
100
+ expect(exitReport?.when).toBe('_early_exit == true');
101
+ // 简化路径必须补齐 s6 拼装所需的全部文件(done_when 锁定)
102
+ const dw = String(exitReport?.done_when);
103
+ for (const f of ['03-options.md', 'branches.json', '05-verification.md', '06-synthesis.md', '07-learning-debt.md', 'gaps.json']) {
104
+ expect(dw).toContain(f);
105
+ }
106
+
107
+ // 主阶段守卫:s2 / s4 / s5 / s5b 在 early-exit 时跳过
108
+ for (const id of ['s2_options', 's4_verify', 's5_synthesis', 's5b_loop']) {
109
+ expect(byId.get(id)?.when).toBe('_early_exit != true');
110
+ }
111
+ // assert 只在正常路径执行(early 路径 branches=[] 天然放行,无需弱化条件)
112
+ expect(byId.get('s2_assert_branches')?.when).toBe('_early_exit != true');
113
+ expect(String(byId.get('s2_assert_branches')?.condition)).toBe('length(branches) > 0');
114
+ // s1_map 的完成闸门包含 early-exit.json
115
+ expect(String(byId.get('s1_map')?.done_when)).toContain('early-exit.json');
116
+ });
117
+
118
+ it('阶段0 零搜索(设计文档红线):allowed_tools 不含 WebSearch/WebFetch', async () => {
119
+ const def = await parseWorkflowFile(wfPath);
120
+ const s0 = def.steps.find((s) => s.id === 's0_goal');
121
+ const tools = (s0?.allowed_tools ?? []) as string[];
122
+ expect(tools).not.toContain('WebSearch');
123
+ expect(tools).not.toContain('WebFetch');
124
+ expect(tools).toEqual(['Read', 'Write']);
125
+ });
126
+
127
+ it('s5c 形式校验:确定性检查综合报告含必备章节(不依赖 agent 自觉)', async () => {
128
+ const def = await parseWorkflowFile(wfPath);
129
+ const check = def.steps.find((s) => s.id === 's5c_check_synthesis');
130
+ expect(check?.tool).toBe('Bash');
131
+ expect(String((check?.args as any)?.command)).toContain('grep');
132
+
133
+ const gate = def.steps.find((s) => s.id === 's5c_assert_synthesis');
134
+ expect(gate?.type).toBe('assert');
135
+ expect(String(gate?.condition)).toContain('核心结论');
136
+ expect(String(gate?.condition)).toContain('推荐方案');
137
+ });
138
+
139
+ it('s3_deep_dive:for-each loop over branches、fresh_context、done_when 用 + 拼路径', async () => {
140
+ const def = await parseWorkflowFile(wfPath);
141
+ const loop = def.steps.find((s) => s.id === 's3_deep_dive');
142
+ expect(loop?.type).toBe('loop');
143
+ expect(String(loop?.over)).toContain('branches');
144
+ expect(loop?.as).toBe('branch');
145
+ expect(loop?.fresh_context).toBe(true);
146
+
147
+ const dive = ((loop?.do ?? []) as StepDef[]).find((s) => s.id === 's3_dive_one');
148
+ expect(dive?.type).toBeUndefined(); // 不是控制流,是 agent 步
149
+ expect(typeof dive?.agent).toBe('string');
150
+ // allowed_tools 必须限制为只读调研工具
151
+ expect(dive?.allowed_tools).toEqual(
152
+ expect.arrayContaining(['WebSearch', 'WebFetch', 'Read', 'Write']),
153
+ );
154
+ // done_when 用 + 拼接路径
155
+ expect(String(dive?.done_when)).toContain('branch.safe_name');
156
+ expect(String(dive?.done_when)).toContain('+');
157
+ });
158
+
159
+ it('s5b_loop:while 模式(无 over)+ max_iterations=${inputs.max_iterations} + branch condition 判定缺口', async () => {
160
+ const def = await parseWorkflowFile(wfPath);
161
+ const loop = def.steps.find((s) => s.id === 's5b_loop');
162
+ expect(loop?.type).toBe('loop');
163
+ expect(loop?.over).toBeUndefined(); // while 模式
164
+ expect(String(loop?.max_iterations)).toContain('inputs.max_iterations');
165
+
166
+ const inner = (loop?.do ?? []) as StepDef[];
167
+ const brk = inner.find((s) => s.id === 's5b_break_if_empty');
168
+ expect(brk?.type).toBe('branch');
169
+ expect(String(brk?.condition)).toContain('length(_gaps)');
170
+ // then 里有 break
171
+ expect(((brk?.then ?? []) as StepDef[]).some((s) => s.type === 'break')).toBe(true);
172
+ });
173
+
174
+ it('s0_pause:真 HITL 闸门(free_text=approve,用户输入作为补充要求贯穿)', async () => {
175
+ const def = await parseWorkflowFile(wfPath);
176
+ const pause = def.steps.find((s) => s.id === 's0_pause');
177
+ expect(pause?.type).toBe('pause');
178
+ expect(pause?.free_text).toBe('approve');
179
+ expect(typeof pause?.prompt).toBe('string');
180
+ });
181
+
182
+ it('s6_build_report:确定性 Bash 拼装 out/research/${session}/deliverable/report.md(不烧 LLM),sec 降级标题,s6_package 精选脚本', async () => {
183
+ const def = await parseWorkflowFile(wfPath);
184
+ const build = def.steps.find((s) => s.id === 's6_build_report');
185
+ expect(build?.tool).toBe('Bash');
186
+ const cmd = String((build?.args as any)?.command);
187
+ // Phase 3:产物路径按 session 隔离(${session} 占位符由引擎展开)
188
+ expect(cmd).toContain('out/research/${session}/deliverable/report.md');
189
+ expect(cmd).toContain('sec out/research/${session}/01-goal.md');
190
+ expect(cmd).toContain('04-deep-dive');
191
+ expect(cmd).toContain("sed -E 's/^(#+) /\\1## /'");
192
+ expect((build?.artifacts ?? [])?.length).toBeGreaterThan(0);
193
+
194
+ const pkg = def.steps.find((s) => s.id === 's6_package');
195
+ expect(typeof pkg?.agent).toBe('string');
196
+ expect(String(pkg?.done_when)).toContain('deliverable/scripts/README.md');
197
+
198
+ const gate = def.steps.find((s) => s.id === 's6_assert_report');
199
+ expect(String(gate?.condition)).toContain('out/research/${session}/deliverable/report.md');
200
+ });
201
+
202
+ it('s6 Bash 转义与注入防线:\\$ 经引擎插值还原为 $(Bash 变量可展开);自由文本 topic 不进命令体', async () => {
203
+ const def = await parseWorkflowFile(wfPath);
204
+ const build = def.steps.find((s) => s.id === 's6_build_report');
205
+ const rawCmd = String((build?.args as any)?.command);
206
+
207
+ // 注入防线:自由文本 inputs.topic 不得出现在任何 Bash 命令体里(enum 白名单的 depth 可以)
208
+ expect(rawCmd).not.toContain('${inputs.topic}');
209
+ const initCmd = String(
210
+ ((def.steps.find((s) => s.id === 'init')?.args as any) ?? {}).command,
211
+ );
212
+ expect(initCmd).not.toContain('${inputs.topic}');
213
+
214
+ // 转义还原:引擎 interpolate 把 \$ 还原为 $ 后再交给 Bash --
215
+ // \$f/\$OUT 必须被还原(否则 Bash 拿到字面 $f 文件名,deep-dive 拼装会静默空白)
216
+ const vars = new VarStack();
217
+ vars.setGlobal('inputs', { topic: 'T', depth: 'standard', max_iterations: 2 });
218
+ const out = interpolate(rawCmd, vars);
219
+ expect(out).not.toContain('\\$');
220
+ expect(out).toContain('$OUT');
221
+ expect(out).toContain('$f');
222
+ });
223
+
224
+ it('所有 tool: Bash 步骤的命令体都不触发 permanentDeleteGuard(包括注释里的示例代码)', async () => {
225
+ const def = await parseWorkflowFile(wfPath);
226
+ const bashSteps = def.steps.filter(
227
+ (s) => (s as any).tool === 'Bash',
228
+ );
229
+ expect(bashSteps.length).toBeGreaterThan(0);
230
+
231
+ // 逐行匹配 permanentDeleteGuard 的 BASH_DELETE 正则:
232
+ // /(?:^|[;&|\n])\s*(?:sudo\s+)?(?:rm|unlink|rmdir)\b/i
233
+ // /(?:^|[;&|\n])\s*find\b[^\n;&|]*\s-delete\b/i
234
+ const delPatterns = [
235
+ /(?:^|[;&|\n])\s*(?:sudo\s+)?(?:rm|unlink|rmdir)\b/i,
236
+ /(?:^|[;&|\n])\s*find\b[^\n;&|]*\s-delete\b/i,
237
+ ];
238
+ for (const step of bashSteps) {
239
+ const cmd = String(((step as any).args as any).command ?? '');
240
+ for (const pat of delPatterns) {
241
+ if (pat.test(cmd)) {
242
+ throw new Error(
243
+ `步骤 ${step.id} 的 Bash 命令体命中了 permanentDeleteGuard 正则 ${pat.toString()}`,
244
+ );
245
+ }
246
+ }
247
+ }
248
+ });
249
+
250
+ it('所有 ${} 模板插值和裸表达式都能被引擎求值(不抛错)', async () => {
251
+ const def = await parseWorkflowFile(wfPath);
252
+ const vars = new VarStack();
253
+ vars.setGlobal('inputs', {
254
+ topic: 'Rust 异步运行时',
255
+ depth: 'standard',
256
+ max_iterations: 2,
257
+ });
258
+ vars.setGlobal('branches', [
259
+ { name: 'Tokio', safe_name: 'tokio', description: '主流' },
260
+ { name: 'async-std', safe_name: 'async-std', description: '另一个' },
261
+ ]);
262
+ vars.setGlobal('_gaps', []);
263
+ vars.setGlobal('_init_out', 'init_ok');
264
+ vars.setGlobal('session', 'default');
265
+ vars.setGlobal('_report_out', 'ok:out/research/${session}/deliverable/report.md');
266
+ vars.setIteration('branch', { name: 'Tokio', safe_name: 'tokio', description: '主流' });
267
+ vars.setIteration('branch_idx', 0);
268
+ vars.setIteration('_iter', 0);
269
+ vars.setIteration('_iter_idx', 0);
270
+
271
+ // 模板插值
272
+ for (const s of allStrings(def)) {
273
+ if (!s.includes('${')) continue;
274
+ expect(() => interpolate(s, vars)).not.toThrow(
275
+ `字符串插值失败: ${s.slice(0, 200)}`,
276
+ );
277
+ }
278
+
279
+ // 裸表达式:when / condition / over / done_when / set 值 / max_iterations
280
+ const naked: Array<[string, string]> = [];
281
+ for (const s of allStepsRec(def.steps)) {
282
+ if (s.when) naked.push([`${s.id}.when`, s.when]);
283
+ if (s.condition) naked.push([`${s.id}.condition`, s.condition]);
284
+ if (typeof s.over === 'string') naked.push([`${s.id}.over`, s.over]);
285
+ if (s.done_when) naked.push([`${s.id}.done_when`, s.done_when]);
286
+ if (typeof s.max_iterations === 'string')
287
+ naked.push([`${s.id}.max_iterations`, s.max_iterations]);
288
+ for (const [k, v] of Object.entries(s.set ?? {})) {
289
+ if (typeof v === 'string' && v.startsWith('${') && v.endsWith('}')) {
290
+ naked.push([`${s.id}.set.${k}`, v.slice(2, -1)]);
291
+ }
292
+ }
293
+ }
294
+ for (const [loc, expr] of naked) {
295
+ expect(() => evalExpr(expr, vars)).not.toThrow(
296
+ `表达式失败 [${loc}]: ${expr}`,
297
+ );
298
+ }
299
+ });
300
+
301
+ it('补洞收敛可见化:s5b_final_gaps 恒执行(无 when),done 按剩余缺口数分流提示', async () => {
302
+ const def = await parseWorkflowFile(wfPath);
303
+ const steps = allStepsRec(def.steps);
304
+
305
+ // s5b_final_gaps 无 when 守卫:early-exit 路径也执行(gaps.json=[] 由 s1_exit_report 补齐),
306
+ // 保证 done 引用的 _remaining_gaps 恒有定义
307
+ const fg = steps.find((s) => s.id === 's5b_final_gaps');
308
+ expect(fg).toBeDefined();
309
+ expect(fg?.when).toBeUndefined();
310
+ expect(String(fg?.set?._remaining_gaps)).toContain("readJson('out/research/${session}/gaps.json')");
311
+
312
+ // done 消息按 _remaining_gaps 分流:>0 警告剩余缺口,=0 报告收敛
313
+ const done = steps.find((s) => s.id === 'done');
314
+ const msg = String(done?.message);
315
+ expect(msg).toContain('_remaining_gaps > 0');
316
+ expect(msg).toContain('缺口未补全');
317
+ expect(msg).toContain('已全部收敛');
318
+
319
+ // 三元插值在两种取值下都能求值(模拟 done 执行)
320
+ const vars = new VarStack();
321
+ vars.setGlobal('inputs', { topic: 'T', depth: 'light', max_iterations: 1 });
322
+ const ternary = '_remaining_gaps > 0 ? \'⚠️ 仍有 \' + _remaining_gaps + \' 条缺口\' : \'✅ 收敛\'';
323
+ vars.setGlobal('_remaining_gaps', 3);
324
+ expect(evalExpr(ternary, vars)).toContain('3 条缺口');
325
+ vars.setGlobal('_remaining_gaps', 0);
326
+ expect(evalExpr(ternary, vars)).toContain('收敛');
327
+ });
328
+ });
@@ -28,6 +28,7 @@ import {
28
28
  existsSync,
29
29
  mkdirSync,
30
30
  mkdtempSync,
31
+ readdirSync,
31
32
  readFileSync,
32
33
  rmSync,
33
34
  writeFileSync,
@@ -36,6 +37,7 @@ import { tmpdir } from 'node:os';
36
37
  import { join } from 'node:path';
37
38
 
38
39
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
40
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
39
41
  import { ALL_TOOLS } from '../../../src/tools/index.js';
40
42
  import { runWorkflow } from '../src/runner.js';
41
43
  import type { LoopEvent, Provider } from '../../../src/types.js';
@@ -43,6 +45,43 @@ import type { RunContext, WorkflowDef, WorkflowEvent } from '../src/types.js';
43
45
 
44
46
  type WfOrLoop = WorkflowEvent | LoopEvent;
45
47
 
48
+ const DEFAULT_SESSION = 'test-session';
49
+
50
+ function findInstanceDir(base: string, wfName: string, suffix = ''): string | null {
51
+ const wfDir = join(base, '.minimal-agent-workflow', wfName);
52
+ if (!existsSync(wfDir)) {
53
+ const legacyDir = suffix
54
+ ? join(base, '.minimal-agent-workflow', wfName + suffix)
55
+ : wfDir;
56
+ if (existsSync(legacyDir)) return legacyDir;
57
+ return null;
58
+ }
59
+
60
+ // Phase 3:session 化——实例目录名 = sessionId(默认 'default')
61
+ const sessionDir = suffix
62
+ ? join(wfDir, DEFAULT_SESSION + suffix)
63
+ : join(wfDir, DEFAULT_SESSION);
64
+ if (existsSync(sessionDir)) return sessionDir;
65
+
66
+ // 旧布局兼容:wfDir 本身有 current.json(无子实例目录时判定为 legacy)
67
+ if (!suffix && existsSync(join(wfDir, 'current.json'))) {
68
+ try {
69
+ const entries = readdirSync(wfDir, { withFileTypes: true });
70
+ const hasInstanceSubdir = entries.some(
71
+ (e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
72
+ );
73
+ if (!hasInstanceSubdir) return wfDir;
74
+ } catch {
75
+ return wfDir;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function hasInstance(base: string, wfName: string, suffix = ''): boolean {
82
+ return findInstanceDir(base, wfName, suffix) !== null;
83
+ }
84
+
46
85
  const fakeProvider: Provider = {
47
86
  name: 'test',
48
87
  baseURL: '',
@@ -72,7 +111,13 @@ describe('runner::pre-flight fresh 模式', () => {
72
111
  initWorkingDir: () => tmp,
73
112
  _resetWorkingDir: () => {},
74
113
  }));
75
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
114
+ ctx = {
115
+ provider: fakeProvider,
116
+ history: [],
117
+ cwd: tmp,
118
+ tools: [],
119
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
120
+ };
76
121
  });
77
122
 
78
123
  afterEach(() => {
@@ -125,7 +170,13 @@ describe('runner::resume 与断点续执行', () => {
125
170
  initWorkingDir: () => tmp,
126
171
  _resetWorkingDir: () => {},
127
172
  }));
128
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: ALL_TOOLS };
173
+ ctx = {
174
+ provider: fakeProvider,
175
+ history: [],
176
+ cwd: tmp,
177
+ tools: ALL_TOOLS,
178
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
179
+ };
129
180
  });
130
181
 
131
182
  afterEach(() => {
@@ -254,7 +305,13 @@ describe('runner::self_heal retry 机制', () => {
254
305
  initWorkingDir: () => tmp,
255
306
  _resetWorkingDir: () => {},
256
307
  }));
257
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
308
+ ctx = {
309
+ provider: fakeProvider,
310
+ history: [],
311
+ cwd: tmp,
312
+ tools: [],
313
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
314
+ };
258
315
  });
259
316
 
260
317
  afterEach(() => {
@@ -350,9 +407,9 @@ describe('runner::self_heal retry 机制', () => {
350
407
 
351
408
  await collect(runWorkflow(def, {}, ctx));
352
409
 
353
- // 成功后应 archive(活动目录改名 .done;E6 后按名隔离)
354
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'archive-test'))).toBe(false);
355
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'archive-test.done'))).toBe(true);
410
+ // 成功后应 archive(活动实例目录改名 .done;workflow 级父目录保留)
411
+ expect(hasInstance(tmp, 'archive-test')).toBe(false);
412
+ expect(hasInstance(tmp, 'archive-test', '.done')).toBe(true);
356
413
  });
357
414
 
358
415
  it('失败路径 → 保留状态目录供排查', async () => {
@@ -367,12 +424,12 @@ describe('runner::self_heal retry 机制', () => {
367
424
 
368
425
  await collect(runWorkflow(def, {}, ctx));
369
426
 
370
- // 失败时应保留状态目录(E6 后按名隔离)
371
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'keep-state'))).toBe(true);
427
+ // 失败时应保留状态目录(workflow 级父目录 + 实例目录)
428
+ expect(hasInstance(tmp, 'keep-state')).toBe(true);
372
429
  // progress.md 应包含失败步骤信息
373
- const progressPath = join(tmp, '.minimal-agent-workflow', 'keep-state', 'progress.md');
374
- if (existsSync(progressPath)) {
375
- const progress = readFileSync(progressPath, 'utf8');
430
+ const instDir = findInstanceDir(tmp, 'keep-state');
431
+ if (instDir) {
432
+ const progress = readFileSync(join(instDir, 'progress.md'), 'utf8');
376
433
  expect(progress).toContain('fail');
377
434
  }
378
435
  });
@@ -12,11 +12,12 @@
12
12
  */
13
13
 
14
14
  import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
15
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
15
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
20
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
20
21
  import { ALL_TOOLS } from '../../../src/tools/index.js';
21
22
  import { runWorkflow } from '../src/runner.js';
22
23
  import type { LoopEvent, Provider } from '../../../src/types.js';
@@ -41,6 +42,45 @@ async function collect(
41
42
  }
42
43
 
43
44
  const STATE_DIR = '.minimal-agent-workflow';
45
+ // 集成测试统一用显式 sessionId(引擎对未指定 session 自动命名,见 runner 的
46
+ // generateDefaultSessionId;测目录生命周期需要可预测的 session 名)
47
+ const SESSION = 'test-session';
48
+
49
+ function findInstanceDir(base: string, wfName: string, suffix = ''): string | null {
50
+ const wfDir = join(base, STATE_DIR, wfName);
51
+ if (!existsSync(wfDir)) {
52
+ // 旧布局兼容:wfDir 本身就是实例目录(wfName.done 形式)
53
+ const legacyDir = suffix
54
+ ? join(base, STATE_DIR, wfName + suffix)
55
+ : wfDir;
56
+ if (existsSync(legacyDir)) return legacyDir;
57
+ return null;
58
+ }
59
+
60
+ // Phase 3:session 化——实例目录名 = sessionId
61
+ const sessionDir = suffix
62
+ ? join(wfDir, SESSION + suffix)
63
+ : join(wfDir, SESSION);
64
+ if (existsSync(sessionDir)) return sessionDir;
65
+
66
+ // 旧布局兼容:wfDir 本身有 current.json(无子实例目录时判定为 legacy)
67
+ if (!suffix && existsSync(join(wfDir, 'current.json'))) {
68
+ try {
69
+ const entries = readdirSync(wfDir, { withFileTypes: true });
70
+ const hasInstanceSubdir = entries.some(
71
+ (e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
72
+ );
73
+ if (!hasInstanceSubdir) return wfDir;
74
+ } catch {
75
+ return wfDir;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function hasInstance(base: string, wfName: string, suffix = ''): boolean {
82
+ return findInstanceDir(base, wfName, suffix) !== null;
83
+ }
44
84
 
45
85
  describe('workflow state-dir 生命周期', () => {
46
86
  let tmp: string;
@@ -55,7 +95,14 @@ describe('workflow state-dir 生命周期', () => {
55
95
  initWorkingDir: () => tmp,
56
96
  _resetWorkingDir: () => {},
57
97
  }));
58
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: ALL_TOOLS };
98
+ ctx = {
99
+ provider: fakeProvider,
100
+ history: [],
101
+ cwd: tmp,
102
+ tools: ALL_TOOLS,
103
+ // 显式 sessionId:目录名可预测,resume/归档断言稳定
104
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: SESSION, owner: 'local-owner', interactiveAsk: false }),
105
+ };
59
106
  });
60
107
 
61
108
  afterEach(() => {
@@ -73,8 +120,8 @@ describe('workflow state-dir 生命周期', () => {
73
120
  const events = await collect(runWorkflow(def, {}, ctx));
74
121
  expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
75
122
  // 成功路径:活动目录被归档改名(completed 检测靠它),不留活动现场
76
- expect(existsSync(join(tmp, STATE_DIR, 'ok'))).toBe(false);
77
- expect(existsSync(join(tmp, STATE_DIR, 'ok.done'))).toBe(true);
123
+ expect(hasInstance(tmp, 'ok')).toBe(false);
124
+ expect(hasInstance(tmp, 'ok', '.done')).toBe(true);
78
125
  });
79
126
 
80
127
  it('halt → 保留状态目录 + progress.md 含失败那步', async () => {
@@ -91,9 +138,10 @@ describe('workflow state-dir 生命周期', () => {
91
138
  expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
92
139
 
93
140
  // 失败现场必须保留,供事后排查
94
- const stateDir = join(tmp, STATE_DIR, 'boom');
95
- expect(existsSync(stateDir)).toBe(true);
96
- const progress = readFileSync(join(stateDir, 'progress.md'), 'utf8');
141
+ const stateDir = findInstanceDir(tmp, 'boom');
142
+ expect(stateDir).not.toBeNull();
143
+ expect(stateDir).toBeDefined();
144
+ const progress = readFileSync(join(stateDir as string, 'progress.md'), 'utf8');
97
145
  // 失败那步的 ✗ 行 + onFail 文案都应在 progress 里
98
146
  expect(progress).toContain('will_fail');
99
147
  expect(progress).toContain('nope');
@@ -106,7 +154,7 @@ describe('workflow state-dir 生命周期', () => {
106
154
  steps: [{ id: 'f', type: 'assert', condition: 'false', onFail: 'x' }],
107
155
  };
108
156
  await collect(runWorkflow(failDef, {}, ctx));
109
- expect(existsSync(join(tmp, STATE_DIR, 'rerun'))).toBe(true); // 失败留下了
157
+ expect(hasInstance(tmp, 'rerun')).toBe(true); // 失败留下了
110
158
 
111
159
  // 第二次成功跑(同名同 inputs = 同任务 resume):成功后归档 → 活动目录干净
112
160
  const okDef: WorkflowDef = {
@@ -115,8 +163,8 @@ describe('workflow state-dir 生命周期', () => {
115
163
  steps: [{ id: 'ok', type: 'assert', condition: 'true' }],
116
164
  };
117
165
  await collect(runWorkflow(okDef, {}, ctx));
118
- expect(existsSync(join(tmp, STATE_DIR, 'rerun'))).toBe(false); // 不堆积
119
- expect(existsSync(join(tmp, STATE_DIR, 'rerun.done'))).toBe(true);
166
+ expect(hasInstance(tmp, 'rerun')).toBe(false); // 不堆积
167
+ expect(hasInstance(tmp, 'rerun', '.done')).toBe(true);
120
168
  });
121
169
 
122
170
  it('E6:A 工作流失败现场不被不同名工作流 B 的运行破坏(按名隔离)', async () => {
@@ -131,7 +179,7 @@ describe('workflow state-dir 生命周期', () => {
131
179
  ],
132
180
  };
133
181
  await collect(runWorkflow(aDef, {}, ctx));
134
- expect(existsSync(join(tmp, STATE_DIR, 'wf-a'))).toBe(true);
182
+ expect(hasInstance(tmp, 'wf-a')).toBe(true);
135
183
  expect(existsSync(join(tmp, 'A-PLAN.md'))).toBe(true);
136
184
 
137
185
  // 跑不同名工作流 B(曾经会清掉 A 的状态目录和产物——E6 事故)
@@ -144,7 +192,7 @@ describe('workflow state-dir 生命周期', () => {
144
192
  expect(evB.some((e) => e.type === 'workflow_done')).toBe(true);
145
193
 
146
194
  // A 的现场与产物完好,resume 能力保留
147
- expect(existsSync(join(tmp, STATE_DIR, 'wf-a'))).toBe(true);
195
+ expect(hasInstance(tmp, 'wf-a')).toBe(true);
148
196
  expect(existsSync(join(tmp, 'A-PLAN.md'))).toBe(true);
149
197
  });
150
198
  });
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
27
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
27
28
  import type { LoopEvent, Provider } from '../../../src/types.js';
28
29
  import {
29
30
  buildTaskArbitrator,
@@ -185,11 +186,13 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
185
186
  });
186
187
 
187
188
  it('仲裁判 resume → 保留旧状态与产物、回放 checkpoint、带 prevGoal/supplementaryGoal', async () => {
188
- // 旧任务:fresh + 完成一步 + 留产物(不 markDone = 未完成)
189
+ // 旧任务:fresh + 完成一步 + 留产物 + 留补充要求(不 markDone = 未完成)
189
190
  const s1 = new WorkflowState(tmp, 'plan');
190
191
  await s1.init('plan', { goal: '写产品架构 PPT' }, STEPS, 1, ['PLAN.md']);
191
192
  await s1.recordCheckpoint('s1', 'ok', { out: 'v1' }, {});
193
+ await s1.appendSupplement('用户要求加一章');
192
194
  writeFileSync(join(tmp, 'PLAN.md'), '# 半成品');
195
+ const oldDir = s1.dir;
193
196
 
194
197
  // 新输入「继续」(hash 必然不等)+ 仲裁器判 resume
195
198
  const s2 = new WorkflowState(tmp, 'plan');
@@ -202,6 +205,10 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
202
205
  expect(r.supplementaryGoal).toBe('继续');
203
206
  // 现场未被清理
204
207
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(true);
208
+ // 回归:resume 后实例目录必须仍指向旧任务(否则 supplements / checkpoint 全写丢,
209
+ // 断点续跑静默失效——曾因 finally 误把 this.dir 改回新 hash 目录)
210
+ expect(s2.dir).toBe(oldDir);
211
+ expect(await s2.readSupplements()).toEqual(['用户要求加一章']);
205
212
  });
206
213
 
207
214
  it('仲裁判 new → 走 new-task 清理重开', async () => {
@@ -257,7 +264,9 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
257
264
  expect(r.mode).toBe('fresh');
258
265
  expect(arbCalled).toBe(0);
259
266
  // A 的现场完好(曾经会被 B 的 new-task 清掉——E6 事故)
260
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'other-wf', 'current.json'))).toBe(true);
267
+ // 新布局:current.json 在实例子目录里;旧布局兼容时直接在 wfDir 下
268
+ const aDir = sA.dir;
269
+ expect(existsSync(join(aDir, 'current.json'))).toBe(true);
261
270
  });
262
271
  });
263
272
 
@@ -276,7 +285,14 @@ describe('runWorkflow × 仲裁延续(端到端)', () => {
276
285
  initWorkingDir: () => tmp,
277
286
  _resetWorkingDir: () => {},
278
287
  }));
279
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
288
+ ctx = {
289
+ provider: fakeProvider,
290
+ history: [],
291
+ cwd: tmp,
292
+ tools: [],
293
+ // 显式 sessionId:两次 runWorkflow 同 session,仲裁「继续」才能 resume 旧任务
294
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: 'test-session', owner: 'local-owner', interactiveAsk: false }),
295
+ };
280
296
  });
281
297
  afterEach(() => {
282
298
  delete process.env.MINIMAL_AGENT_CWD;