minimal-agent 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/package.json +5 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/expressions.js +29 -8
  7. package/plugins/workflow-runner/src/expressions.ts +28 -10
  8. package/plugins/workflow-runner/src/index.js +50 -20
  9. package/plugins/workflow-runner/src/index.ts +75 -31
  10. package/plugins/workflow-runner/src/multimodal.js +2 -1
  11. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  12. package/plugins/workflow-runner/src/recovery.js +9 -2
  13. package/plugins/workflow-runner/src/recovery.ts +9 -2
  14. package/plugins/workflow-runner/src/runner.js +31 -5
  15. package/plugins/workflow-runner/src/runner.ts +33 -5
  16. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  17. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  18. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  19. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  20. package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
  21. package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
  22. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  23. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  24. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  25. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  26. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  27. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  28. package/plugins/workflow-runner/src/types.js +113 -7
  29. package/plugins/workflow-runner/src/types.ts +137 -14
  30. package/plugins/workflow-runner/src/workflowState.js +455 -54
  31. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  32. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  33. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  34. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  35. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  36. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  37. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  38. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  39. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  40. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  41. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  42. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  43. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  44. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  45. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  46. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  47. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  48. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  49. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  50. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  51. package/skills/skill-creator/scripts/run_eval.py +3 -2
  52. package/src/cli/print.js +36 -4
  53. package/src/cli/print.ts +42 -4
  54. package/src/cli/streamJson.js +19 -4
  55. package/src/cli/streamJson.ts +17 -4
  56. package/src/config/configDefaults.js +25 -0
  57. package/src/config/configDefaults.ts +33 -0
  58. package/src/config/configFile.js +2 -21
  59. package/src/config/configFile.ts +11 -24
  60. package/src/config/configManagement.js +1 -1
  61. package/src/config/configManagement.ts +2 -2
  62. package/src/config/configService.js +24 -0
  63. package/src/config/configService.ts +23 -0
  64. package/src/context/compact.js +4 -0
  65. package/src/context/compact.ts +4 -0
  66. package/src/context/contextBundle.js +38 -13
  67. package/src/context/contextBundle.ts +47 -13
  68. package/src/harness/stallGuard.js +19 -4
  69. package/src/harness/stallGuard.ts +30 -7
  70. package/src/infra/verification.js +25 -18
  71. package/src/infra/verification.ts +50 -18
  72. package/src/loop.js +95 -11
  73. package/src/loop.ts +125 -16
  74. package/src/main.js +8 -1
  75. package/src/main.tsx +7 -1
  76. package/src/memory/constants.js +10 -0
  77. package/src/memory/constants.ts +13 -0
  78. package/src/memory/episodicMemory.js +274 -2
  79. package/src/memory/episodicMemory.ts +306 -1
  80. package/src/memory/fileStore.js +24 -4
  81. package/src/memory/fileStore.ts +22 -2
  82. package/src/memory/memoryService.js +79 -12
  83. package/src/memory/memoryService.ts +108 -9
  84. package/src/memory/repository.js +415 -85
  85. package/src/memory/repository.ts +437 -86
  86. package/src/memory/turnJournal.js +95 -0
  87. package/src/memory/turnJournal.ts +120 -0
  88. package/src/memory/types.ts +16 -0
  89. package/src/plugins/pluginApi.ts +30 -6
  90. package/src/plugins/pluginRunner.js +23 -8
  91. package/src/plugins/pluginRunner.ts +32 -13
  92. package/src/prompts/policyRegistry.js +55 -1
  93. package/src/prompts/policyRegistry.ts +55 -1
  94. package/src/prompts/promptAssembly.js +39 -7
  95. package/src/prompts/promptAssembly.ts +145 -59
  96. package/src/session/runAgentTurn.js +43 -21
  97. package/src/session/runAgentTurn.ts +68 -26
  98. package/src/session/runTurn.js +11 -46
  99. package/src/session/runTurn.ts +12 -46
  100. package/src/session/runtimeStore.js +149 -0
  101. package/src/session/runtimeStore.ts +197 -0
  102. package/src/session/sessionState.js +5 -0
  103. package/src/session/sessionState.ts +14 -0
  104. package/src/tools/advisor/advisor.js +76 -9
  105. package/src/tools/advisor/advisor.ts +79 -9
  106. package/src/tools/agent/agent.js +143 -14
  107. package/src/tools/agent/agent.ts +149 -14
  108. package/src/tools/agent/agentTaskService.js +60 -0
  109. package/src/tools/agent/agentTaskService.ts +113 -0
  110. package/src/tools/agent/agentTypes.js +5 -0
  111. package/src/tools/agent/agentTypes.ts +5 -0
  112. package/src/tools/bash/bash.js +80 -15
  113. package/src/tools/bash/bash.ts +83 -15
  114. package/src/tools/capabilities.js +5 -0
  115. package/src/tools/capabilities.ts +11 -1
  116. package/src/tools/edit/edit.js +7 -4
  117. package/src/tools/edit/edit.ts +7 -4
  118. package/src/tools/edit/multi-edit.js +4 -2
  119. package/src/tools/edit/multi-edit.ts +4 -2
  120. package/src/tools/glob/glob.js +156 -40
  121. package/src/tools/glob/glob.ts +185 -46
  122. package/src/tools/index.js +35 -17
  123. package/src/tools/index.ts +81 -20
  124. package/src/tools/lsp/lsp.js +165 -22
  125. package/src/tools/lsp/lsp.ts +206 -36
  126. package/src/tools/powershell/powershell.js +134 -27
  127. package/src/tools/powershell/powershell.ts +171 -30
  128. package/src/tools/presentresult/presentresult.js +10 -0
  129. package/src/tools/presentresult/presentresult.ts +10 -0
  130. package/src/tools/read/read.js +249 -29
  131. package/src/tools/read/read.ts +396 -59
  132. package/src/tools/shared/diffSummary.js +44 -0
  133. package/src/tools/shared/diffSummary.ts +63 -0
  134. package/src/tools/shared/fileActivity.js +2 -1
  135. package/src/tools/shared/fileActivity.ts +3 -0
  136. package/src/tools/shared/permanentDeleteGuard.js +57 -0
  137. package/src/tools/shared/permanentDeleteGuard.ts +71 -0
  138. package/src/tools/shared/toolOutputStore.js +97 -0
  139. package/src/tools/shared/toolOutputStore.ts +110 -0
  140. package/src/tools/skill/skill.js +86 -0
  141. package/src/tools/skill/skill.ts +94 -0
  142. package/src/tools/snip/snip.js +67 -0
  143. package/src/tools/snip/snip.ts +80 -0
  144. package/src/tools/tasks/backgroundTaskService.js +79 -28
  145. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  146. package/src/tools/tasks/taskManagement.js +163 -0
  147. package/src/tools/tasks/taskManagement.ts +180 -0
  148. package/src/tools/tasks/taskOutput.js +64 -14
  149. package/src/tools/tasks/taskOutput.ts +102 -23
  150. package/src/tools/tasks/taskStop.js +48 -8
  151. package/src/tools/tasks/taskStop.ts +69 -9
  152. package/src/tools/tasks/taskStore.js +46 -0
  153. package/src/tools/tasks/taskStore.ts +77 -0
  154. package/src/tools/terminal/terminalCapture.js +167 -0
  155. package/src/tools/terminal/terminalCapture.ts +179 -0
  156. package/src/tools/trash/trash.js +233 -0
  157. package/src/tools/trash/trash.ts +283 -0
  158. package/src/tools/webbrowser/browserControl.js +5 -1
  159. package/src/tools/webbrowser/browserControl.ts +7 -1
  160. package/src/tools/webbrowser/webbrowser.js +8 -4
  161. package/src/tools/webbrowser/webbrowser.ts +8 -4
  162. package/src/tools/worktree/sessionWorktree.js +128 -0
  163. package/src/tools/worktree/sessionWorktree.ts +158 -0
  164. package/src/tools/write/write.js +6 -2
  165. package/src/tools/write/write.ts +6 -2
  166. package/src/types.ts +22 -4
  167. package/src/ui/hooks/useChat.js +31 -11
  168. package/src/ui/hooks/useChat.ts +31 -13
  169. package/src/web/protocol.ts +78 -3
  170. package/workflows/adaptive-research.yaml +5 -5
  171. package/workflows/book-review-short.yaml +5 -5
  172. package/workflows/e2e-write-greet.yaml +2 -2
  173. package/workflows/illustrate.yaml +12 -12
  174. package/workflows/plan.yaml +18 -18
  175. package/workflows/ppt.yaml +201 -201
  176. package/workflows/ralph-loop.yaml +3 -1
  177. package/workflows/research.md +449 -0
  178. package/workflows/research.yaml +873 -0
  179. package/workflows/webppt.yaml +47 -47
  180. package/workflows/word.yaml +80 -80
  181. package/workflows/youtube-shorts.yaml +25 -25
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { describe, expect, it } from 'bun:test';
7
7
 
8
+ import { ALL_TOOLS } from '../../../src/tools/index.js';
8
9
  import type {
9
10
  ChatContentPart,
10
11
  LlmStreamEvent,
@@ -16,7 +17,6 @@ import { execAgentStep } from '../src/stepExecutors/agent.js';
16
17
  import { execLlmStep } from '../src/stepExecutors/llm.js';
17
18
  import { VarStack } from '../src/types.js';
18
19
  import type { RunContext, StepDef } from '../src/types.js';
19
- import { ALL_TOOLS } from '../../../src/tools/index.js';
20
20
 
21
21
  const provider: Provider = {
22
22
  name: 'test',
@@ -72,7 +72,11 @@ describe('workflow-runner multimodal images', () => {
72
72
  const chat = async function* (): AsyncGenerator<LlmStreamEvent> {
73
73
  if (round++ === 0) {
74
74
  yield { type: 'tool_call_delta', index: 0, id: 'hidden', name: 'Bash' };
75
- yield { type: 'tool_call_delta', index: 0, argumentsDelta: '{"command":"echo no"}' };
75
+ yield {
76
+ type: 'tool_call_delta',
77
+ index: 0,
78
+ argumentsDelta: '{"command":"echo no"}',
79
+ };
76
80
  yield { type: 'done', stopReason: 'tool_calls' };
77
81
  return;
78
82
  }
@@ -90,15 +94,58 @@ describe('workflow-runner multimodal images', () => {
90
94
  return { ok: true, content: 'unexpected' };
91
95
  },
92
96
  };
93
- const result = await drain(execAgentStep(
94
- { id: 'bounded', agent: '完成任务', allowed_tools: ['Bash'] },
95
- new VarStack(),
96
- ctx,
97
- ));
97
+ const result = await drain(
98
+ execAgentStep(
99
+ { id: 'bounded', agent: '完成任务', allowed_tools: ['Bash'] },
100
+ new VarStack(),
101
+ ctx,
102
+ ),
103
+ );
98
104
  expect(executed).toBe(0);
99
105
  expect(result.raw.text).toBe('已改道。');
100
106
  });
101
107
 
108
+ it('an empty host tool catalog remains empty instead of restoring global tools', async () => {
109
+ let round = 0;
110
+ let executed = 0;
111
+ const chat = async function* (): AsyncGenerator<LlmStreamEvent> {
112
+ if (round++ === 0) {
113
+ yield { type: 'tool_call_delta', index: 0, id: 'hidden', name: 'Read' };
114
+ yield {
115
+ type: 'tool_call_delta',
116
+ index: 0,
117
+ argumentsDelta: '{"file_path":"README.md"}',
118
+ };
119
+ yield { type: 'done', stopReason: 'tool_calls' };
120
+ return;
121
+ }
122
+ yield { type: 'text_delta', delta: '没有可用工具。' };
123
+ yield { type: 'done', stopReason: 'stop' };
124
+ };
125
+ const ctx: RunContext = {
126
+ provider,
127
+ history: [{ role: 'system', content: 'stable' }],
128
+ cwd: process.cwd(),
129
+ tools: [],
130
+ chat,
131
+ executeTool: async () => {
132
+ executed += 1;
133
+ return { ok: true, content: 'unexpected' };
134
+ },
135
+ };
136
+
137
+ const result = await drain(
138
+ execAgentStep(
139
+ { id: 'empty-bound', agent: '完成任务', allowed_tools: ['Read'] },
140
+ new VarStack(),
141
+ ctx,
142
+ ),
143
+ );
144
+
145
+ expect(executed).toBe(0);
146
+ expect(result.raw.text).toBe('没有可用工具。');
147
+ });
148
+
102
149
  it('execLlmStep 把 images 传成 image_url content block,并支持变量插值', async () => {
103
150
  const captured: Message[][] = [];
104
151
  const ctx = createContext(captured);
@@ -195,7 +242,9 @@ describe('workflow-runner multimodal images', () => {
195
242
 
196
243
  expect(result.raw.text).toBe('图片可用');
197
244
  expect(messagesContainImage(captured[0])).toBe(true);
198
- expect(ctx.history).toEqual([{ role: 'system', content: 'workflow system prompt' }]);
245
+ expect(ctx.history).toEqual([
246
+ { role: 'system', content: 'workflow system prompt' },
247
+ ]);
199
248
  });
200
249
 
201
250
  it('isolated 多模态步骤之后的普通 agent 不会收到前一步 image_url', async () => {
@@ -262,7 +311,7 @@ describe('workflow-runner multimodal images', () => {
262
311
  provider,
263
312
  history: [{ role: 'system', content: 'workflow system prompt' }],
264
313
  cwd: process.cwd(),
265
- tools: [],
314
+ tools: ALL_TOOLS.filter((tool) => tool.name === 'Write'),
266
315
  chat,
267
316
  executeTool: async () => {
268
317
  toolCalled = true;
@@ -285,7 +334,9 @@ describe('workflow-runner multimodal images', () => {
285
334
 
286
335
  expect(toolCalled).toBe(true);
287
336
  expect(result.raw.text).toBe('文件已写好。');
288
- expect(ctx.history).toEqual([{ role: 'system', content: 'workflow system prompt' }]);
337
+ expect(ctx.history).toEqual([
338
+ { role: 'system', content: 'workflow system prompt' },
339
+ ]);
289
340
  expect(captured.length).toBe(2);
290
341
  });
291
342
  });
@@ -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 的 ./build 下
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('./out/deck.pptx');
100
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
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('./build/draft_coverage_report.json', 'ok') == false");
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 ./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 ./build/img_route_ok');
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('./build/img_plan.json');
668
- expect(String(gen?.args?.assets)).toBe('./build/assets');
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('./build/image_generation_report.json');
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('./build/generated_image_semantic_retry_report.json');
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 ./build/visual_quality_ok');
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('./build/build_complete')");
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('./build/build_complete')");
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 ./build/export ./build/generated-image-reviews');
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 ./build');
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
- // 注意成功后状态目录被归档为 ralph-loop.done
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.done',
136
+ 'ralph-loop',
136
137
  'learnings.md',
137
138
  );
138
139
  expect(existsSync(learningsPath)).toBe(true);
@@ -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
+ });