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.
Files changed (51) hide show
  1. package/README.md +405 -122
  2. package/dist/main.js +423 -941
  3. package/package.json +5 -2
  4. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  5. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +9 -0
  6. package/plugins/ralph-wiggum/README.md +179 -0
  7. package/plugins/ralph-wiggum/commands/cancel-ralph.md +18 -0
  8. package/plugins/ralph-wiggum/commands/help.md +126 -0
  9. package/plugins/ralph-wiggum/commands/ralph-loop.md +59 -0
  10. package/plugins/ralph-wiggum/hooks/hooks.json +15 -0
  11. package/plugins/ralph-wiggum/hooks/stop-hook.sh +191 -0
  12. package/plugins/ralph-wiggum/plugin.ts +275 -0
  13. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +203 -0
  14. package/plugins/ralph-wiggum/src/goalState.ts +310 -0
  15. package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
  16. package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
  17. package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
  18. package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
  19. package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
  20. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  21. package/plugins/workflow-runner/commands/workflow.md +15 -0
  22. package/plugins/workflow-runner/commands/workflows.md +8 -0
  23. package/plugins/workflow-runner/plugin.ts +42 -0
  24. package/plugins/workflow-runner/src/expressions.ts +371 -0
  25. package/plugins/workflow-runner/src/index.ts +194 -0
  26. package/plugins/workflow-runner/src/loader.ts +193 -0
  27. package/plugins/workflow-runner/src/runner.ts +313 -0
  28. package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
  29. package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
  30. package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
  31. package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
  32. package/plugins/workflow-runner/src/types.ts +183 -0
  33. package/plugins/workflow-runner/src/workflowState.ts +65 -0
  34. package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
  35. package/plugins/workflow-runner/test/e2e.test.ts +268 -0
  36. package/plugins/workflow-runner/test/expressions.test.ts +140 -0
  37. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
  38. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
  39. package/plugins/workflow-runner/test/graceful.test.ts +139 -0
  40. package/plugins/workflow-runner/test/loader.test.ts +216 -0
  41. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
  42. package/plugins/workflow-runner/test/runner.test.ts +511 -0
  43. package/skills/config/SKILL.md +27 -1
  44. package/skills/image-gen-openrouter/SKILL.md +121 -0
  45. package/skills/subtitle-srt/SKILL.md +134 -0
  46. package/skills/tts-zh/SKILL.md +137 -0
  47. package/skills/video-compose/SKILL.md +139 -0
  48. package/workflows/book-review-short.yaml +99 -0
  49. package/workflows/e2e-write-greet.yaml +27 -0
  50. package/workflows/schema.json +74 -0
  51. 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
+ }