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.
- package/package.json +3 -2
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +45 -17
- package/plugins/workflow-runner/src/index.ts +44 -18
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +111 -7
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +17 -0
- package/src/cli/print.ts +25 -0
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/plugins/pluginRunner.js +18 -7
- package/src/plugins/pluginRunner.ts +20 -6
- package/src/prompts/policyRegistry.js +28 -1
- package/src/prompts/policyRegistry.ts +28 -1
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/bash/bash.js +51 -6
- package/src/tools/bash/bash.ts +56 -7
- package/src/tools/powershell/powershell.js +18 -1
- package/src/tools/powershell/powershell.ts +18 -2
- package/src/tools/read/read.js +9 -4
- package/src/tools/read/read.ts +9 -4
- package/src/tools/shared/permanentDeleteGuard.js +34 -3
- package/src/tools/shared/permanentDeleteGuard.ts +43 -6
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/types.ts +10 -4
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- 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 = {
|
|
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 = {
|
|
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 = {
|
|
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
|
|
354
|
-
expect(
|
|
355
|
-
expect(
|
|
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
|
-
// 失败时应保留状态目录(
|
|
371
|
-
expect(
|
|
427
|
+
// 失败时应保留状态目录(workflow 级父目录 + 实例目录)
|
|
428
|
+
expect(hasInstance(tmp, 'keep-state')).toBe(true);
|
|
372
429
|
// progress.md 应包含失败步骤信息
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
const progress = readFileSync(
|
|
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 = {
|
|
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(
|
|
77
|
-
expect(
|
|
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 =
|
|
95
|
-
expect(
|
|
96
|
-
|
|
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(
|
|
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(
|
|
119
|
-
expect(
|
|
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(
|
|
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(
|
|
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 + 完成一步 +
|
|
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
|
-
|
|
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 = {
|
|
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;
|