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
|
@@ -144,4 +144,24 @@ describe('CLI 端到端 /workflow(拉子进程跑 main.tsx)', () => {
|
|
|
144
144
|
},
|
|
145
145
|
SPAWN_TIMEOUT,
|
|
146
146
|
);
|
|
147
|
+
|
|
148
|
+
// B7:用户 shell 习惯写 --input brief=A B C(没加引号),shell 拆成 --input brief=A + B + C
|
|
149
|
+
// 三个 token;引擎识别为首参已由 --input 提供、但仍存在散落位置参数,按贪婪语义自动拼回,
|
|
150
|
+
// 不丢 B、C(之前 B/C 被当占位参数映射给后续非贪婪 input,或直接静默丢弃)。
|
|
151
|
+
it(
|
|
152
|
+
'B7:贪婪首参用 --input 提供但没加引号被拆 → 位置参数自动拼回首参',
|
|
153
|
+
async () => {
|
|
154
|
+
// 注意:命令字符串本身在这里是单条 input,但引擎 tokenize 后是:
|
|
155
|
+
// [/workflow, greedy-e2e, --input, brief=做, 一个, 小玩具]
|
|
156
|
+
// 即 greedy-e2e 之后的内容中,--input brief=做 是首参赋值,「一个」「小玩具」
|
|
157
|
+
// 是被 shell-like 拆分出来的位置参数,B7 修复后必须自动拼回为「做 一个 小玩具」。
|
|
158
|
+
const r = await spawnMain('/workflow greedy-e2e --input brief=做 一个 小玩具', tmp);
|
|
159
|
+
expect(r.exitCode).toBe(0);
|
|
160
|
+
expect(r.stdout).toContain('GREEDY-BRIEF=[做 一个 小玩具]');
|
|
161
|
+
// 修复附带的用户提示:告诉用户下次记得加引号(-p 模式 warning 走 stderr 或 stdout 都算)
|
|
162
|
+
const combined = r.stdout + '\n' + r.stderr;
|
|
163
|
+
expect(combined).toContain('已自动拼回');
|
|
164
|
+
},
|
|
165
|
+
SPAWN_TIMEOUT,
|
|
166
|
+
);
|
|
147
167
|
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 端到端验证 Phase 3:session 化状态目录布局(<wfDir>/<sessionId>/)
|
|
3
|
+
* 在真实 runWorkflow 下的完整行为:fresh → 同 goal completed →
|
|
4
|
+
* 不同 goal new-task + 归档 → 旧布局兼容 → 多 session 并行隔离。
|
|
5
|
+
*
|
|
6
|
+
* 复用 e2e.test.ts 的 mock 模式(mock workingDir.ts + MINIMAL_AGENT_CWD),
|
|
7
|
+
* 用 hello-workflow fixture(无 LLM 依赖,确定性强)。
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
it,
|
|
15
|
+
mock,
|
|
16
|
+
} from 'bun:test';
|
|
17
|
+
import {
|
|
18
|
+
copyFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
|
|
31
|
+
import { _resetPluginCache } from '../../../src/plugins/commandRouter.js';
|
|
32
|
+
import type { Provider } from '../../../src/types.js';
|
|
33
|
+
import type { PluginEvent } from '../../../src/plugins/pluginApi.js';
|
|
34
|
+
import type { WorkflowEvent } from '../src/types.js';
|
|
35
|
+
|
|
36
|
+
type Ev = PluginEvent | WorkflowEvent;
|
|
37
|
+
|
|
38
|
+
const FIXTURE_SRC = join(import.meta.dir, 'fixtures', 'hello-workflow.yaml');
|
|
39
|
+
|
|
40
|
+
const provider: Provider = {
|
|
41
|
+
name: 'test',
|
|
42
|
+
baseURL: '',
|
|
43
|
+
apiKey: '',
|
|
44
|
+
model: 'm',
|
|
45
|
+
contextWindow: 128000,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function collect(gen: AsyncGenerator<Ev, void, void>): Promise<Ev[]> {
|
|
49
|
+
const out: Ev[] = [];
|
|
50
|
+
for await (const ev of gen) out.push(ev);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SESSION_ALPHA = 'alpha';
|
|
55
|
+
const SESSION_BETA = 'beta';
|
|
56
|
+
|
|
57
|
+
describe('workflow E2E · session 化新布局(Phase 3)', () => {
|
|
58
|
+
let tmp: string;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-phase3-'));
|
|
62
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
63
|
+
// 拷贝 fixture 并:① 加 goal input(hash 身份的关键字段,区分任务)
|
|
64
|
+
// ② 补静态路径 artifacts 声明 + 一个写它的 step(new-task 归档逻辑依赖声明产物;
|
|
65
|
+
// 注意 artifacts 不支持 ${var} 插值,必须用固定路径)
|
|
66
|
+
const raw = readFileSync(FIXTURE_SRC, 'utf8');
|
|
67
|
+
const patched = raw
|
|
68
|
+
.replace(
|
|
69
|
+
'version: "0.1"',
|
|
70
|
+
'version: "0.1"\n\nartifacts:\n - "greetings/output.txt"',
|
|
71
|
+
)
|
|
72
|
+
.replace(
|
|
73
|
+
'inputs:\n - name: name',
|
|
74
|
+
'inputs:\n - name: goal\n type: string\n required: true\n description: 任务身份\n - name: name',
|
|
75
|
+
)
|
|
76
|
+
// 在 verify_files 前插一个写 output.txt 的 step
|
|
77
|
+
.replace(
|
|
78
|
+
' - id: verify_files',
|
|
79
|
+
` - id: write_output\n tool: Write\n args:\n file_path: "greetings/output.txt"\n content: "ok"\n\n - id: verify_files`,
|
|
80
|
+
);
|
|
81
|
+
writeFileSync(join(tmp, 'workflows', 'hello-workflow.yaml'), patched);
|
|
82
|
+
|
|
83
|
+
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
84
|
+
_resetWorkingDir();
|
|
85
|
+
_resetPluginCache();
|
|
86
|
+
mock.module('../../../src/bootstrap/workingDir.js', () => ({
|
|
87
|
+
getWorkingDir: () => tmp,
|
|
88
|
+
initWorkingDir: () => tmp,
|
|
89
|
+
_resetWorkingDir: () => {},
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
delete process.env.MINIMAL_AGENT_CWD;
|
|
95
|
+
_resetWorkingDir();
|
|
96
|
+
_resetPluginCache();
|
|
97
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function runCmd(
|
|
101
|
+
input: string,
|
|
102
|
+
opts: { sessionId?: string } = {},
|
|
103
|
+
): Promise<Ev[]> {
|
|
104
|
+
const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.js');
|
|
105
|
+
const runtimeContext: Record<string, unknown> = {};
|
|
106
|
+
if (opts.sessionId) runtimeContext.sessionId = opts.sessionId;
|
|
107
|
+
return collect(runWithPlugins(input, {
|
|
108
|
+
provider,
|
|
109
|
+
history: [],
|
|
110
|
+
runtimeContext: runtimeContext as any,
|
|
111
|
+
}) as AsyncGenerator<Ev>);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function wfDir(): string {
|
|
115
|
+
return join(tmp, '.minimal-agent-workflow', 'hello-workflow');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sessionActive(session: string): boolean {
|
|
119
|
+
return existsSync(join(wfDir(), session));
|
|
120
|
+
}
|
|
121
|
+
function sessionDone(session: string): boolean {
|
|
122
|
+
return existsSync(join(wfDir(), `${session}.done`));
|
|
123
|
+
}
|
|
124
|
+
function sessionTrash(session: string): boolean {
|
|
125
|
+
return existsSync(join(wfDir(), `${session}.trash`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('fresh → 真实 runWorkflow 跑完归档到 <wfDir>/<sessionId>.done/(成功路径)', async () => {
|
|
129
|
+
await runCmd(
|
|
130
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
131
|
+
{ sessionId: SESSION_BETA },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// 跑完后状态被 archive,活动实例目录被改名 <sessionId>.done/
|
|
135
|
+
expect(sessionActive(SESSION_BETA)).toBe(false);
|
|
136
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
137
|
+
// 产物实际落盘到 cwd(不是状态目录里)
|
|
138
|
+
expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
|
|
139
|
+
// 状态目录保留了 current.json / inputs.json / progress.md 等(供后续回溯)
|
|
140
|
+
const doneDir = join(wfDir(), `${SESSION_BETA}.done`);
|
|
141
|
+
expect(existsSync(join(doneDir, 'current.json'))).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('未指定 sessionId → 引擎自动生成唯一 session(非 default),多 session 互不覆盖', async () => {
|
|
145
|
+
// 不传 sessionId:宿主未提供有意义 session(runtimeContext.sessionId 缺失),
|
|
146
|
+
// 引擎应自动命名一个唯一 session(YYYYMMDD-HHmmss-xxxxxx),而非固定 'default'
|
|
147
|
+
await runCmd(
|
|
148
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
149
|
+
);
|
|
150
|
+
let doneEntries = readdirSync(wfDir()).filter((n) => n.endsWith('.done'));
|
|
151
|
+
expect(doneEntries.length).toBe(1);
|
|
152
|
+
const autoSession = doneEntries[0].slice(0, -'.done'.length);
|
|
153
|
+
expect(autoSession).not.toBe('default');
|
|
154
|
+
expect(/^\d{8}-\d{6}-[0-9a-f]{6}$/.test(autoSession)).toBe(true);
|
|
155
|
+
// 产物落盘
|
|
156
|
+
expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
|
|
157
|
+
|
|
158
|
+
// 再跑一次(不同 goal):引擎自动生成第二个独立 session,各自归档互不覆盖
|
|
159
|
+
await runCmd(
|
|
160
|
+
'/workflow hello-workflow --input goal=任务X --input name=carol --input lang=en',
|
|
161
|
+
);
|
|
162
|
+
doneEntries = readdirSync(wfDir()).filter((n) => n.endsWith('.done'));
|
|
163
|
+
expect(doneEntries.length).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('fresh + 同 goal 再跑 → completed(命中 .done/ 归档,第二次不写产物)', async () => {
|
|
167
|
+
await runCmd(
|
|
168
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
169
|
+
{ sessionId: SESSION_BETA },
|
|
170
|
+
);
|
|
171
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
172
|
+
|
|
173
|
+
const events = await runCmd(
|
|
174
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
175
|
+
{ sessionId: SESSION_BETA },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// 第二次是 completed:.done 归档不变(同一任务已完成,不重新归档)
|
|
179
|
+
expect(sessionActive(SESSION_BETA)).toBe(false);
|
|
180
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
181
|
+
// 产物保留(completed 不删)
|
|
182
|
+
expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
|
|
183
|
+
// 事件序列表明已完成分支被命中
|
|
184
|
+
expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('goal A 完成 → 改 goal B(同 session 内 new-task):B 产物覆盖,A 产物归档到 .trash/<stamp>/', async () => {
|
|
188
|
+
await runCmd(
|
|
189
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
190
|
+
{ sessionId: SESSION_BETA },
|
|
191
|
+
);
|
|
192
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
193
|
+
expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
|
|
194
|
+
|
|
195
|
+
// 改 goal → hash 不同 → new-task(同 session 内,A 被归档后重跑 B)
|
|
196
|
+
await runCmd(
|
|
197
|
+
'/workflow hello-workflow --input goal=任务B --input name=bob --input lang=zh',
|
|
198
|
+
{ sessionId: SESSION_BETA },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// 最终仍是 .done(B 成功)
|
|
202
|
+
expect(sessionActive(SESSION_BETA)).toBe(false);
|
|
203
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
204
|
+
// B 产物存在
|
|
205
|
+
expect(existsSync(join(tmp, 'greetings/bob/hello.txt'))).toBe(true);
|
|
206
|
+
// 声明产物(output.txt)被 goal B 重新生成(write_output step 又跑了一遍)
|
|
207
|
+
expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
|
|
208
|
+
// A 的旧产物被归档到 .trash 里(goal A 的旧 output.txt)
|
|
209
|
+
const trashDir = join(wfDir(), `${SESSION_BETA}.trash`);
|
|
210
|
+
expect(sessionTrash(SESSION_BETA)).toBe(true);
|
|
211
|
+
const outputInTrash = flattenDir(trashDir).some((f) =>
|
|
212
|
+
f.endsWith('output.txt'),
|
|
213
|
+
);
|
|
214
|
+
expect(outputInTrash).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('同 goal 两个 session → 各自独立完成(Phase 3 核心:多 session 并行隔离)', async () => {
|
|
218
|
+
// session alpha 跑 goal=任务A
|
|
219
|
+
await runCmd(
|
|
220
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
221
|
+
{ sessionId: SESSION_ALPHA },
|
|
222
|
+
);
|
|
223
|
+
// session beta 跑 goal=任务A(同 goal,不同 session)
|
|
224
|
+
await runCmd(
|
|
225
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
226
|
+
{ sessionId: SESSION_BETA },
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// 两个 session 都有自己的 .done 归档,互不干扰
|
|
230
|
+
expect(sessionDone(SESSION_ALPHA)).toBe(true);
|
|
231
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
232
|
+
// 各自的 current.json 都在
|
|
233
|
+
expect(existsSync(join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'))).toBe(true);
|
|
234
|
+
expect(existsSync(join(wfDir(), `${SESSION_BETA}.done`, 'current.json'))).toBe(true);
|
|
235
|
+
// 产物(共享 cwd 下)都存在
|
|
236
|
+
expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
|
|
237
|
+
expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('不同 session 的 new-task 互不影响:A session 跑 goalA,B session 跑 goalB 时 A 完好', async () => {
|
|
241
|
+
// session alpha: goalA 完成
|
|
242
|
+
await runCmd(
|
|
243
|
+
'/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
|
|
244
|
+
{ sessionId: SESSION_ALPHA },
|
|
245
|
+
);
|
|
246
|
+
expect(sessionDone(SESSION_ALPHA)).toBe(true);
|
|
247
|
+
const alphaBefore = readFileSync(
|
|
248
|
+
join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'),
|
|
249
|
+
'utf8',
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// session beta: 跑 goalB(new-task,不应碰 alpha 的任何东西)
|
|
253
|
+
await runCmd(
|
|
254
|
+
'/workflow hello-workflow --input goal=任务B --input name=bob --input lang=zh',
|
|
255
|
+
{ sessionId: SESSION_BETA },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// alpha 的 .done 完好无损
|
|
259
|
+
expect(sessionDone(SESSION_ALPHA)).toBe(true);
|
|
260
|
+
const alphaAfter = readFileSync(
|
|
261
|
+
join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'),
|
|
262
|
+
'utf8',
|
|
263
|
+
);
|
|
264
|
+
expect(alphaAfter).toBe(alphaBefore);
|
|
265
|
+
// alpha 的产物归档(.trash)也没被 beta session 碰
|
|
266
|
+
// beta 自己的 .done 也有了
|
|
267
|
+
expect(sessionDone(SESSION_BETA)).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('旧布局状态文件(wfDir/current.json)→ 新引擎正确识别 + 处理(兼容)', async () => {
|
|
271
|
+
// 手写旧布局状态(status=running,未完成)
|
|
272
|
+
mkdirSync(wfDir(), { recursive: true });
|
|
273
|
+
writeFileSync(
|
|
274
|
+
join(wfDir(), 'current.json'),
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
name: 'hello-workflow',
|
|
277
|
+
taskId: '1234567890abcdef',
|
|
278
|
+
status: 'running',
|
|
279
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
280
|
+
artifacts: [],
|
|
281
|
+
defFingerprint: 'legacy',
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// 跑真实工作流:新引擎应当从旧布局读到状态,根据 hash 走对应分支
|
|
286
|
+
const events = await runCmd(
|
|
287
|
+
'/workflow hello-workflow --input goal=任务C --input name=carol --input lang=en',
|
|
288
|
+
{ sessionId: SESSION_BETA },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// 核心断言:新引擎成功处理(无 error),最终有归档或活动实例
|
|
292
|
+
expect(events.filter((e) => e.type === 'error').length).toBe(0);
|
|
293
|
+
expect(sessionDone(SESSION_BETA) || sessionActive(SESSION_BETA)).toBe(true);
|
|
294
|
+
// carol 产物落盘
|
|
295
|
+
expect(existsSync(join(tmp, 'greetings/carol/hello.txt'))).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** 递归列出目录下所有文件路径 */
|
|
300
|
+
function flattenDir(dir: string): string[] {
|
|
301
|
+
const out: string[] = [];
|
|
302
|
+
const walk = (d: string) => {
|
|
303
|
+
for (const e of readdirSync(d)) {
|
|
304
|
+
const p = join(d, e);
|
|
305
|
+
const isDir = existsSync(p) && (() => {
|
|
306
|
+
try { readdirSync(p); return true; } catch { return false; }
|
|
307
|
+
})();
|
|
308
|
+
if (isDir) walk(p);
|
|
309
|
+
else out.push(p);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
walk(dir);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
@@ -20,6 +20,7 @@ import { tmpdir } from 'node:os';
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
|
|
23
|
+
import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
|
|
23
24
|
import type { LoopEvent, Provider } from '../../../src/types.js';
|
|
24
25
|
import { evalExpr, interpolate } from '../src/expressions.js';
|
|
25
26
|
import { runWorkflow } from '../src/runner.js';
|
|
@@ -80,7 +81,14 @@ describe('E3/E2/E1 引擎层(runWorkflow 集成)', () => {
|
|
|
80
81
|
initWorkingDir: () => tmp,
|
|
81
82
|
_resetWorkingDir: () => {},
|
|
82
83
|
}));
|
|
83
|
-
ctx = {
|
|
84
|
+
ctx = {
|
|
85
|
+
provider: fakeProvider,
|
|
86
|
+
history: [],
|
|
87
|
+
cwd: tmp,
|
|
88
|
+
tools: [],
|
|
89
|
+
// 显式 sessionId:resume(两次 runWorkflow 同 session)场景可预测
|
|
90
|
+
runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: 'test-session', owner: 'local-owner', interactiveAsk: false }),
|
|
91
|
+
};
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
afterEach(() => {
|
|
@@ -270,6 +270,48 @@ describe('workflows/expressions::fileExists', () => {
|
|
|
270
270
|
expect(evalExpr('fileExists(inputs.name + ".txt")', vars)).toBe(true);
|
|
271
271
|
expect(evalExpr('fileExists("videos/" + inputs.name + ".txt")', vars)).toBe(false);
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
// 会话进入 session worktree 后,写文件的工具跟着 worktree 走(loop.ts 按
|
|
275
|
+
// sessionState.activeWorktree 重定向 cwd)。done_when / when / over 全靠
|
|
276
|
+
// fileExists,若它仍锚原 workspace,就会在空目录里找产物 —— 恒 false,
|
|
277
|
+
// 工作流被判失败并反复重试,而产物其实好好躺在 worktree 里。
|
|
278
|
+
it('resolveCwd 让 fileExists 跟随 session worktree 而非原 workspace', () => {
|
|
279
|
+
const worktree = mkdtempSync(join(tmpdir(), 'wf-expr-wt-'));
|
|
280
|
+
try {
|
|
281
|
+
// 产物只存在于 worktree,workspace 是空的。
|
|
282
|
+
writeFileSync(join(worktree, 'out.md'), 'artifact');
|
|
283
|
+
|
|
284
|
+
const vars = new VarStack();
|
|
285
|
+
// 未设 resolveCwd:锚 workspace(旧行为)→ 找不到。
|
|
286
|
+
expect(evalExpr('fileExists("out.md")', vars)).toBe(false);
|
|
287
|
+
|
|
288
|
+
// 设了 resolveCwd:锚 worktree → 命中。
|
|
289
|
+
vars.resolveCwd = () => worktree;
|
|
290
|
+
expect(evalExpr('fileExists("out.md")', vars)).toBe(true);
|
|
291
|
+
|
|
292
|
+
// 解析器是函数而非快照:运行途中退出 worktree 后立刻跟着变回去。
|
|
293
|
+
let active: string | undefined = worktree;
|
|
294
|
+
vars.resolveCwd = () => active ?? tmp;
|
|
295
|
+
expect(evalExpr('fileExists("out.md")', vars)).toBe(true);
|
|
296
|
+
active = undefined;
|
|
297
|
+
expect(evalExpr('fileExists("out.md")', vars)).toBe(false);
|
|
298
|
+
} finally {
|
|
299
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('resolveCwd 下越界守卫仍以该 cwd 为界', () => {
|
|
304
|
+
const worktree = mkdtempSync(join(tmpdir(), 'wf-expr-wt2-'));
|
|
305
|
+
try {
|
|
306
|
+
writeFileSync(join(tmp, 'outside.txt'), 'x');
|
|
307
|
+
const vars = new VarStack();
|
|
308
|
+
vars.resolveCwd = () => worktree;
|
|
309
|
+
// 换锚点后,原 workspace 的文件反而成了「界外」,fail-closed 仍生效。
|
|
310
|
+
expect(evalExpr('fileExists("../outside.txt")', vars)).toBe(false);
|
|
311
|
+
} finally {
|
|
312
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
273
315
|
});
|
|
274
316
|
|
|
275
317
|
describe('workflows/expressions::readJson', () => {
|
|
@@ -52,7 +52,7 @@ describe('workflows/illustrate.yaml(网上抓图配图)', () => {
|
|
|
52
52
|
expect(inputs.find((i) => i.name === 'topic')?.greedy).toBe(true);
|
|
53
53
|
expect(inputs.find((i) => i.name === 'count')?.default).toBe(3);
|
|
54
54
|
expect(inputs.find((i) => i.name === 'min_candidates')?.default).toBe(6);
|
|
55
|
-
expect(def.artifacts).toContain('
|
|
55
|
+
expect(def.artifacts).toContain('out/illustrate/${session}/build/illustrations.json');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it('关键步齐全且顺序正确:三家图搜 → 守门 → 下载 → 打分 → 选优 → 报告', async () => {
|
|
@@ -152,7 +152,7 @@ describe('workflows/illustrate.yaml(网上抓图配图)', () => {
|
|
|
152
152
|
expect(a).toContain('illustrations.json');
|
|
153
153
|
expect(String(sel?.done_when)).toContain('illustrations.json');
|
|
154
154
|
for (const t of ['Glob', 'Read', 'Write']) expect(sel?.allowed_tools).toContain(t);
|
|
155
|
-
expect(sel?.artifacts).toContain('
|
|
155
|
+
expect(sel?.artifacts).toContain('out/illustrate/${session}/build/illustrations.json');
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
it('用到本轮新增通用件:parseJson 表达式 + Download 工具', async () => {
|
|
@@ -61,6 +61,25 @@ describe('parseWorkflowArgs / tokenizeArgs 边界行为冻结', () => {
|
|
|
61
61
|
expect(r.inputs).toEqual({ topic: 'AI 教程' });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
test('引号在 = 后的 KV:value 含空格也完整捕获(回归:曾按空白硬切+引号残留)', () => {
|
|
65
|
+
// 用户 shell 习惯 `--input topic="带空格 的值"` —— 引号在 token 内部(= 之后),
|
|
66
|
+
// 曾被 tokenizeArgs 按空白切分 → 值截断 + 引号残留 + 散落 positional。
|
|
67
|
+
const r = parseWorkflowArgs(
|
|
68
|
+
'hello --input topic="比特币量化 长线 短线策略" --input depth=standard',
|
|
69
|
+
);
|
|
70
|
+
expect(r.inputs).toEqual({
|
|
71
|
+
topic: '比特币量化 长线 短线策略',
|
|
72
|
+
depth: 'standard',
|
|
73
|
+
});
|
|
74
|
+
expect(r.positional).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('单引号在 = 后同理支持', () => {
|
|
78
|
+
const r = parseWorkflowArgs("hello --input topic='AI 教程 入门'");
|
|
79
|
+
expect(r.inputs).toEqual({ topic: 'AI 教程 入门' });
|
|
80
|
+
expect(r.positional).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
64
83
|
test('未知 -- flag 跳过其后一个 token(保守解析)', () => {
|
|
65
84
|
const r = parseWorkflowArgs('hello --foo bar alice');
|
|
66
85
|
// --foo bar 被吃掉,alice 进 positional
|
|
@@ -93,11 +93,11 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
93
93
|
expect(String(s.title)).toContain('/22');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
// 收尾 echo;artifacts 只列最终交付物、不在被 rm 的
|
|
96
|
+
// 收尾 echo;artifacts 只列最终交付物、不在被 rm 的 out/ppt/${session}/build 下
|
|
97
97
|
expect(def.steps.find((s) => s.id === 'final_report')?.type).toBe('echo');
|
|
98
98
|
const declared = def.artifacts ?? [];
|
|
99
|
-
expect(declared).toContain('
|
|
100
|
-
for (const a of declared) expect(a.startsWith('
|
|
99
|
+
expect(declared).toContain('out/ppt/${session}/deck.pptx');
|
|
100
|
+
for (const a of declared) expect(a.startsWith('out/ppt/${session}/build')).toBe(false);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('stage0_clarify:样式推荐按配色感知、覆盖全部 13 个 VI(不再只露 6 个)', async () => {
|
|
@@ -205,7 +205,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
205
205
|
|
|
206
206
|
const repair = def.steps.find((s) => s.id === 'stage_draft_coverage_repair');
|
|
207
207
|
expect(String(repair?.when)).toContain('draft_coverage_report.json');
|
|
208
|
-
expect(String(repair?.when)).toContain("readJson('
|
|
208
|
+
expect(String(repair?.when)).toContain("readJson('out/ppt/${session}/build/draft_coverage_report.json', 'ok') == false");
|
|
209
209
|
// 用户点名最高优先级问题:fast/best 都要跑,when 不能限死 quality=='best'。
|
|
210
210
|
expect(String(repair?.when)).not.toContain("quality == 'best'");
|
|
211
211
|
expect(String(repair?.agent)).toContain('draft_coverage_report.md');
|
|
@@ -482,7 +482,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
482
482
|
// ① 摄取后跑确定性规划(在 design 之前,data 已齐)
|
|
483
483
|
const ids = def.steps.map((s) => s.id);
|
|
484
484
|
const am = def.steps.find((s) => s.id === 'stage_assemble_material');
|
|
485
|
-
expect(String(am?.args?.command)).toContain('plan_source_layout.py --build
|
|
485
|
+
expect(String(am?.args?.command)).toContain('plan_source_layout.py --build out/ppt/${session}/build');
|
|
486
486
|
expect(ids.indexOf('stage_assemble_material')).toBeLessThan(ids.indexOf('stage_design_doc'));
|
|
487
487
|
|
|
488
488
|
// ② 设计步「读规划、照它执行」——逐图心算被收成确定性
|
|
@@ -516,7 +516,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
516
516
|
expect(ga).toContain('--ensure-page-backgrounds');
|
|
517
517
|
expect(ga).toContain('image_prompt_rewrite.py');
|
|
518
518
|
expect(ga).toContain('image_plan_lint.py');
|
|
519
|
-
expect(ga).toContain('touch
|
|
519
|
+
expect(ga).toContain('touch out/ppt/${session}/build/img_route_ok');
|
|
520
520
|
// when 放宽:img_plan 存在 或 规划里有待增强图 —— 表达式可被引擎求值(不抛、不畸形)
|
|
521
521
|
const whenExpr = String(gate?.when);
|
|
522
522
|
expect(whenExpr).toContain('enhance_entries');
|
|
@@ -664,8 +664,8 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
664
664
|
expect(gen?.agent).toBeUndefined();
|
|
665
665
|
expect(gen?.tool).toBe('GenerateImage');
|
|
666
666
|
expect(gen?.tool).not.toBe('Bash');
|
|
667
|
-
expect(String(gen?.args?.plan)).toBe('
|
|
668
|
-
expect(String(gen?.args?.assets)).toBe('
|
|
667
|
+
expect(String(gen?.args?.plan)).toBe('out/ppt/${session}/build/img_plan.json');
|
|
668
|
+
expect(String(gen?.args?.assets)).toBe('out/ppt/${session}/build/assets');
|
|
669
669
|
expect(String(gen?.args?.timeout_ms)).toBe('300000');
|
|
670
670
|
expect(String(gen?.args?.wall_budget_ms)).toBe('3600000');
|
|
671
671
|
expect(gen?.onError).toBe('continue');
|
|
@@ -673,7 +673,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
673
673
|
const quality = def.steps.find((s) => s.id === 'stage_image_quality_gate');
|
|
674
674
|
expect(quality?.tool).toBe('GenerateImage');
|
|
675
675
|
expect(String(quality?.when)).toContain('image_generation_report.json');
|
|
676
|
-
expect(String(quality?.args?.retry_degraded_report)).toBe('
|
|
676
|
+
expect(String(quality?.args?.retry_degraded_report)).toBe('out/ppt/${session}/build/image_generation_report.json');
|
|
677
677
|
expect(String(quality?.title)).toContain('fast一轮');
|
|
678
678
|
expect(quality?.args?.max_rounds).toBe(1);
|
|
679
679
|
|
|
@@ -705,7 +705,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
705
705
|
const imageRetry = def.steps.find((s) => s.id === 'stage_generated_image_retry');
|
|
706
706
|
expect(imageRetry?.tool).toBe('GenerateImage');
|
|
707
707
|
expect(String(imageRetry?.when)).toBe('false');
|
|
708
|
-
expect(String(imageRetry?.args?.retry_degraded_report)).toBe('
|
|
708
|
+
expect(String(imageRetry?.args?.retry_degraded_report)).toBe('out/ppt/${session}/build/generated_image_semantic_retry_report.json');
|
|
709
709
|
expect(String(imageRetry?.args?.max_rounds)).toBe('3');
|
|
710
710
|
|
|
711
711
|
const visualGate = def.steps.find((s) => s.id === 'stage_visual_quality_gate');
|
|
@@ -753,7 +753,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
753
753
|
// 完全同参双跑、且视觉门达轮数已带残留放行)——style 检查唯一跑点在
|
|
754
754
|
// stage_visual_quality_gate(fast/best 统一 2 轮修复),导出只验哨兵。
|
|
755
755
|
expect(String(exp?.args?.command)).not.toContain('deck_visual_style_check.py');
|
|
756
|
-
expect(String(exp?.args?.command)).toContain('test -f
|
|
756
|
+
expect(String(exp?.args?.command)).toContain('test -f out/ppt/${session}/build/visual_quality_ok');
|
|
757
757
|
expect(exp?.done_when).toBeUndefined();
|
|
758
758
|
|
|
759
759
|
const design = def.steps.find((s) => s.id === 'stage_design_doc');
|
|
@@ -829,7 +829,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
829
829
|
expect(String(probe?.args?.command)).toContain('build_probe.json');
|
|
830
830
|
expect(String(probe?.args?.command)).toContain('build_complete');
|
|
831
831
|
const batchStep = dos.find((s) => s.id === 'build_batch');
|
|
832
|
-
expect(String(batchStep?.when)).toContain("!fileExists('
|
|
832
|
+
expect(String(batchStep?.when)).toContain("!fileExists('out/ppt/${session}/build/build_complete')");
|
|
833
833
|
expect(batchStep?.maxTurns).toBeLessThanOrEqual(24);
|
|
834
834
|
expect(batchStep?.max_retries).toBe(1);
|
|
835
835
|
const batch = String(batchStep?.agent);
|
|
@@ -840,7 +840,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
840
840
|
expect(batch).toContain('次顶层'); // 插图图层:文本之下、背景之上
|
|
841
841
|
expect(batch).toContain('遮挡');
|
|
842
842
|
const done = dos.find((s) => s.id === 'build_done');
|
|
843
|
-
expect(String(done?.when)).toContain("fileExists('
|
|
843
|
+
expect(String(done?.when)).toContain("fileExists('out/ppt/${session}/build/build_complete')");
|
|
844
844
|
});
|
|
845
845
|
|
|
846
846
|
it('fast 链路清理旧运行哨兵,且说明/报告顺序为先生图后构建', async () => {
|
|
@@ -866,12 +866,12 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
|
|
|
866
866
|
]) {
|
|
867
867
|
expect(cmd).toContain(marker);
|
|
868
868
|
}
|
|
869
|
-
expect(cmd).toContain('rm -rf
|
|
869
|
+
expect(cmd).toContain('rm -rf out/ppt/${session}/build/export out/ppt/${session}/build/generated-image-reviews');
|
|
870
870
|
|
|
871
871
|
const cleanup = def.steps.find((s) => s.id === 'cleanup_intermediates');
|
|
872
872
|
const cleanupCmd = String(cleanup?.args?.command);
|
|
873
873
|
expect(cleanupCmd).toContain('inputs.debug_keep_build');
|
|
874
|
-
expect(cleanupCmd).toContain('rm -rf
|
|
874
|
+
expect(cleanupCmd).toContain('rm -rf out/ppt/${session}/build');
|
|
875
875
|
expect(cleanupCmd).toContain('debug_keep_build=true');
|
|
876
876
|
expect(cleanupCmd).not.toContain('[TEMP]');
|
|
877
877
|
expect(cleanupCmd).not.toContain('上线前');
|
|
@@ -128,11 +128,12 @@ describe('ralph-loop.yaml 端到端(while + fresh_context + 哨兵协议)',
|
|
|
128
128
|
expect(p2).not.toContain('我做了规划');
|
|
129
129
|
|
|
130
130
|
// 教训档案恰好一条、不重复(iter1 无 learning 标签时 extract → '' 不得重复累积)
|
|
131
|
-
//
|
|
131
|
+
// learnings 是 workflow 级共享存档,写在 ralph-loop/ 下(Phase 2 父目录不参与归档改名;
|
|
132
|
+
// 状态实例归档为 ralph-loop/<taskId>.done/)
|
|
132
133
|
const learningsPath = join(
|
|
133
134
|
tmp,
|
|
134
135
|
'.minimal-agent-workflow',
|
|
135
|
-
'ralph-loop
|
|
136
|
+
'ralph-loop',
|
|
136
137
|
'learnings.md',
|
|
137
138
|
);
|
|
138
139
|
expect(existsSync(learningsPath)).toBe(true);
|