minimal-agent 0.1.8 → 0.2.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/README.md +405 -122
- package/dist/main.js +423 -941
- package/package.json +5 -2
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/.claude-plugin/plugin.json +9 -0
- package/plugins/ralph-wiggum/README.md +179 -0
- package/plugins/ralph-wiggum/commands/cancel-ralph.md +18 -0
- package/plugins/ralph-wiggum/commands/help.md +126 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +59 -0
- package/plugins/ralph-wiggum/hooks/hooks.json +15 -0
- package/plugins/ralph-wiggum/hooks/stop-hook.sh +191 -0
- package/plugins/ralph-wiggum/plugin.ts +275 -0
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +203 -0
- package/plugins/ralph-wiggum/src/goalState.ts +310 -0
- package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
- package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
- package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.ts +42 -0
- package/plugins/workflow-runner/src/expressions.ts +371 -0
- package/plugins/workflow-runner/src/index.ts +194 -0
- package/plugins/workflow-runner/src/loader.ts +193 -0
- package/plugins/workflow-runner/src/runner.ts +313 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
- package/plugins/workflow-runner/src/types.ts +183 -0
- package/plugins/workflow-runner/src/workflowState.ts +65 -0
- package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
- package/plugins/workflow-runner/test/e2e.test.ts +268 -0
- package/plugins/workflow-runner/test/expressions.test.ts +140 -0
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
- package/plugins/workflow-runner/test/graceful.test.ts +139 -0
- package/plugins/workflow-runner/test/loader.test.ts +216 -0
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
- package/plugins/workflow-runner/test/runner.test.ts +511 -0
- package/skills/config/SKILL.md +27 -1
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* test/workflows/e2e.test.ts —— /workflow 端到端测试
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 从用户敲 `/workflow hello-workflow --input ...` 这一最外层入口开始,
|
|
6
|
+
* 穿过:
|
|
7
|
+
*
|
|
8
|
+
* runWithPlugins
|
|
9
|
+
* → discoverPlugins (扫到真实 workflow-runner 插件)
|
|
10
|
+
* → resolveCommand (识别 /workflow)
|
|
11
|
+
* → cmd.executionMode === 'workflow' 路径
|
|
12
|
+
* → lazy import workflows/index.ts
|
|
13
|
+
* → parseWorkflowArgs + coerceInputs
|
|
14
|
+
* → findWorkflowByName (从 tmp/workflows/ 加载 fixture)
|
|
15
|
+
* → runWorkflow → step executors (真实 executeTool('Write'))
|
|
16
|
+
*
|
|
17
|
+
* 断言:
|
|
18
|
+
* - 整个事件序列形态合理(workflow_start → step_start/end ×N → workflow_done)
|
|
19
|
+
* - 文件真的落盘到正确路径,内容正确
|
|
20
|
+
* - 缺必填 input 时不写任何文件
|
|
21
|
+
* - assert 失败时整个 workflow halt
|
|
22
|
+
*
|
|
23
|
+
* 不 mock chat / runQuery —— fixture 不依赖 LLM。
|
|
24
|
+
* 只 mock workingDir.ts,因为其它测试文件留下的全局 mock 会污染。
|
|
25
|
+
* ============================================================
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
afterEach,
|
|
30
|
+
beforeEach,
|
|
31
|
+
describe,
|
|
32
|
+
expect,
|
|
33
|
+
it,
|
|
34
|
+
mock,
|
|
35
|
+
} from 'bun:test';
|
|
36
|
+
import {
|
|
37
|
+
copyFileSync,
|
|
38
|
+
existsSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
mkdtempSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
} from 'node:fs';
|
|
44
|
+
import { tmpdir } from 'node:os';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
|
|
47
|
+
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.ts';
|
|
48
|
+
import { _resetPluginCache } from '../../../src/plugins/commandRouter.ts';
|
|
49
|
+
import type { LoopEvent, Provider } from '../../../src/types.ts';
|
|
50
|
+
import type { PluginEvent } from '../../../src/plugins/pluginApi.ts';
|
|
51
|
+
import type { WorkflowEvent } from '../src/types.ts';
|
|
52
|
+
|
|
53
|
+
type WfOrLoop = LoopEvent | PluginEvent | WorkflowEvent;
|
|
54
|
+
|
|
55
|
+
const FIXTURE_SRC = join(import.meta.dir, 'fixtures', 'hello-workflow.yaml');
|
|
56
|
+
|
|
57
|
+
const provider: Provider = {
|
|
58
|
+
name: 'test',
|
|
59
|
+
baseURL: '',
|
|
60
|
+
apiKey: '',
|
|
61
|
+
model: 'm',
|
|
62
|
+
contextWindow: 128000,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
async function collect(
|
|
66
|
+
gen: AsyncGenerator<WfOrLoop, void, void>,
|
|
67
|
+
): Promise<WfOrLoop[]> {
|
|
68
|
+
const out: WfOrLoop[] = [];
|
|
69
|
+
for await (const ev of gen) out.push(ev as WfOrLoop);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('workflow E2E(/workflow → 路由 → runner → 文件落盘)', () => {
|
|
74
|
+
let tmp: string;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-'));
|
|
78
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
79
|
+
copyFileSync(FIXTURE_SRC, join(tmp, 'workflows', 'hello-workflow.yaml'));
|
|
80
|
+
|
|
81
|
+
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
82
|
+
_resetWorkingDir();
|
|
83
|
+
_resetPluginCache();
|
|
84
|
+
|
|
85
|
+
// 抵抗其它测试文件留下的 workingDir.ts 全局 mock 污染。
|
|
86
|
+
// 注:这一行让 getWorkingDir 跟着 tmp 走,相对路径锚点 + workflows/ 双源扫描都受益。
|
|
87
|
+
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
88
|
+
getWorkingDir: () => tmp,
|
|
89
|
+
initWorkingDir: () => tmp,
|
|
90
|
+
_resetWorkingDir: () => {},
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
delete process.env.MINIMAL_AGENT_CWD;
|
|
96
|
+
_resetWorkingDir();
|
|
97
|
+
_resetPluginCache();
|
|
98
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function runCmd(input: string): Promise<WfOrLoop[]> {
|
|
102
|
+
const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.ts');
|
|
103
|
+
return collect(
|
|
104
|
+
runWithPlugins(input, { provider, history: [] }) as AsyncGenerator<WfOrLoop, void, void>,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('golden path:/workflow hello-workflow --input name=alice --input lang=zh 跑完且文件落盘', async () => {
|
|
109
|
+
const events = await runCmd('/workflow hello-workflow --input name=alice --input lang=zh');
|
|
110
|
+
|
|
111
|
+
// 整体形态
|
|
112
|
+
expect(events[0]?.type).toBe('workflow_start');
|
|
113
|
+
expect(events[events.length - 1]?.type).toBe('workflow_done');
|
|
114
|
+
expect(events.some((e) => e.type === 'error')).toBe(false);
|
|
115
|
+
|
|
116
|
+
// 每个 step 都跑完且 ok=true
|
|
117
|
+
const ends = events.filter(
|
|
118
|
+
(e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> => e.type === 'workflow_step_end',
|
|
119
|
+
);
|
|
120
|
+
expect(ends.length).toBe(4);
|
|
121
|
+
expect(ends.every((e) => e.ok)).toBe(true);
|
|
122
|
+
expect(ends.map((e) => e.id)).toEqual([
|
|
123
|
+
'write_greeting',
|
|
124
|
+
'write_meta',
|
|
125
|
+
'verify_files',
|
|
126
|
+
'verify_capture',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// 文件实际写盘
|
|
130
|
+
const helloPath = join(tmp, 'greetings', 'alice', 'hello.txt');
|
|
131
|
+
const metaPath = join(tmp, 'greetings', 'alice', 'meta.json');
|
|
132
|
+
expect(existsSync(helloPath)).toBe(true);
|
|
133
|
+
expect(existsSync(metaPath)).toBe(true);
|
|
134
|
+
|
|
135
|
+
// hello.txt 内容验证(${var} 插值确实工作了)
|
|
136
|
+
expect(readFileSync(helloPath, 'utf8')).toBe('Hello, alice! (lang=zh)');
|
|
137
|
+
|
|
138
|
+
// meta.json 是合法 JSON,字段对得上
|
|
139
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
140
|
+
expect(meta.name).toBe('alice');
|
|
141
|
+
expect(meta.lang).toBe('zh');
|
|
142
|
+
// length() 内置函数 + capture 链路:greeting_text="Hello, alice! (lang=zh)" → 长度 23
|
|
143
|
+
expect(meta.greeting_length).toBe('Hello, alice! (lang=zh)'.length);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('lang 使用默认值 en(input enum default 路径)', async () => {
|
|
147
|
+
const events = await runCmd('/workflow hello-workflow --input name=bob');
|
|
148
|
+
expect(events[events.length - 1]?.type).toBe('workflow_done');
|
|
149
|
+
expect(readFileSync(join(tmp, 'greetings', 'bob', 'hello.txt'), 'utf8'))
|
|
150
|
+
.toBe('Hello, bob! (lang=en)');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('workflow_step_start 事件 index/total 单调且与 yaml 一致', async () => {
|
|
154
|
+
const events = await runCmd('/workflow hello-workflow --input name=carol');
|
|
155
|
+
const starts = events.filter(
|
|
156
|
+
(e): e is Extract<WorkflowEvent, { type: 'workflow_step_start' }> => e.type === 'workflow_step_start',
|
|
157
|
+
);
|
|
158
|
+
expect(starts.length).toBe(4);
|
|
159
|
+
expect(starts.map((s) => s.index)).toEqual([0, 1, 2, 3]);
|
|
160
|
+
expect(starts.every((s) => s.total === 4)).toBe(true);
|
|
161
|
+
expect(starts.map((s) => s.kind)).toEqual(['tool', 'tool', 'assert', 'assert']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('缺必填 input name → yield error,且没写任何文件', async () => {
|
|
165
|
+
const events = await runCmd('/workflow hello-workflow');
|
|
166
|
+
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
167
|
+
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
168
|
+
// tmp/greetings/ 不应被创建
|
|
169
|
+
expect(existsSync(join(tmp, 'greetings'))).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('lang 传非法值 → yield enum 校验 error,且不进入 runWorkflow', async () => {
|
|
173
|
+
const events = await runCmd('/workflow hello-workflow --input name=dave --input lang=fr');
|
|
174
|
+
const err = events.find(
|
|
175
|
+
(e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error',
|
|
176
|
+
);
|
|
177
|
+
expect(err).toBeDefined();
|
|
178
|
+
expect(err!.error).toContain('lang');
|
|
179
|
+
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('未知 workflow 名 → yield error,不污染文件系统', async () => {
|
|
183
|
+
const events = await runCmd('/workflow definitely-not-exists --input name=eve');
|
|
184
|
+
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
185
|
+
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
186
|
+
expect(existsSync(join(tmp, 'greetings'))).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('/workflows 列表能看到 hello-workflow(cwd 优先双源加载)', async () => {
|
|
190
|
+
const events = await runCmd('/workflows');
|
|
191
|
+
const text = events
|
|
192
|
+
.filter((e): e is Extract<LoopEvent, { type: 'text' }> => e.type === 'text')
|
|
193
|
+
.map((e) => e.delta)
|
|
194
|
+
.join('');
|
|
195
|
+
expect(text).toContain('hello-workflow');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('workflow E2E:assert 失败 → halt(用变体 fixture)', () => {
|
|
200
|
+
let tmp: string;
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-fail-'));
|
|
204
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
205
|
+
// 写一个故意 assert 不通过的 yaml
|
|
206
|
+
const failYaml = `name: assert-fail-fixture
|
|
207
|
+
description: assert 必失败的端到端 fixture
|
|
208
|
+
steps:
|
|
209
|
+
- id: write_a
|
|
210
|
+
tool: Write
|
|
211
|
+
args:
|
|
212
|
+
file_path: "a.txt"
|
|
213
|
+
content: "hi"
|
|
214
|
+
- id: bad_assert
|
|
215
|
+
type: assert
|
|
216
|
+
condition: '1 == 2'
|
|
217
|
+
onFail: "故意失败"
|
|
218
|
+
- id: should_not_run
|
|
219
|
+
tool: Write
|
|
220
|
+
args:
|
|
221
|
+
file_path: "b.txt"
|
|
222
|
+
content: "should not exist"
|
|
223
|
+
`;
|
|
224
|
+
require('node:fs').writeFileSync(
|
|
225
|
+
join(tmp, 'workflows', 'assert-fail-fixture.yaml'),
|
|
226
|
+
failYaml,
|
|
227
|
+
'utf8',
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
231
|
+
_resetWorkingDir();
|
|
232
|
+
_resetPluginCache();
|
|
233
|
+
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
234
|
+
getWorkingDir: () => tmp,
|
|
235
|
+
initWorkingDir: () => tmp,
|
|
236
|
+
_resetWorkingDir: () => {},
|
|
237
|
+
}));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
afterEach(() => {
|
|
241
|
+
delete process.env.MINIMAL_AGENT_CWD;
|
|
242
|
+
_resetWorkingDir();
|
|
243
|
+
_resetPluginCache();
|
|
244
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('assert 失败 → 后续 step 不执行 + yield error + 没 workflow_done', async () => {
|
|
248
|
+
const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.ts');
|
|
249
|
+
const events = await collect(
|
|
250
|
+
runWithPlugins('/workflow assert-fail-fixture', { provider, history: [] }),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
|
|
254
|
+
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
255
|
+
|
|
256
|
+
// a.txt 在 assert 前就写盘,应存在
|
|
257
|
+
expect(existsSync(join(tmp, 'a.txt'))).toBe(true);
|
|
258
|
+
// b.txt 在 assert 后,不应被写
|
|
259
|
+
expect(existsSync(join(tmp, 'b.txt'))).toBe(false);
|
|
260
|
+
|
|
261
|
+
// 错误信息含 onFail 自定义文案
|
|
262
|
+
const errs = events
|
|
263
|
+
.filter((e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error')
|
|
264
|
+
.map((e) => e.error)
|
|
265
|
+
.join('\n');
|
|
266
|
+
expect(errs).toContain('故意失败');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* test/workflows/expressions.test.ts
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* workflow 表达式引擎单测:tokenizer / evalExpr / interpolate /
|
|
6
|
+
* interpolateDeep。重点覆盖:
|
|
7
|
+
* - 变量点路径(inputs.book)
|
|
8
|
+
* - 比较 + 布尔运算
|
|
9
|
+
* - 白名单内置函数(fileExists / length / lower / upper)
|
|
10
|
+
* - ${} 插值(包括括号匹配 + 转义 + 对象 JSON 化)
|
|
11
|
+
* - 安全:禁止 eval/Function 等被插入
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.ts';
|
|
21
|
+
import {
|
|
22
|
+
evalExpr,
|
|
23
|
+
interpolate,
|
|
24
|
+
interpolateDeep,
|
|
25
|
+
} from '../src/expressions.ts';
|
|
26
|
+
import { VarStack } from '../src/types.ts';
|
|
27
|
+
|
|
28
|
+
describe('workflows/expressions', () => {
|
|
29
|
+
let vars: VarStack;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vars = new VarStack();
|
|
33
|
+
vars.setGlobal('inputs', { book: '原则', angle: 'insight', n: 3 });
|
|
34
|
+
vars.set('thumb_prompt', 'a book and a lamp');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('字面量数字 / 字符串 / 布尔', () => {
|
|
38
|
+
expect(evalExpr('42', vars)).toBe(42);
|
|
39
|
+
expect(evalExpr('"hello"', vars)).toBe('hello');
|
|
40
|
+
expect(evalExpr("'x'", vars)).toBe('x');
|
|
41
|
+
expect(evalExpr('true', vars)).toBe(true);
|
|
42
|
+
expect(evalExpr('false', vars)).toBe(false);
|
|
43
|
+
expect(evalExpr('null', vars)).toBe(null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('变量 + 点路径', () => {
|
|
47
|
+
expect(evalExpr('thumb_prompt', vars)).toBe('a book and a lamp');
|
|
48
|
+
expect(evalExpr('inputs.book', vars)).toBe('原则');
|
|
49
|
+
expect(evalExpr('inputs.n', vars)).toBe(3);
|
|
50
|
+
expect(evalExpr('inputs.missing', vars)).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('比较运算 ==/!=/>/<', () => {
|
|
54
|
+
expect(evalExpr('inputs.n == 3', vars)).toBe(true);
|
|
55
|
+
expect(evalExpr('inputs.n != 4', vars)).toBe(true);
|
|
56
|
+
expect(evalExpr('inputs.n > 2', vars)).toBe(true);
|
|
57
|
+
expect(evalExpr('inputs.n < 2', vars)).toBe(false);
|
|
58
|
+
expect(evalExpr('"a" == "a"', vars)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('布尔运算 && / || / !', () => {
|
|
62
|
+
expect(evalExpr('true && false', vars)).toBe(false);
|
|
63
|
+
expect(evalExpr('true || false', vars)).toBe(true);
|
|
64
|
+
expect(evalExpr('!false', vars)).toBe(true);
|
|
65
|
+
expect(evalExpr('inputs.n > 0 && inputs.book == "原则"', vars)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('白名单函数 length / lower / upper', () => {
|
|
69
|
+
expect(evalExpr('length("abcd")', vars)).toBe(4);
|
|
70
|
+
expect(evalExpr('length(inputs.book)', vars)).toBe(2);
|
|
71
|
+
expect(evalExpr('lower("HELLO")', vars)).toBe('hello');
|
|
72
|
+
expect(evalExpr('upper("hi")', vars)).toBe('HI');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('未知函数被拒绝(白名单外)', () => {
|
|
76
|
+
expect(() => evalExpr('eval("1+1")', vars)).toThrow();
|
|
77
|
+
expect(() => evalExpr('require("fs")', vars)).toThrow();
|
|
78
|
+
expect(() => evalExpr('Function("x")', vars)).toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('interpolate ${EXPR} 替换变量', () => {
|
|
82
|
+
expect(interpolate('book=${inputs.book}', vars)).toBe('book=原则');
|
|
83
|
+
expect(interpolate('${inputs.n} times', vars)).toBe('3 times');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('interpolate 处理对象 → JSON.stringify', () => {
|
|
87
|
+
const out = interpolate('${inputs}', vars);
|
|
88
|
+
expect(out).toContain('原则');
|
|
89
|
+
expect(out).toContain('insight');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('interpolate 支持 \\$ 转义', () => {
|
|
93
|
+
expect(interpolate('price=\\${inputs.n}', vars)).toBe('price=${inputs.n}');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('interpolate 未闭合 ${ 原样保留', () => {
|
|
97
|
+
expect(interpolate('hello ${unclosed', vars)).toBe('hello ${unclosed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('interpolateDeep 递归处理对象/数组', () => {
|
|
101
|
+
const input = {
|
|
102
|
+
file_path: 'videos/${inputs.book}/script.txt',
|
|
103
|
+
content: '${thumb_prompt}',
|
|
104
|
+
nested: { list: ['${inputs.angle}', 'static'] },
|
|
105
|
+
};
|
|
106
|
+
const out = interpolateDeep(input, vars);
|
|
107
|
+
expect(out.file_path).toBe('videos/原则/script.txt');
|
|
108
|
+
expect(out.content).toBe('a book and a lamp');
|
|
109
|
+
expect(out.nested.list[0]).toBe('insight');
|
|
110
|
+
expect(out.nested.list[1]).toBe('static');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('workflows/expressions::fileExists', () => {
|
|
115
|
+
let tmp: string;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
tmp = mkdtempSync(join(tmpdir(), 'wf-expr-'));
|
|
119
|
+
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
120
|
+
_resetWorkingDir();
|
|
121
|
+
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
122
|
+
getWorkingDir: () => tmp,
|
|
123
|
+
initWorkingDir: () => tmp,
|
|
124
|
+
_resetWorkingDir: () => {},
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
delete process.env.MINIMAL_AGENT_CWD;
|
|
130
|
+
_resetWorkingDir();
|
|
131
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('fileExists 命中真实文件', () => {
|
|
135
|
+
writeFileSync(join(tmp, 'a.txt'), 'x');
|
|
136
|
+
const vars = new VarStack();
|
|
137
|
+
expect(evalExpr('fileExists("a.txt")', vars)).toBe(true);
|
|
138
|
+
expect(evalExpr('fileExists("missing.txt")', vars)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: cli-e2e
|
|
2
|
+
description: CLI 端到端测试 fixture:纯 Write + assert,不调 LLM 不调网络
|
|
3
|
+
version: "0.1"
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
- name: topic
|
|
7
|
+
type: string
|
|
8
|
+
required: true
|
|
9
|
+
|
|
10
|
+
steps:
|
|
11
|
+
- id: write_note
|
|
12
|
+
tool: Write
|
|
13
|
+
args:
|
|
14
|
+
file_path: "out/${inputs.topic}/note.txt"
|
|
15
|
+
content: "topic=${inputs.topic} ok"
|
|
16
|
+
capture:
|
|
17
|
+
args.content: note_body
|
|
18
|
+
|
|
19
|
+
- id: verify_file
|
|
20
|
+
type: assert
|
|
21
|
+
condition: 'fileExists("out/${inputs.topic}/note.txt")'
|
|
22
|
+
onFail: "note.txt 未落盘"
|
|
23
|
+
|
|
24
|
+
- id: verify_capture
|
|
25
|
+
type: assert
|
|
26
|
+
condition: 'length(note_body) > 0'
|
|
27
|
+
onFail: "capture 没绑到 note_body"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: hello-workflow
|
|
2
|
+
description: |
|
|
3
|
+
E2E 测试用极简 workflow:不依赖 LLM、不调网络,只用确定性工具。
|
|
4
|
+
覆盖:input 必填校验 / ${var} 插值 / 嵌套目录 / 多步 capture / assert+fileExists。
|
|
5
|
+
version: "0.1"
|
|
6
|
+
|
|
7
|
+
inputs:
|
|
8
|
+
- name: name
|
|
9
|
+
type: string
|
|
10
|
+
required: true
|
|
11
|
+
description: 收件人名字
|
|
12
|
+
- name: lang
|
|
13
|
+
type: enum
|
|
14
|
+
values: [zh, en]
|
|
15
|
+
default: en
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
# 1. 写问候语文件 + capture 内容
|
|
19
|
+
- id: write_greeting
|
|
20
|
+
tool: Write
|
|
21
|
+
args:
|
|
22
|
+
file_path: "greetings/${inputs.name}/hello.txt"
|
|
23
|
+
content: "Hello, ${inputs.name}! (lang=${inputs.lang})"
|
|
24
|
+
capture:
|
|
25
|
+
args.content: greeting_text
|
|
26
|
+
|
|
27
|
+
# 2. 写 meta json
|
|
28
|
+
- id: write_meta
|
|
29
|
+
tool: Write
|
|
30
|
+
args:
|
|
31
|
+
file_path: "greetings/${inputs.name}/meta.json"
|
|
32
|
+
content: |
|
|
33
|
+
{
|
|
34
|
+
"name": "${inputs.name}",
|
|
35
|
+
"lang": "${inputs.lang}",
|
|
36
|
+
"greeting_length": ${length(greeting_text)}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# 3. 守门 assert:两个文件都得在
|
|
40
|
+
- id: verify_files
|
|
41
|
+
type: assert
|
|
42
|
+
condition: 'fileExists("greetings/${inputs.name}/hello.txt") && fileExists("greetings/${inputs.name}/meta.json")'
|
|
43
|
+
onFail: "hello-workflow 文件未全部落盘"
|
|
44
|
+
|
|
45
|
+
# 4. 守门 assert:capture 的变量非空
|
|
46
|
+
- id: verify_capture
|
|
47
|
+
type: assert
|
|
48
|
+
condition: 'length(greeting_text) > 0'
|
|
49
|
+
onFail: "capture 没绑到 greeting_text"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* test/workflows/graceful.test.ts
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 降级契约(R1):
|
|
6
|
+
* - workflows/ 不存在 → listWorkflows() 返回包内自带 [包内 yaml],绝不抛错
|
|
7
|
+
* - 全是坏 yaml → 跳过坏的,列出好的
|
|
8
|
+
* - findWorkflowByName 未命中 → 返回 null(让调用方 yield 友好 error)
|
|
9
|
+
* - runWorkflowFromCommand 对未知 name → yield error,不污染 history
|
|
10
|
+
* - runWorkflowsList 在空列表时 yield 友好提示
|
|
11
|
+
* ============================================================
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.ts';
|
|
20
|
+
import { listWorkflows } from '../src/loader.ts';
|
|
21
|
+
import {
|
|
22
|
+
runWorkflowFromCommand,
|
|
23
|
+
runWorkflowsList,
|
|
24
|
+
} from '../src/index.ts';
|
|
25
|
+
import type { LoopEvent, Provider } from '../../../src/types.ts';
|
|
26
|
+
import type { WorkflowEvent } from '../src/types.ts';
|
|
27
|
+
|
|
28
|
+
type WfOrLoop = WorkflowEvent | LoopEvent;
|
|
29
|
+
|
|
30
|
+
const fakeProvider: Provider = {
|
|
31
|
+
name: 'test',
|
|
32
|
+
baseURL: '',
|
|
33
|
+
apiKey: '',
|
|
34
|
+
model: 'm',
|
|
35
|
+
contextWindow: 128000,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function collect(
|
|
39
|
+
gen: AsyncGenerator<WfOrLoop, void, void>,
|
|
40
|
+
): Promise<WfOrLoop[]> {
|
|
41
|
+
const out: WfOrLoop[] = [];
|
|
42
|
+
for await (const ev of gen) out.push(ev);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('workflows/graceful', () => {
|
|
47
|
+
let tmp: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmp = mkdtempSync(join(tmpdir(), 'wf-graceful-'));
|
|
51
|
+
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
52
|
+
_resetWorkingDir();
|
|
53
|
+
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
54
|
+
getWorkingDir: () => tmp,
|
|
55
|
+
initWorkingDir: () => tmp,
|
|
56
|
+
_resetWorkingDir: () => {},
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
delete process.env.MINIMAL_AGENT_CWD;
|
|
62
|
+
_resetWorkingDir();
|
|
63
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('cwd 下无 workflows/ 目录 → listWorkflows() 仍返回(packageRoot fallback 也只是空降级,不抛)', async () => {
|
|
67
|
+
const list = await listWorkflows();
|
|
68
|
+
// 至少应该包含包内自带的 book-review-short
|
|
69
|
+
expect(Array.isArray(list)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('cwd 下 workflows/ 全空 → 不抛,不增加任何条目', async () => {
|
|
73
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
74
|
+
const list = await listWorkflows();
|
|
75
|
+
expect(Array.isArray(list)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('cwd 下全是坏 yaml → 不抛,只跳过坏的', async () => {
|
|
79
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
80
|
+
writeYamlIn(tmp, 'bad1.yaml', 'name: x\n : weird:::\n');
|
|
81
|
+
writeYamlIn(tmp, 'bad2.yaml', '@@@@@@\n');
|
|
82
|
+
const list = await listWorkflows();
|
|
83
|
+
// 不抛错,至少包内自带的仍然可见
|
|
84
|
+
expect(Array.isArray(list)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('/workflow unknown-name → yield error,无 workflow_start', async () => {
|
|
88
|
+
const events = await collect(
|
|
89
|
+
runWorkflowFromCommand('definitely-not-a-real-wf-xyz', {
|
|
90
|
+
provider: fakeProvider,
|
|
91
|
+
history: [],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
95
|
+
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('/workflow(不带 name)→ yield 用法提示 error', async () => {
|
|
99
|
+
const events = await collect(
|
|
100
|
+
runWorkflowFromCommand('', {
|
|
101
|
+
provider: fakeProvider,
|
|
102
|
+
history: [],
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const err = events.find(
|
|
106
|
+
(e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error',
|
|
107
|
+
);
|
|
108
|
+
expect(err).toBeDefined();
|
|
109
|
+
expect(err!.error).toContain('/workflow');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('/workflows 列表 yield text 事件 + turn_done', async () => {
|
|
113
|
+
const events = await collect(runWorkflowsList());
|
|
114
|
+
expect(events.some((e) => e.type === 'text')).toBe(true);
|
|
115
|
+
expect(events[events.length - 1].type).toBe('turn_done');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('缺必填 input → yield error,不进入 runWorkflow', async () => {
|
|
119
|
+
// book-review-short 要求 book 必填
|
|
120
|
+
const events = await collect(
|
|
121
|
+
runWorkflowFromCommand('book-review-short', {
|
|
122
|
+
provider: fakeProvider,
|
|
123
|
+
history: [],
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
const err = events.find(
|
|
127
|
+
(e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error',
|
|
128
|
+
);
|
|
129
|
+
expect(err).toBeDefined();
|
|
130
|
+
expect(err!.error).toContain('book');
|
|
131
|
+
// 没有 workflow_start
|
|
132
|
+
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function writeYamlIn(root: string, name: string, body: string) {
|
|
137
|
+
mkdirSync(join(root, 'workflows'), { recursive: true });
|
|
138
|
+
writeFileSync(join(root, 'workflows', name), body, 'utf8');
|
|
139
|
+
}
|