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
@@ -28,6 +28,7 @@ import {
28
28
  existsSync,
29
29
  mkdirSync,
30
30
  mkdtempSync,
31
+ readdirSync,
31
32
  readFileSync,
32
33
  rmSync,
33
34
  writeFileSync,
@@ -36,6 +37,7 @@ import { tmpdir } from 'node:os';
36
37
  import { join } from 'node:path';
37
38
 
38
39
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
40
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
39
41
  import { ALL_TOOLS } from '../../../src/tools/index.js';
40
42
  import { runWorkflow } from '../src/runner.js';
41
43
  import type { LoopEvent, Provider } from '../../../src/types.js';
@@ -43,6 +45,43 @@ import type { RunContext, WorkflowDef, WorkflowEvent } from '../src/types.js';
43
45
 
44
46
  type WfOrLoop = WorkflowEvent | LoopEvent;
45
47
 
48
+ const DEFAULT_SESSION = 'test-session';
49
+
50
+ function findInstanceDir(base: string, wfName: string, suffix = ''): string | null {
51
+ const wfDir = join(base, '.minimal-agent-workflow', wfName);
52
+ if (!existsSync(wfDir)) {
53
+ const legacyDir = suffix
54
+ ? join(base, '.minimal-agent-workflow', wfName + suffix)
55
+ : wfDir;
56
+ if (existsSync(legacyDir)) return legacyDir;
57
+ return null;
58
+ }
59
+
60
+ // Phase 3:session 化——实例目录名 = sessionId(默认 'default')
61
+ const sessionDir = suffix
62
+ ? join(wfDir, DEFAULT_SESSION + suffix)
63
+ : join(wfDir, DEFAULT_SESSION);
64
+ if (existsSync(sessionDir)) return sessionDir;
65
+
66
+ // 旧布局兼容:wfDir 本身有 current.json(无子实例目录时判定为 legacy)
67
+ if (!suffix && existsSync(join(wfDir, 'current.json'))) {
68
+ try {
69
+ const entries = readdirSync(wfDir, { withFileTypes: true });
70
+ const hasInstanceSubdir = entries.some(
71
+ (e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
72
+ );
73
+ if (!hasInstanceSubdir) return wfDir;
74
+ } catch {
75
+ return wfDir;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function hasInstance(base: string, wfName: string, suffix = ''): boolean {
82
+ return findInstanceDir(base, wfName, suffix) !== null;
83
+ }
84
+
46
85
  const fakeProvider: Provider = {
47
86
  name: 'test',
48
87
  baseURL: '',
@@ -72,7 +111,13 @@ describe('runner::pre-flight fresh 模式', () => {
72
111
  initWorkingDir: () => tmp,
73
112
  _resetWorkingDir: () => {},
74
113
  }));
75
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
114
+ ctx = {
115
+ provider: fakeProvider,
116
+ history: [],
117
+ cwd: tmp,
118
+ tools: [],
119
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
120
+ };
76
121
  });
77
122
 
78
123
  afterEach(() => {
@@ -125,7 +170,13 @@ describe('runner::resume 与断点续执行', () => {
125
170
  initWorkingDir: () => tmp,
126
171
  _resetWorkingDir: () => {},
127
172
  }));
128
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: ALL_TOOLS };
173
+ ctx = {
174
+ provider: fakeProvider,
175
+ history: [],
176
+ cwd: tmp,
177
+ tools: ALL_TOOLS,
178
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
179
+ };
129
180
  });
130
181
 
131
182
  afterEach(() => {
@@ -254,7 +305,13 @@ describe('runner::self_heal retry 机制', () => {
254
305
  initWorkingDir: () => tmp,
255
306
  _resetWorkingDir: () => {},
256
307
  }));
257
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
308
+ ctx = {
309
+ provider: fakeProvider,
310
+ history: [],
311
+ cwd: tmp,
312
+ tools: [],
313
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: DEFAULT_SESSION, owner: 'local-owner', interactiveAsk: false }),
314
+ };
258
315
  });
259
316
 
260
317
  afterEach(() => {
@@ -350,9 +407,9 @@ describe('runner::self_heal retry 机制', () => {
350
407
 
351
408
  await collect(runWorkflow(def, {}, ctx));
352
409
 
353
- // 成功后应 archive(活动目录改名 .done;E6 后按名隔离)
354
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'archive-test'))).toBe(false);
355
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'archive-test.done'))).toBe(true);
410
+ // 成功后应 archive(活动实例目录改名 .done;workflow 级父目录保留)
411
+ expect(hasInstance(tmp, 'archive-test')).toBe(false);
412
+ expect(hasInstance(tmp, 'archive-test', '.done')).toBe(true);
356
413
  });
357
414
 
358
415
  it('失败路径 → 保留状态目录供排查', async () => {
@@ -367,12 +424,12 @@ describe('runner::self_heal retry 机制', () => {
367
424
 
368
425
  await collect(runWorkflow(def, {}, ctx));
369
426
 
370
- // 失败时应保留状态目录(E6 后按名隔离)
371
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'keep-state'))).toBe(true);
427
+ // 失败时应保留状态目录(workflow 级父目录 + 实例目录)
428
+ expect(hasInstance(tmp, 'keep-state')).toBe(true);
372
429
  // progress.md 应包含失败步骤信息
373
- const progressPath = join(tmp, '.minimal-agent-workflow', 'keep-state', 'progress.md');
374
- if (existsSync(progressPath)) {
375
- const progress = readFileSync(progressPath, 'utf8');
430
+ const instDir = findInstanceDir(tmp, 'keep-state');
431
+ if (instDir) {
432
+ const progress = readFileSync(join(instDir, 'progress.md'), 'utf8');
376
433
  expect(progress).toContain('fail');
377
434
  }
378
435
  });
@@ -12,11 +12,12 @@
12
12
  */
13
13
 
14
14
  import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
15
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
15
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
20
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
20
21
  import { ALL_TOOLS } from '../../../src/tools/index.js';
21
22
  import { runWorkflow } from '../src/runner.js';
22
23
  import type { LoopEvent, Provider } from '../../../src/types.js';
@@ -41,6 +42,45 @@ async function collect(
41
42
  }
42
43
 
43
44
  const STATE_DIR = '.minimal-agent-workflow';
45
+ // 集成测试统一用显式 sessionId(引擎对未指定 session 自动命名,见 runner 的
46
+ // generateDefaultSessionId;测目录生命周期需要可预测的 session 名)
47
+ const SESSION = 'test-session';
48
+
49
+ function findInstanceDir(base: string, wfName: string, suffix = ''): string | null {
50
+ const wfDir = join(base, STATE_DIR, wfName);
51
+ if (!existsSync(wfDir)) {
52
+ // 旧布局兼容:wfDir 本身就是实例目录(wfName.done 形式)
53
+ const legacyDir = suffix
54
+ ? join(base, STATE_DIR, wfName + suffix)
55
+ : wfDir;
56
+ if (existsSync(legacyDir)) return legacyDir;
57
+ return null;
58
+ }
59
+
60
+ // Phase 3:session 化——实例目录名 = sessionId
61
+ const sessionDir = suffix
62
+ ? join(wfDir, SESSION + suffix)
63
+ : join(wfDir, SESSION);
64
+ if (existsSync(sessionDir)) return sessionDir;
65
+
66
+ // 旧布局兼容:wfDir 本身有 current.json(无子实例目录时判定为 legacy)
67
+ if (!suffix && existsSync(join(wfDir, 'current.json'))) {
68
+ try {
69
+ const entries = readdirSync(wfDir, { withFileTypes: true });
70
+ const hasInstanceSubdir = entries.some(
71
+ (e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
72
+ );
73
+ if (!hasInstanceSubdir) return wfDir;
74
+ } catch {
75
+ return wfDir;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function hasInstance(base: string, wfName: string, suffix = ''): boolean {
82
+ return findInstanceDir(base, wfName, suffix) !== null;
83
+ }
44
84
 
45
85
  describe('workflow state-dir 生命周期', () => {
46
86
  let tmp: string;
@@ -55,7 +95,14 @@ describe('workflow state-dir 生命周期', () => {
55
95
  initWorkingDir: () => tmp,
56
96
  _resetWorkingDir: () => {},
57
97
  }));
58
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: ALL_TOOLS };
98
+ ctx = {
99
+ provider: fakeProvider,
100
+ history: [],
101
+ cwd: tmp,
102
+ tools: ALL_TOOLS,
103
+ // 显式 sessionId:目录名可预测,resume/归档断言稳定
104
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: SESSION, owner: 'local-owner', interactiveAsk: false }),
105
+ };
59
106
  });
60
107
 
61
108
  afterEach(() => {
@@ -73,8 +120,8 @@ describe('workflow state-dir 生命周期', () => {
73
120
  const events = await collect(runWorkflow(def, {}, ctx));
74
121
  expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
75
122
  // 成功路径:活动目录被归档改名(completed 检测靠它),不留活动现场
76
- expect(existsSync(join(tmp, STATE_DIR, 'ok'))).toBe(false);
77
- expect(existsSync(join(tmp, STATE_DIR, 'ok.done'))).toBe(true);
123
+ expect(hasInstance(tmp, 'ok')).toBe(false);
124
+ expect(hasInstance(tmp, 'ok', '.done')).toBe(true);
78
125
  });
79
126
 
80
127
  it('halt → 保留状态目录 + progress.md 含失败那步', async () => {
@@ -91,9 +138,10 @@ describe('workflow state-dir 生命周期', () => {
91
138
  expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
92
139
 
93
140
  // 失败现场必须保留,供事后排查
94
- const stateDir = join(tmp, STATE_DIR, 'boom');
95
- expect(existsSync(stateDir)).toBe(true);
96
- const progress = readFileSync(join(stateDir, 'progress.md'), 'utf8');
141
+ const stateDir = findInstanceDir(tmp, 'boom');
142
+ expect(stateDir).not.toBeNull();
143
+ expect(stateDir).toBeDefined();
144
+ const progress = readFileSync(join(stateDir as string, 'progress.md'), 'utf8');
97
145
  // 失败那步的 ✗ 行 + onFail 文案都应在 progress 里
98
146
  expect(progress).toContain('will_fail');
99
147
  expect(progress).toContain('nope');
@@ -106,7 +154,7 @@ describe('workflow state-dir 生命周期', () => {
106
154
  steps: [{ id: 'f', type: 'assert', condition: 'false', onFail: 'x' }],
107
155
  };
108
156
  await collect(runWorkflow(failDef, {}, ctx));
109
- expect(existsSync(join(tmp, STATE_DIR, 'rerun'))).toBe(true); // 失败留下了
157
+ expect(hasInstance(tmp, 'rerun')).toBe(true); // 失败留下了
110
158
 
111
159
  // 第二次成功跑(同名同 inputs = 同任务 resume):成功后归档 → 活动目录干净
112
160
  const okDef: WorkflowDef = {
@@ -115,8 +163,8 @@ describe('workflow state-dir 生命周期', () => {
115
163
  steps: [{ id: 'ok', type: 'assert', condition: 'true' }],
116
164
  };
117
165
  await collect(runWorkflow(okDef, {}, ctx));
118
- expect(existsSync(join(tmp, STATE_DIR, 'rerun'))).toBe(false); // 不堆积
119
- expect(existsSync(join(tmp, STATE_DIR, 'rerun.done'))).toBe(true);
166
+ expect(hasInstance(tmp, 'rerun')).toBe(false); // 不堆积
167
+ expect(hasInstance(tmp, 'rerun', '.done')).toBe(true);
120
168
  });
121
169
 
122
170
  it('E6:A 工作流失败现场不被不同名工作流 B 的运行破坏(按名隔离)', async () => {
@@ -131,7 +179,7 @@ describe('workflow state-dir 生命周期', () => {
131
179
  ],
132
180
  };
133
181
  await collect(runWorkflow(aDef, {}, ctx));
134
- expect(existsSync(join(tmp, STATE_DIR, 'wf-a'))).toBe(true);
182
+ expect(hasInstance(tmp, 'wf-a')).toBe(true);
135
183
  expect(existsSync(join(tmp, 'A-PLAN.md'))).toBe(true);
136
184
 
137
185
  // 跑不同名工作流 B(曾经会清掉 A 的状态目录和产物——E6 事故)
@@ -144,7 +192,7 @@ describe('workflow state-dir 生命周期', () => {
144
192
  expect(evB.some((e) => e.type === 'workflow_done')).toBe(true);
145
193
 
146
194
  // A 的现场与产物完好,resume 能力保留
147
- expect(existsSync(join(tmp, STATE_DIR, 'wf-a'))).toBe(true);
195
+ expect(hasInstance(tmp, 'wf-a')).toBe(true);
148
196
  expect(existsSync(join(tmp, 'A-PLAN.md'))).toBe(true);
149
197
  });
150
198
  });
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
27
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
27
28
  import type { LoopEvent, Provider } from '../../../src/types.js';
28
29
  import {
29
30
  buildTaskArbitrator,
@@ -185,11 +186,13 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
185
186
  });
186
187
 
187
188
  it('仲裁判 resume → 保留旧状态与产物、回放 checkpoint、带 prevGoal/supplementaryGoal', async () => {
188
- // 旧任务:fresh + 完成一步 + 留产物(不 markDone = 未完成)
189
+ // 旧任务:fresh + 完成一步 + 留产物 + 留补充要求(不 markDone = 未完成)
189
190
  const s1 = new WorkflowState(tmp, 'plan');
190
191
  await s1.init('plan', { goal: '写产品架构 PPT' }, STEPS, 1, ['PLAN.md']);
191
192
  await s1.recordCheckpoint('s1', 'ok', { out: 'v1' }, {});
193
+ await s1.appendSupplement('用户要求加一章');
192
194
  writeFileSync(join(tmp, 'PLAN.md'), '# 半成品');
195
+ const oldDir = s1.dir;
193
196
 
194
197
  // 新输入「继续」(hash 必然不等)+ 仲裁器判 resume
195
198
  const s2 = new WorkflowState(tmp, 'plan');
@@ -202,6 +205,10 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
202
205
  expect(r.supplementaryGoal).toBe('继续');
203
206
  // 现场未被清理
204
207
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(true);
208
+ // 回归:resume 后实例目录必须仍指向旧任务(否则 supplements / checkpoint 全写丢,
209
+ // 断点续跑静默失效——曾因 finally 误把 this.dir 改回新 hash 目录)
210
+ expect(s2.dir).toBe(oldDir);
211
+ expect(await s2.readSupplements()).toEqual(['用户要求加一章']);
205
212
  });
206
213
 
207
214
  it('仲裁判 new → 走 new-task 清理重开', async () => {
@@ -257,7 +264,9 @@ describe('workflowState::init × 仲裁(resume 保留现场 / new 清理重开
257
264
  expect(r.mode).toBe('fresh');
258
265
  expect(arbCalled).toBe(0);
259
266
  // A 的现场完好(曾经会被 B 的 new-task 清掉——E6 事故)
260
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'other-wf', 'current.json'))).toBe(true);
267
+ // 新布局:current.json 在实例子目录里;旧布局兼容时直接在 wfDir 下
268
+ const aDir = sA.dir;
269
+ expect(existsSync(join(aDir, 'current.json'))).toBe(true);
261
270
  });
262
271
  });
263
272
 
@@ -276,7 +285,14 @@ describe('runWorkflow × 仲裁延续(端到端)', () => {
276
285
  initWorkingDir: () => tmp,
277
286
  _resetWorkingDir: () => {},
278
287
  }));
279
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
288
+ ctx = {
289
+ provider: fakeProvider,
290
+ history: [],
291
+ cwd: tmp,
292
+ tools: [],
293
+ // 显式 sessionId:两次 runWorkflow 同 session,仲裁「继续」才能 resume 旧任务
294
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: 'test-session', owner: 'local-owner', interactiveAsk: false }),
295
+ };
280
296
  });
281
297
  afterEach(() => {
282
298
  delete process.env.MINIMAL_AGENT_CWD;
@@ -67,9 +67,10 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
67
67
  writeFileSync(join(tmp, 'PLAN.md'), '# plan');
68
68
  await s1.markDone();
69
69
  await s1.archive();
70
- // 归档后活动目录已改名 .done(E6 后按名隔离)
71
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan'))).toBe(false);
72
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan.done'))).toBe(true);
70
+ // 归档后活动实例被改名 .done(workflow 级父目录保留——里面可能有多个实例)
71
+ expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan'))).toBe(true);
72
+ expect(existsSync(s1.dir + '.done')).toBe(true);
73
+ expect(existsSync(s1.dir)).toBe(false);
73
74
 
74
75
  // 第二次:同 goal 重跑 → completed(经 .done 检测),报真实产物
75
76
  const s2 = new WorkflowState(tmp, 'plan');
@@ -104,8 +105,9 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
104
105
  const s2 = new WorkflowState(tmp, 'plan');
105
106
  const r2 = await s2.init('plan', { goal: '完全不相关的目标B' }, STEPS, 1, ['PLAN.md']);
106
107
  expect(r2.mode).toBe('new-task');
107
- expect(r2.cleanedArtifacts).toContain('PLAN.md');
108
108
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
109
+ expect(r2.archivedTo).toBeDefined();
110
+ expect(existsSync(join(tmp, r2.archivedTo as string, 'PLAN.md'))).toBe(true);
109
111
  });
110
112
 
111
113
  it('legacy 状态文件(无 artifacts 字段)+ 同名 workflow 换 goal → new-task 用本次声明清单兜底清理', async () => {
@@ -128,8 +130,9 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
128
130
  const r = await s.init('plan', { goal: '全新目标' }, STEPS, 1, ['PLAN.md']);
129
131
  expect(r.mode).toBe('new-task');
130
132
  // 兜底清理生效:同名 workflow 的声明产物语义稳定,按本次声明清单清掉残留
131
- expect(r.cleanedArtifacts).toContain('PLAN.md');
132
133
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
134
+ expect(r.archivedTo).toBeDefined();
135
+ expect(existsSync(join(tmp, r.archivedTo as string, 'PLAN.md'))).toBe(true);
133
136
  });
134
137
 
135
138
  it('/new 后孤儿产物:无任何状态但声明产物已存在 → fresh 启动时清理(全新任务从干净状态开始)', async () => {
@@ -141,8 +144,59 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
141
144
  const r = await s.init('plan', { goal: '目标A' }, STEPS, 1, ['PLAN.md']);
142
145
  expect(r.mode).toBe('fresh');
143
146
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
144
- // 可见化:runner 据此报「已清理旧产物」warning
145
- expect(r.cleanedArtifacts).toEqual(['PLAN.md']);
147
+ expect(r.archivedTo).toBeDefined();
148
+ expect(existsSync(join(tmp, r.archivedTo as string, 'PLAN.md'))).toBe(true);
149
+ });
150
+
151
+ it('A4:归档保留相对路径结构,只归档声明产物、不碰未声明文件', async () => {
152
+ const { mkdirSync } = await import('node:fs');
153
+ mkdirSync(join(tmp, 'out'), { recursive: true });
154
+ const s1 = new WorkflowState(tmp, 'plan');
155
+ await s1.init('plan', { goal: '目标A' }, STEPS, 1, ['out/PLAN.md']);
156
+ writeFileSync(join(tmp, 'out', 'PLAN.md'), '# A');
157
+ writeFileSync(join(tmp, 'out', 'USER-OWN.md'), '# 用户自己的文件');
158
+
159
+ const s2 = new WorkflowState(tmp, 'plan');
160
+ const r2 = await s2.init('plan', { goal: '目标B' }, STEPS, 1, ['out/PLAN.md']);
161
+ expect(r2.mode).toBe('new-task');
162
+ expect(existsSync(join(tmp, 'out', 'PLAN.md'))).toBe(false);
163
+ expect(existsSync(join(tmp, 'out', 'USER-OWN.md'))).toBe(true);
164
+ expect(existsSync(join(tmp, r2.archivedTo as string, 'out', 'PLAN.md'))).toBe(true);
165
+ });
166
+
167
+ it('B8:声明产物支持 glob(out/*.md),new-task 清理时展开为实际文件归档', async () => {
168
+ const { mkdirSync } = await import('node:fs');
169
+ mkdirSync(join(tmp, 'out'), { recursive: true });
170
+ const s1 = new WorkflowState(tmp, 'research');
171
+ await s1.init('research', { goal: '目标A' }, STEPS, 1, ['out/deep-dive-*.md']);
172
+ writeFileSync(join(tmp, 'out', 'deep-dive-a.md'), '# A');
173
+ writeFileSync(join(tmp, 'out', 'deep-dive-b.md'), '# B');
174
+ writeFileSync(join(tmp, 'out', 'notes.txt'), '# 不匹配 md');
175
+
176
+ const s2 = new WorkflowState(tmp, 'research');
177
+ const r2 = await s2.init('research', { goal: '目标B' }, STEPS, 1, ['out/deep-dive-*.md']);
178
+ expect(r2.mode).toBe('new-task');
179
+ expect(existsSync(join(tmp, 'out', 'deep-dive-a.md'))).toBe(false);
180
+ expect(existsSync(join(tmp, 'out', 'deep-dive-b.md'))).toBe(false);
181
+ expect(existsSync(join(tmp, 'out', 'notes.txt'))).toBe(true);
182
+ const trash = join(tmp, r2.archivedTo as string);
183
+ expect(existsSync(join(trash, 'out', 'deep-dive-a.md'))).toBe(true);
184
+ expect(existsSync(join(trash, 'out', 'deep-dive-b.md'))).toBe(true);
185
+ });
186
+
187
+ it('B8:越界 glob(../x*)不展开不清理(fail-closed)', async () => {
188
+ writeFileSync(join(tmp, 'PLAN.md'), '# inside');
189
+ const outside = mkdtempSync(join(tmpdir(), 'wf-outside-'));
190
+ try {
191
+ writeFileSync(join(outside, 'secret.md'), '# outside');
192
+ const s = new WorkflowState(tmp, 'plan');
193
+ const r = await s.init('plan', { goal: '目标A' }, STEPS, 1, ['../x*.md', 'PLAN.md']);
194
+ expect(r.mode).toBe('fresh');
195
+ expect(existsSync(join(outside, 'secret.md'))).toBe(true);
196
+ expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
197
+ } finally {
198
+ rmSync(outside, { recursive: true, force: true });
199
+ }
146
200
  });
147
201
 
148
202
  it('resume 恢复点用最后完成步的【索引】而非 checkpoint 计数(when 跳过步不再被重复执行)', async () => {
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ ALL_TOOLS,
5
+ type LoopEvent,
6
+ type Provider,
7
+ type ToolCallContext,
8
+ type ToolResult,
9
+ createAgentRuntimeContext,
10
+ createSessionState,
11
+ } from '../../../src/plugin-sdk.js';
12
+ import { execToolStep } from '../src/stepExecutors/tool.js';
13
+ import { type RunContext, type StepResult, VarStack } from '../src/types.js';
14
+
15
+ const provider: Provider = {
16
+ name: 'workflow-tool-test',
17
+ baseURL: '',
18
+ apiKey: '',
19
+ model: 'fixture',
20
+ contextWindow: 128_000,
21
+ };
22
+
23
+ async function collect(
24
+ generator: AsyncGenerator<LoopEvent, StepResult, void>,
25
+ ): Promise<{ events: LoopEvent[]; result: StepResult }> {
26
+ const events: LoopEvent[] = [];
27
+ while (true) {
28
+ const next = await generator.next();
29
+ if (next.done) return { events, result: next.value };
30
+ events.push(next.value);
31
+ }
32
+ }
33
+
34
+ function makeContext(
35
+ executeTool: NonNullable<RunContext['executeTool']>,
36
+ ): RunContext {
37
+ const sessionState = createSessionState();
38
+ sessionState.activeWorktree = {
39
+ id: 'wt_test',
40
+ path: '/tmp/workflow-active-worktree',
41
+ branch: 'workflow/test',
42
+ baseSha: 'deadbeef',
43
+ gitRoot: '/tmp/workflow-root',
44
+ };
45
+ return {
46
+ provider,
47
+ history: [],
48
+ cwd: '/tmp/workflow-root',
49
+ tools: ALL_TOOLS,
50
+ sessionState,
51
+ runtimeContext: createAgentRuntimeContext({
52
+ surface: 'workflow',
53
+ profile: 'main',
54
+ cwd: '/tmp/workflow-root',
55
+ workspaceId: 'workspace_test',
56
+ sessionId: 'session_test',
57
+ owner: 'local-owner',
58
+ memoryAccess: 'read-only',
59
+ interactiveAsk: false,
60
+ }),
61
+ promptCache: {
62
+ scopeKey: 'workflow-test-scope',
63
+ conversationKey: 'workflow-test-conversation',
64
+ },
65
+ executeTool,
66
+ };
67
+ }
68
+
69
+ describe('workflow deterministic tool runtime', () => {
70
+ test('all registered tools use the same generic deterministic node dispatcher', async () => {
71
+ const dispatched: string[] = [];
72
+ const ctx = makeContext(async (name) => {
73
+ dispatched.push(name);
74
+ return { ok: true, content: `${name} ok` };
75
+ });
76
+
77
+ for (const tool of ALL_TOOLS) {
78
+ const { result } = await collect(
79
+ execToolStep(
80
+ { id: `catalog_${tool.name}`, tool: tool.name, args: {} },
81
+ new VarStack(),
82
+ ctx,
83
+ ),
84
+ );
85
+ expect(result.raw).toMatchObject({ content: `${tool.name} ok` });
86
+ }
87
+
88
+ expect(dispatched).toEqual(ALL_TOOLS.map((tool) => tool.name));
89
+ expect(new Set(dispatched).size).toBe(ALL_TOOLS.length);
90
+ });
91
+
92
+ test('passes canonical runtime context and follows the active worktree cwd', async () => {
93
+ let captured: ToolCallContext | undefined;
94
+ const ctx = makeContext(async (_name, _args, _signal, context) => {
95
+ captured = context;
96
+ return { ok: true, content: 'ok' };
97
+ });
98
+
99
+ await collect(
100
+ execToolStep(
101
+ { id: 'context', tool: 'Read', args: { file_path: 'README.md' } },
102
+ new VarStack(),
103
+ ctx,
104
+ ),
105
+ );
106
+
107
+ expect(captured).toBeDefined();
108
+ expect(captured?.provider).toBe(provider);
109
+ expect(captured?.history).toBe(ctx.history);
110
+ expect(captured?.sessionState).toBe(ctx.sessionState);
111
+ expect(captured?.workspaceId).toBe('workspace_test');
112
+ expect(captured?.sessionId).toBe('session_test');
113
+ expect(captured?.memoryAccess).toBe('read-only');
114
+ expect(captured?.cwd).toBe('/tmp/workflow-active-worktree');
115
+ expect(captured?.toolCallId).toBe('wf_context');
116
+ expect(captured?.promptCache).toBe(ctx.promptCache);
117
+ });
118
+
119
+ test('streams tool progress and preserves structured result fields', async () => {
120
+ const metadata = {
121
+ kind: 'process_execution' as const,
122
+ shell: 'bash' as const,
123
+ mode: 'foreground' as const,
124
+ commandKind: 'test' as const,
125
+ status: 'completed' as const,
126
+ exitCode: 0,
127
+ };
128
+ const ctx = makeContext(async (_name, _args, _signal, context) => {
129
+ context?.reportProgress?.('running tests');
130
+ await Promise.resolve();
131
+ return {
132
+ ok: true,
133
+ content: 'tests passed',
134
+ metadata,
135
+ recovery: 'none',
136
+ sensitivity: 'local',
137
+ visionNote: 'verification image',
138
+ } satisfies ToolResult;
139
+ });
140
+
141
+ const { events, result } = await collect(
142
+ execToolStep(
143
+ { id: 'progress', tool: 'Bash', args: { command: 'bun test' } },
144
+ new VarStack(),
145
+ ctx,
146
+ ),
147
+ );
148
+
149
+ expect(events.map((event) => event.type)).toEqual([
150
+ 'tool_start',
151
+ 'tool_progress',
152
+ 'tool_end',
153
+ ]);
154
+ expect(events[1]).toMatchObject({
155
+ type: 'tool_progress',
156
+ toolName: 'Bash',
157
+ toolCallId: 'wf_progress',
158
+ message: 'running tests',
159
+ });
160
+ expect(events[2]).toMatchObject({ type: 'tool_end', metadata });
161
+ expect(result.raw).toMatchObject({
162
+ metadata,
163
+ recovery: 'none',
164
+ sensitivity: 'local',
165
+ visionNote: 'verification image',
166
+ });
167
+ });
168
+ });
@@ -70,11 +70,11 @@ describe('workflows/webppt.yaml', () => {
70
70
  expect(String(s.title)).toContain('/11');
71
71
  }
72
72
 
73
- // 收尾报告 echo;artifacts 只列最终交付物、不在被 rm 的 ./build 下
73
+ // 收尾报告 echo;artifacts 只列最终交付物、不在被 rm 的 out/webppt/${session}/build 下
74
74
  expect(def.steps.find((s) => s.id === 'final_report')?.type).toBe('echo');
75
75
  const declared = def.artifacts ?? [];
76
- expect(declared).toContain('./out/deck.pptx');
77
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
76
+ expect(declared).toContain('out/webppt/${session}/deck.pptx');
77
+ for (const a of declared) expect(a.startsWith('out/webppt/${session}/build')).toBe(false);
78
78
  });
79
79
 
80
80
  it('需求分析步:style 为空时必问视觉样式(VI 选项 + 自定义输入),AskUserQuestion 可用', async () => {
@@ -199,7 +199,7 @@ describe('workflows/webppt.yaml', () => {
199
199
  // 配图分流闸门:image_plan_lint 的 --outline 直接吃 design.json
200
200
  const gate = def.steps.find((s) => s.id === 'stage_img_route_gate');
201
201
  expect(String(gate?.agent)).toContain('image_plan_lint.py');
202
- expect(String(gate?.agent)).toContain('--outline ./build/design.json');
202
+ expect(String(gate?.agent)).toContain('--outline out/webppt/${session}/build/design.json');
203
203
  expect(String(gate?.done_when)).toContain('img_route_ok');
204
204
  // 顺序:build_loop → img_route_gate → gen_images
205
205
  expect(ids.indexOf('stage_img_route_gate')).toBeGreaterThan(ids.indexOf('stage_build_loop'));
@@ -3,7 +3,7 @@
3
3
  * ------------------------------------------------------------
4
4
  * 锁定 workflows/word.yaml(素材驱动知乎深度长文 Word 工作流)的
5
5
  * 「可解析 + 关键骨架」:
6
- * - name=word,最终产物是 ./out/article.docx
6
+ * - name=word,最终产物是 out/word/${session}/article.docx
7
7
  * - inputs 支持 topic(greedy) / source / out / image_intensity / quality / debug_keep_build
8
8
  * - Markdown-first:material → outline → article.md → image_map/img_plan → docx skill → OPC
9
9
  * - 客户图必须使用;需要解释型视觉时积极文生图,提示词必须结合上下文
@@ -32,8 +32,8 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
32
32
  expect(def.description).toContain('知乎深度文章');
33
33
 
34
34
  const declared = def.artifacts ?? [];
35
- expect(declared).toContain('./out/article.docx');
36
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
35
+ expect(declared).toContain('out/word/${session}/article.docx');
36
+ for (const a of declared) expect(a.startsWith('out/word/${session}/build')).toBe(false);
37
37
 
38
38
  const inputNames = (def.inputs ?? []).map((i) => i.name);
39
39
  expect(inputNames).toEqual(
@@ -49,7 +49,7 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
49
49
  expect(inputNames[0]).toBe('topic');
50
50
  expect((def.inputs ?? []).find((i) => i.name === 'topic')?.greedy).toBe(true);
51
51
  expect((def.inputs ?? []).find((i) => i.name === 'out')?.default).toBe(
52
- './out/article.docx',
52
+ 'out/word/${session}/article.docx',
53
53
  );
54
54
  });
55
55
 
@@ -163,11 +163,11 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
163
163
  const extract = def.steps.find((s) => s.id === 'stage_extract');
164
164
  const command = String(extract?.args?.command ?? '');
165
165
 
166
- expect(command).toContain('./build/source.md');
167
- expect(command).toContain('rm -rf ./build/assets');
168
- expect(command).toContain('mkdir -p ./build/assets');
169
- expect(command).toContain('./build/word_topic_image_gate.json');
170
- expect(command).toContain('./build/word_topic_image_gate.md');
166
+ expect(command).toContain('out/word/${session}/build/source.md');
167
+ expect(command).toContain('rm -rf out/word/${session}/build/assets');
168
+ expect(command).toContain('mkdir -p out/word/${session}/build/assets');
169
+ expect(command).toContain('out/word/${session}/build/word_topic_image_gate.json');
170
+ expect(command).toContain('out/word/${session}/build/word_topic_image_gate.md');
171
171
  });
172
172
 
173
173
  it('生成配图失败时会在导出前硬失败,不会把渐变占位图交付进 Word', async () => {