@meiyukichan/dsh-sandore 0.1.0-rc.6

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 (295) hide show
  1. package/LICENSE +201 -0
  2. package/cordis.patch.yml +39 -0
  3. package/dsh.plugin.json +16 -0
  4. package/lib/client/CommandEditor.d.ts +19 -0
  5. package/lib/client/DagEditor.d.ts +22 -0
  6. package/lib/client/DefinitionDetail.d.ts +13 -0
  7. package/lib/client/DefinitionList.d.ts +19 -0
  8. package/lib/client/DiscussionInput.d.ts +63 -0
  9. package/lib/client/DragHandle.d.ts +16 -0
  10. package/lib/client/EditorPane.d.ts +13 -0
  11. package/lib/client/FlowEditor.d.ts +31 -0
  12. package/lib/client/InstanceList.d.ts +24 -0
  13. package/lib/client/NewInstanceDialog.d.ts +19 -0
  14. package/lib/client/PreviewPanel.d.ts +13 -0
  15. package/lib/client/RunView.d.ts +79 -0
  16. package/lib/client/StepChatPanel.d.ts +34 -0
  17. package/lib/client/TreeNav.d.ts +27 -0
  18. package/lib/client/UnifiedTimeline.d.ts +11 -0
  19. package/lib/client/WorkflowButton.d.ts +11 -0
  20. package/lib/client/WorkflowList.d.ts +18 -0
  21. package/lib/client/WorkflowOverlay.d.ts +6 -0
  22. package/lib/client/chat/AssistantMarkdown.d.ts +22 -0
  23. package/lib/client/chat/CompactionItem.d.ts +24 -0
  24. package/lib/client/chat/ContextBody.d.ts +136 -0
  25. package/lib/client/chat/ContextInjectionRow.d.ts +25 -0
  26. package/lib/client/chat/GenericCommandCard.d.ts +10 -0
  27. package/lib/client/chat/MessageIconActions.d.ts +41 -0
  28. package/lib/client/chat/MessageItem.d.ts +65 -0
  29. package/lib/client/chat/ReasoningRow.d.ts +13 -0
  30. package/lib/client/chat/TurnTailNodeView.d.ts +29 -0
  31. package/lib/client/chat/UserMessageRow.d.ts +18 -0
  32. package/lib/client/chat/attachment/ImageLightbox.d.ts +26 -0
  33. package/lib/client/chat/attachment/MessageImage.d.ts +56 -0
  34. package/lib/client/chat/attachment/image-attachment.d.ts +41 -0
  35. package/lib/client/chat/attachment/image-labels.d.ts +21 -0
  36. package/lib/client/chat/message-chrome.d.ts +51 -0
  37. package/lib/client/chat/reference/ReferenceIcon.d.ts +20 -0
  38. package/lib/client/chat/turn-assistant.d.ts +12 -0
  39. package/lib/client/chat/turn-metrics.d.ts +42 -0
  40. package/lib/client/chat/use-calendar-day.d.ts +5 -0
  41. package/lib/client/chat/use-throttled-visual-update.d.ts +7 -0
  42. package/lib/client/chat-flow.d.ts +110 -0
  43. package/lib/client/composer-selects.d.ts +17 -0
  44. package/lib/client/dag-editor-model.d.ts +141 -0
  45. package/lib/client/describe-error.d.ts +9 -0
  46. package/lib/client/index.d.ts +6 -0
  47. package/lib/client/locales.d.ts +777 -0
  48. package/lib/client/model-faces.d.ts +156 -0
  49. package/lib/client/open-discussion.d.ts +24 -0
  50. package/lib/client/panel-model.d.ts +34 -0
  51. package/lib/client/remote.d.ts +34 -0
  52. package/lib/client/run-view-model.d.ts +304 -0
  53. package/lib/client/sandore-save.d.ts +41 -0
  54. package/lib/client/sandore-sse.d.ts +15 -0
  55. package/lib/client/step-chat-retry.d.ts +44 -0
  56. package/lib/client/tool/GenericToolCard.d.ts +28 -0
  57. package/lib/client/tool/ToolRow.d.ts +95 -0
  58. package/lib/client/tool/diff-card-model.d.ts +63 -0
  59. package/lib/client/tool/read-card-model.d.ts +67 -0
  60. package/lib/client/tool/search-card-model.d.ts +94 -0
  61. package/lib/client/tool/terminal-card-model.d.ts +86 -0
  62. package/lib/client/tool/tool-call-model.d.ts +74 -0
  63. package/lib/client/tool/web-card-model.d.ts +44 -0
  64. package/lib/client/use-focus-refresh.d.ts +1 -0
  65. package/lib/client/workflow-face.d.ts +68 -0
  66. package/lib/client/workflow-store.d.ts +80 -0
  67. package/lib/client/workspace-path.d.ts +15 -0
  68. package/lib/client.js +38389 -0
  69. package/lib/index.js +8854 -0
  70. package/lib/microflow/branch-evaluator.d.ts +290 -0
  71. package/lib/microflow/concurrency-limiter.d.ts +39 -0
  72. package/lib/microflow/errors.d.ts +39 -0
  73. package/lib/microflow/file-utils.d.ts +210 -0
  74. package/lib/microflow/finalize-manager.d.ts +414 -0
  75. package/lib/microflow/fork-session.d.ts +151 -0
  76. package/lib/microflow/index.d.ts +17 -0
  77. package/lib/microflow/internal.d.ts +56 -0
  78. package/lib/microflow/loop-manager.d.ts +127 -0
  79. package/lib/microflow/rules-resolver.d.ts +28 -0
  80. package/lib/microflow/stage-executor.d.ts +362 -0
  81. package/lib/microflow/step-navigator.d.ts +108 -0
  82. package/lib/microflow/step-runner.d.ts +190 -0
  83. package/lib/microflow/subagents.d.ts +380 -0
  84. package/lib/tool/index.d.ts +65 -0
  85. package/lib/typert.host.d.ts +16 -0
  86. package/lib/typert.host.js +25 -0
  87. package/lib/typert.remote-client.d.ts +114 -0
  88. package/lib/typert.remote-client.js +399 -0
  89. package/lib/workflow/ask-tool.d.ts +78 -0
  90. package/lib/workflow/dag-navigator.d.ts +136 -0
  91. package/lib/workflow/domain.d.ts +304 -0
  92. package/lib/workflow/engine-surface.d.ts +70 -0
  93. package/lib/workflow/errors.d.ts +13 -0
  94. package/lib/workflow/host-agent.d.ts +53 -0
  95. package/lib/workflow/index.d.ts +23 -0
  96. package/lib/workflow/interaction-manager.d.ts +170 -0
  97. package/lib/workflow/lifecycle-manager.d.ts +111 -0
  98. package/lib/workflow/persistence.d.ts +35 -0
  99. package/lib/workflow/plugin.d.ts +27 -0
  100. package/lib/workflow/reactive-state.d.ts +27 -0
  101. package/lib/workflow/recovery-manager.d.ts +158 -0
  102. package/lib/workflow/remote.d.ts +139 -0
  103. package/lib/workflow/schema.d.ts +1917 -0
  104. package/lib/workflow/seam.d.ts +254 -0
  105. package/lib/workflow/shell.d.ts +127 -0
  106. package/lib/workflow/sse.d.ts +16 -0
  107. package/lib/workflow/typert/typert.host.d.ts +16 -0
  108. package/lib/workflow/typert/typert.remote-client.d.ts +114 -0
  109. package/lib/workflow/types.d.ts +187 -0
  110. package/lib/workflow/wire.d.ts +256 -0
  111. package/lib/workflow/workflow-engine.d.ts +373 -0
  112. package/lib/workflow/yaml-error.d.ts +22 -0
  113. package/lib/workflow/yaml-parser.d.ts +23 -0
  114. package/package.json +140 -0
  115. package/packages/client/CommandEditor.tsx +73 -0
  116. package/packages/client/DagEditor.module.css +711 -0
  117. package/packages/client/DagEditor.tsx +1203 -0
  118. package/packages/client/DefinitionDetail.module.css +110 -0
  119. package/packages/client/DefinitionDetail.tsx +126 -0
  120. package/packages/client/DefinitionList.tsx +173 -0
  121. package/packages/client/DiscussionInput.tsx +763 -0
  122. package/packages/client/DragHandle.tsx +92 -0
  123. package/packages/client/EditorPane.tsx +70 -0
  124. package/packages/client/FlowEditor.tsx +227 -0
  125. package/packages/client/InstanceList.tsx +250 -0
  126. package/packages/client/NewInstanceDialog.module.css +244 -0
  127. package/packages/client/NewInstanceDialog.tsx +478 -0
  128. package/packages/client/PreviewPanel.tsx +55 -0
  129. package/packages/client/RunView.module.css +246 -0
  130. package/packages/client/RunView.tsx +314 -0
  131. package/packages/client/StepChatPanel.tsx +486 -0
  132. package/packages/client/TreeNav.tsx +294 -0
  133. package/packages/client/UnifiedTimeline.tsx +484 -0
  134. package/packages/client/WorkflowButton.module.css +72 -0
  135. package/packages/client/WorkflowButton.tsx +50 -0
  136. package/packages/client/WorkflowList.tsx +158 -0
  137. package/packages/client/WorkflowOverlay.module.css +390 -0
  138. package/packages/client/WorkflowOverlay.tsx +136 -0
  139. package/packages/client/WorkflowPanel.module.css +247 -0
  140. package/packages/client/chat/AssistantMarkdown.module.css +38 -0
  141. package/packages/client/chat/AssistantMarkdown.tsx +120 -0
  142. package/packages/client/chat/ChatView.module.css +198 -0
  143. package/packages/client/chat/CompactionItem.tsx +85 -0
  144. package/packages/client/chat/ContextBody.module.css +162 -0
  145. package/packages/client/chat/ContextBody.tsx +598 -0
  146. package/packages/client/chat/ContextInjectionRow.module.css +63 -0
  147. package/packages/client/chat/ContextInjectionRow.tsx +78 -0
  148. package/packages/client/chat/GenericCommandCard.module.css +87 -0
  149. package/packages/client/chat/GenericCommandCard.tsx +76 -0
  150. package/packages/client/chat/MessageIconActions.module.css +84 -0
  151. package/packages/client/chat/MessageIconActions.tsx +147 -0
  152. package/packages/client/chat/MessageItem.module.css +293 -0
  153. package/packages/client/chat/MessageItem.tsx +384 -0
  154. package/packages/client/chat/ReasoningRow.module.css +82 -0
  155. package/packages/client/chat/ReasoningRow.tsx +70 -0
  156. package/packages/client/chat/TurnTailNodeView.module.css +10 -0
  157. package/packages/client/chat/TurnTailNodeView.tsx +63 -0
  158. package/packages/client/chat/UserMessageRow.module.css +75 -0
  159. package/packages/client/chat/UserMessageRow.tsx +92 -0
  160. package/packages/client/chat/accessibility.module.css +9 -0
  161. package/packages/client/chat/attachment/ImageLightbox.module.css +45 -0
  162. package/packages/client/chat/attachment/ImageLightbox.tsx +70 -0
  163. package/packages/client/chat/attachment/MessageImage.module.css +70 -0
  164. package/packages/client/chat/attachment/MessageImage.tsx +136 -0
  165. package/packages/client/chat/attachment/image-attachment.ts +98 -0
  166. package/packages/client/chat/attachment/image-labels.ts +34 -0
  167. package/packages/client/chat/message-chrome.ts +96 -0
  168. package/packages/client/chat/reference/ReferenceIcon.tsx +40 -0
  169. package/packages/client/chat/turn-assistant.ts +15 -0
  170. package/packages/client/chat/turn-metrics.ts +102 -0
  171. package/packages/client/chat/use-calendar-day.ts +27 -0
  172. package/packages/client/chat/use-throttled-visual-update.ts +44 -0
  173. package/packages/client/chat-flow.spec.ts +357 -0
  174. package/packages/client/chat-flow.ts +278 -0
  175. package/packages/client/composer-selects.module.css +376 -0
  176. package/packages/client/composer-selects.tsx +531 -0
  177. package/packages/client/dag-editor-model.spec.ts +173 -0
  178. package/packages/client/dag-editor-model.ts +359 -0
  179. package/packages/client/describe-error.spec.ts +18 -0
  180. package/packages/client/describe-error.ts +12 -0
  181. package/packages/client/index.ts +144 -0
  182. package/packages/client/input.module.css +854 -0
  183. package/packages/client/is-conditional-check.spec.ts +34 -0
  184. package/packages/client/locales.ts +789 -0
  185. package/packages/client/model-faces.ts +225 -0
  186. package/packages/client/open-discussion.spec.ts +39 -0
  187. package/packages/client/open-discussion.ts +32 -0
  188. package/packages/client/panel-model.spec.ts +152 -0
  189. package/packages/client/panel-model.ts +72 -0
  190. package/packages/client/preview.module.css +95 -0
  191. package/packages/client/react-dom.d.ts +11 -0
  192. package/packages/client/reactflow-theme.css +91 -0
  193. package/packages/client/remote.spec.ts +41 -0
  194. package/packages/client/remote.ts +46 -0
  195. package/packages/client/run-view-model.spec.ts +641 -0
  196. package/packages/client/run-view-model.ts +636 -0
  197. package/packages/client/sandore-save.spec.ts +146 -0
  198. package/packages/client/sandore-save.ts +97 -0
  199. package/packages/client/sandore-sse.ts +59 -0
  200. package/packages/client/step-chat-retry.spec.ts +70 -0
  201. package/packages/client/step-chat-retry.ts +65 -0
  202. package/packages/client/timeline.module.css +503 -0
  203. package/packages/client/tool/GenericToolCard.tsx +99 -0
  204. package/packages/client/tool/ToolRow.module.css +308 -0
  205. package/packages/client/tool/ToolRow.tsx +328 -0
  206. package/packages/client/tool/diff-card-model.ts +103 -0
  207. package/packages/client/tool/read-card-model.ts +86 -0
  208. package/packages/client/tool/search-card-model.ts +166 -0
  209. package/packages/client/tool/terminal-card-model.ts +225 -0
  210. package/packages/client/tool/tool-call-model.ts +267 -0
  211. package/packages/client/tool/web-card-model.ts +80 -0
  212. package/packages/client/tree-nav.module.css +282 -0
  213. package/packages/client/use-focus-refresh.ts +23 -0
  214. package/packages/client/workflow-face.ts +72 -0
  215. package/packages/client/workflow-store.spec.ts +49 -0
  216. package/packages/client/workflow-store.ts +161 -0
  217. package/packages/client/workspace-path.ts +27 -0
  218. package/packages/css-modules.d.ts +6 -0
  219. package/packages/microflow/branch-evaluator.spec.ts +745 -0
  220. package/packages/microflow/branch-evaluator.ts +1037 -0
  221. package/packages/microflow/concurrency-limiter.ts +70 -0
  222. package/packages/microflow/errors.ts +64 -0
  223. package/packages/microflow/file-utils.spec.ts +604 -0
  224. package/packages/microflow/file-utils.ts +521 -0
  225. package/packages/microflow/finalize-manager.spec.ts +1977 -0
  226. package/packages/microflow/finalize-manager.ts +1464 -0
  227. package/packages/microflow/fork-session.spec.ts +184 -0
  228. package/packages/microflow/fork-session.ts +316 -0
  229. package/packages/microflow/index.ts +17 -0
  230. package/packages/microflow/internal.ts +166 -0
  231. package/packages/microflow/loop-manager.spec.ts +294 -0
  232. package/packages/microflow/loop-manager.ts +255 -0
  233. package/packages/microflow/rules-resolver.spec.ts +147 -0
  234. package/packages/microflow/rules-resolver.ts +58 -0
  235. package/packages/microflow/stage-executor.spec.ts +1577 -0
  236. package/packages/microflow/stage-executor.ts +1478 -0
  237. package/packages/microflow/step-navigator.spec.ts +301 -0
  238. package/packages/microflow/step-navigator.ts +300 -0
  239. package/packages/microflow/step-runner.spec.ts +1132 -0
  240. package/packages/microflow/step-runner.ts +752 -0
  241. package/packages/microflow/subagents.spec.ts +215 -0
  242. package/packages/microflow/subagents.ts +422 -0
  243. package/packages/tests/fixtures/test-workflow.yaml +128 -0
  244. package/packages/tests/mocks/agents.ts +206 -0
  245. package/packages/tests/mocks/llm.ts +46 -0
  246. package/packages/tests/mocks/session-title.ts +31 -0
  247. package/packages/tests/mocks/sessions.ts +131 -0
  248. package/packages/tests/mocks/shell.ts +60 -0
  249. package/packages/tests/mocks/storage-domain.ts +101 -0
  250. package/packages/tests/mocks/subagents.ts +197 -0
  251. package/packages/tests/mocks/subprocess.ts +59 -0
  252. package/packages/tests/mocks/user-questions.ts +33 -0
  253. package/packages/tests/mocks/workspace.ts +52 -0
  254. package/packages/tool/index.spec.ts +287 -0
  255. package/packages/tool/index.ts +348 -0
  256. package/packages/workflow/ask-tool.ts +173 -0
  257. package/packages/workflow/dag-navigator.spec.ts +236 -0
  258. package/packages/workflow/dag-navigator.ts +425 -0
  259. package/packages/workflow/domain.ts +26 -0
  260. package/packages/workflow/engine-surface.ts +71 -0
  261. package/packages/workflow/errors.ts +13 -0
  262. package/packages/workflow/host-agent.spec.ts +92 -0
  263. package/packages/workflow/host-agent.ts +62 -0
  264. package/packages/workflow/index.ts +25 -0
  265. package/packages/workflow/interaction-manager.spec.ts +390 -0
  266. package/packages/workflow/interaction-manager.ts +397 -0
  267. package/packages/workflow/lifecycle-manager.spec.ts +683 -0
  268. package/packages/workflow/lifecycle-manager.ts +288 -0
  269. package/packages/workflow/persistence.spec.ts +109 -0
  270. package/packages/workflow/persistence.ts +64 -0
  271. package/packages/workflow/plugin.ts +39 -0
  272. package/packages/workflow/reactive-state.spec.ts +69 -0
  273. package/packages/workflow/reactive-state.ts +52 -0
  274. package/packages/workflow/recovery-manager.spec.ts +606 -0
  275. package/packages/workflow/recovery-manager.ts +471 -0
  276. package/packages/workflow/remote.spec.ts +1045 -0
  277. package/packages/workflow/remote.ts +479 -0
  278. package/packages/workflow/schema.spec.ts +303 -0
  279. package/packages/workflow/schema.ts +562 -0
  280. package/packages/workflow/seam.ts +293 -0
  281. package/packages/workflow/shell.ts +142 -0
  282. package/packages/workflow/sse.ts +110 -0
  283. package/packages/workflow/typert/typert.host.d.ts +16 -0
  284. package/packages/workflow/typert/typert.host.js +25 -0
  285. package/packages/workflow/typert/typert.remote-client.d.ts +114 -0
  286. package/packages/workflow/typert/typert.remote-client.js +399 -0
  287. package/packages/workflow/types.ts +258 -0
  288. package/packages/workflow/wire.ts +267 -0
  289. package/packages/workflow/workflow-engine-e2e.spec.ts +262 -0
  290. package/packages/workflow/workflow-engine-real-e2e.spec.ts +922 -0
  291. package/packages/workflow/workflow-engine.spec.ts +4389 -0
  292. package/packages/workflow/workflow-engine.ts +2133 -0
  293. package/packages/workflow/yaml-error.ts +29 -0
  294. package/packages/workflow/yaml-parser.spec.ts +757 -0
  295. package/packages/workflow/yaml-parser.ts +287 -0
@@ -0,0 +1,4389 @@
1
+ /**
2
+ * workflow-engine.ts(SandoreEngineRuntime)的集成测试:线性执行到 completed、
3
+ * 事件序、双发可观测、childId/sessionMeta、失败/中止终态与持久化快照。
4
+ * @module dsh-sandore/workflow/src/workflow-engine.spec
5
+ */
6
+ import { Context } from '@deepseek-ai/cordis';
7
+ import type { LlmRuntime, TextBlock } from '@deepseek-ai/dsh-llm';
8
+ import type { SessionStore } from '@deepseek-ai/dsh-session';
9
+ import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ import { assistantMessageEvent, FakeAgent, FakeAgentRegistry } from '../tests/mocks/agents.ts';
13
+ import { FakeLlm, textChunks } from '../tests/mocks/llm.ts';
14
+ import { FakeSessionStore } from '../tests/mocks/sessions.ts';
15
+ import { FakeSessionTitle } from '../tests/mocks/session-title.ts';
16
+ import { FakeShell, successResult } from '../tests/mocks/shell.ts';
17
+ import { MemoryStorageDomain } from '../tests/mocks/storage-domain.ts';
18
+ import { FakeSubagents } from '../tests/mocks/subagents.ts';
19
+ import { FakeSubprocess } from '../tests/mocks/subprocess.ts';
20
+ import { FakeUserQuestions } from '../tests/mocks/user-questions.ts';
21
+ import { FakeWorkspace, FakeWorkspaceRegistry } from '../tests/mocks/workspace.ts';
22
+ import { SandoreError } from './errors.ts';
23
+ import { SandoreEngineRuntime, SandoreEngineStub } from './index.ts';
24
+ import type { SandoreConfig, SandoreEngine, WorkflowTaskState } from './index.ts';
25
+ import { SandorePersistence } from './persistence.ts';
26
+ import type { ShellExecutor } from './shell.ts';
27
+ import { parseWorkflowYaml } from './yaml-parser.ts';
28
+
29
+ /** UUID 正则(合成 sessionId 统一纯 UUID 格式)。 */
30
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
+
32
+ /** 两 Stage(shell → prompt)线性定义。 */
33
+ const linearYaml = `id: wfl-linear
34
+ name: linear
35
+ version: "1.0.0"
36
+ stages:
37
+ - id: stg-1
38
+ name: 分析
39
+ steps:
40
+ - id: step-abc
41
+ - id: stg-2
42
+ name: 推理
43
+ steps:
44
+ - id: step-prompt
45
+ steps:
46
+ - id: step-abc
47
+ command:
48
+ type: shell
49
+ value:
50
+ command: "echo hello"
51
+ - id: step-prompt
52
+ command:
53
+ type: prompt
54
+ value:
55
+ promptText: "summarize"
56
+ `;
57
+
58
+ /** Stage 级条件分支:stg-entry 完成后按 branch.condition 选下一 Stage
59
+ * (验证 tryAdvanceStage 评估 Stage branch.condition,不再盲取首个 Stage 目标)。 */
60
+ const stageBranchYaml = `id: wfl-stage-branch
61
+ name: stage-branch
62
+ version: "1.0.0"
63
+ stages:
64
+ - id: stg-entry
65
+ name: 入口
66
+ steps:
67
+ - id: step-entry
68
+ branches:
69
+ - condition:
70
+ type: prompt
71
+ value:
72
+ promptText: "判断是否进入分支B"
73
+ next: stg-b
74
+ - next: stg-default
75
+ - id: stg-b
76
+ name: 分支B
77
+ steps:
78
+ - id: step-b
79
+ - id: stg-c
80
+ name: 分支C
81
+ steps:
82
+ - id: step-c
83
+ - id: stg-default
84
+ name: 默认
85
+ steps:
86
+ - id: step-default
87
+ steps:
88
+ - id: step-entry
89
+ command:
90
+ type: shell
91
+ value:
92
+ command: "echo entry"
93
+ - id: step-b
94
+ command:
95
+ type: shell
96
+ value:
97
+ command: "echo b"
98
+ - id: step-c
99
+ command:
100
+ type: shell
101
+ value:
102
+ command: "echo c"
103
+ - id: step-default
104
+ command:
105
+ type: shell
106
+ value:
107
+ command: "echo default"
108
+ `;
109
+
110
+ /** Stage 级多条件分支(无默认分支):entry→[condA→stg-a, condB→stg-b],
111
+ * stageOrder=[entry, mid, a, b],数组下一 of entry=stg-mid(≠ 条件目标,便于区分条件命中与兜底)。 */
112
+ const stageBranchMultiYaml = `id: wfl-stage-multi
113
+ name: stage-multi
114
+ version: "1.0.0"
115
+ stages:
116
+ - id: stg-entry
117
+ name: 入口
118
+ steps:
119
+ - id: step-entry
120
+ branches:
121
+ - condition:
122
+ type: prompt
123
+ value:
124
+ promptText: "判断是否进入A"
125
+ next: stg-a
126
+ - condition:
127
+ type: prompt
128
+ value:
129
+ promptText: "判断是否进入B"
130
+ next: stg-b
131
+ - id: stg-mid
132
+ name: 中转
133
+ steps:
134
+ - id: step-mid
135
+ - id: stg-a
136
+ name: A
137
+ steps:
138
+ - id: step-a
139
+ - id: stg-b
140
+ name: B
141
+ steps:
142
+ - id: step-b
143
+ steps:
144
+ - id: step-entry
145
+ command:
146
+ type: shell
147
+ value:
148
+ command: "echo entry"
149
+ - id: step-mid
150
+ command:
151
+ type: shell
152
+ value:
153
+ command: "echo mid"
154
+ - id: step-a
155
+ command:
156
+ type: shell
157
+ value:
158
+ command: "echo a"
159
+ - id: step-b
160
+ command:
161
+ type: shell
162
+ value:
163
+ command: "echo b"
164
+ `;
165
+
166
+ /** 单 Stage 多 Step(A 输出被 B 引用)定义。 */
167
+ const multiStepYaml = `id: wfl-multi
168
+ name: multi
169
+ version: "1.0.0"
170
+ stages:
171
+ - id: stg-1
172
+ name: 处理
173
+ steps:
174
+ - id: step-a
175
+ - id: step-b
176
+ steps:
177
+ - id: step-a
178
+ command:
179
+ type: shell
180
+ value:
181
+ command: "echo hello"
182
+ outputTo:
183
+ - id: opt-a
184
+ path: "out.txt"
185
+ - id: step-b
186
+ command:
187
+ type: prompt
188
+ value:
189
+ promptText: "summarize"
190
+ inputFrom:
191
+ - id: in-a
192
+ path: "step-a"
193
+ `;
194
+
195
+ /** 单 Stage 两个 shell Step(用于第二步失败断言)。 */
196
+ const twoShellYaml = `id: wfl-two-shell
197
+ name: two-shell
198
+ version: "1.0.0"
199
+ stages:
200
+ - id: stg-1
201
+ name: 处理
202
+ steps:
203
+ - id: step-a
204
+ - id: step-b
205
+ steps:
206
+ - id: step-a
207
+ command:
208
+ type: shell
209
+ value:
210
+ command: "echo ok"
211
+ - id: step-b
212
+ command:
213
+ type: shell
214
+ value:
215
+ command: "false"
216
+ `;
217
+
218
+ /** 单 Stage 单 shell Step(用于崩溃恢复重跑:无需 llm 目录即可跑通)。 */
219
+ const shellOnlyYaml = `id: wfl-shell-only
220
+ name: shell-only
221
+ version: "1.0.0"
222
+ stages:
223
+ - id: stg-1
224
+ name: 单shell
225
+ steps:
226
+ - id: step-shell
227
+ steps:
228
+ - id: step-shell
229
+ command:
230
+ type: shell
231
+ value:
232
+ command: "echo hi"
233
+ `;
234
+
235
+ /** 单 Stage 两步线性 shell(step-a → step-b 均成功,无 branches 数组顺序兜底),供 between-steps 崩溃恢复测试。 */
236
+ const linearShellYaml = `id: wfl-linear-shell
237
+ name: linear-shell
238
+ version: "1.0.0"
239
+ stages:
240
+ - id: stg-1
241
+ name: 两步
242
+ steps:
243
+ - id: step-a
244
+ - id: step-b
245
+ steps:
246
+ - id: step-a
247
+ command:
248
+ type: shell
249
+ value:
250
+ command: "echo a"
251
+ - id: step-b
252
+ command:
253
+ type: shell
254
+ value:
255
+ command: "echo b"
256
+ `;
257
+
258
+ /** 完整 DAG(分支 + 并行 + 汇合 + 循环 + 门禁 + 透传变量 + agent 子代理)。 */
259
+ const dagYaml = `id: wfl-dag
260
+ name: dag
261
+ version: "1.0.0"
262
+ stages:
263
+ - id: stg-1
264
+ name: 分支
265
+ steps:
266
+ - id: step-branch
267
+ branches:
268
+ - condition:
269
+ type: prompt
270
+ value:
271
+ promptText: "route"
272
+ next: step-agent
273
+ - next: step-parallel
274
+ - id: step-agent
275
+ branches:
276
+ - next: step-merge
277
+ - id: step-parallel
278
+ branches:
279
+ - next: step-merge
280
+ - id: step-fallback
281
+ branches:
282
+ - next: step-merge
283
+ - id: step-merge
284
+ - id: stg-2
285
+ name: 循环
286
+ steps:
287
+ - id: step-loop
288
+ branches:
289
+ - next: step-iter
290
+ loop:
291
+ maxIterations: 3
292
+ next: step-iter
293
+ exitTo: step-finalize
294
+ - id: step-iter
295
+ branches:
296
+ - next: step-loop
297
+ - id: step-finalize
298
+ steps:
299
+ - id: step-branch
300
+ - id: step-agent
301
+ command:
302
+ type: agent
303
+ value:
304
+ promptText: "do-work"
305
+ - id: step-parallel
306
+ command:
307
+ type: shell
308
+ value:
309
+ command: "echo parallel"
310
+ - id: step-fallback
311
+ command:
312
+ type: shell
313
+ value:
314
+ command: "echo fallback"
315
+ - id: step-merge
316
+ command:
317
+ type: shell
318
+ value:
319
+ command: "echo merge"
320
+ - id: step-loop
321
+ - id: step-iter
322
+ command:
323
+ type: shell
324
+ value:
325
+ command: "echo iter"
326
+ - id: step-finalize
327
+ command:
328
+ type: prompt
329
+ value:
330
+ promptText: "report \${projectName}"
331
+ passThroughVars:
332
+ - name: projectName
333
+ value: "\${projectName}"
334
+ description: "项目名"
335
+ gates:
336
+ primary:
337
+ enabled: true
338
+ `;
339
+
340
+ /** 单 Stage 单 shell Step 引用 \${projectName}(无声明),供缺陷 15「崩溃恢复保留提取值」测试。 */
341
+ const recoverPtvYaml = `id: wfl-recover-ptv
342
+ name: recover-ptv
343
+ version: "1.0.0"
344
+ stages:
345
+ - id: stg-1
346
+ name: 恢复
347
+ steps:
348
+ - id: step-use
349
+ steps:
350
+ - id: step-use
351
+ command:
352
+ type: shell
353
+ value:
354
+ command: "echo \${projectName}"
355
+ `;
356
+
357
+ /** 两 Stage:stg-extract(prompt 提取 projectName=sandore)→ stg-use(shell 引用
358
+ * \${projectName} 且声明同名默认值 "unknown"),供缺陷 15「跨 Stage 同名变量
359
+ * A 提取 B 默认 → B 不覆盖 A」测试(残余差异二:initState 合并顺序)。 */
360
+ const crossStagePtvYaml = `id: wfl-cross-stage-ptv
361
+ name: cross-stage-ptv
362
+ version: "1.0.0"
363
+ stages:
364
+ - id: stg-extract
365
+ name: 提取
366
+ steps:
367
+ - id: step-extract
368
+ - id: stg-use
369
+ name: 使用
370
+ steps:
371
+ - id: step-use
372
+ steps:
373
+ - id: step-extract
374
+ command:
375
+ type: prompt
376
+ value:
377
+ promptText: "extract projectName"
378
+ passThroughVars:
379
+ - name: projectName
380
+ value: "\${projectName}"
381
+ description: "项目名"
382
+ - id: step-use
383
+ command:
384
+ type: shell
385
+ value:
386
+ command: "echo \${projectName}"
387
+ passThroughVars:
388
+ - name: projectName
389
+ value: "unknown"
390
+ description: "项目名默认"
391
+ `;
392
+
393
+ /** 两 Stage:stg-use(shell 引用 \${config})→ stg-define(shell + 声明 config 默认值
394
+ * "default-cfg"),供缺陷 15「先引用后定义」全局默认收集测试(残余差异一)。 */
395
+ const forwardRefYaml = `id: wfl-forward-ref
396
+ name: forward-ref
397
+ version: "1.0.0"
398
+ stages:
399
+ - id: stg-use
400
+ name: 使用
401
+ steps:
402
+ - id: step-use
403
+ - id: stg-define
404
+ name: 定义
405
+ steps:
406
+ - id: step-define
407
+ steps:
408
+ - id: step-use
409
+ command:
410
+ type: shell
411
+ value:
412
+ command: "echo \${config}"
413
+ - id: step-define
414
+ command:
415
+ type: shell
416
+ value:
417
+ command: "echo defined"
418
+ passThroughVars:
419
+ - name: config
420
+ value: "default-cfg"
421
+ description: "配置"
422
+ `;
423
+
424
+ /** 分支无匹配(条件 false)→ 数组顺序兜底(用于验证兜底 + branchMeta 现场保留)。 */
425
+ const branchFallbackYaml = `id: wfl-branch-fallback
426
+ name: branch-fallback
427
+ version: "1.0.0"
428
+ stages:
429
+ - id: stg-1
430
+ name: 分支
431
+ steps:
432
+ - id: step-branch
433
+ branches:
434
+ - condition:
435
+ type: prompt
436
+ value:
437
+ promptText: "route"
438
+ next: step-matched
439
+ - id: step-fallback
440
+ - id: step-matched
441
+ branches: []
442
+ steps:
443
+ - id: step-branch
444
+ - id: step-fallback
445
+ command:
446
+ type: shell
447
+ value:
448
+ command: "echo fallback"
449
+ - id: step-matched
450
+ command:
451
+ type: shell
452
+ value:
453
+ command: "echo matched"
454
+ `;
455
+
456
+ /** 嵌套循环定义:外层 3 次 × 内层 3 次(基准模型:路由门 body 跑 maxIter-1 次,
457
+ * 故 (3-1)×(3-1)=4 次 body 执行;外层重入内层时内层 visitCount 经 resetLoopVisitCounts
458
+ * 从 0 起重新计数——验证跳出时归零语义)。 */
459
+ const nestedLoopYaml = `id: wfl-nested-loop
460
+ name: nested-loop
461
+ version: "1.0.0"
462
+ stages:
463
+ - id: stg-1
464
+ name: 嵌套循环
465
+ steps:
466
+ - id: step-outer-loop
467
+ branches:
468
+ - next: step-inner-loop
469
+ loop:
470
+ maxIterations: 3
471
+ next: step-inner-loop
472
+ exitTo: step-done
473
+ - id: step-inner-loop
474
+ branches:
475
+ - next: step-iter
476
+ loop:
477
+ maxIterations: 3
478
+ next: step-iter
479
+ exitTo: step-outer-loop
480
+ - id: step-iter
481
+ branches:
482
+ - next: step-inner-loop
483
+ - id: step-done
484
+ steps:
485
+ - id: step-outer-loop
486
+ - id: step-inner-loop
487
+ - id: step-iter
488
+ command:
489
+ type: shell
490
+ value:
491
+ command: "echo iter"
492
+ - id: step-done
493
+ command:
494
+ type: shell
495
+ value:
496
+ command: "echo done"
497
+ `;
498
+
499
+ /** 带 breakCondition 的循环定义(prompt 判定跳出,验证 loopMeta 回写)。 */
500
+ const loopBreakYaml = `id: wfl-loop-break
501
+ name: loop-break
502
+ version: "1.0.0"
503
+ stages:
504
+ - id: stg-1
505
+ name: 循环跳出
506
+ steps:
507
+ - id: step-loop
508
+ branches:
509
+ - next: step-iter
510
+ loop:
511
+ maxIterations: 5
512
+ breakCondition:
513
+ type: prompt
514
+ value:
515
+ promptText: "should we break?"
516
+ next: step-iter
517
+ exitTo: step-done
518
+ - id: step-iter
519
+ branches:
520
+ - next: step-loop
521
+ - id: step-done
522
+ steps:
523
+ - id: step-loop
524
+ - id: step-iter
525
+ command:
526
+ type: shell
527
+ value:
528
+ command: "echo iter"
529
+ - id: step-done
530
+ command:
531
+ type: shell
532
+ value:
533
+ command: "echo done"
534
+ `;
535
+
536
+ /** 命令门循环:循环节点自身带 command 且自回边,每趟产生 executed 条目(验证命令门 visitCount 不双重计数)。 */
537
+ const loopCommandGateYaml = `id: wfl-loop-command-gate
538
+ name: loop-command-gate
539
+ version: "1.0.0"
540
+ stages:
541
+ - id: stg-1
542
+ name: 命令门循环
543
+ steps:
544
+ - id: step-loop
545
+ branches:
546
+ - next: step-loop
547
+ loop:
548
+ maxIterations: 3
549
+ next: step-loop
550
+ exitTo: step-done
551
+ - id: step-done
552
+ steps:
553
+ - id: step-loop
554
+ command:
555
+ type: shell
556
+ value:
557
+ command: "echo loop"
558
+ - id: step-done
559
+ command:
560
+ type: shell
561
+ value:
562
+ command: "echo done"
563
+ `;
564
+
565
+ /** 生成指定 maxIter 的命令门循环 YAML(loop head 自带 command + 自回边)。 */
566
+ function commandGateYaml(maxIter: number): string {
567
+ return `id: wfl-cg-${maxIter}
568
+ name: loop-command-gate
569
+ version: "1.0.0"
570
+ stages:
571
+ - id: stg-1
572
+ name: 命令门循环
573
+ steps:
574
+ - id: step-loop
575
+ branches:
576
+ - next: step-loop
577
+ loop:
578
+ maxIterations: ${maxIter}
579
+ next: step-loop
580
+ exitTo: step-done
581
+ - id: step-done
582
+ steps:
583
+ - id: step-loop
584
+ command:
585
+ type: shell
586
+ value:
587
+ command: "echo loop"
588
+ - id: step-done
589
+ command:
590
+ type: shell
591
+ value:
592
+ command: "echo done"
593
+ `;
594
+ }
595
+
596
+ /** 带 breakCondition 但恒不跳出的循环(每趟评估都产出 loopMeta,不 break),验证 breakCondition 门 visitCount 不双重计数。 */
597
+ const loopBreakNoBreakYaml = `id: wfl-loop-break-no-break
598
+ name: loop-break-no-break
599
+ version: "1.0.0"
600
+ stages:
601
+ - id: stg-1
602
+ name: 循环不跳出
603
+ steps:
604
+ - id: step-loop
605
+ branches:
606
+ - next: step-iter
607
+ loop:
608
+ maxIterations: 3
609
+ breakCondition:
610
+ type: prompt
611
+ value:
612
+ promptText: "should we break?"
613
+ next: step-iter
614
+ exitTo: step-done
615
+ - id: step-iter
616
+ branches:
617
+ - next: step-loop
618
+ - id: step-done
619
+ steps:
620
+ - id: step-loop
621
+ - id: step-iter
622
+ command:
623
+ type: shell
624
+ value:
625
+ command: "echo iter"
626
+ - id: step-done
627
+ command:
628
+ type: shell
629
+ value:
630
+ command: "echo done"
631
+ `;
632
+
633
+ /** 混合分支门循环:循环节点同时带 condition 分支与 loop 分支(isLoopControl=false),每趟回边都建路由条目并递增 visitCount(验证不与回边计数叠加)。 */
634
+ const loopMixedGateYaml = `id: wfl-loop-mixed-gate
635
+ name: loop-mixed-gate
636
+ version: "1.0.0"
637
+ stages:
638
+ - id: stg-1
639
+ name: 混合门循环
640
+ steps:
641
+ - id: step-loop
642
+ branches:
643
+ - condition:
644
+ type: prompt
645
+ value:
646
+ promptText: "exit early?"
647
+ next: step-exit
648
+ - next: step-iter
649
+ loop:
650
+ maxIterations: 3
651
+ next: step-iter
652
+ exitTo: step-done
653
+ - id: step-iter
654
+ branches:
655
+ - next: step-loop
656
+ - id: step-exit
657
+ - id: step-done
658
+ steps:
659
+ - id: step-loop
660
+ - id: step-iter
661
+ command:
662
+ type: shell
663
+ value:
664
+ command: "echo iter"
665
+ - id: step-exit
666
+ command:
667
+ type: shell
668
+ value:
669
+ command: "echo exit"
670
+ - id: step-done
671
+ command:
672
+ type: shell
673
+ value:
674
+ command: "echo done"
675
+ `;
676
+
677
+ /**
678
+ * 复杂工作流 e2e 定义(代码审查与修复流程):覆盖 prompt 命令 + shell 命令 + 条件分支
679
+ * (shouldExecute)+ 并行分支(无 condition 多 next)+ 循环(loop + breakCondition
680
+ * shouldBreak + maxIterations)+ 收尾门禁(gates primary/secondary)+ 跨 Stage。
681
+ *
682
+ * Stage 1「代码审查与修复」:step-scan(shell) → step-route(路由门) 经条件分支(shouldExecute)
683
+ * 走 step-fix + 并行分支走 step-report,二者汇合 step-merge → step-loop(循环门) 循环体
684
+ * step-iter,breakCondition(shouldBreak) 跳出到 step-exit。
685
+ * Stage 2「门禁收尾」:step-finalize(prompt) 带透传变量(projectName) + 双门禁(primary +
686
+ * secondary),assess 提取 projectName、primary 纯代码产物校验 + secondary AI 门禁审查
687
+ * 判定产物合格。
688
+ */
689
+ const complexReviewFixYaml = `id: wfl-complex
690
+ name: complex-review-fix
691
+ version: "1.0.0"
692
+ stages:
693
+ - id: stg-review
694
+ name: 代码审查与修复
695
+ steps:
696
+ - id: step-scan
697
+ - id: step-route
698
+ branches:
699
+ - condition:
700
+ type: prompt
701
+ value:
702
+ promptText: "判断是否需要修复"
703
+ next: step-fix
704
+ - next: step-report
705
+ - id: step-fix
706
+ branches:
707
+ - next: step-merge
708
+ - id: step-report
709
+ branches:
710
+ - next: step-merge
711
+ - id: step-merge
712
+ branches:
713
+ - next: step-loop
714
+ - id: step-loop
715
+ branches:
716
+ - next: step-iter
717
+ loop:
718
+ maxIterations: 4
719
+ breakCondition:
720
+ type: prompt
721
+ value:
722
+ promptText: "判断是否跳出循环"
723
+ next: step-iter
724
+ exitTo: step-exit
725
+ - id: step-iter
726
+ branches:
727
+ - next: step-loop
728
+ - id: step-exit
729
+ - id: stg-gate
730
+ name: 门禁收尾
731
+ steps:
732
+ - id: step-finalize
733
+ steps:
734
+ - id: step-scan
735
+ command:
736
+ type: shell
737
+ value:
738
+ command: "echo scan"
739
+ outputTo:
740
+ - id: opt-scan
741
+ path: "scan-report.txt"
742
+ - id: step-route
743
+ - id: step-fix
744
+ command:
745
+ type: shell
746
+ value:
747
+ command: "echo fix"
748
+ - id: step-report
749
+ command:
750
+ type: shell
751
+ value:
752
+ command: "echo report"
753
+ - id: step-merge
754
+ command:
755
+ type: shell
756
+ value:
757
+ command: "echo merge"
758
+ - id: step-loop
759
+ - id: step-iter
760
+ command:
761
+ type: shell
762
+ value:
763
+ command: "echo iter"
764
+ - id: step-exit
765
+ command:
766
+ type: shell
767
+ value:
768
+ command: "echo stage1-done"
769
+ - id: step-finalize
770
+ command:
771
+ type: prompt
772
+ value:
773
+ promptText: "分析代码并生成最终报告"
774
+ passThroughVars:
775
+ - name: projectName
776
+ value: "\${projectName}"
777
+ description: "项目名"
778
+ gates:
779
+ primary:
780
+ enabled: true
781
+ secondary:
782
+ enabled: true
783
+ prompt: "审查产物是否合格"
784
+ `;
785
+
786
+ /** 写入一条工作流定义记录。 */
787
+ async function putDefinition(persistence: SandorePersistence, id: string, yaml: string): Promise<void> {
788
+ const definition = parseWorkflowYaml(yaml);
789
+ const createdAt = new Date().toISOString();
790
+ await persistence.definitions.put(id, {
791
+ yaml,
792
+ name: definition.name,
793
+ version: definition.version,
794
+ createdAt,
795
+ updatedAt: createdAt,
796
+ });
797
+ }
798
+
799
+ /** 组装一条已完成的实例状态快照(供 getState 兜底读回测试)。 */
800
+ function completedSnapshot(instanceId: string): WorkflowTaskState {
801
+ const createdAt = new Date().toISOString();
802
+ return {
803
+ taskId: instanceId,
804
+ workflowId: 'wfl-linear',
805
+ cwd: '/tmp',
806
+ status: 'completed',
807
+ originalInput: 'hello world',
808
+ currentStageId: 'stg-1',
809
+ executedSteps: [{
810
+ id: 'exec-1',
811
+ stepId: 'step-abc',
812
+ stageId: 'stg-1',
813
+ name: 'step-abc',
814
+ status: 'completed',
815
+ visitCount: 1,
816
+ output: [{ type: 'text', text: 'hello' }],
817
+ }],
818
+ stageInfos: [{ id: 'stg-1', name: '分析', status: 'completed' }],
819
+ pendingInteractions: [],
820
+ pendingQueue: [],
821
+ visitCounts: {},
822
+ passThroughVars: {},
823
+ activeLoopIterationStack: [],
824
+ activeLoopNodeId: null,
825
+ activeLoopStack: [],
826
+ pausedChildIds: [],
827
+ stageContracts: {},
828
+ createdAt,
829
+ updatedAt: createdAt,
830
+ };
831
+ }
832
+
833
+ /** 组装一条非终态(running)实例状态快照(供恢复扫描测试)。 */
834
+ function runningSnapshot(instanceId: string, workflowId = 'wfl-linear'): WorkflowTaskState {
835
+ const createdAt = new Date().toISOString();
836
+ return {
837
+ taskId: instanceId,
838
+ workflowId,
839
+ cwd: '/tmp',
840
+ status: 'running',
841
+ originalInput: 'hello world',
842
+ currentStageId: 'stg-1',
843
+ executedSteps: [],
844
+ stageInfos: [{ id: 'stg-1', name: '分析', status: 'running' }],
845
+ pendingInteractions: [],
846
+ pendingQueue: [],
847
+ visitCounts: {},
848
+ passThroughVars: {},
849
+ activeLoopIterationStack: [],
850
+ activeLoopNodeId: null,
851
+ activeLoopStack: [],
852
+ pausedChildIds: [],
853
+ stageContracts: {},
854
+ createdAt,
855
+ updatedAt: createdAt,
856
+ };
857
+ }
858
+
859
+ /** 单个已执行 Step 的快照类型。 */
860
+ type SnapshotStep = WorkflowTaskState['executedSteps'][number];
861
+
862
+ /** 构造一条 agent 子代理 Step 快照(running/paused 两态,供恢复分类测试)。 */
863
+ function agentStepSnapshot(status: 'running' | 'paused', childId = 'child-1'): SnapshotStep {
864
+ const createdAt = new Date().toISOString();
865
+ return {
866
+ id: 'exec-1',
867
+ stepId: 'step-agent',
868
+ stageId: 'stg-1',
869
+ name: 'step-agent',
870
+ status,
871
+ command: { type: 'agent', value: { promptText: 'do work' } },
872
+ sessionMeta: { childId, synthetic: false, command: 'agent' },
873
+ visitCount: 1,
874
+ ...(status === 'paused' ? { pausedAt: createdAt } : {}),
875
+ };
876
+ }
877
+
878
+ /** 构造一条 shell 合成 Step 快照(synthetic,供「合成 session 标 failed」测试)。 */
879
+ function shellStepSnapshot(status: 'running' = 'running'): SnapshotStep {
880
+ return {
881
+ id: 'exec-1',
882
+ stepId: 'step-shell',
883
+ stageId: 'stg-1',
884
+ name: 'step-shell',
885
+ status,
886
+ command: { type: 'shell', value: { command: 'echo hi' } },
887
+ sessionMeta: { childId: 'step-shell-synthetic', synthetic: true, command: 'shell' },
888
+ visitCount: 1,
889
+ };
890
+ }
891
+
892
+ /** 组装一条含单个指定 Step 的实例状态快照(status 决定 pausedChildIds 是否重建)。 */
893
+ function stepSnapshot(instanceId: string, workflowId: string, step: SnapshotStep, status: WorkflowTaskState['status']): WorkflowTaskState {
894
+ const createdAt = new Date().toISOString();
895
+ return {
896
+ taskId: instanceId,
897
+ workflowId,
898
+ cwd: '/tmp',
899
+ status,
900
+ originalInput: 'do work',
901
+ currentStageId: step.stageId,
902
+ executedSteps: [step],
903
+ stageInfos: [{ id: step.stageId, name: 'stage', status: status === 'completed' ? 'completed' : 'running' }],
904
+ pendingInteractions: [],
905
+ pendingQueue: [],
906
+ visitCounts: {},
907
+ passThroughVars: {},
908
+ activeLoopIterationStack: [],
909
+ activeLoopNodeId: null,
910
+ activeLoopStack: [],
911
+ pausedChildIds: status === 'paused' ? [step.sessionMeta?.childId ?? step.id] : [],
912
+ stageContracts: {},
913
+ createdAt,
914
+ updatedAt: createdAt,
915
+ };
916
+ }
917
+
918
+ /** 组装一条非终态(paused)实例状态快照(含 paused Step + pausedChildIds,供登记表重建测试)。 */
919
+ function pausedSnapshot(instanceId: string, workflowId = 'wfl-agent-fail'): WorkflowTaskState {
920
+ return stepSnapshot(instanceId, workflowId, agentStepSnapshot('paused'), 'paused');
921
+ }
922
+
923
+ /** 组装一条 between-steps 崩溃快照:step-a 已完成落盘、step-b 未执行(无活跃段),供缺陷 9 G 测试。 */
924
+ function betweenStepsSnapshot(instanceId: string, workflowId = 'wfl-linear-shell'): WorkflowTaskState {
925
+ const createdAt = new Date().toISOString();
926
+ return {
927
+ taskId: instanceId,
928
+ workflowId,
929
+ cwd: '/tmp',
930
+ status: 'running',
931
+ originalInput: 'do it',
932
+ currentStageId: 'stg-1',
933
+ executedSteps: [{
934
+ id: 'exec-a',
935
+ stepId: 'step-a',
936
+ stageId: 'stg-1',
937
+ name: 'step-a',
938
+ status: 'completed',
939
+ command: { type: 'shell', value: { command: 'echo a' } },
940
+ sessionMeta: { childId: 'step-a-synthetic', synthetic: true, command: 'shell' },
941
+ visitCount: 1,
942
+ createdAt,
943
+ updatedAt: createdAt,
944
+ attemptIndex: 0,
945
+ output: [{ type: 'text', text: 'a' }],
946
+ }],
947
+ stageInfos: [{ id: 'stg-1', name: '两步', status: 'running' }],
948
+ pendingInteractions: [],
949
+ pendingQueue: [],
950
+ visitCounts: { 'step-a': 1 },
951
+ passThroughVars: {},
952
+ activeLoopIterationStack: [],
953
+ activeLoopNodeId: null,
954
+ activeLoopStack: [],
955
+ pausedChildIds: [],
956
+ stageContracts: {},
957
+ createdAt,
958
+ updatedAt: createdAt,
959
+ };
960
+ }
961
+
962
+ /** 组装一条含已提取透传变量(projectName=sandore)的 running 快照(无活跃段、无已完成
963
+ * Step),供缺陷 15「崩溃恢复保留提取值」测试:recoverInstance 重跑 step-use 时
964
+ * 断言 shell 命令经 replacePassThroughVars 拿到提取值而非保留字面量。 */
965
+ function extractedPtvSnapshot(instanceId: string, workflowId = 'wfl-recover-ptv'): WorkflowTaskState {
966
+ const createdAt = new Date().toISOString();
967
+ return {
968
+ taskId: instanceId,
969
+ workflowId,
970
+ cwd: '/tmp',
971
+ status: 'running',
972
+ originalInput: 'do it',
973
+ currentStageId: 'stg-1',
974
+ executedSteps: [],
975
+ stageInfos: [{ id: 'stg-1', name: '恢复', status: 'running' }],
976
+ pendingInteractions: [],
977
+ pendingQueue: [],
978
+ visitCounts: {},
979
+ passThroughVars: {
980
+ projectName: { value: 'sandore', description: '项目名' },
981
+ },
982
+ activeLoopIterationStack: [],
983
+ activeLoopNodeId: null,
984
+ activeLoopStack: [],
985
+ pausedChildIds: [],
986
+ stageContracts: {},
987
+ createdAt,
988
+ updatedAt: createdAt,
989
+ };
990
+ }
991
+
992
+ /** 组装一条含 stale pendingInteractions 的快照(崩溃前 ask 挂起,恢复时需清理),供缺陷 9 B / 27 测试。 */
993
+ function stalePendingSnapshot(instanceId: string): WorkflowTaskState {
994
+ const snapshot = pausedSnapshot(instanceId);
995
+ snapshot.pendingInteractions = [{
996
+ id: 'req-stale',
997
+ stageId: 'stg-1',
998
+ stepId: 'step-agent',
999
+ sessionId: instanceId,
1000
+ requestId: 'req-stale',
1001
+ kind: 'question',
1002
+ status: 'pending',
1003
+ payload: { questions: [{ id: 'q1', question: 'stale?' }] },
1004
+ createdAt: '2026-08-16T00:00:00.000Z',
1005
+ }];
1006
+ return snapshot;
1007
+ }
1008
+
1009
+ /** 组装集成测试 harness:注入内存 DSH 服务并加载引擎运行时。 */
1010
+ async function harness(config: SandoreConfig = {}): Promise<{
1011
+ ctx: Context;
1012
+ engine: SandoreEngine;
1013
+ persistence: SandorePersistence;
1014
+ llm: FakeLlm;
1015
+ shell: FakeShell;
1016
+ sessions: FakeSessionStore;
1017
+ subagents: FakeSubagents;
1018
+ agents: FakeAgentRegistry;
1019
+ userQuestions: FakeUserQuestions;
1020
+ sessionTitle: FakeSessionTitle;
1021
+ }> {
1022
+ const ctx = new Context();
1023
+ const storageDomain = new MemoryStorageDomain();
1024
+ ctx.provide('storageDomain', storageDomain as unknown as DomainFacility);
1025
+ await ctx.plugin(SandorePersistence);
1026
+ const persistence = ctx.sandorePersistence;
1027
+
1028
+ const llm = new FakeLlm();
1029
+ const shell = new FakeShell();
1030
+ const sessions = new FakeSessionStore();
1031
+ const agents = new FakeAgentRegistry(sessions);
1032
+ const subagents = new FakeSubagents(ctx, (id) => agents.get(id));
1033
+ const subprocess = new FakeSubprocess();
1034
+ const userQuestions = new FakeUserQuestions();
1035
+ const sessionTitle = new FakeSessionTitle();
1036
+ ctx.provide('llm', llm as unknown as LlmRuntime);
1037
+ ctx.provide('shell', shell as unknown as ShellExecutor);
1038
+ ctx.provide('sessions', sessions as unknown as SessionStore);
1039
+ ctx.provide('sessionPersistence', {});
1040
+ ctx.provide('subagents', subagents);
1041
+ ctx.provide('agents', agents);
1042
+ ctx.provide('subprocess', subprocess);
1043
+ ctx.provide('userQuestions', userQuestions);
1044
+ ctx.provide('sessionTitle', sessionTitle);
1045
+ ctx.provide('fs', {});
1046
+
1047
+ await ctx.plugin(SandoreEngineRuntime, config);
1048
+ const engine = ctx.sandoreEngine;
1049
+ return { ctx, engine, persistence, llm, shell, sessions, subagents, agents, userQuestions, sessionTitle };
1050
+ }
1051
+
1052
+ /** 注册全部 sandore/* 监听器并记录事件(名称 + 参数)。 */
1053
+ function recordSandoreEvents(ctx: Context): Array<{ name: string; args: unknown[] }> {
1054
+ const records: Array<{ name: string; args: unknown[] }> = [];
1055
+ ctx.on('sandore/start', info => records.push({ name: 'sandore/start', args: [info] }));
1056
+ ctx.on('sandore/stage-start', (info, stageId) => records.push({ name: 'sandore/stage-start', args: [info, stageId] }));
1057
+ ctx.on('sandore/stage-completed', (info, stageId, contract) => records.push({ name: 'sandore/stage-completed', args: [info, stageId, contract] }));
1058
+ ctx.on('sandore/step-start', (info, stepId, childId, name, loopIteration) => records.push({ name: 'sandore/step-start', args: [info, stepId, childId, name, loopIteration] }));
1059
+ ctx.on('sandore/step-completed', (info, stepId, childId, output, name, loopIteration) => records.push({ name: 'sandore/step-completed', args: [info, stepId, childId, output, name, loopIteration] }));
1060
+ ctx.on('sandore/step-failed', (info, stepId, childId, error, name, loopIteration) => records.push({ name: 'sandore/step-failed', args: [info, stepId, childId, error, name, loopIteration] }));
1061
+ ctx.on('sandore/step-phase', (info, stepId, childId, phase, name, loopIteration) => records.push({ name: 'sandore/step-phase', args: [info, stepId, childId, phase, name, loopIteration] }));
1062
+ ctx.on('sandore/end', (info, result) => records.push({ name: 'sandore/end', args: [info, result] }));
1063
+ ctx.on('sandore/aborted', info => records.push({ name: 'sandore/aborted', args: [info] }));
1064
+ return records;
1065
+ }
1066
+
1067
+ /** 等待实例到达 completed/failed 终态(以 sandore/end 为信号)。 */
1068
+ function waitForEnd(ctx: Context): Promise<void> {
1069
+ return new Promise<void>(resolve => {
1070
+ ctx.once('sandore/end', () => resolve());
1071
+ });
1072
+ }
1073
+
1074
+ /** 段模型定位:同 stepId 取 attemptIndex 最大(最后)的执行段(重试/续跑后新段)。 */
1075
+ function lastSegmentOf(
1076
+ steps: readonly SnapshotStep[],
1077
+ stepId: string,
1078
+ ): SnapshotStep | undefined {
1079
+ const matches = steps.filter(step => step.stepId === stepId);
1080
+ if (matches.length === 0) {
1081
+ return undefined;
1082
+ }
1083
+ return matches.sort((a, b) => (b.attemptIndex ?? 0) - (a.attemptIndex ?? 0))[0];
1084
+ }
1085
+
1086
+ /** 等待实例到达 aborted 终态(以 sandore/aborted 为信号)。 */
1087
+ function waitForAborted(ctx: Context): Promise<void> {
1088
+ return new Promise<void>(resolve => {
1089
+ ctx.once('sandore/aborted', () => resolve());
1090
+ });
1091
+ }
1092
+
1093
+ /** 等待一次 step-start(用于确认 Step 已开始执行)。 */
1094
+ function waitForStepStart(ctx: Context): Promise<void> {
1095
+ return new Promise<void>(resolve => {
1096
+ ctx.once('sandore/step-start', () => resolve());
1097
+ });
1098
+ }
1099
+
1100
+ /** `agent/pre-step` waterfall 载荷/决策最小面(与引擎冻结监听器结构对齐,不 import DSH 类型)。 */
1101
+ type PreStepPayload = {
1102
+ agent: { id: string };
1103
+ messages: unknown[];
1104
+ turn: number;
1105
+ step: number;
1106
+ signal: AbortSignal;
1107
+ };
1108
+ type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: unknown[] };
1109
+ type PreStepNext = () => Promise<PreStepDecision>;
1110
+
1111
+ /**
1112
+ * 模拟 DSH 对某 agent 分发一次 `agent/pre-step` waterfall:thisArg 的 filter 全放行,
1113
+ * 命中引擎 ctx 注册的冻结监听器(与真实 DSH 在 agent scope 分发、事件沿 scope 链
1114
+ * 冒泡到引擎 ctx 等价)。返回最外层监听器(引擎冻结监听器)的决策;`next` 为内建
1115
+ * 行为替身(记录是否被调用——被调用意味着步骤未被 veto)。
1116
+ */
1117
+ async function dispatchPreStep(ctx: Context, agentId: string, next: PreStepNext): Promise<PreStepDecision> {
1118
+ const thisArg = { [Context.filter]: () => true };
1119
+ const payload: PreStepPayload = {
1120
+ agent: { id: agentId },
1121
+ messages: [],
1122
+ turn: 1,
1123
+ step: 1,
1124
+ signal: new AbortController().signal,
1125
+ };
1126
+ return await (ctx.waterfall as unknown as (
1127
+ thisArg: object,
1128
+ name: string,
1129
+ payload: PreStepPayload,
1130
+ next: PreStepNext,
1131
+ ) => Promise<PreStepDecision>)(thisArg, 'agent/pre-step', payload, next);
1132
+ }
1133
+
1134
+ /** 让 shell step 挂起(直到 abort signal 触发 finish),保持实例 running 不进入终态。 */
1135
+ function hangingShell(shell: FakeShell): void {
1136
+ shell.runHandler = spec => new Promise(resolve => {
1137
+ const finish = (): void => { resolve(successResult(spec, '')); };
1138
+ if (spec.signal?.aborted) { finish(); return; }
1139
+ spec.signal?.addEventListener('abort', finish, { once: true });
1140
+ });
1141
+ }
1142
+
1143
+ describe('SandoreEngineRuntime interface', () => {
1144
+ it('is the default export and keeps SandoreEngineStub as a named export', async () => {
1145
+ const { ctx } = await harness();
1146
+ expect(ctx.sandoreEngine).toBeInstanceOf(SandoreEngineRuntime);
1147
+ expect(SandoreEngineStub).toBeTypeOf('function');
1148
+ });
1149
+
1150
+ it('declares agents, userQuestions, and sessionTitle in the seam inject list', () => {
1151
+ expect(SandoreEngineStub.inject).toContain('agents');
1152
+ expect(SandoreEngineStub.inject).toContain('userQuestions');
1153
+ expect(SandoreEngineStub.inject).toContain('sessionTitle');
1154
+ expect(SandoreEngineRuntime.inject).toContain('agents');
1155
+ expect(SandoreEngineRuntime.inject).toContain('userQuestions');
1156
+ expect(SandoreEngineRuntime.inject).toContain('sessionTitle');
1157
+ });
1158
+
1159
+ it('routes recoverInstance and retryStep on unknown instances to NODE_NOT_FOUND (no longer not-implemented)', async () => {
1160
+ const { engine } = await harness();
1161
+ await expect(engine.recoverInstance('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1162
+ await expect(engine.retryStep('x', 's', 'continue', 'm')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1163
+ });
1164
+
1165
+ it('throws NODE_NOT_FOUND for pause/resume on unknown instances', async () => {
1166
+ const { engine } = await harness();
1167
+ await expect(engine.pauseStep('x', 's')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1168
+ await expect(engine.resumeStep('x', 's')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1169
+ await expect(engine.pauseAllSteps('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1170
+ await expect(engine.resumeAllSteps('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1171
+ });
1172
+
1173
+ it('replyQuestion for an unknown instance throws NODE_NOT_FOUND', async () => {
1174
+ const { engine } = await harness();
1175
+ await expect(engine.replyQuestion('x', 'q', { answers: [] })).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1176
+ await expect(engine.replyPermission('x', 'q', 'once')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1177
+ });
1178
+ });
1179
+
1180
+ describe('SandoreEngineRuntime startInstance', () => {
1181
+ it('throws when the workflowId does not exist and leaves the state table empty', async () => {
1182
+ const { engine, persistence } = await harness();
1183
+ await expect(
1184
+ engine.startInstance({ workflowId: 'wfl-none', originalInput: 'x', cwd: '/tmp' }),
1185
+ ).rejects.toBeInstanceOf(SandoreError);
1186
+ expect(persistence.state.size).toBe(0);
1187
+ });
1188
+
1189
+ it('returns a run whose state is immediately readable via getState', async () => {
1190
+ const { engine, persistence } = await harness();
1191
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1192
+
1193
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'hello world', cwd: '/tmp' });
1194
+ expect(run.status).toBe('running');
1195
+
1196
+ const state = engine.getState(run.instanceId);
1197
+ expect(state.originalInput).toBe('hello world');
1198
+ expect(['pending', 'running']).toContain(state.status);
1199
+ });
1200
+
1201
+ it('creates a host agent via agents.create with meta.cwd and holds it idle (no auto prompt)', async () => {
1202
+ const { engine, persistence, agents } = await harness();
1203
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1204
+
1205
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/srv/app' });
1206
+
1207
+ expect(agents.createCwds).toEqual(['/srv/app']);
1208
+ expect(agents.setupCount).toBe(1);
1209
+ const hostAgent = agents.get(run.instanceId);
1210
+ expect(hostAgent).toBeDefined();
1211
+ expect(hostAgent?.status).toBe('idle');
1212
+ expect(hostAgent?.inbox.hasPending).toBe(false);
1213
+ });
1214
+
1215
+ it('rejects startInstance when agents.create setup throws and leaves no state', async () => {
1216
+ const { engine, persistence, agents } = await harness();
1217
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1218
+ agents.setupError = new Error('setup boom');
1219
+
1220
+ await expect(
1221
+ engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' }),
1222
+ ).rejects.toThrow('setup boom');
1223
+ expect(persistence.state.size).toBe(0);
1224
+ });
1225
+
1226
+ it('renames the host session title to the user originalInput after creating the host session', async () => {
1227
+ const { engine, persistence, sessions, sessionTitle } = await harness();
1228
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1229
+
1230
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1231
+
1232
+ expect(sessionTitle.calls).toHaveLength(1);
1233
+ // 新建宿主会话标题 = 用户输入的需求(originalInput),非实例 id。
1234
+ expect(sessionTitle.calls[0]?.title).toBe('do it');
1235
+ // rename 传入的是经 ctx.sessions.get 读回的 live 宿主 Session(同一引用)。
1236
+ expect(sessionTitle.calls[0]?.session).toBe(sessions.get(run.instanceId));
1237
+ });
1238
+
1239
+ it('reuses the caller-provided host agent session when hostSessionId is given (no create, freeze applies)', async () => {
1240
+ const { ctx, engine, persistence, shell, agents, sessions } = await harness();
1241
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1242
+ // 预置已存在的宿主主代理会话(模拟调用方传入复用)。
1243
+ const existingHost = new FakeAgent('host-1');
1244
+ agents.register(existingHost);
1245
+ sessions.create('host-1');
1246
+ hangingShell(shell); // 保持实例 running
1247
+
1248
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
1249
+ expect(run.status).toBe('running');
1250
+
1251
+ // 未新建 hostAgent(复用 host-1):无 agents.create、无 setup 重跑。
1252
+ expect(agents.createCwds).toHaveLength(0);
1253
+ expect(agents.setupCount).toBe(0);
1254
+ expect(agents.get('host-1')).toBe(existingHost);
1255
+
1256
+ // 复用的 hostAgent 同样冻结:pre-step 被 reject、下游 next 不被调用(冻结登记按
1257
+ // hostAgent.id = hostSessionId 登记,复用与新建等价)。
1258
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1259
+ const decision = await dispatchPreStep(ctx, 'host-1', next);
1260
+ expect(decision).toEqual({ kind: 'reject' });
1261
+ expect(next).not.toHaveBeenCalled();
1262
+
1263
+ // abort 清表:复用的 hostAgent 随实例生命周期结束放行(清表分支移除冻结登记)。
1264
+ const aborted = waitForAborted(ctx);
1265
+ await engine.abortInstance(run.instanceId);
1266
+ await aborted;
1267
+ const afterAbortNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1268
+ const afterAbort = await dispatchPreStep(ctx, 'host-1', afterAbortNext);
1269
+ expect(afterAbort).toEqual({ kind: 'enter', messages: [] });
1270
+ expect(afterAbortNext).toHaveBeenCalledTimes(1);
1271
+ });
1272
+
1273
+ it('thaws the reused host agent after the workflow completes', async () => {
1274
+ const { ctx, engine, persistence, shell, agents, sessions } = await harness();
1275
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1276
+ const existingHost = new FakeAgent('host-1');
1277
+ agents.register(existingHost);
1278
+ sessions.create('host-1');
1279
+ shell.runHandler = async spec => successResult(spec, 'hi');
1280
+
1281
+ const ended = waitForEnd(ctx);
1282
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
1283
+ await ended;
1284
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1285
+
1286
+ // completed 后复用的 hostAgent 解冻:pre-step 放行(唯一解冻点同样作用于复用场景)。
1287
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1288
+ const decision = await dispatchPreStep(ctx, 'host-1', next);
1289
+ expect(decision).toEqual({ kind: 'enter', messages: [] });
1290
+ expect(next).toHaveBeenCalledTimes(1);
1291
+ });
1292
+
1293
+ it('rejects startInstance when hostSessionId does not resolve to a live agent', async () => {
1294
+ const { engine, persistence } = await harness();
1295
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1296
+
1297
+ await expect(
1298
+ engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'ghost-host' }),
1299
+ ).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1300
+ expect(persistence.state.size).toBe(0);
1301
+ });
1302
+
1303
+ it('does not fail startInstance when sessionTitle.rename throws and still executes to completion', async () => {
1304
+ const { ctx, engine, persistence, sessionTitle } = await harness();
1305
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1306
+ sessionTitle.renameError = new Error('title boom');
1307
+
1308
+ const ended = waitForEnd(ctx);
1309
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1310
+
1311
+ expect(run.status).toBe('running');
1312
+ await ended;
1313
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1314
+ expect(sessionTitle.calls).toHaveLength(1);
1315
+ });
1316
+
1317
+ it('falls back to the state table when the instance is not in memory', async () => {
1318
+ const { engine, persistence } = await harness();
1319
+ await persistence.state.put('inst-fallback', completedSnapshot('inst-fallback'));
1320
+
1321
+ const state = engine.getState('inst-fallback');
1322
+ expect(state.taskId).toBe('inst-fallback');
1323
+ expect(state.status).toBe('completed');
1324
+ expect(state.originalInput).toBe('hello world');
1325
+ expect(state.currentStageId).toBe('stg-1');
1326
+ expect(state.executedSteps).toHaveLength(1);
1327
+ expect(state.executedSteps[0]?.stepId).toBe('step-abc');
1328
+ });
1329
+
1330
+ it('throws NODE_NOT_FOUND when neither memory nor the state table has the instance', async () => {
1331
+ const { engine } = await harness();
1332
+ let caught: unknown;
1333
+ try {
1334
+ engine.getState('inst-none');
1335
+ } catch (error) {
1336
+ caught = error;
1337
+ }
1338
+ expect(caught).toBeInstanceOf(SandoreError);
1339
+ expect(caught).toMatchObject({ code: 'NODE_NOT_FOUND' });
1340
+ });
1341
+
1342
+ it('getInstanceState returns active.state for a running instance (含阶段中间态)', async () => {
1343
+ const { engine, persistence } = await harness();
1344
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1345
+
1346
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'hello', cwd: '/tmp' });
1347
+ // 实例刚启动(fire-and-forget),内存有 active instance
1348
+ const live = engine.getInstanceState(run.instanceId);
1349
+ expect(live).toBeDefined();
1350
+ expect(live?.taskId).toBe(run.instanceId);
1351
+ expect(live?.originalInput).toBe('hello');
1352
+ // 状态应为 running/pending(fire-and-forget 后内存 state 已更新)
1353
+ expect(['pending', 'running']).toContain(live?.status);
1354
+ });
1355
+
1356
+ it('getInstanceState returns undefined when instance is not in memory (终态清表/未启动)', async () => {
1357
+ const { engine, persistence } = await harness();
1358
+ // 直接写入持久化(不经过引擎内存)
1359
+ await persistence.state.put('inst-persisted', completedSnapshot('inst-persisted'));
1360
+
1361
+ // 内存无此实例 → getInstanceState 返回 undefined
1362
+ const live = engine.getInstanceState('inst-persisted');
1363
+ expect(live).toBeUndefined();
1364
+ });
1365
+
1366
+ it('seeds global default pass-through vars at start so a forward reference resolves (defect 15)', async () => {
1367
+ const { ctx, engine, persistence, shell } = await harness();
1368
+ await putDefinition(persistence, 'wfl-forward-ref', forwardRefYaml);
1369
+ // shell 回显命令本身,便于断言经 replacePassThroughVars 替换后的实际命令。
1370
+ shell.runHandler = async spec => successResult(spec, spec.command);
1371
+
1372
+ const ended = waitForEnd(ctx);
1373
+ await engine.startInstance({ workflowId: 'wfl-forward-ref', originalInput: 'do it', cwd: '/tmp' });
1374
+ await ended;
1375
+
1376
+ // 全局默认收集(first-wins,残余差异一):step-use(Stage 1)引用 ${config},而
1377
+ // config 的字面量默认值 "default-cfg" 只在 Stage 2 的 step-define 声明。startInstance
1378
+ // 全局播种后,Stage 1 执行时 replacePassThroughVars 拿到 'default-cfg' 而非保留
1379
+ // 字面量 ${config}。修复前(passThroughVars 初始为 {})此处为 'echo ${config}'。
1380
+ expect(shell.specs.map(spec => spec.command)).toEqual(['echo default-cfg', 'echo defined']);
1381
+ });
1382
+ });
1383
+
1384
+ describe('SandoreEngineRuntime host agent workspace attach', () => {
1385
+ it('attaches the new host agent to the web workspace resolved by cwd', async () => {
1386
+ const { ctx, engine, persistence } = await harness();
1387
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1388
+ // 显式提供 workspaceRegistry(默认 harness 不 provide,仅本 describe 内注入)。
1389
+ const workspace = new FakeWorkspace('/ws/project');
1390
+ const registry = new FakeWorkspaceRegistry([workspace]);
1391
+ ctx.provide('workspaceRegistry', registry);
1392
+
1393
+ const ended = waitForEnd(ctx);
1394
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/ws/project' });
1395
+ await ended;
1396
+
1397
+ // 新建分支 attach:hostAgent.id = instanceId 挂到 cwd 命中的 workspace 成员账。
1398
+ expect(workspace.attached).toEqual([run.instanceId]);
1399
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1400
+ });
1401
+
1402
+ it('auto-creates a workspace when cwd does not resolve to a registered one (对齐 DSH ensureWorkspace)', async () => {
1403
+ const { ctx, engine, persistence } = await harness();
1404
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1405
+ const registry = new FakeWorkspaceRegistry([new FakeWorkspace('/ws/project')]);
1406
+ ctx.provide('workspaceRegistry', registry);
1407
+
1408
+ // cwd 未命中任何已登记 workspace:attach 前自动 create(不再抛 INVALID_OPERATION),
1409
+ // 实例正常启动,hostAgent 会话挂到按 cwd 新建的 workspace 成员账。
1410
+ const ended = waitForEnd(ctx);
1411
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/other/dir' });
1412
+ await ended;
1413
+
1414
+ const created = await registry.resolveByPath('/other/dir');
1415
+ expect(created).toBeDefined();
1416
+ expect(created?.attached).toEqual([run.instanceId]);
1417
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1418
+ });
1419
+
1420
+ it('silently skips attach when no workspaceRegistry is provided (test/headless degradation)', async () => {
1421
+ const { ctx, engine, persistence } = await harness();
1422
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1423
+
1424
+ // 默认 harness 不 provide workspaceRegistry:attach 静默跳过(测试/headless 退化),
1425
+ // 实例正常启动;cwd 仍按必填透传进状态快照。
1426
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1427
+ expect(run.status).toBe('running');
1428
+ expect(persistence.state.get(run.instanceId)?.cwd).toBe('/tmp');
1429
+ expect(ctx.get('workspaceRegistry')).toBeUndefined();
1430
+ });
1431
+
1432
+ it('re-attaches the recovered host agent to the workspace from the snapshot cwd', async () => {
1433
+ const { ctx, engine, persistence, sessions } = await harness();
1434
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1435
+ const workspace = new FakeWorkspace('/ws/project');
1436
+ const registry = new FakeWorkspaceRegistry([workspace]);
1437
+ ctx.provide('workspaceRegistry', registry);
1438
+
1439
+ // 崩溃恢复:running 快照的 cwd 指向 workspace 目录(宿主会话 header 同 cwd)。
1440
+ await persistence.state.put('inst-recover', { ...runningSnapshot('inst-recover', 'wfl-shell-only'), cwd: '/ws/project' });
1441
+ sessions.create('inst-recover');
1442
+
1443
+ const ended = waitForEnd(ctx);
1444
+ const result = await engine.recoverInstance('inst-recover');
1445
+ expect(result.status).toBe('running');
1446
+ await ended;
1447
+
1448
+ // 恢复重建 hostAgent 后重新 attach(hostAgent.id = instanceId,对齐 startInstance 新建分支)。
1449
+ expect(workspace.attached).toEqual(['inst-recover']);
1450
+ expect(persistence.state.get('inst-recover')?.status).toBe('completed');
1451
+ });
1452
+ });
1453
+
1454
+ describe('SandoreEngineRuntime linear execution', () => {
1455
+ it('runs a shell Step then a prompt Step to completed with the exact event order', async () => {
1456
+ const { ctx, engine, persistence, llm, shell, agents, sessions } = await harness();
1457
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1458
+ shell.runHandler = async spec => successResult(spec, 'hello');
1459
+ llm.providers.push({ id: 'p', name: 'Provider' });
1460
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1461
+ llm.streamHandler = () => textChunks('summary');
1462
+ // prompt Step 走可续接子代理(决策 D3):注册真实 childId 并回填最终输出。
1463
+ const promptChild = new FakeAgent('child-1');
1464
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
1465
+ agents.register(promptChild);
1466
+
1467
+ const records = recordSandoreEvents(ctx);
1468
+ const ended = waitForEnd(ctx);
1469
+
1470
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1471
+ await ended;
1472
+
1473
+ expect(records.map(record => record.name)).toEqual([
1474
+ 'sandore/start',
1475
+ 'sandore/stage-start',
1476
+ 'sandore/step-start',
1477
+ 'sandore/step-completed',
1478
+ 'sandore/stage-completed',
1479
+ 'sandore/stage-start',
1480
+ 'sandore/step-start',
1481
+ 'sandore/step-completed',
1482
+ 'sandore/stage-completed',
1483
+ 'sandore/end',
1484
+ ]);
1485
+
1486
+ // Round 25(去污染):宿主会话不再承载 sandore/* 事件(Cordis 事件断言见上
1487
+ // L1473-1484 records;宿主会话仅保留 sandbox/mode 等宿主自身事件)。
1488
+ const session = sessions.get(run.instanceId);
1489
+ expect(session?.events.some(event => event.type.startsWith('sandore/'))).toBe(false);
1490
+
1491
+ // prompt 成功:step-completed 事件 payload 的 output 即聚合 ContentBlock[],
1492
+ // 且 name 为 Step 定义 id(YAML 未写 name 时兜底回退 id);非循环 Step 无 loopIteration。
1493
+ const promptCompleted = records.find(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-prompt');
1494
+ expect(promptCompleted?.args[3]).toEqual([{ type: 'text', text: 'summary' }]);
1495
+ expect(promptCompleted?.args[4]).toBe('step-prompt');
1496
+ expect(promptCompleted?.args[5]).toBeUndefined();
1497
+
1498
+ // 完成后 state 表读回快照。
1499
+ const state = persistence.state.get(run.instanceId);
1500
+ expect(state?.status).toBe('completed');
1501
+ expect(state?.executedSteps).toHaveLength(2);
1502
+ expect(state?.executedSteps[0]?.status).toBe('completed');
1503
+ expect(state?.executedSteps[1]?.status).toBe('completed');
1504
+ expect(state?.executedSteps[0]?.output).toEqual([{ type: 'text', text: 'hello' }]);
1505
+ expect(state?.executedSteps[1]?.output).toEqual([{ type: 'text', text: 'summary' }]);
1506
+ expect(state?.stageInfos[0]?.status).toBe('completed');
1507
+ expect(state?.stageInfos[1]?.status).toBe('completed');
1508
+ expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
1509
+ });
1510
+
1511
+ it('executes multiple steps within a stage in order and assembles the stage contract', async () => {
1512
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
1513
+ await putDefinition(persistence, 'wfl-multi', multiStepYaml);
1514
+ shell.runHandler = async spec => successResult(spec, 'hello');
1515
+ llm.providers.push({ id: 'p', name: 'Provider' });
1516
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1517
+ llm.streamHandler = () => textChunks('summary');
1518
+ // step-b 为 prompt Step,走可续接子代理:注册真实 childId 并回填最终输出。
1519
+ const promptChild = new FakeAgent('child-1');
1520
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
1521
+ agents.register(promptChild);
1522
+
1523
+ const ended = waitForEnd(ctx);
1524
+ const run = await engine.startInstance({ workflowId: 'wfl-multi', originalInput: 'do it', cwd: '/tmp' });
1525
+ await ended;
1526
+
1527
+ const state = persistence.state.get(run.instanceId);
1528
+ expect(state?.status).toBe('completed');
1529
+ expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-a', 'step-b']);
1530
+ const contract = state?.stageContracts['stg-1'];
1531
+ expect(contract?.stageId).toBe('stg-1');
1532
+ expect(contract?.stageName).toBe('处理');
1533
+ expect(contract?.status).toBe('completed');
1534
+ expect(contract?.completedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
1535
+ expect(contract?.artifacts[0]?.stepId).toBe('step-a');
1536
+ expect(contract?.artifacts[0]?.outputToId).toBe('opt-a');
1537
+ });
1538
+
1539
+ it('continues to completed even when a ctx.emit listener throws', async () => {
1540
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
1541
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1542
+ shell.runHandler = async spec => successResult(spec, 'hello');
1543
+ llm.providers.push({ id: 'p', name: 'Provider' });
1544
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1545
+ llm.streamHandler = () => textChunks('summary');
1546
+ // prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
1547
+ const promptChild = new FakeAgent('child-1');
1548
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
1549
+ agents.register(promptChild);
1550
+
1551
+ ctx.on('sandore/step-start', () => {
1552
+ throw new Error('listener boom');
1553
+ });
1554
+
1555
+ const ended = waitForEnd(ctx);
1556
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1557
+ await ended;
1558
+
1559
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1560
+ });
1561
+
1562
+ it('synthesizes childId and sessionMeta for each executed step', async () => {
1563
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
1564
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1565
+ shell.runHandler = async spec => successResult(spec, 'hello');
1566
+ llm.providers.push({ id: 'p', name: 'Provider' });
1567
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1568
+ llm.streamHandler = () => textChunks('summary');
1569
+ // prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
1570
+ const promptChild = new FakeAgent('child-1');
1571
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
1572
+ agents.register(promptChild);
1573
+
1574
+ const ended = waitForEnd(ctx);
1575
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1576
+ await ended;
1577
+
1578
+ const state = persistence.state.get(run.instanceId);
1579
+ // shell 合成会话:childId 为合成占位、synthetic:true。
1580
+ expect(state?.executedSteps[0]?.sessionMeta?.childId).toMatch(UUID_RE);
1581
+ expect(state?.executedSteps[0]?.sessionMeta?.synthetic).toBe(true);
1582
+ expect(state?.executedSteps[0]?.sessionMeta?.command).toBe('shell');
1583
+ // prompt 走真实子代理(决策 D3):childId 为真实会话 id、synthetic:false、command 保留 'prompt'。
1584
+ expect(state?.executedSteps[1]?.sessionMeta?.childId).toBe('child-1');
1585
+ expect(state?.executedSteps[1]?.sessionMeta?.synthetic).toBe(false);
1586
+ expect(state?.executedSteps[1]?.sessionMeta?.command).toBe('prompt');
1587
+ });
1588
+ });
1589
+
1590
+ describe('SandoreEngineRuntime failure', () => {
1591
+ it('fails the instance on shell nonzero exit and emits step-failed', async () => {
1592
+ const { ctx, engine, persistence, shell } = await harness();
1593
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1594
+ shell.runHandler = async spec => ({
1595
+ exitCode: 1,
1596
+ signal: null,
1597
+ timedOut: false,
1598
+ aborted: false,
1599
+ timeoutMs: spec.timeoutMs,
1600
+ stdout: { text: '', truncated: false },
1601
+ stderr: { text: 'boom', truncated: false },
1602
+ });
1603
+
1604
+ const records = recordSandoreEvents(ctx);
1605
+ const ended = waitForEnd(ctx);
1606
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1607
+ await ended;
1608
+
1609
+ const state = persistence.state.get(run.instanceId);
1610
+ expect(state?.status).toBe('failed');
1611
+ expect(state?.executedSteps[0]?.error).toContain('boom');
1612
+ const failed = records.find(record => record.name === 'sandore/step-failed');
1613
+ expect(failed).toBeDefined();
1614
+ expect(failed?.args[1]).toBe('step-abc');
1615
+ expect(failed?.args[4]).toBe('step-abc');
1616
+ });
1617
+
1618
+ it('keeps the first step completed when the second step fails', async () => {
1619
+ const { ctx, engine, persistence, shell } = await harness();
1620
+ await putDefinition(persistence, 'wfl-two-shell', twoShellYaml);
1621
+ shell.runHandler = async spec => (spec.command.includes('echo')
1622
+ ? successResult(spec, 'ok')
1623
+ : {
1624
+ exitCode: 2,
1625
+ signal: null,
1626
+ timedOut: false,
1627
+ aborted: false,
1628
+ timeoutMs: spec.timeoutMs,
1629
+ stdout: { text: '', truncated: false },
1630
+ stderr: { text: 'second boom', truncated: false },
1631
+ });
1632
+
1633
+ const ended = waitForEnd(ctx);
1634
+ const run = await engine.startInstance({ workflowId: 'wfl-two-shell', originalInput: 'do it', cwd: '/tmp' });
1635
+ await ended;
1636
+
1637
+ const state = persistence.state.get(run.instanceId);
1638
+ expect(state?.status).toBe('failed');
1639
+ expect(state?.executedSteps).toHaveLength(2);
1640
+ expect(state?.executedSteps[0]?.status).toBe('completed');
1641
+ expect(state?.executedSteps[1]?.status).toBe('failed');
1642
+ expect(state?.executedSteps[1]?.error).toBeTruthy();
1643
+ });
1644
+
1645
+ it('fails the instance with failure.message on prompt finish error', async () => {
1646
+ const { ctx, engine, persistence, llm, shell, agents, subagents } = await harness();
1647
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1648
+ shell.runHandler = async spec => successResult(spec, 'hello');
1649
+ llm.providers.push({ id: 'p', name: 'Provider' });
1650
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1651
+ // prompt Step 走可续接子代理:child status=running 不自动 settle,由测试触发
1652
+ // settle(error) 模拟模型失败(对齐 DSH `subagent/end` stopReason=error)。
1653
+ const promptChild = new FakeAgent('child-1');
1654
+ promptChild.status = 'running';
1655
+ agents.register(promptChild);
1656
+
1657
+ const stepStarted = waitForStepStart(ctx);
1658
+ const ended = waitForEnd(ctx);
1659
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1660
+ await stepStarted;
1661
+ subagents.settleChild('child-1', 'error');
1662
+ await ended;
1663
+
1664
+ const state = persistence.state.get(run.instanceId);
1665
+ expect(state?.status).toBe('failed');
1666
+ expect(state?.executedSteps[1]?.error).toContain('settle error');
1667
+ });
1668
+ });
1669
+
1670
+ describe('SandoreEngineRuntime abort', () => {
1671
+ it('aborts a hanging shell step to the aborted terminal state', async () => {
1672
+ const { ctx, engine, persistence, shell } = await harness();
1673
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1674
+ shell.runHandler = spec => new Promise(resolve => {
1675
+ const finish = (): void => {
1676
+ resolve({
1677
+ exitCode: null,
1678
+ signal: 'SIGTERM',
1679
+ timedOut: false,
1680
+ aborted: true,
1681
+ timeoutMs: spec.timeoutMs,
1682
+ stdout: { text: '', truncated: false },
1683
+ stderr: { text: '', truncated: false },
1684
+ });
1685
+ };
1686
+ if (spec.signal?.aborted) {
1687
+ finish();
1688
+ return;
1689
+ }
1690
+ spec.signal?.addEventListener('abort', finish, { once: true });
1691
+ });
1692
+
1693
+ const records = recordSandoreEvents(ctx);
1694
+ const aborted = waitForAborted(ctx);
1695
+ const stepStarted = waitForStepStart(ctx);
1696
+
1697
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1698
+ await stepStarted;
1699
+ await engine.abortInstance(run.instanceId);
1700
+ await aborted;
1701
+
1702
+ // 收敛到单一出口:abortInstance 显式发射一次,runInstance failed 分支不再重复发射。
1703
+ expect(records.filter(record => record.name === 'sandore/aborted')).toHaveLength(1);
1704
+ expect(persistence.state.get(run.instanceId)?.status).toBe('aborted');
1705
+ });
1706
+
1707
+ it('rejects abortInstance for a completed instance', async () => {
1708
+ const { ctx, engine, persistence, llm, shell } = await harness();
1709
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1710
+ shell.runHandler = async spec => successResult(spec, 'hello');
1711
+ llm.providers.push({ id: 'p', name: 'Provider' });
1712
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1713
+ llm.streamHandler = () => textChunks('summary');
1714
+
1715
+ const ended = waitForEnd(ctx);
1716
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
1717
+ await ended;
1718
+
1719
+ await expect(engine.abortInstance(run.instanceId)).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
1720
+ });
1721
+ });
1722
+
1723
+ describe('SandoreEngineRuntime hostAgent freeze', () => {
1724
+ it('rejects hostAgent pre-step while the workflow is running and passes child pre-steps through', async () => {
1725
+ const { ctx, engine, persistence, shell } = await harness();
1726
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1727
+ hangingShell(shell);
1728
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1729
+
1730
+ // 执行中(running):宿主主代理 pre-step 被 reject——DSH 将 turn 以 blocked 关闭、
1731
+ // 不发 user/message、不产生任何模型调用(reject 即 veto,下游 next 不被调用,
1732
+ // 消息不会进入 step)。
1733
+ const hostNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1734
+ const hostDecision = await dispatchPreStep(ctx, run.instanceId, hostNext);
1735
+ expect(hostDecision).toEqual({ kind: 'reject' });
1736
+ expect(hostNext).not.toHaveBeenCalled();
1737
+
1738
+ // 子代理(childId 不在 instances 表):放行——next 被调用、决策为下游 enter
1739
+ // (冻结只作用于宿主主代理,不冻结 Step 子代理)。
1740
+ const childNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1741
+ const childDecision = await dispatchPreStep(ctx, 'child-1', childNext);
1742
+ expect(childDecision).toEqual({ kind: 'enter', messages: [] });
1743
+ expect(childNext).toHaveBeenCalledTimes(1);
1744
+
1745
+ await engine.abortInstance(run.instanceId);
1746
+ });
1747
+
1748
+ it('passes hostAgent pre-step through after the workflow completes (thawed)', async () => {
1749
+ const { ctx, engine, persistence, shell } = await harness();
1750
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1751
+ shell.runHandler = async spec => successResult(spec, 'hi');
1752
+ const ended = waitForEnd(ctx);
1753
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1754
+ await ended;
1755
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
1756
+
1757
+ // **唯一解冻点**:completed 后 hostAgent pre-step 放行(引擎不 dispose hostAgent、
1758
+ // ActiveInstance 仍留 instances 表,冻结登记已移除 → 监听器委托 next)。
1759
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1760
+ const decision = await dispatchPreStep(ctx, run.instanceId, next);
1761
+ expect(decision).toEqual({ kind: 'enter', messages: [] });
1762
+ expect(next).toHaveBeenCalledTimes(1);
1763
+ });
1764
+
1765
+ it('still rejects hostAgent pre-step after the workflow fails (stays frozen)', async () => {
1766
+ const { ctx, engine, persistence, shell } = await harness();
1767
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1768
+ shell.runHandler = async () => ({
1769
+ exitCode: 1,
1770
+ signal: null,
1771
+ timedOut: false,
1772
+ aborted: false,
1773
+ timeoutMs: 0,
1774
+ stdout: { text: '', truncated: false },
1775
+ stderr: { text: 'boom', truncated: false },
1776
+ });
1777
+ const ended = waitForEnd(ctx);
1778
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1779
+ await ended;
1780
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
1781
+
1782
+ // **仅 completed 解冻**:failed 终态保持冻结——pre-step 仍被 reject、下游 next 不被调用
1783
+ // (工作流未成功完成,hostAgent 不恢复活跃)。
1784
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1785
+ const decision = await dispatchPreStep(ctx, run.instanceId, next);
1786
+ expect(decision).toEqual({ kind: 'reject' });
1787
+ expect(next).not.toHaveBeenCalled();
1788
+ });
1789
+
1790
+ it('passes hostAgent pre-step through after abort (instance cleared from memory → auto thaw)', async () => {
1791
+ const { ctx, engine, persistence, shell } = await harness();
1792
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1793
+ hangingShell(shell);
1794
+ const stepStarted = waitForStepStart(ctx);
1795
+ const aborted = waitForAborted(ctx);
1796
+ const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
1797
+ await stepStarted;
1798
+ await engine.abortInstance(run.instanceId);
1799
+ await aborted;
1800
+ expect(persistence.state.get(run.instanceId)?.status).toBe('aborted');
1801
+
1802
+ // 清表分支:aborted 后实例清出 instances 表,冻结登记同步移除 → pre-step 放行
1803
+ // (hostAgent 恢复普通响应;与 failed 留表保持冻结区分)。
1804
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1805
+ const decision = await dispatchPreStep(ctx, run.instanceId, next);
1806
+ expect(decision).toEqual({ kind: 'enter', messages: [] });
1807
+ expect(next).toHaveBeenCalledTimes(1);
1808
+ });
1809
+
1810
+ it('freezes the recovered hostAgent again after recoverInstance resumes the instance', async () => {
1811
+ const { ctx, engine, persistence, sessions, shell } = await harness();
1812
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
1813
+ // 空 executedSteps 的 running 快照:recoverInstance 冷恢复宿主主代理(agents.resume)
1814
+ // 后重跑 runInstance;挂起 shell 保持续跑实例 running,便于断言恢复后重新冻结。
1815
+ await persistence.state.put('inst-rec', runningSnapshot('inst-rec', 'wfl-shell-only'));
1816
+ sessions.create('inst-rec');
1817
+ hangingShell(shell);
1818
+
1819
+ const result = await engine.recoverInstance('inst-rec');
1820
+ expect(result.status).toBe('running');
1821
+
1822
+ // 恢复重建的 hostAgent 重新冻结(recoverInstance 把实例 id 重新加入冻结登记):
1823
+ // pre-step 被 reject、下游 next 不被调用。
1824
+ const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
1825
+ const decision = await dispatchPreStep(ctx, 'inst-rec', next);
1826
+ expect(decision).toEqual({ kind: 'reject' });
1827
+ expect(next).not.toHaveBeenCalled();
1828
+
1829
+ await engine.abortInstance('inst-rec');
1830
+ });
1831
+ });
1832
+
1833
+ describe('SandoreEngineRuntime setPermissionMode', () => {
1834
+ /**
1835
+ * 折叠读取 session.events 中最新 `sandbox/mode` 事件的 mode(对齐 DSH
1836
+ * `setSandboxMode` 折叠语义:每次受限调用取最新 sandbox/mode 事件值,
1837
+ * 而非创建时快照)。缺陷 29 验证运行中切换后宿主会话折叠值即时变化。
1838
+ */
1839
+ function effectiveSandboxMode(events: ReadonlyArray<{ type: string; data: unknown }>): string | undefined {
1840
+ let mode: string | undefined;
1841
+ for (const event of events) {
1842
+ if (event.type === 'sandbox/mode' && event.data !== null && typeof event.data === 'object'
1843
+ && 'mode' in (event.data as Record<string, unknown>)) {
1844
+ mode = (event.data as { mode: string }).mode;
1845
+ }
1846
+ }
1847
+ return mode;
1848
+ }
1849
+
1850
+ /** 让 shell step 挂起(直到 abort signal 触发 finish),保持实例 running 不进入终态。 */
1851
+ function hangingShell(shell: FakeShell): void {
1852
+ shell.runHandler = spec => new Promise(resolve => {
1853
+ const finish = (): void => { resolve(successResult(spec, '')); };
1854
+ if (spec.signal?.aborted) { finish(); return; }
1855
+ spec.signal?.addEventListener('abort', finish, { once: true });
1856
+ });
1857
+ }
1858
+
1859
+ it('re-applies sandbox mode on the host session and persists state.permission (缺陷 29)', async () => {
1860
+ const { ctx, engine, persistence, shell, sessions } = await harness();
1861
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1862
+ hangingShell(shell);
1863
+ const stepStarted = waitForStepStart(ctx);
1864
+
1865
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
1866
+ await stepStarted;
1867
+ const session = sessions.get(run.instanceId)!;
1868
+
1869
+ // 创建时应用 read-only:宿主 session 含 sandbox/mode=read-only 事件,快照 permission=read-only。
1870
+ expect(effectiveSandboxMode(session.events)).toBe('read-only');
1871
+ expect(persistence.state.get(run.instanceId)?.permission).toBe('read-only');
1872
+
1873
+ // 运行中切换到 workspace-write(复用 applyPermission 可重入:追加 sandbox/mode
1874
+ // 事件 + setPolicy,DSH 折叠读取取最新,幂等重放不累积副作用)。
1875
+ await engine.setPermissionMode(run.instanceId, 'workspace-write');
1876
+
1877
+ // 宿主 session 新增 sandbox/mode=workspace-write 事件(折叠后 effectiveSandboxMode 变化)。
1878
+ expect(effectiveSandboxMode(session.events)).toBe('workspace-write');
1879
+ // 快照 permission 同步更新并持久化(UI 列表/详情可读当前权限;recoverInstance 靠
1880
+ // session 事件重放恢复权限,快照字段保持一致避免漂移)。
1881
+ expect(persistence.state.get(run.instanceId)?.permission).toBe('workspace-write');
1882
+
1883
+ await engine.abortInstance(run.instanceId);
1884
+ });
1885
+
1886
+ it('throws NODE_NOT_FOUND for an unknown instance (缺陷 29)', async () => {
1887
+ const { engine } = await harness();
1888
+ await expect(engine.setPermissionMode('inst-none', 'read-only')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
1889
+ });
1890
+
1891
+ it('rejects a terminal instance with INVALID_OPERATION (缺陷 29)', async () => {
1892
+ const { ctx, engine, persistence, llm, shell } = await harness();
1893
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1894
+ shell.runHandler = async spec => successResult(spec, 'hello');
1895
+ llm.providers.push({ id: 'p', name: 'Provider' });
1896
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1897
+ llm.streamHandler = () => textChunks('summary');
1898
+
1899
+ const ended = waitForEnd(ctx);
1900
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
1901
+ await ended;
1902
+
1903
+ await expect(engine.setPermissionMode(run.instanceId, 'workspace-write')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
1904
+ });
1905
+
1906
+ // ── 子代理快照契约(SA8 核实要求固化)──────────────────────────────────
1907
+ // sandore Step 以子代理运行(step-runner.ts startContinuable,parent=宿主),委派时
1908
+ // 快照宿主 sandbox+approval。故运行中切换权限对【宿主会话】与【切换后新委派的
1909
+ // Step】即时生效,但【已运行 Step 子代理】保持其委派时的权限快照——与基准
1910
+ // 「StepRunner 闭包 `() => this._permissionMode` 每次权限请求读最新值」语义不同。
1911
+ // 此为既定架构差异,非缺陷。mock 不模拟真实 sandbox 折叠,故以「setPermissionMode
1912
+ // 不触及 subagents 任何调用路径」+「宿主 effectiveSandboxMode 即时变化」为可观测契约
1913
+ // 代理指标,固化「已委派子代理快照不被运行中切换回溯」的语义边界。
1914
+ it('subagent delegation snapshot is not retroactively changed by runtime switch (缺陷 29 架构差异, 非缺陷)', async () => {
1915
+ const { ctx, engine, persistence, shell, sessions, subagents } = await harness();
1916
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
1917
+ hangingShell(shell);
1918
+ const stepStarted = waitForStepStart(ctx);
1919
+
1920
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
1921
+ await stepStarted;
1922
+ const session = sessions.get(run.instanceId)!;
1923
+
1924
+ // 委派时刻基线:宿主会话 effectiveSandboxMode=read-only(子代理在 startContinuable
1925
+ // 时快照此值——已运行 Step 子代理的权限请求按此旧值判定)。
1926
+ expect(effectiveSandboxMode(session.events)).toBe('read-only');
1927
+ const startsBefore = subagents.continuableStarts.length;
1928
+
1929
+ // 运行中切换到 workspace-write。
1930
+ await engine.setPermissionMode(run.instanceId, 'workspace-write');
1931
+
1932
+ // ①宿主会话 effectiveSandboxMode 即时变为 workspace-write(折叠读取最新事件)。
1933
+ // 切换后新委派的 Step 在 startContinuable 时会快照此新值 → 即时生效。
1934
+ expect(effectiveSandboxMode(session.events)).toBe('workspace-write');
1935
+
1936
+ // ②setPermissionMode 不触及 subagents 任何调用路径:不触发新子代理委派、不
1937
+ // followup、不 interrupt 已运行子代理。sandore 子代理在 startContinuable 时
1938
+ // 快照宿主 sandbox+approval,运行中切换只对宿主会话追加 sandbox/mode 事件,
1939
+ // 不回溯已委派子代理——故已运行 Step 子代理的委派快照保持其创建时的值
1940
+ // (权限请求仍按旧模式判定)。【架构差异 vs 基准】基准 StepRunner 以闭包
1941
+ // `() => this._permissionMode` 每次权限请求读最新值,运行中切换对已运行 Step
1942
+ // 也即时生效;sandore 因子代理委派快照机制,已运行 Step 保持旧快照。此差异
1943
+ // 为既定架构,非缺陷。
1944
+ expect(subagents.continuableStarts.length).toBe(startsBefore);
1945
+ expect(subagents.followups).toHaveLength(0);
1946
+ expect(subagents.interrupts).toHaveLength(0);
1947
+
1948
+ await engine.abortInstance(run.instanceId);
1949
+ });
1950
+ });
1951
+
1952
+ describe('SandoreEngineRuntime DAG execution', () => {
1953
+ it('routes branches, runs the loop, extracts pass-through vars and passes the gate to completed', async () => {
1954
+ const { ctx, engine, persistence, llm, shell, subagents, agents } = await harness();
1955
+ await putDefinition(persistence, 'wfl-dag', dagYaml);
1956
+ shell.runHandler = async spec => successResult(spec, 'ok');
1957
+ llm.providers.push({ id: 'p', name: 'Provider' });
1958
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
1959
+
1960
+ // 条件分支评估(批量 shouldExecute 分支 id 数组)→ agent 执行 → prompt 执行(子代理)→
1961
+ // 收尾 Phase A assess(hasAsk 格式)→ Phase B 真 fork/降级(compressedOutput 合并
1962
+ // JSON,偏离 2:不复用 Phase A 结果)。primary 门禁为纯代码产物校验(零 AI)。
1963
+ // 按首条消息文本分发:shouldExecute → 批量分支 JSON;compressedOutput → Phase B 收尾 JSON;
1964
+ // 其余(hasAsk)→ Phase A 评估 JSON。
1965
+ llm.streamHandler = (options) => {
1966
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
1967
+ if (text.includes('shouldExecute')) {
1968
+ // Round 20:批量模板——返回满足条件的分支 id 数组(branchId = branch.next)。
1969
+ return textChunks('{"shouldExecute": ["step-agent"]}');
1970
+ }
1971
+ if (text.includes('compressedOutput')) {
1972
+ return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"finalize 摘要"}');
1973
+ }
1974
+ return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
1975
+ };
1976
+
1977
+ // agent Step 走可续接子代理:注册真实 childId 并回填最终 assistant 输出。
1978
+ const child = new FakeAgent('child-1');
1979
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'agent output' }]));
1980
+ agents.register(child);
1981
+ // prompt Step(step-finalize)同为真实子代理(决策 D3):childId=child-2。
1982
+ const promptChild = new FakeAgent('child-2');
1983
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'report done' }]));
1984
+ agents.register(promptChild);
1985
+
1986
+ const records = recordSandoreEvents(ctx);
1987
+ const ended = waitForEnd(ctx);
1988
+ const run = await engine.startInstance({ workflowId: 'wfl-dag', originalInput: 'do it', cwd: '/tmp' });
1989
+ await ended;
1990
+
1991
+ const state = persistence.state.get(run.instanceId);
1992
+ expect(state?.status).toBe('completed');
1993
+
1994
+ // 分支路由:匹配 agent 分支 + 并行分支;回退分支未执行;汇合节点仅执行一次。
1995
+ // 注:基准模型下路由门+body 的 body 跑 maxIter-1 次(loop head 每 visit 一次,
1996
+ // 末次 break 不路由到 body),故 step-loop(maxIter=3) 的 body(step-iter) 跑 2 次。
1997
+ const stepIds = state?.executedSteps.map(step => step.stepId);
1998
+ expect(stepIds).toEqual([
1999
+ 'step-branch',
2000
+ 'step-parallel',
2001
+ 'step-agent',
2002
+ 'step-merge',
2003
+ 'step-iter',
2004
+ 'step-iter',
2005
+ 'step-finalize',
2006
+ ]);
2007
+ expect(stepIds?.filter(id => id === 'step-fallback')).toHaveLength(0);
2008
+ expect(stepIds?.filter(id => id === 'step-merge')).toHaveLength(1);
2009
+
2010
+ // 分支决策点保留 branchMeta 评估现场(批量 shouldExecute 分支 id 数组判定输出)。
2011
+ const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
2012
+ expect(branchEntry?.branchMeta?.status).toBe('completed');
2013
+ expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-agent"]}');
2014
+
2015
+ // agent Step:真实 childId(synthetic:false)+ 子代理最终输出。
2016
+ const agentStep = state?.executedSteps.find(step => step.stepId === 'step-agent');
2017
+ expect(agentStep?.sessionMeta).toEqual({ childId: 'child-1', synthetic: false, command: 'agent' });
2018
+ expect(agentStep?.output).toEqual([{ type: 'text', text: 'agent output' }]);
2019
+
2020
+ // hostAgent 注入:agent Step 的子代理 parent 即宿主主代理(id = instanceId)。
2021
+ expect(subagents.continuableStarts[0]?.request.parent.id).toBe(run.instanceId);
2022
+
2023
+ // 循环:2 次扁平迭代(路由门 body 跑 maxIter-1 次),loopIteration 嵌套路径;
2024
+ // 段 id 为纯 UUID(§3.3),sessionMeta.childId 也为纯 UUID(合成占位)。
2025
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2026
+ expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
2027
+ expect(iterations[0]?.id).toMatch(UUID_RE);
2028
+ expect(iterations[0]?.sessionMeta?.childId).toMatch(/^step-iter-[0-9a-f]{4}$/);
2029
+ expect(iterations[1]?.visitCount).toBe(2);
2030
+
2031
+ // 透传变量回写:finalize 提取 projectName 并保留 description。
2032
+ expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
2033
+
2034
+ // 门禁通过(primary 纯代码产物校验,无 outputTo → 通过;无 secondary → 零门禁 AI):
2035
+ // finalizeMeta 记录 gateResult + currentPhase='completed'。
2036
+ const finalizeStep = state?.executedSteps.find(step => step.stepId === 'step-finalize');
2037
+ expect(finalizeStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
2038
+ expect(finalizeStep?.finalizeMeta?.gateResult?.primaryPassed).toBe(true);
2039
+ expect(finalizeStep?.finalizeMeta?.currentPhase).toBe('completed');
2040
+
2041
+ // llm 调用覆盖:条件分支 1 + Phase A assess 1 + Phase B 重新 assess 1 = 3 次
2042
+ // (primary 纯代码校验零 AI;prompt Step 输出来自子代理会话,不消费 llm.stream)。
2043
+ expect(llm.calls).toHaveLength(3);
2044
+
2045
+ // agent Step 的 step-completed 事件载荷:真实 childId + 最终 output + name;
2046
+ // 非循环 Step 无 loopIteration。
2047
+ const agentCompleted = records.find(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-agent');
2048
+ expect(agentCompleted?.args[2]).toBe('child-1');
2049
+ expect(agentCompleted?.args[3]).toEqual([{ type: 'text', text: 'agent output' }]);
2050
+ expect(agentCompleted?.args[4]).toBe('step-agent');
2051
+ expect(agentCompleted?.args[5]).toBeUndefined();
2052
+
2053
+ // 循环内 Step 的 step-completed 事件载荷:name + 扁平迭代路径(末尾位置)。
2054
+ const iterCompleted = records.filter(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-iter');
2055
+ expect(iterCompleted.map(record => record.args[4])).toEqual(['step-iter', 'step-iter']);
2056
+ expect(iterCompleted.map(record => record.args[5])).toEqual([[1], [2]]);
2057
+
2058
+ // 两 Stage 均 completed,契约落盘。
2059
+ expect(state?.stageInfos.map(info => info.status)).toEqual(['completed', 'completed']);
2060
+ expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
2061
+ });
2062
+
2063
+ it('falls back to array order and preserves branchMeta when no branch matches', async () => {
2064
+ const { ctx, engine, persistence, llm, shell } = await harness();
2065
+ await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
2066
+ shell.runHandler = async spec => successResult(spec, 'ok');
2067
+ llm.providers.push({ id: 'p', name: 'Provider' });
2068
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2069
+ // Round 20:批量模板——空数组 = 无分支命中。
2070
+ llm.streamHandler = () => textChunks('{"shouldExecute": []}');
2071
+
2072
+ const ended = waitForEnd(ctx);
2073
+ const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
2074
+ await ended;
2075
+
2076
+ const state = persistence.state.get(run.instanceId);
2077
+ expect(state?.status).toBe('completed');
2078
+ const stepIds = state?.executedSteps.map(step => step.stepId);
2079
+ expect(stepIds).toEqual(['step-branch', 'step-fallback', 'step-matched']);
2080
+
2081
+ // branchMeta 保留原始评估结果(空数组证明条件分支未命中)。
2082
+ const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
2083
+ expect(branchEntry?.branchMeta?.status).toBe('completed');
2084
+ expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": []}');
2085
+ });
2086
+
2087
+ it('retries an AI condition branch when the first output is unparseable then passes on the retry', async () => {
2088
+ const { ctx, engine, persistence, llm, shell } = await harness();
2089
+ await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
2090
+ shell.runHandler = async spec => successResult(spec, 'ok');
2091
+ llm.providers.push({ id: 'p', name: 'Provider' });
2092
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2093
+ // 首轮返回乱码(不可解析 shouldExecute 数组)→ evaluateConditions 触发 follow-up
2094
+ // 重试 → 第二轮返回合法分支 id 数组(branchId = branch.next = step-matched)。
2095
+ const responses = ['I cannot decide', '{"shouldExecute": ["step-matched"]}'];
2096
+ let callIndex = 0;
2097
+ llm.streamHandler = () => textChunks(responses[callIndex++] ?? '');
2098
+
2099
+ const ended = waitForEnd(ctx);
2100
+ const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
2101
+ await ended;
2102
+
2103
+ const state = persistence.state.get(run.instanceId);
2104
+ expect(state?.status).toBe('completed');
2105
+
2106
+ // 重试后条件通过 → step-matched 执行;step-fallback 未命中(条件分支优先于数组兜底)。
2107
+ const stepIds = state?.executedSteps.map(step => step.stepId);
2108
+ expect(stepIds).toEqual(['step-branch', 'step-matched']);
2109
+
2110
+ // 条件评估共 2 次 llm.stream 调用(首轮乱码 + 1 次纠正重试)。
2111
+ expect(llm.calls).toHaveLength(2);
2112
+ const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
2113
+ expect(branchEntry?.branchMeta?.status).toBe('completed');
2114
+ expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-matched"]}');
2115
+ });
2116
+
2117
+ it('falls back to array order when an AI condition branch exhausts shouldExecute retries', async () => {
2118
+ const { ctx, engine, persistence, llm, shell } = await harness();
2119
+ await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
2120
+ shell.runHandler = async spec => successResult(spec, 'ok');
2121
+ llm.providers.push({ id: 'p', name: 'Provider' });
2122
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2123
+ // 始终返回乱码:首轮 + MAX_BRANCH_EVAL_RETRIES(2) 次重试均不可解析 → 兜底 false。
2124
+ llm.streamHandler = () => textChunks('totally not json');
2125
+
2126
+ const ended = waitForEnd(ctx);
2127
+ const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
2128
+ await ended;
2129
+
2130
+ const state = persistence.state.get(run.instanceId);
2131
+ expect(state?.status).toBe('completed');
2132
+
2133
+ // 重试耗尽 → 条件未通过 → 数组顺序兜底 step-fallback;step-matched 未执行。
2134
+ const stepIds = state?.executedSteps.map(step => step.stepId);
2135
+ expect(stepIds).toEqual(['step-branch', 'step-fallback', 'step-matched']);
2136
+
2137
+ // 条件评估共 3 次 llm.stream 调用(首轮 + 2 次重试)。
2138
+ expect(llm.calls).toHaveLength(3);
2139
+ const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
2140
+ expect(branchEntry?.branchMeta?.status).toBe('completed');
2141
+ expect(branchEntry?.branchMeta?.result).toBe('totally not json');
2142
+ });
2143
+
2144
+ it('executes nested loops fully on every outer iteration (inner loop visitCount resets)', async () => {
2145
+ const { ctx, engine, persistence, shell } = await harness();
2146
+ await putDefinition(persistence, 'wfl-nested-loop', nestedLoopYaml);
2147
+ shell.runHandler = async spec => successResult(spec, 'iter');
2148
+
2149
+ const records = recordSandoreEvents(ctx);
2150
+ const ended = waitForEnd(ctx);
2151
+ const run = await engine.startInstance({ workflowId: 'wfl-nested-loop', originalInput: 'do it', cwd: '/tmp' });
2152
+ await ended;
2153
+
2154
+ const state = persistence.state.get(run.instanceId);
2155
+ expect(state?.status).toBe('completed');
2156
+
2157
+ // 基准模型:路由门 body 跑 maxIter-1 次。外层 maxIter=3 → 外层 body(内层循环) 跑 2
2158
+ // 次(外层 visit 1、2 继续,visit 3 跳出);内层 maxIter=3 → 内层 body 跑 2 次。
2159
+ // 故 (3-1)×(3-1)=4 次 body 执行,loopIteration 路径 [[1,1],[1,2],[2,1],[2,2]]。
2160
+ // 外层重入内层时内层 visitCount 经 resetLoopVisitCounts 从 0 起重新计数
2161
+ // (跳出时归零作用域语义),验证嵌套重入不残留旧计数。
2162
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2163
+ expect(iterations.map(step => step.loopIteration)).toEqual([[1, 1], [1, 2], [2, 1], [2, 2]]);
2164
+
2165
+ // 事件 payload 的 loopIteration 为嵌套完整路径数组(与 state 快照一致)。
2166
+ const iterCompleted = records.filter(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-iter');
2167
+ expect(iterCompleted.map(record => record.args[5])).toEqual([[1, 1], [1, 2], [2, 1], [2, 2]]);
2168
+ });
2169
+
2170
+ it('runs the loop to maxIterations when the output-driven breakCondition never signals done', async () => {
2171
+ const { ctx, engine, persistence, llm, shell } = await harness();
2172
+ await putDefinition(persistence, 'wfl-loop-break', loopBreakYaml);
2173
+ shell.runHandler = async spec => successResult(spec, 'iter');
2174
+ llm.providers.push({ id: 'p', name: 'Provider' });
2175
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2176
+ // fork-based 改造(缺陷 4):breakCondition 走 fork 会话注入全量历史;step-iter 为 shell
2177
+ // 合成 session(无真实 session 可 fork)→ 判断降级为临时会话(judgePrompt 不含手动 I/O 块)。
2178
+ // mock 恒返回 shouldBreak=false → 每趟不跳 → 跑满 maxIter=5 后硬上限 break。
2179
+ llm.streamHandler = () => textChunks('{"shouldBreak": false}');
2180
+
2181
+ const ended = waitForEnd(ctx);
2182
+ const run = await engine.startInstance({ workflowId: 'wfl-loop-break', originalInput: 'do it', cwd: '/tmp' });
2183
+ await ended;
2184
+
2185
+ const state = persistence.state.get(run.instanceId);
2186
+ expect(state?.status).toBe('completed');
2187
+ // 循环体 step-iter 跑 maxIter-1 次(visit 1..4 继续,visit 5 硬上限 break)。
2188
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2189
+ expect(iterations).toHaveLength(4);
2190
+ // 跑满 maxIter 后跳出,进入 step-done 收尾。
2191
+ expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
2192
+ // fork-based 改造:breakCondition 走专用 shouldBreak 模板(不再手动注入 I/O 块——
2193
+ // shell step 降级为临时会话,AI 依据 promptText 判断)。
2194
+ const breakPrompt = llm.calls
2195
+ .map(call => (call.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '')
2196
+ .find(text => text.includes('shouldBreak'));
2197
+ expect(breakPrompt).toBeDefined();
2198
+ });
2199
+
2200
+ it('runs a command-gate loop exactly maxIterations times (loop head carries a command)', async () => {
2201
+ const { ctx, engine, persistence, shell } = await harness();
2202
+ await putDefinition(persistence, 'wfl-loop-command-gate', loopCommandGateYaml);
2203
+ shell.runHandler = async spec => successResult(spec, 'loop');
2204
+
2205
+ const ended = waitForEnd(ctx);
2206
+ const run = await engine.startInstance({ workflowId: 'wfl-loop-command-gate', originalInput: 'do it', cwd: '/tmp' });
2207
+ await ended;
2208
+
2209
+ const state = persistence.state.get(run.instanceId);
2210
+ expect(state?.status).toBe('completed');
2211
+
2212
+ // 命令门(loop head 自带 command + 自回边):基准模型 maxIter = loop head 被 visit
2213
+ // 的次数,命令门每 visit 一次就跑一次 command → 命令跑 maxIter 次。visitCount 递增
2214
+ // 已统一移至 runLoop(单一递增点),checkLoopBreak 只读,根治双重计数。
2215
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
2216
+ expect(iterations).toHaveLength(3);
2217
+ // 跳出后进入 step-done 收尾。
2218
+ expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
2219
+ });
2220
+
2221
+ it('runs a command-gate loop exactly N times for maxIter=N (1..6)', async () => {
2222
+ const { ctx, engine, persistence, shell } = await harness();
2223
+ shell.runHandler = async spec => successResult(spec, 'loop');
2224
+
2225
+ for (let maxIter = 1; maxIter <= 6; maxIter += 1) {
2226
+ const wfId = `wfl-cg-${maxIter}`;
2227
+ await putDefinition(persistence, wfId, commandGateYaml(maxIter));
2228
+
2229
+ const ended = waitForEnd(ctx);
2230
+ const run = await engine.startInstance({ workflowId: wfId, originalInput: 'do it', cwd: '/tmp' });
2231
+ await ended;
2232
+
2233
+ const state = persistence.state.get(run.instanceId);
2234
+ expect(state?.status).toBe('completed');
2235
+ // 命令门:maxIter=N → command 跑 N 次(loop head 每 visit 一次即跑一次 command,
2236
+ // 末次 visit 触发 break)。根治修前 maxIter=4 只跑 3 次的双重计数 bug。
2237
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
2238
+ expect(iterations).toHaveLength(maxIter);
2239
+ // 跳出后进入 step-done 收尾。
2240
+ expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
2241
+ // 清理实例避免跨轮干扰。
2242
+ persistence.state.delete(run.instanceId);
2243
+ }
2244
+ });
2245
+
2246
+ it('runs a breakCondition loop exactly maxIterations times when shouldBreak stays false', async () => {
2247
+ const { ctx, engine, persistence, llm, shell } = await harness();
2248
+ await putDefinition(persistence, 'wfl-loop-break-no-break', loopBreakNoBreakYaml);
2249
+ shell.runHandler = async spec => successResult(spec, 'iter');
2250
+ llm.providers.push({ id: 'p', name: 'Provider' });
2251
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2252
+ llm.streamHandler = () => textChunks('{"shouldBreak": false}');
2253
+
2254
+ const ended = waitForEnd(ctx);
2255
+ const run = await engine.startInstance({ workflowId: 'wfl-loop-break-no-break', originalInput: 'do it', cwd: '/tmp' });
2256
+ await ended;
2257
+
2258
+ const state = persistence.state.get(run.instanceId);
2259
+ expect(state?.status).toBe('completed');
2260
+
2261
+ // breakCondition 门:loop-manager 每趟评估产出 loopMeta,step-navigator continue
2262
+ // 与 break 分支均透传 loopMeta(缺陷 23)→ 每次评估都回写对应 step-loop 路由条目。
2263
+ // 不双重计数:visitCount 递增仍由 runLoop 单一完成(缺陷 3 已重构),路由条目仅
2264
+ // 快照当前值——continue 透传不触发提前跳出。基准模型下路由门 body 跑 maxIter-1 次,
2265
+ // 故循环体执行 2 次。
2266
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2267
+ expect(iterations).toHaveLength(2);
2268
+ expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
2269
+ });
2270
+
2271
+ it('writes loopMeta to step-loop routing entries on both continue and break (defect 23)', async () => {
2272
+ const { ctx, engine, persistence, llm, shell } = await harness();
2273
+ await putDefinition(persistence, 'wfl-loop-break', loopBreakYaml);
2274
+ shell.runHandler = async spec => successResult(spec, 'iter');
2275
+ llm.providers.push({ id: 'p', name: 'Provider' });
2276
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2277
+ // fork-based 改造(缺陷 4):breakCondition 走 fork 会话注入全量历史;step-iter 为 shell
2278
+ // 合成 session(无真实 session 可 fork)→ 判断降级为临时会话。mock:首次评估 false(继续),
2279
+ // 第二次评估 true(跳出)——验证 continue 与 break 两条 step-loop 路由条目均回写 loopMeta
2280
+ // (缺陷 23:修复前 continue 分支丢弃 outcome.loopMeta,仅 break 条目回写)。
2281
+ let breakCall = 0;
2282
+ llm.streamHandler = (options) => {
2283
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
2284
+ if (text.includes('shouldBreak')) {
2285
+ breakCall += 1;
2286
+ return textChunks(breakCall === 1 ? '{"shouldBreak": false}' : '{"shouldBreak": true}');
2287
+ }
2288
+ return textChunks('summary');
2289
+ };
2290
+
2291
+ const ended = waitForEnd(ctx);
2292
+ const run = await engine.startInstance({ workflowId: 'wfl-loop-break', originalInput: 'do it', cwd: '/tmp' });
2293
+ await ended;
2294
+
2295
+ const state = persistence.state.get(run.instanceId);
2296
+ expect(state?.status).toBe('completed');
2297
+
2298
+ // 缺陷 23:continue 分支(首评 false)与 break 分支(次评 true)均透传 loopMeta →
2299
+ // 两条 step-loop 路由条目均含 loopMeta(completed)。修复前 continue 分支不透传,
2300
+ // 仅 break 条目回写(loop-manager.spec L174-175 实证 checkLoopBreak 在 shouldBreak=false
2301
+ // 时同样产出 loopMeta(completed))。
2302
+ const loopEntries = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
2303
+ expect(loopEntries).toHaveLength(2);
2304
+ expect(loopEntries.every(entry => entry.loopMeta?.status === 'completed')).toBe(true);
2305
+ // 两条均携带 shouldBreak 评估现场(continue=false 现场、break=true 现场)。
2306
+ expect(loopEntries[0]?.loopMeta?.result).toContain('shouldBreak');
2307
+ expect(loopEntries[1]?.loopMeta?.result).toContain('shouldBreak');
2308
+
2309
+ // continue 透传不双重计数回归(缺陷 3 point ② 已由 runLoop 单一递增解决):
2310
+ // 循环体 step-iter 仅在首趟 continue 后跑一次(第二趟 break 直接跳出,未再跑 body)。
2311
+ const iterEntries = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2312
+ expect(iterEntries).toHaveLength(1);
2313
+ // 跳出后进入 step-done 收尾。
2314
+ expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
2315
+ });
2316
+
2317
+ it('runs a mixed-gate loop exactly maxIterations times when the condition branch never matches', async () => {
2318
+ const { ctx, engine, persistence, llm, shell } = await harness();
2319
+ await putDefinition(persistence, 'wfl-loop-mixed-gate', loopMixedGateYaml);
2320
+ shell.runHandler = async spec => successResult(spec, 'iter');
2321
+ llm.providers.push({ id: 'p', name: 'Provider' });
2322
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2323
+ // 条件分支恒不命中(批量空数组)→ 循环跑到 maxIterations 硬上限。
2324
+ llm.streamHandler = () => textChunks('{"shouldExecute": []}');
2325
+
2326
+ const ended = waitForEnd(ctx);
2327
+ const run = await engine.startInstance({ workflowId: 'wfl-loop-mixed-gate', originalInput: 'do it', cwd: '/tmp' });
2328
+ await ended;
2329
+
2330
+ const state = persistence.state.get(run.instanceId);
2331
+ expect(state?.status).toBe('completed');
2332
+
2333
+ // 混合门(condition + loop,isLoopControl=false):基准模型下路由门 body 跑
2334
+ // maxIter-1 次。visitCount 递增已统一移至 runLoop(单一递增点),回边/checkLoopBreak
2335
+ // 只读不递增,根治双重计数。故循环体执行 2 次。
2336
+ const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
2337
+ expect(iterations).toHaveLength(2);
2338
+ expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
2339
+ // 条件分支恒不命中 → step-exit 未执行;跳出后进入 step-done 收尾。
2340
+ expect(state?.executedSteps.some(step => step.stepId === 'step-exit')).toBe(false);
2341
+ expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
2342
+ });
2343
+
2344
+ it('does not let a later stage default override an earlier stage extracted pass-through var (defect 15)', async () => {
2345
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
2346
+ await putDefinition(persistence, 'wfl-cross-stage-ptv', crossStagePtvYaml);
2347
+ // shell 回显命令本身,便于断言 step-use 拿到的实际值。
2348
+ shell.runHandler = async spec => successResult(spec, spec.command);
2349
+ llm.providers.push({ id: 'p', name: 'Provider' });
2350
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2351
+ // step-extract 为 prompt Step,走可续接子代理:注册真实 childId 并回填最终输出。
2352
+ const promptChild = new FakeAgent('child-1');
2353
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'extracted text' }]));
2354
+ agents.register(promptChild);
2355
+
2356
+ // step-extract(prompt)执行 → finalize Phase A assess(hasAsk 格式)+ Phase B 收尾
2357
+ // (compressedOutput 合并 JSON)提取 projectName=sandore;Stage 2 step-use 声明同名
2358
+ // 默认值 "unknown"(非 \${} 引用 → 不触发 assess)。按首条消息文本分发:
2359
+ // compressedOutput → Phase B 收尾 JSON;其余(hasAsk)→ Phase A 评估 JSON。
2360
+ llm.streamHandler = (options) => {
2361
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
2362
+ if (text.includes('compressedOutput')) {
2363
+ return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"摘要"}');
2364
+ }
2365
+ return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
2366
+ };
2367
+
2368
+ const ended = waitForEnd(ctx);
2369
+ await engine.startInstance({ workflowId: 'wfl-cross-stage-ptv', originalInput: 'do it', cwd: '/tmp' });
2370
+ await ended;
2371
+
2372
+ // 残余差异二修复(initState 合并顺序):Stage 2 step-use 声明同名 projectName
2373
+ // 默认值 "unknown",但合并顺序已改为「input 提取值覆盖定义默认值」→ step-use
2374
+ // 拿到 'sandore' 而非被 'unknown' 覆盖。修复前(旧合并顺序:定义值覆盖提取值)
2375
+ // 此处为 'echo unknown',破坏跨 Stage 变量传递链。
2376
+ expect(shell.specs.map(spec => spec.command)).toEqual(['echo sandore']);
2377
+ });
2378
+ });
2379
+
2380
+ /** 单 Stage prompt Step 带透传变量(触发 finalize assess → ask)。 */
2381
+ const askYaml = `id: wfl-ask
2382
+ name: ask
2383
+ version: "1.0.0"
2384
+ stages:
2385
+ - id: stg-1
2386
+ name: 提问
2387
+ steps:
2388
+ - id: step-ask
2389
+ steps:
2390
+ - id: step-ask
2391
+ command:
2392
+ type: prompt
2393
+ value:
2394
+ promptText: "do it"
2395
+ passThroughVars:
2396
+ - name: projectName
2397
+ value: "\${projectName}"
2398
+ description: "项目名"
2399
+ `;
2400
+
2401
+ /** 单 Stage 多 Step:step-ask(prompt + 门禁 + 透传变量)触发中途 ask,step-after 为剩余 Step。 */
2402
+ const askMidYaml = `id: wfl-ask-mid
2403
+ name: ask-mid
2404
+ version: "1.0.0"
2405
+ stages:
2406
+ - id: stg-1
2407
+ name: 中途提问
2408
+ steps:
2409
+ - id: step-ask
2410
+ - id: step-after
2411
+ steps:
2412
+ - id: step-ask
2413
+ command:
2414
+ type: prompt
2415
+ value:
2416
+ promptText: "do it"
2417
+ passThroughVars:
2418
+ - name: projectName
2419
+ value: "\${projectName}"
2420
+ description: "项目名"
2421
+ gates:
2422
+ primary:
2423
+ enabled: true
2424
+ - id: step-after
2425
+ command:
2426
+ type: shell
2427
+ value:
2428
+ command: "echo done"
2429
+ `;
2430
+
2431
+ /**
2432
+ * 收尾阶段流转(sandore/step-phase 事件全四相):
2433
+ * step-ask(prompt + 透传变量)触发 ask 保底中断(ask-detect)→ 回答后续跑
2434
+ * (finalizing → completed);step-gate(shell + 门禁 + outputTo 缺失文件)
2435
+ * primary 纯代码产物校验不通过(finalizing → gating → Step failed)。
2436
+ */
2437
+ const phaseFlowYaml = `id: wfl-phase-flow
2438
+ name: phase-flow
2439
+ version: "1.0.0"
2440
+ stages:
2441
+ - id: stg-1
2442
+ name: 收尾阶段
2443
+ steps:
2444
+ - id: step-ask
2445
+ - id: step-gate
2446
+ steps:
2447
+ - id: step-ask
2448
+ command:
2449
+ type: prompt
2450
+ value:
2451
+ promptText: "do it"
2452
+ passThroughVars:
2453
+ - name: projectName
2454
+ value: "\${projectName}"
2455
+ description: "项目名"
2456
+ - id: step-gate
2457
+ command:
2458
+ type: shell
2459
+ value:
2460
+ command: "echo gate"
2461
+ outputTo:
2462
+ - id: opt-a
2463
+ path: "missing-report.txt"
2464
+ gates:
2465
+ primary:
2466
+ enabled: true
2467
+ `;
2468
+
2469
+ /** 单 Stage agent Step(子代理 final result 含 [[SANDORE_ASK]] 标记块——双通道 ask 重构后按普通输出透传)。 */
2470
+ const delegatedAskYaml = `id: wfl-delegated
2471
+ name: delegated
2472
+ version: "1.0.0"
2473
+ stages:
2474
+ - id: stg-1
2475
+ name: 委托
2476
+ steps:
2477
+ - id: step-agent
2478
+ steps:
2479
+ - id: step-agent
2480
+ command:
2481
+ type: agent
2482
+ value:
2483
+ promptText: "do-work"
2484
+ `;
2485
+
2486
+ /** 等待一次 sandore/question 事件并返回载荷(用于读取 requestId)。 */
2487
+ function waitForQuestion(ctx: Context): Promise<{ info: unknown; question: unknown }> {
2488
+ return new Promise(resolve => {
2489
+ ctx.once('sandore/question', (info, question) => resolve({ info, question }));
2490
+ });
2491
+ }
2492
+
2493
+ describe('SandoreEngineRuntime Stage-level conditional branching', () => {
2494
+ it('evaluates the Stage branch condition and routes to the matched Stage when shouldExecute is true', async () => {
2495
+ const { ctx, engine, persistence, llm, shell } = await harness();
2496
+ await putDefinition(persistence, 'wfl-stage-branch', stageBranchYaml);
2497
+ shell.runHandler = async spec => successResult(spec, 'ok');
2498
+ llm.providers.push({ id: 'p', name: 'Provider' });
2499
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2500
+ // Stage 条件评估(StageContract-based,非 fork):批量返回命中的 branchId → 取 stg-b。
2501
+ llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-b"]}');
2502
+
2503
+ const records = recordSandoreEvents(ctx);
2504
+ const ended = waitForEnd(ctx);
2505
+ await engine.startInstance({ workflowId: 'wfl-stage-branch', originalInput: 'do it', cwd: '/tmp' });
2506
+ await ended;
2507
+
2508
+ const completedStages = records
2509
+ .filter(record => record.name === 'sandore/stage-completed')
2510
+ .map(record => record.args[1]);
2511
+ // 条件命中 stg-b → 数组顺序继续 stg-c → stg-default → 结束。
2512
+ expect(completedStages).toEqual(['stg-entry', 'stg-b', 'stg-c', 'stg-default']);
2513
+ });
2514
+
2515
+ it('skips a false Stage branch condition and falls through to the default (no-condition) branch', async () => {
2516
+ const { ctx, engine, persistence, llm, shell } = await harness();
2517
+ await putDefinition(persistence, 'wfl-stage-branch', stageBranchYaml);
2518
+ shell.runHandler = async spec => successResult(spec, 'ok');
2519
+ llm.providers.push({ id: 'p', name: 'Provider' });
2520
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2521
+ // Stage 条件评估:批量返回空数组 → 跳过 stg-b,走无 condition 的默认分支 stg-default。
2522
+ llm.streamHandler = () => textChunks('{"shouldExecute": []}');
2523
+
2524
+ const records = recordSandoreEvents(ctx);
2525
+ const ended = waitForEnd(ctx);
2526
+ await engine.startInstance({ workflowId: 'wfl-stage-branch', originalInput: 'do it', cwd: '/tmp' });
2527
+ await ended;
2528
+
2529
+ const completedStages = records
2530
+ .filter(record => record.name === 'sandore/stage-completed')
2531
+ .map(record => record.args[1]);
2532
+ // 条件不命中 → 默认分支 stg-default(stg-default 为数组末位,无后续 → 结束),
2533
+ // 不走盲取首个 Stage 目标(旧 tryAdvanceStage 会取 stg-b)。
2534
+ expect(completedStages).toEqual(['stg-entry', 'stg-default']);
2535
+ });
2536
+
2537
+ it('routes to Stage A when the first multi-condition branch matches (skips array-order stg-mid)', async () => {
2538
+ const { ctx, engine, persistence, llm, shell } = await harness();
2539
+ await putDefinition(persistence, 'wfl-stage-multi', stageBranchMultiYaml);
2540
+ shell.runHandler = async spec => successResult(spec, 'ok');
2541
+ llm.providers.push({ id: 'p', name: 'Provider' });
2542
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2543
+ // 批量一次评估:condA 命中 → 取 stg-a(定义序首个命中;condB 同批评估但不影响结果)。
2544
+ llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-a"]}');
2545
+
2546
+ const records = recordSandoreEvents(ctx);
2547
+ const ended = waitForEnd(ctx);
2548
+ await engine.startInstance({ workflowId: 'wfl-stage-multi', originalInput: 'do it', cwd: '/tmp' });
2549
+ await ended;
2550
+
2551
+ const completedStages = records
2552
+ .filter(record => record.name === 'sandore/stage-completed')
2553
+ .map(record => record.args[1]);
2554
+ // 条件命中 stg-a(跳过数组下一 stg-mid)→ stg-a 数组顺序继续 stg-b → 结束。
2555
+ expect(completedStages).toEqual(['stg-entry', 'stg-a', 'stg-b']);
2556
+ });
2557
+
2558
+ it('routes to Stage B when the first condition is false and the second matches', async () => {
2559
+ const { ctx, engine, persistence, llm, shell } = await harness();
2560
+ await putDefinition(persistence, 'wfl-stage-multi', stageBranchMultiYaml);
2561
+ shell.runHandler = async spec => successResult(spec, 'ok');
2562
+ llm.providers.push({ id: 'p', name: 'Provider' });
2563
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2564
+ // 批量一次评估:condA false → 跳过;condB 命中 → 取 stg-b。
2565
+ llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-b"]}');
2566
+
2567
+ const records = recordSandoreEvents(ctx);
2568
+ const ended = waitForEnd(ctx);
2569
+ await engine.startInstance({ workflowId: 'wfl-stage-multi', originalInput: 'do it', cwd: '/tmp' });
2570
+ await ended;
2571
+
2572
+ const completedStages = records
2573
+ .filter(record => record.name === 'sandore/stage-completed')
2574
+ .map(record => record.args[1]);
2575
+ // 第二条件命中 stg-b(跳过数组下一 stg-mid 与 stg-a)→ stg-b 数组末位无后续 → 结束。
2576
+ expect(completedStages).toEqual(['stg-entry', 'stg-b']);
2577
+ });
2578
+ });
2579
+
2580
+ describe('SandoreEngineRuntime interaction', () => {
2581
+ it('registers a pending question, asks via userQuestions (hostAgent), and resumes after the card reply', async () => {
2582
+ const { ctx, engine, persistence, llm, agents, subagents, sessions, userQuestions } = await harness();
2583
+ await putDefinition(persistence, 'wfl-ask', askYaml);
2584
+ llm.providers.push({ id: 'p', name: 'Provider' });
2585
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2586
+ // Round 26(场景 B):复用宿主会话(hostSessionId)→ ask 上报宿主(userQuestions.ask)。
2587
+ const existingHost = new FakeAgent('host-1');
2588
+ agents.register(existingHost);
2589
+ sessions.create('host-1');
2590
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2591
+ const child = new FakeAgent('child-1');
2592
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2593
+ agents.register(child);
2594
+
2595
+ // prompt Step 输出(子代理)→ finalize assess ①(ask JSON)→ 回答注回子代理 →
2596
+ // 重新收集输出 + 重新评估 ②(completed JSON)。
2597
+ const responses = [
2598
+ '{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2599
+ '{"completionType":"completed","passThroughVars":{}}',
2600
+ ];
2601
+ let callIndex = 0;
2602
+ llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
2603
+
2604
+ const firedQuestions: unknown[] = [];
2605
+ ctx.on('sandore/question', (info, question) => firedQuestions.push({ info, question }));
2606
+
2607
+ // 卡片回复:捕获 ask 请求并回填答案。
2608
+ userQuestions.resolveNext = async (request) => {
2609
+ expect(request.questions).toEqual([{ id: 'q1', question: 'Which?', options: [{ label: 'A' }, { label: 'B' }] }]);
2610
+ return { answers: [{ id: 'q1', selected: ['A'] }] };
2611
+ };
2612
+
2613
+ const ended = waitForEnd(ctx);
2614
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2615
+ await ended;
2616
+
2617
+ // ask 以 hostAgent(id = hostSessionId)身份发起(场景 B:复用宿主会话 → 上报宿主)。
2618
+ expect(userQuestions.calls).toHaveLength(1);
2619
+ expect(userQuestions.calls[0]?.agent?.id).toBe('host-1');
2620
+
2621
+ // sandore/question 事件:pending 条目(kind/status/payload)。
2622
+ expect(firedQuestions).toHaveLength(1);
2623
+ const fired = firedQuestions[0] as { question: { kind: string; status: string; payload: { questions: Array<{ id: string }> } } };
2624
+ expect(fired.question.kind).toBe('question');
2625
+ expect(fired.question.status).toBe('pending');
2626
+ expect(fired.question.payload.questions[0]?.id).toBe('q1');
2627
+
2628
+ // 回复后恢复:Step completed + 实例 completed + 条目不再持久化(pending 已清,
2629
+ // 交互管理器只持久化 pending,§3.6A)。Round 26:回答**不写入** userMessages
2630
+ // (kind:'answer')——回答仅 followup 注回子代理,不注入 discussion 时间轴。
2631
+ const state = persistence.state.get(run.instanceId);
2632
+ expect(state?.status).toBe('completed');
2633
+ expect(state?.executedSteps.find(step => step.stepId === 'step-ask')?.status).toBe('completed');
2634
+ expect(state?.pendingInteractions).toEqual([]);
2635
+ const answers = state?.userMessages?.filter(message => message.kind === 'answer');
2636
+ expect(answers).toEqual([]);
2637
+ // 回答经 followup 注回子代理(source user,内容 'A'——子代理会话内可见)。
2638
+ const followup = subagents.followups.find(item => item.childId === 'child-1');
2639
+ expect(followup?.content).toEqual([{ type: 'text', text: 'A' }]);
2640
+ });
2641
+
2642
+ it('supports the replyQuestion compat path when the card channel is unavailable', async () => {
2643
+ const { ctx, engine, persistence, llm, agents, subagents } = await harness();
2644
+ await putDefinition(persistence, 'wfl-ask', askYaml);
2645
+ llm.providers.push({ id: 'p', name: 'Provider' });
2646
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2647
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2648
+ const child = new FakeAgent('child-1');
2649
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2650
+ agents.register(child);
2651
+
2652
+ const responses = [
2653
+ '{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2654
+ '{"completionType":"completed","passThroughVars":{}}',
2655
+ ];
2656
+ let callIndex = 0;
2657
+ llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
2658
+
2659
+ const question = waitForQuestion(ctx);
2660
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
2661
+ const fired = await question;
2662
+ const requestId = (fired.question as { requestId: string }).requestId;
2663
+
2664
+ // 兼容路径回复:resolve ask 的 await 并恢复执行。
2665
+ await engine.replyQuestion(run.instanceId, requestId, { answers: [{ id: 'q1', selected: ['B'] }] });
2666
+ await waitForEnd(ctx);
2667
+
2668
+ const state = persistence.state.get(run.instanceId);
2669
+ expect(state?.status).toBe('completed');
2670
+ // 回答后 pending 已清(只持久化 pending)。Round 26:回答不写入 userMessages。
2671
+ expect(state?.pendingInteractions).toEqual([]);
2672
+ const answers = state?.userMessages?.filter(message => message.kind === 'answer');
2673
+ expect(answers).toEqual([]);
2674
+ // 回答经 followup 注回子代理(内容 'B')。
2675
+ const followup = subagents.followups.find(item => item.childId === 'child-1');
2676
+ expect(followup?.content).toEqual([{ type: 'text', text: 'B' }]);
2677
+ });
2678
+
2679
+ it('resumes the remaining steps and re-runs the gate after a mid-stage ask', async () => {
2680
+ const { ctx, engine, persistence, llm, shell, agents, sessions, userQuestions } = await harness();
2681
+ await putDefinition(persistence, 'wfl-ask-mid', askMidYaml);
2682
+ llm.providers.push({ id: 'p', name: 'Provider' });
2683
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2684
+ shell.runHandler = async spec => successResult(spec, 'done');
2685
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主。
2686
+ const existingHost = new FakeAgent('host-1');
2687
+ agents.register(existingHost);
2688
+ sessions.create('host-1');
2689
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2690
+ const child = new FakeAgent('child-1');
2691
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2692
+ agents.register(child);
2693
+
2694
+ // assess ①(ask JSON)→ 回答注回 → 重新评估 ②(completed JSON)→ Phase B 重新
2695
+ // fork + 重新 assess ③(completed JSON)→ 门禁(primary 纯代码产物校验,零 AI)。
2696
+ // 偏离 2:Phase B 重新 assess,故 completed JSON 需供给两次;step-ask 无 outputTo
2697
+ // → primary 校验直接通过,无门禁 AI 调用。
2698
+ const responses = [
2699
+ '{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2700
+ '{"completionType":"completed","passThroughVars":{}}',
2701
+ '{"completionType":"completed","passThroughVars":{}}',
2702
+ ];
2703
+ let callIndex = 0;
2704
+ llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
2705
+
2706
+ userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
2707
+
2708
+ const ended = waitForEnd(ctx);
2709
+ const run = await engine.startInstance({ workflowId: 'wfl-ask-mid', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2710
+ await ended;
2711
+
2712
+ const state = persistence.state.get(run.instanceId);
2713
+ expect(state?.status).toBe('completed');
2714
+ // 剩余 Step 不丢失:step-ask 与 step-after 均执行。
2715
+ expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-ask', 'step-after']);
2716
+ // ask 回复后重跑门禁:step-ask 的 finalizeMeta 记录 gateResult。
2717
+ const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
2718
+ expect(askStep?.status).toBe('completed');
2719
+ expect(askStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
2720
+ });
2721
+
2722
+ it('emits sandore/step-phase on finalize phase transitions (ask-detect → finalizing → routing → gating/completed)', async () => {
2723
+ const { ctx, engine, persistence, llm, shell, agents, userQuestions, sessions } = await harness();
2724
+ await putDefinition(persistence, 'wfl-phase-flow', phaseFlowYaml);
2725
+ llm.providers.push({ id: 'p', name: 'Provider' });
2726
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2727
+ shell.runHandler = async spec => successResult(spec, 'gate done');
2728
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主。
2729
+ const existingHost = new FakeAgent('host-1');
2730
+ agents.register(existingHost);
2731
+ sessions.create('host-1');
2732
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2733
+ const child = new FakeAgent('child-1');
2734
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2735
+ agents.register(child);
2736
+
2737
+ // LLM 调用序:① step-ask Phase A assess(ask JSON)→ 回答注回 → 重新评估 ②
2738
+ // (completed JSON)→ Phase B 收尾 ③(compressedOutput 合并 JSON,走降级——child-1
2739
+ // 无事件源)→ routing → completed;④ step-gate 为 shell 步骤(Round 20:跳过
2740
+ // Phase A/B 收尾评估,零 llm 调用)→ Phase C primary 纯代码门禁(outputTo 缺失
2741
+ // 文件,fs 空 → 不通过)→ gating → Step failed → 实例 failed。
2742
+ // 按首条消息文本分发:分支/循环专用模板(shouldExecute/shouldBreak)按其字段名;
2743
+ // 其余为收尾调用——Phase A(buildAssessPrompt 精简后)含「输出是否是在向用户提问」,
2744
+ // Phase B(buildPhaseBFinalizePrompt)含 compressedOutput——按调用序:第 1 次 Phase A
2745
+ // 调用(step-ask)返回 ask JSON 触发 ask-detect;后续 Phase A 返回无 ask JSON;
2746
+ // Phase B 返回统一收尾 JSON(passThroughVars + compressedOutput,对 parseFinalizeResult
2747
+ // 有效)。
2748
+ let phaseACall = 0;
2749
+ llm.streamHandler = (options) => {
2750
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
2751
+ if (text.includes('shouldBreak')) {
2752
+ return textChunks('{"shouldBreak": false}');
2753
+ }
2754
+ if (text.includes('输出是否是在向用户提问')) {
2755
+ // Phase A ask 判定(精简:仅 hasAsk/askQuestion)。
2756
+ phaseACall += 1;
2757
+ return phaseACall === 1
2758
+ ? textChunks('{"hasAsk":true,"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}')
2759
+ : textChunks('{"hasAsk":false,"askQuestion":{"questions":[]}}');
2760
+ }
2761
+ if (text.includes('compressedOutput')) {
2762
+ // Phase B 收尾(透传变量 + 输出压缩)。
2763
+ return textChunks('{"hasAsk":false,"passThroughVars":{},"compressedOutput":"摘要"}');
2764
+ }
2765
+ // 理论不可达(收尾/分支均含特征字段);兜底返回完成 JSON。
2766
+ return textChunks('{"hasAsk":false,"passThroughVars":{}}');
2767
+ };
2768
+
2769
+ userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
2770
+
2771
+ const records = recordSandoreEvents(ctx);
2772
+ const ended = waitForEnd(ctx);
2773
+ const run = await engine.startInstance({ workflowId: 'wfl-phase-flow', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2774
+ await ended;
2775
+
2776
+ // 收尾阶段流转事件:step-ask ask-detect → finalizing → routing → completed(ask 回复
2777
+ // 后续跑;Phase D routing 在门禁通过后、completed 前落盘);step-gate finalizing →
2778
+ // gating(primary 门禁不通过,**不执行 Phase D**)。载荷 phase/name 正确,
2779
+ // childId 为真实子代理(prompt)或合成占位(shell)。
2780
+ const phaseRecords = records.filter(record => record.name === 'sandore/step-phase');
2781
+ expect(phaseRecords.map(record => ({
2782
+ stepId: record.args[1] as string,
2783
+ childId: record.args[2] as string,
2784
+ phase: record.args[3] as string,
2785
+ name: record.args[4] as string,
2786
+ }))).toEqual([
2787
+ { stepId: 'step-ask', childId: 'child-1', phase: 'ask-detect', name: 'step-ask' },
2788
+ // Round 17:onForkCreated 替代空快照——shell 降级无 fork 子代理,仅 1 个 finalizing(done)
2789
+ { stepId: 'step-ask', childId: 'child-1', phase: 'finalizing', name: 'step-ask' },
2790
+ { stepId: 'step-ask', childId: 'child-1', phase: 'routing', name: 'step-ask' },
2791
+ { stepId: 'step-ask', childId: 'child-1', phase: 'completed', name: 'step-ask' },
2792
+ // Round 20:step-gate 为 shell 步骤,跳过 Phase A/B → 无 finalizing 事件;
2793
+ // Phase C primary 门禁直接失败(2 个 gating:running 快照 + 终态)。
2794
+ { stepId: 'step-gate', childId: expect.stringMatching(UUID_RE), phase: 'gating', name: 'step-gate' },
2795
+ { stepId: 'step-gate', childId: expect.stringMatching(UUID_RE), phase: 'gating', name: 'step-gate' },
2796
+ ]);
2797
+
2798
+ // Round 25(去污染):宿主会话不再承载 sandore/* 事件(Cordis 半边断言已覆盖
2799
+ // L2758-2775 records;此处反转断言宿主会话保持干净——复用场景宿主会话 id = host-1)。
2800
+ expect(sessions.get('host-1')?.events.some(event => event.type === 'sandore/step-phase')).toBe(false);
2801
+
2802
+ // 阶段状态落盘对齐事件:step-ask completed(finalizeMeta completed)、
2803
+ // step-gate failed(finalizeMeta gating + gateResult 未过)。
2804
+ const state = persistence.state.get(run.instanceId);
2805
+ expect(state?.status).toBe('failed');
2806
+ const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
2807
+ expect(askStep?.status).toBe('completed');
2808
+ expect(askStep?.finalizeMeta?.currentPhase).toBe('completed');
2809
+ const gateStep = state?.executedSteps.find(step => step.stepId === 'step-gate');
2810
+ expect(gateStep?.status).toBe('failed');
2811
+ expect(gateStep?.finalizeMeta?.currentPhase).toBe('gating');
2812
+ expect(gateStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(false);
2813
+ });
2814
+
2815
+ // ── Round 7:收尾阶段中间态同步集成测试 ──────────────────────────────────
2816
+
2817
+ it('emitStepPhase 发出时 active.state 已含最新 finalizeMeta(Round 7 中间态同步)', async () => {
2818
+ const { ctx, engine, persistence, llm, shell, agents, sessions, userQuestions } = await harness();
2819
+ await putDefinition(persistence, 'wfl-phase-flow', phaseFlowYaml);
2820
+ llm.providers.push({ id: 'p', name: 'Provider' });
2821
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2822
+ shell.runHandler = async spec => successResult(spec, 'gate done');
2823
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主。
2824
+ const existingHost = new FakeAgent('host-1');
2825
+ agents.register(existingHost);
2826
+ sessions.create('host-1');
2827
+ const child = new FakeAgent('child-1');
2828
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2829
+ agents.register(child);
2830
+
2831
+ // LLM 调用序:与 wfl-phase-flow 测试一致。
2832
+ let finalizeCall = 0;
2833
+ llm.streamHandler = (options) => {
2834
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
2835
+ if (text.includes('shouldBreak')) {
2836
+ return textChunks('{"shouldBreak": false}');
2837
+ }
2838
+ if (text.includes('compressedOutput')) {
2839
+ finalizeCall += 1;
2840
+ return finalizeCall === 1
2841
+ ? textChunks('{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]},"compressedOutput":"摘要"}')
2842
+ : textChunks('{"hasAsk":false,"passThroughVars":{},"compressedOutput":"摘要"}');
2843
+ }
2844
+ return textChunks('{"hasAsk":false,"passThroughVars":{}}');
2845
+ };
2846
+ userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
2847
+
2848
+ // 关键断言:在 sandore/step-phase 事件监听器中,检查 engine.getInstanceState 返回的
2849
+ // active.state 里对应 step 的 finalizeMeta.currentPhase 已等于事件 phase。
2850
+ const midstateSnapshots: Array<{ stepId: string; phase: string; finalizeMetaPhase: string | undefined; hasStep: boolean; stepsCount: number }> = [];
2851
+ ctx.on('sandore/step-phase', (info, stepId, childId, phase) => {
2852
+ const state = engine.getInstanceState((info as { taskId: string }).taskId);
2853
+ const step = state?.executedSteps.find(s => s.stepId === stepId);
2854
+ midstateSnapshots.push({
2855
+ stepId,
2856
+ phase,
2857
+ finalizeMetaPhase: step?.finalizeMeta?.currentPhase,
2858
+ hasStep: step !== undefined,
2859
+ stepsCount: state?.executedSteps.length ?? 0,
2860
+ });
2861
+ });
2862
+
2863
+ const ended = waitForEnd(ctx);
2864
+ const run = await engine.startInstance({ workflowId: 'wfl-phase-flow', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2865
+ await ended;
2866
+
2867
+ // 验证每个 step-phase 事件发出时,active.state 里的 finalizeMeta.currentPhase 已是最新。
2868
+ // ask-detect 事件是 Phase A 发出的(在 commitFinalize 之前),不含 finalizeMeta,跳过。
2869
+ for (const snap of midstateSnapshots) {
2870
+ if (snap.phase === 'ask-detect') continue;
2871
+ expect({ stepId: snap.stepId, phase: snap.phase, hasStep: snap.hasStep, stepsCount: snap.stepsCount, finalizeMetaPhase: snap.finalizeMetaPhase })
2872
+ .toEqual(expect.objectContaining({ finalizeMetaPhase: snap.phase }));
2873
+ }
2874
+
2875
+ // 完整事件流存在。
2876
+ const phases = midstateSnapshots.map(s => s.phase);
2877
+ expect(phases).toEqual(expect.arrayContaining(['finalizing', 'gating', 'completed']));
2878
+ });
2879
+
2880
+ it('rejectQuestion marks the ask Step failed + instance failed (no reject bubbling to runAndCatch, 缺陷 26)', async () => {
2881
+ const { ctx, engine, persistence, llm, agents } = await harness();
2882
+ await putDefinition(persistence, 'wfl-ask', askYaml);
2883
+ llm.providers.push({ id: 'p', name: 'Provider' });
2884
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2885
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2886
+ const child = new FakeAgent('child-1');
2887
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2888
+ agents.register(child);
2889
+ // 不设 resolveNext / 不推 answers → FakeUserQuestions.ask 挂起,等待 rejectQuestion。
2890
+ const responses = [
2891
+ '{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2892
+ ];
2893
+ let callIndex = 0;
2894
+ llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
2895
+
2896
+ const records = recordSandoreEvents(ctx);
2897
+ const question = waitForQuestion(ctx);
2898
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
2899
+ const fired = await question;
2900
+ const requestId = (fired.question as { requestId: string }).requestId;
2901
+
2902
+ // 拒绝问答:rejectQuestion resolve 占位解挂 waiter(非 reject),引擎检测后标 Step failed。
2903
+ await engine.rejectQuestion(run.instanceId, requestId);
2904
+ await waitForEnd(ctx);
2905
+
2906
+ const state = persistence.state.get(run.instanceId);
2907
+ // 实例落 failed(经 handleRejectedQuestion 显式代码,非异常冒泡 runAndCatch)。
2908
+ expect(state?.status).toBe('failed');
2909
+ // ask Step 正确置 failed(非滞留 interacting)。
2910
+ const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
2911
+ expect(askStep?.status).toBe('failed');
2912
+ expect(askStep?.error).toBe('Question rejected by user');
2913
+ // 双发 sandore/step-failed(StageExecutor 未对 interacting Step 触发,引擎显式补发)。
2914
+ const stepFailed = records.find(record => record.name === 'sandore/step-failed');
2915
+ expect(stepFailed?.args[1]).toBe('step-ask');
2916
+ // 双发 sandore/end(failed)。
2917
+ const ended = records.find(record => record.name === 'sandore/end');
2918
+ expect((ended?.args[1] as { status: string }).status).toBe('failed');
2919
+ // pending 条目已清(终态条目不进快照)。
2920
+ expect(state?.pendingInteractions).toEqual([]);
2921
+ });
2922
+
2923
+ it('rejectQuestion emits sandore/question_rejected for UI refresh (缺陷 26)', async () => {
2924
+ const { ctx, engine, persistence, llm, agents, sessions } = await harness();
2925
+ await putDefinition(persistence, 'wfl-ask', askYaml);
2926
+ llm.providers.push({ id: 'p', name: 'Provider' });
2927
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2928
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主。
2929
+ const existingHost = new FakeAgent('host-1');
2930
+ agents.register(existingHost);
2931
+ sessions.create('host-1');
2932
+ // prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
2933
+ const child = new FakeAgent('child-1');
2934
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2935
+ agents.register(child);
2936
+ const responses = [
2937
+ '{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2938
+ ];
2939
+ let callIndex = 0;
2940
+ llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
2941
+
2942
+ const rejectedFired: unknown[] = [];
2943
+ ctx.on('sandore/question_rejected', (info, questionEntry) => rejectedFired.push({ info, questionEntry }));
2944
+ const question = waitForQuestion(ctx);
2945
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2946
+ const fired = await question;
2947
+ const requestId = (fired.question as { requestId: string }).requestId;
2948
+
2949
+ await engine.rejectQuestion(run.instanceId, requestId);
2950
+ await waitForEnd(ctx);
2951
+
2952
+ expect(rejectedFired).toHaveLength(1);
2953
+ const entry = (rejectedFired[0] as { questionEntry: { kind: string; status: string; requestId: string } }).questionEntry;
2954
+ expect(entry.kind).toBe('question');
2955
+ expect(entry.status).toBe('rejected');
2956
+ expect(entry.requestId).toBe(requestId);
2957
+ });
2958
+
2959
+ it('caps finalize ask rounds at MAX_FINALIZE_ASK_ROUNDS (3) and completes with the last output (status 流转)', async () => {
2960
+ const { ctx, engine, persistence, llm, agents, sessions, userQuestions } = await harness();
2961
+ await putDefinition(persistence, 'wfl-ask', askYaml);
2962
+ llm.providers.push({ id: 'p', name: 'Provider' });
2963
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2964
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主。
2965
+ const existingHost = new FakeAgent('host-1');
2966
+ agents.register(existingHost);
2967
+ sessions.create('host-1');
2968
+ const child = new FakeAgent('child-1');
2969
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
2970
+ agents.register(child);
2971
+ // 恒返回 hasAsk=true + askQuestion(评估永不判定完成)→ 引擎 ask 闭环反复触发。
2972
+ llm.streamHandler = () => textChunks(
2973
+ '{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
2974
+ );
2975
+ userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
2976
+
2977
+ const ended = waitForEnd(ctx);
2978
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
2979
+ await ended;
2980
+
2981
+ // 轮次上限(4.7):最多 3 轮 ask(首次 + 2 次续问),之后 runPhaseA 把最后 output
2982
+ // 视为完成(不再续问),实例收敛 completed。
2983
+ expect(userQuestions.calls).toHaveLength(3);
2984
+ const state = persistence.state.get(run.instanceId);
2985
+ expect(state?.status).toBe('completed');
2986
+ const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
2987
+ expect(askStep?.status).toBe('completed');
2988
+ });
2989
+ });
2990
+
2991
+ describe('SandoreEngineRuntime 双通道 ask(4.4:[[SANDORE_ASK]] 机制已删)', () => {
2992
+ it('treats a [[SANDORE_ASK]] marker block as ordinary output (no delegation, no followup)', async () => {
2993
+ const { ctx, engine, persistence, llm, agents, subagents, userQuestions } = await harness();
2994
+ await putDefinition(persistence, 'wfl-delegated', delegatedAskYaml);
2995
+ llm.providers.push({ id: 'p', name: 'Provider' });
2996
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
2997
+
2998
+ const child = new FakeAgent('child-1');
2999
+ child.session.append(assistantMessageEvent([
3000
+ { type: 'text', text: '[[SANDORE_ASK]]{"questions":[{"id":"q1","question":"Which?"}]}[[/SANDORE_ASK]]' },
3001
+ ]));
3002
+ agents.register(child);
3003
+
3004
+ const ended = waitForEnd(ctx);
3005
+ const run = await engine.startInstance({ workflowId: 'wfl-delegated', originalInput: 'do it', cwd: '/tmp' });
3006
+ await ended;
3007
+
3008
+ // 标记块机制已删:不转接、不 ask、不 followup——输出原样透传(子代理提问走
3009
+ // child-scope ask_user_question 工具,见 askFromSubagent 测试)。
3010
+ expect(userQuestions.calls).toHaveLength(0);
3011
+ expect(subagents.followups).toHaveLength(0);
3012
+ const state = persistence.state.get(run.instanceId);
3013
+ expect(state?.status).toBe('completed');
3014
+ expect(state?.executedSteps.find(step => step.stepId === 'step-agent')?.output).toEqual([
3015
+ { type: 'text', text: '[[SANDORE_ASK]]{"questions":[{"id":"q1","question":"Which?"}]}[[/SANDORE_ASK]]' },
3016
+ ]);
3017
+ });
3018
+
3019
+ it('does not delegate an invalid marker block (treated as ordinary output)', async () => {
3020
+ const { ctx, engine, persistence, llm, agents, subagents, userQuestions } = await harness();
3021
+ await putDefinition(persistence, 'wfl-delegated', delegatedAskYaml);
3022
+ llm.providers.push({ id: 'p', name: 'Provider' });
3023
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3024
+
3025
+ const child = new FakeAgent('child-1');
3026
+ child.session.append(assistantMessageEvent([
3027
+ { type: 'text', text: '[[SANDORE_ASK]]not json[[/SANDORE_ASK]]' },
3028
+ ]));
3029
+ agents.register(child);
3030
+
3031
+ const ended = waitForEnd(ctx);
3032
+ const run = await engine.startInstance({ workflowId: 'wfl-delegated', originalInput: 'do it', cwd: '/tmp' });
3033
+ await ended;
3034
+
3035
+ expect(userQuestions.calls).toHaveLength(0);
3036
+ expect(subagents.followups).toHaveLength(0);
3037
+ const state = persistence.state.get(run.instanceId);
3038
+ expect(state?.status).toBe('completed');
3039
+ expect(state?.executedSteps.find(step => step.stepId === 'step-agent')?.output).toEqual([
3040
+ { type: 'text', text: '[[SANDORE_ASK]]not json[[/SANDORE_ASK]]' },
3041
+ ]);
3042
+ });
3043
+ });
3044
+
3045
+ describe('SandoreEngineRuntime askFromSubagent(双通道 ask 通道 1)', () => {
3046
+ it('routes a subagent question to the engine-level ask (hostAgent identity) and returns the answer', async () => {
3047
+ const { ctx, engine, persistence, llm, agents, sessions, userQuestions } = await harness();
3048
+ await putDefinition(persistence, 'wfl-ask', askYaml);
3049
+ llm.providers.push({ id: 'p', name: 'Provider' });
3050
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3051
+ // Round 26(场景 B):复用宿主会话 → ask 上报宿主(askFromSubagent 同受 reportToHost 门控)。
3052
+ const existingHost = new FakeAgent('host-1');
3053
+ agents.register(existingHost);
3054
+ sessions.create('host-1');
3055
+ const child = new FakeAgent('child-1');
3056
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
3057
+ agents.register(child);
3058
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
3059
+
3060
+ // 等待实例跑完(Step 段已写入 executedSteps,sessionMeta.childId = child-1)。
3061
+ const ended = waitForEnd(ctx);
3062
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
3063
+ await ended;
3064
+ // 真实 DSH:子代理 session.header.parentSession = 宿主主代理会话 id(childSessionMeta);
3065
+ // mock 手动补写(hostAgent.id = hostSessionId = host-1)。
3066
+ child.session.header = { parentSession: 'host-1' };
3067
+
3068
+ const firedQuestions: unknown[] = [];
3069
+ ctx.on('sandore/question', (info, question) => firedQuestions.push({ info, question }));
3070
+ userQuestions.resolveNext = async request => {
3071
+ expect(request.questions).toEqual([{ id: 'q1', question: 'Which?' }]);
3072
+ return { answers: [{ id: 'q1', selected: ['A'] }] };
3073
+ };
3074
+ const answer = await engine.askFromSubagent(child, [{ id: 'q1', question: 'Which?' }]);
3075
+
3076
+ expect(answer).toEqual({ answers: [{ id: 'q1', selected: ['A'] }] });
3077
+ // 以宿主主代理(id = hostSessionId)身份发起(非子代理自身——避免 DELEGATED_CALLER)。
3078
+ expect(userQuestions.calls[userQuestions.calls.length - 1]?.agent?.id).toBe('host-1');
3079
+ // 提问登记期间发射 sandore/question(pending 条目;回答后已终态化,仅 pending 持久化)。
3080
+ expect(firedQuestions).toHaveLength(1);
3081
+ const firedEntry = firedQuestions[0] as { question: { kind: string; payload: { questions: Array<{ id: string }> } } };
3082
+ expect(firedEntry.question.kind).toBe('question');
3083
+ expect(firedEntry.question.payload.questions[0]?.id).toBe('q1');
3084
+ });
3085
+
3086
+ it('throws STEP_EXECUTION when the question is rejected (缺陷 26 显式落态)', async () => {
3087
+ const { ctx, engine, persistence, llm, agents, userQuestions } = await harness();
3088
+ await putDefinition(persistence, 'wfl-ask', askYaml);
3089
+ llm.providers.push({ id: 'p', name: 'Provider' });
3090
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3091
+ const child = new FakeAgent('child-1');
3092
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
3093
+ agents.register(child);
3094
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
3095
+
3096
+ const ended = waitForEnd(ctx);
3097
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
3098
+ await ended;
3099
+ child.session.header = { parentSession: run.instanceId };
3100
+
3101
+ const asking = engine.askFromSubagent(child, [{ id: 'q1', question: 'Which?' }]);
3102
+ // 等 pending 条目登记后拒绝(resolve 占位 → askFromSubagent 抛 STEP_EXECUTION)。
3103
+ await new Promise(resolve => setImmediate(resolve));
3104
+ const state = persistence.state.get(run.instanceId);
3105
+ const entry = state?.pendingInteractions.find(item => item.kind === 'question');
3106
+ expect(entry).toBeDefined();
3107
+ await engine.rejectQuestion(run.instanceId, (entry as { requestId: string }).requestId);
3108
+
3109
+ await expect(asking).rejects.toMatchObject({ code: 'STEP_EXECUTION' });
3110
+ });
3111
+
3112
+ it('throws INVALID_OPERATION for unknown parent session or unknown child', async () => {
3113
+ const { ctx, engine, persistence, llm, agents } = await harness();
3114
+ await putDefinition(persistence, 'wfl-ask', askYaml);
3115
+ llm.providers.push({ id: 'p', name: 'Provider' });
3116
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3117
+ const child = new FakeAgent('child-1');
3118
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
3119
+ agents.register(child);
3120
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
3121
+
3122
+ const ended = waitForEnd(ctx);
3123
+ const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
3124
+ await ended;
3125
+
3126
+ // 无 parent session header → INVALID_OPERATION。
3127
+ await expect(engine.askFromSubagent(
3128
+ child,
3129
+ [{ id: 'q1', question: 'Q?' }],
3130
+ )).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
3131
+ // parent session 命中但 child 不匹配任何 Step 段 → INVALID_OPERATION。
3132
+ child.session.header = { parentSession: run.instanceId };
3133
+ const unknownChild = new FakeAgent('no-such-child');
3134
+ unknownChild.session.header = { parentSession: run.instanceId };
3135
+ await expect(engine.askFromSubagent(
3136
+ unknownChild,
3137
+ [{ id: 'q1', question: 'Q?' }],
3138
+ )).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
3139
+ });
3140
+ });
3141
+
3142
+ describe('SandoreEngineRuntime registerContinuableSetup gate(4.3:仅当前非终态 sandore 实例的子代理)', () => {
3143
+ it('registers a contribution and installs the child-scope ask tool only for non-terminal sandore instances', async () => {
3144
+ const { ctx, engine, subagents, persistence, llm, agents } = await harness();
3145
+ await putDefinition(persistence, 'wfl-prompt-only', promptOnlyYaml);
3146
+ provideLlmDirectory(llm);
3147
+ // prompt Step 挂起:child status='running' 不自动 settle → 实例保持 running(非终态)。
3148
+ const child = new FakeAgent('child-1');
3149
+ child.status = 'running';
3150
+ agents.register(child);
3151
+
3152
+ const stepStarted = waitForStepStart(ctx);
3153
+ const run = await engine.startInstance({ workflowId: 'wfl-prompt-only', originalInput: 'do it', cwd: '/tmp' });
3154
+ await stepStarted;
3155
+
3156
+ // 引擎启动时注册了 1 个贡献(Service.init → registerSandoreAskToolSetup)。
3157
+ expect(subagents.continuableSetups).toHaveLength(1);
3158
+ const contribution = subagents.continuableSetups[0]!;
3159
+
3160
+ // gate 未命中(parent 非任何 sandore 实例的宿主主代理):不注册工具。
3161
+ const toolsA = { register: vi.fn(() => () => {}) };
3162
+ const disposeA = contribution({
3163
+ tools: toolsA,
3164
+ agent: { session: { header: { parentSession: 'some-other-session' } } },
3165
+ } as unknown as Context);
3166
+ expect(toolsA.register).not.toHaveBeenCalled();
3167
+ disposeA();
3168
+
3169
+ // gate 命中(parent = 当前非终态 sandore 实例的宿主主代理会话 = instanceId):
3170
+ // 注册 child-scope ask_user_question。
3171
+ const toolsB = { register: vi.fn(() => () => {}) };
3172
+ const disposeB = contribution({
3173
+ tools: toolsB,
3174
+ agent: { session: { header: { parentSession: run.instanceId } } },
3175
+ } as unknown as Context);
3176
+ expect(toolsB.register).toHaveBeenCalledTimes(1);
3177
+ expect(toolsB.register).toHaveBeenCalledWith(expect.objectContaining({ name: 'ask_user_question' }));
3178
+ disposeB();
3179
+
3180
+ // 实例终态(completed)后:同一宿主主代理的后续子代理不再注入(避免误注入)。
3181
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
3182
+ subagents.settleChild('child-1', 'completed');
3183
+ await waitForEnd(ctx);
3184
+ const toolsC = { register: vi.fn(() => () => {}) };
3185
+ const disposeC = contribution({
3186
+ tools: toolsC,
3187
+ agent: { session: { header: { parentSession: run.instanceId } } },
3188
+ } as unknown as Context);
3189
+ expect(toolsC.register).not.toHaveBeenCalled();
3190
+ disposeC();
3191
+ });
3192
+ });
3193
+
3194
+ /** 单 Stage 单 agent Step(用于重试续跑集成测试:首跑无输出 failed,续跑回填输出 completed)。 */
3195
+ const agentFailYaml = `id: wfl-agent-fail
3196
+ name: agent-fail
3197
+ version: "1.0.0"
3198
+ stages:
3199
+ - id: stg-1
3200
+ name: 单agent
3201
+ steps:
3202
+ - id: step-agent
3203
+ steps:
3204
+ - id: step-agent
3205
+ command:
3206
+ type: agent
3207
+ value:
3208
+ promptText: "do work"
3209
+ `;
3210
+
3211
+ /** 单 Stage 三 Step:shell(outputTo)→ agent(中间失败可重试)→ shell(剩余 Step)。 */
3212
+ const multiStepRetryYaml = `id: wfl-multi-retry
3213
+ name: multi-retry
3214
+ version: "1.0.0"
3215
+ stages:
3216
+ - id: stg-1
3217
+ name: 重试续跑
3218
+ steps:
3219
+ - id: step-a
3220
+ - id: step-b
3221
+ - id: step-c
3222
+ steps:
3223
+ - id: step-a
3224
+ command:
3225
+ type: shell
3226
+ value:
3227
+ command: "echo hello"
3228
+ outputTo:
3229
+ - id: opt-a
3230
+ path: "out-a.txt"
3231
+ - id: step-b
3232
+ command:
3233
+ type: agent
3234
+ value:
3235
+ promptText: "do work"
3236
+ outputTo:
3237
+ - id: opt-b
3238
+ path: "out-b.txt"
3239
+ - id: step-c
3240
+ command:
3241
+ type: shell
3242
+ value:
3243
+ command: "echo done"
3244
+ `;
3245
+
3246
+ describe('SandoreEngineRuntime recovery', () => {
3247
+ it('throws INVALID_OPERATION when recovering a terminal instance', async () => {
3248
+ const { engine, persistence } = await harness();
3249
+ await persistence.state.put('inst-done', completedSnapshot('inst-done'));
3250
+
3251
+ await expect(engine.recoverInstance('inst-done')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
3252
+ });
3253
+
3254
+ it('retries a failed agent step with continue mode and resumes to completed', async () => {
3255
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3256
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3257
+ llm.providers.push({ id: 'p', name: 'Provider' });
3258
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3259
+
3260
+ // 子代理首次无输出 → Step failed(真实 childId 已记录)。
3261
+ const child = new FakeAgent('child-1');
3262
+ agents.register(child);
3263
+
3264
+ const ended = waitForEnd(ctx);
3265
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3266
+ await ended;
3267
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3268
+
3269
+ // 续跑前给子代理回填最终输出。
3270
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
3271
+
3272
+ const retried: Array<{ stepId: string; mode: string; name: string; loopIteration?: number[] }> = [];
3273
+ ctx.on('sandore/step-retried', (_info, stepId, mode, name, loopIteration) => retried.push({ stepId, mode, name, ...(loopIteration === undefined ? {} : { loopIteration }) }));
3274
+
3275
+ await engine.retryStep(run.instanceId, 'step-agent', 'continue', 'please retry');
3276
+
3277
+ const state = persistence.state.get(run.instanceId);
3278
+ expect(state?.status).toBe('completed');
3279
+ // 段模型:重试追加新段(attemptIndex 1),断言取最后活跃段。
3280
+ const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
3281
+ expect(step?.output).toEqual([{ type: 'text', text: 'retry output' }]);
3282
+ expect(step?.sessionMeta?.childId).toBe('child-1');
3283
+ expect(step?.retryHistory?.[0]?.mode).toBe('continue');
3284
+ expect(step?.retryHistory?.[0]?.result).toBe('completed');
3285
+ expect(subagents.followups.some(item => item.childId === 'child-1')).toBe(true);
3286
+
3287
+ // sandore/step-retried 单发:Cordis 事件断言(Round 25 去污染:宿主会话不再承载)。
3288
+ expect(retried).toEqual([{ stepId: 'step-agent', mode: 'continue', name: 'step-agent' }]);
3289
+ });
3290
+
3291
+ it('retries a failed middle agent step and runs all remaining steps to completed', async () => {
3292
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
3293
+ await putDefinition(persistence, 'wfl-multi-retry', multiStepRetryYaml);
3294
+ shell.runHandler = async spec => successResult(spec, 'ok');
3295
+ llm.providers.push({ id: 'p', name: 'Provider' });
3296
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3297
+
3298
+ const child = new FakeAgent('child-1');
3299
+ agents.register(child);
3300
+
3301
+ const ended = waitForEnd(ctx);
3302
+ const run = await engine.startInstance({ workflowId: 'wfl-multi-retry', originalInput: 'do it', cwd: '/tmp' });
3303
+ await ended;
3304
+
3305
+ // 首跑:step-a 完成、step-b(agent 无输出)失败、step-c 未执行。
3306
+ let state = persistence.state.get(run.instanceId);
3307
+ expect(state?.status).toBe('failed');
3308
+ expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-a', 'step-b']);
3309
+ expect(state?.executedSteps[1]?.status).toBe('failed');
3310
+
3311
+ // 续跑前给子代理回填最终输出。
3312
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
3313
+ await engine.retryStep(run.instanceId, 'step-b', 'continue', 'please retry');
3314
+
3315
+ state = persistence.state.get(run.instanceId);
3316
+ expect(state?.status).toBe('completed');
3317
+ // 段模型:step-b 重试追加新段(attemptIndex 1),旧 failed 段保留定格快照。
3318
+ // 断言聚焦新段状态与输出(不假设 stepIds 顺序,旧 failed 段的存在单独验证)。
3319
+ const stepIds = state?.executedSteps.map(step => step.stepId) ?? [];
3320
+ expect(new Set(stepIds)).toEqual(new Set(['step-a', 'step-b', 'step-c']));
3321
+ expect(stepIds.filter(id => id === 'step-b').length).toBe(2);
3322
+ const retriedB = lastSegmentOf(state?.executedSteps ?? [], 'step-b');
3323
+ expect(retriedB?.status).toBe('completed');
3324
+ expect(retriedB?.output).toEqual([{ type: 'text', text: 'retry output' }]);
3325
+ expect(retriedB?.retryHistory?.[0]?.mode).toBe('continue');
3326
+ expect(retriedB?.retryHistory?.[0]?.result).toBe('completed');
3327
+ const failedB = state?.executedSteps.find(step => step.stepId === 'step-b' && step.status === 'failed');
3328
+ expect(failedB).toBeDefined();
3329
+ });
3330
+
3331
+ it('preserves outputTo artifacts in the stage contract after retrying the producing step', async () => {
3332
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
3333
+ await putDefinition(persistence, 'wfl-multi-retry', multiStepRetryYaml);
3334
+ shell.runHandler = async spec => successResult(spec, 'ok');
3335
+ llm.providers.push({ id: 'p', name: 'Provider' });
3336
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3337
+
3338
+ const child = new FakeAgent('child-1');
3339
+ agents.register(child);
3340
+
3341
+ const ended = waitForEnd(ctx);
3342
+ const run = await engine.startInstance({ workflowId: 'wfl-multi-retry', originalInput: 'do it', cwd: '/tmp' });
3343
+ await ended;
3344
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3345
+
3346
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
3347
+ await engine.retryStep(run.instanceId, 'step-b', 'continue', 'please retry');
3348
+
3349
+ const state = persistence.state.get(run.instanceId);
3350
+ expect(state?.status).toBe('completed');
3351
+ // 重试 Step 的 outputTo 产物保留在 StageContract.artifacts(含已完成的 step-a)。
3352
+ const contract = state?.stageContracts['stg-1'];
3353
+ expect(contract?.status).toBe('completed');
3354
+ const artifacts = contract?.artifacts ?? [];
3355
+ expect(artifacts.map(item => item.outputToId)).toEqual(['opt-a', 'opt-b']);
3356
+ expect(artifacts.find(item => item.outputToId === 'opt-b')?.stepId).toBe('step-b');
3357
+ });
3358
+
3359
+ it('retries a failed agent step with fork mode to a new childId and completes', async () => {
3360
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3361
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3362
+ llm.providers.push({ id: 'p', name: 'Provider' });
3363
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3364
+
3365
+ const child = new FakeAgent('child-1');
3366
+ agents.register(child);
3367
+
3368
+ const ended = waitForEnd(ctx);
3369
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3370
+ await ended;
3371
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3372
+
3373
+ // fork 源:child-1 在 sessions 表登记,其 assistant 输出作为 fork 的 seed 回放。
3374
+ const childSession = sessions.create('child-1');
3375
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'fork output' }]).data);
3376
+
3377
+ await engine.retryStep(run.instanceId, 'step-agent', 'fork', 'fork it');
3378
+
3379
+ const state = persistence.state.get(run.instanceId);
3380
+ expect(state?.status).toBe('completed');
3381
+ // 段模型:fork 新段(attemptIndex 1)承载新 childId/output/retryHistory。
3382
+ const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
3383
+ expect(step?.output).toEqual([{ type: 'text', text: 'fork output' }]);
3384
+ // 新 childId(合成 session id)续跑,parentSessionId 记旧值。
3385
+ expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
3386
+ expect(step?.sessionMeta?.childId).not.toBe('child-1');
3387
+ expect(step?.parentSessionId).toBe('child-1');
3388
+ expect(step?.retryHistory?.[0]?.mode).toBe('fork');
3389
+ expect(step?.retryHistory?.[0]?.result).toBe('completed');
3390
+ expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
3391
+ });
3392
+
3393
+ it('retries a failed agent step with revert mode to a new childId and completes', async () => {
3394
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3395
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3396
+ llm.providers.push({ id: 'p', name: 'Provider' });
3397
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3398
+
3399
+ const child = new FakeAgent('child-1');
3400
+ agents.register(child);
3401
+
3402
+ const ended = waitForEnd(ctx);
3403
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3404
+ await ended;
3405
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3406
+
3407
+ // revert 源:child-1 含闭合 turn(turn/start → assistant → turn/end)供安全边界计算。
3408
+ const childSession = sessions.create('child-1');
3409
+ childSession.append('turn/start', { turn: 0 });
3410
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'revert output' }]).data);
3411
+ childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
3412
+
3413
+ await engine.retryStep(run.instanceId, 'step-agent', 'revert', 'rollback');
3414
+
3415
+ const state = persistence.state.get(run.instanceId);
3416
+ expect(state?.status).toBe('completed');
3417
+ const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
3418
+ expect(step?.output).toEqual([{ type: 'text', text: 'revert output' }]);
3419
+ expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
3420
+ expect(step?.parentSessionId).toBe('child-1');
3421
+ expect(step?.retryHistory?.[0]?.mode).toBe('revert');
3422
+ expect(step?.retryHistory?.[0]?.result).toBe('completed');
3423
+ expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
3424
+ });
3425
+
3426
+ it('retries a failed agent step with revert mode to a user-selected earlier turn boundary', async () => {
3427
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3428
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3429
+ llm.providers.push({ id: 'p', name: 'Provider' });
3430
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3431
+
3432
+ const child = new FakeAgent('child-1');
3433
+ agents.register(child);
3434
+
3435
+ const ended = waitForEnd(ctx);
3436
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3437
+ await ended;
3438
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3439
+
3440
+ // revert 源:两个闭合 turn(seq 2 与 5)+ 一个未闭合尾部 turn(seq 6 turn/start,无 turn/end)。
3441
+ // 用户传 revertBoundary=2 选更早 turn(截断到 seq 2,丢弃 seq 3-6 的失败内容)。
3442
+ const childSession = sessions.create('child-1');
3443
+ childSession.append('turn/start', { turn: 0 });
3444
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'first answer' }]).data);
3445
+ childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
3446
+ childSession.append('turn/start', { turn: 1 });
3447
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'failed attempt' }]).data);
3448
+ childSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } });
3449
+ childSession.append('turn/start', { turn: 2 });
3450
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched tail' }]).data);
3451
+
3452
+ await engine.retryStep(run.instanceId, 'step-agent', 'revert', 'rollback', 2);
3453
+
3454
+ const state = persistence.state.get(run.instanceId);
3455
+ expect(state?.status).toBe('completed');
3456
+ const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
3457
+ // seed 截断到 boundary=2(仅含 turn 0 的 first answer),后续 followup 续跑输出该文本。
3458
+ expect(step?.output).toEqual([{ type: 'text', text: 'first answer' }]);
3459
+ expect(step?.retryHistory?.[0]?.mode).toBe('revert');
3460
+ expect(step?.retryHistory?.[0]?.result).toBe('completed');
3461
+ expect(step?.parentSessionId).toBe('child-1');
3462
+ expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
3463
+ });
3464
+
3465
+ it('lists the available closed-turn revert boundaries for a failed agent step', async () => {
3466
+ const { ctx: _ctx, engine, persistence, llm, agents, sessions } = await harness();
3467
+ void _ctx;
3468
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3469
+ llm.providers.push({ id: 'p', name: 'Provider' });
3470
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3471
+
3472
+ const child = new FakeAgent('child-1');
3473
+ agents.register(child);
3474
+
3475
+ // 不实际跑实例:直接放一个 running→failed 的 step 快照 + child-1 会话事件。
3476
+ const now = new Date().toISOString();
3477
+ const state: WorkflowTaskState = {
3478
+ taskId: 'inst-list',
3479
+ workflowId: 'wfl-agent-fail',
3480
+ cwd: '/tmp',
3481
+ status: 'failed',
3482
+ originalInput: 'do it',
3483
+ currentStageId: 'stg-1',
3484
+ executedSteps: [{
3485
+ id: 'exec-1',
3486
+ stepId: 'step-agent',
3487
+ stageId: 'stg-1',
3488
+ name: 'step-agent',
3489
+ status: 'failed',
3490
+ command: { type: 'agent', value: { promptText: 'do' } },
3491
+ sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
3492
+ visitCount: 1,
3493
+ error: 'boom',
3494
+ }],
3495
+ stageInfos: [{ id: 'stg-1', name: 'stage', status: 'failed' }],
3496
+ pendingInteractions: [],
3497
+ pendingQueue: [],
3498
+ visitCounts: {},
3499
+ passThroughVars: {},
3500
+ activeLoopIterationStack: [],
3501
+ activeLoopNodeId: null,
3502
+ activeLoopStack: [],
3503
+ pausedChildIds: [],
3504
+ stageContracts: {},
3505
+ createdAt: now,
3506
+ updatedAt: now,
3507
+ };
3508
+ void persistence.state.put('inst-list', state);
3509
+
3510
+ const childSession = sessions.create('child-1');
3511
+ childSession.append('turn/start', { turn: 0 });
3512
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'first' }]).data);
3513
+ childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
3514
+ childSession.append('turn/start', { turn: 1 });
3515
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'second' }]).data);
3516
+ childSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } });
3517
+ childSession.append('turn/start', { turn: 2 });
3518
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched' }]).data);
3519
+
3520
+ const boundaries = engine.listRevertBoundaries('inst-list', 'step-agent');
3521
+ // 仅两个闭合 turn 候选(seq 2、5);未闭合尾部 turn 2(turn/start seq 6)不计入。
3522
+ expect(boundaries).toEqual([
3523
+ { seq: 2, turn: 0, preview: 'first' },
3524
+ { seq: 5, turn: 1, preview: 'second' },
3525
+ ]);
3526
+ });
3527
+
3528
+ it('auto-reverts fork to the last closed turn/end when the source session has an unclosed tail', async () => {
3529
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3530
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3531
+ llm.providers.push({ id: 'p', name: 'Provider' });
3532
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
3533
+
3534
+ const child = new FakeAgent('child-1');
3535
+ agents.register(child);
3536
+
3537
+ const ended = waitForEnd(ctx);
3538
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3539
+ await ended;
3540
+ expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
3541
+
3542
+ // fork 源:turn 0 闭合(seq 0-2),turn 1 未闭合(turn/start seq 3 + assistant seq 4,无 turn/end)。
3543
+ // FakeSessionStore.fork 忠实模拟 DSH:fork(undefined) 会抛 OPEN_TURN(末事件 4 落在未闭合 turn 1)。
3544
+ // recovery-manager fork 防御必须先算 boundary=2(最后一个闭合 turn/end)再 fork,避开 OPEN_TURN。
3545
+ const childSession = sessions.create('child-1');
3546
+ childSession.append('turn/start', { turn: 0 });
3547
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'fork output' }]).data);
3548
+ childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
3549
+ childSession.append('turn/start', { turn: 1 });
3550
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched tail' }]).data);
3551
+
3552
+ await engine.retryStep(run.instanceId, 'step-agent', 'fork', 'fork it');
3553
+
3554
+ const state = persistence.state.get(run.instanceId);
3555
+ // 防御生效:未抛 OPEN_TURN,实例完成(新 agent 续跑输出 fork output)。
3556
+ expect(state?.status).toBe('completed');
3557
+ const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
3558
+ expect(step?.output).toEqual([{ type: 'text', text: 'fork output' }]);
3559
+ expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
3560
+ expect(step?.parentSessionId).toBe('child-1');
3561
+ expect(step?.retryHistory?.[0]?.mode).toBe('fork');
3562
+ expect(step?.retryHistory?.[0]?.result).toBe('completed');
3563
+ expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
3564
+ });
3565
+
3566
+ it('truly re-runs an empty-executedSteps non-terminal snapshot to completed (no fake recovery)', async () => {
3567
+ const { ctx, engine, persistence, sessions } = await harness();
3568
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
3569
+
3570
+ await persistence.state.put('inst-running', runningSnapshot('inst-running', 'wfl-shell-only'));
3571
+ sessions.create('inst-running');
3572
+
3573
+ const ended = waitForEnd(ctx);
3574
+ const result = await engine.recoverInstance('inst-running');
3575
+ expect(result).toEqual({ instanceId: 'inst-running', status: 'running' });
3576
+ await ended;
3577
+
3578
+ const state = persistence.state.get('inst-running');
3579
+ expect(state?.status).toBe('completed');
3580
+ expect(state?.executedSteps).toHaveLength(1);
3581
+ expect(state?.executedSteps[0]?.stepId).toBe('step-shell');
3582
+ expect(state?.executedSteps[0]?.status).toBe('completed');
3583
+ });
3584
+
3585
+ it('resumes the host agent before following up the child subagent during recovery', async () => {
3586
+ const { ctx, engine, persistence, sessions, agents, subagents } = await harness();
3587
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3588
+
3589
+ await persistence.state.put('inst-run', stepSnapshot('inst-run', 'wfl-agent-fail', agentStepSnapshot('running'), 'running'));
3590
+ sessions.create('inst-run');
3591
+ // 子代理 child-1 已注册并携带输出(followup 后 resumeSubagent 收集其输出)。
3592
+ const child = new FakeAgent('child-1');
3593
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered output' }]));
3594
+ agents.register(child);
3595
+
3596
+ const ended = waitForEnd(ctx);
3597
+ const result = await engine.recoverInstance('inst-run');
3598
+ expect(result.status).toBe('running');
3599
+ await ended;
3600
+
3601
+ // 宿主主代理经 agents.resume 冷恢复,且作为 followup 的 parent(先宿主后子代理)。
3602
+ expect(agents.setupCount).toBeGreaterThanOrEqual(1);
3603
+ expect(subagents.followups).toHaveLength(1);
3604
+ expect(subagents.followups[0]?.childId).toBe('child-1');
3605
+ expect(subagents.followups[0]?.parent.id).toBe('inst-run');
3606
+
3607
+ const state = persistence.state.get('inst-run');
3608
+ expect(state?.status).toBe('completed');
3609
+ expect(state?.executedSteps[0]?.output).toEqual([{ type: 'text', text: 'recovered output' }]);
3610
+ });
3611
+
3612
+ it('rebuilds the paused registry from pausedChildIds and stays paused without re-running', async () => {
3613
+ const { engine, persistence, sessions, subagents } = await harness();
3614
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3615
+
3616
+ await persistence.state.put('inst-paused', pausedSnapshot('inst-paused'));
3617
+ sessions.create('inst-paused');
3618
+
3619
+ const result = await engine.recoverInstance('inst-paused');
3620
+ expect(result.status).toBe('paused');
3621
+ // 不自动 followup(等待手动 resume)。
3622
+ expect(subagents.followups).toHaveLength(0);
3623
+
3624
+ // 登记表已从 pausedChildIds 重建:resumeStep 可反查并 followup 原 childId。
3625
+ await engine.resumeStep('inst-paused', 'step-agent', 'continue now');
3626
+ expect(subagents.followups).toHaveLength(1);
3627
+ expect(subagents.followups[0]?.childId).toBe('child-1');
3628
+ });
3629
+
3630
+ it('marks an unrecoverable synthetic (shell) running step as failed during recovery', async () => {
3631
+ const { engine, persistence, sessions } = await harness();
3632
+ await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
3633
+
3634
+ await persistence.state.put('inst-shell', stepSnapshot('inst-shell', 'wfl-shell-only', shellStepSnapshot('running'), 'running'));
3635
+ sessions.create('inst-shell');
3636
+
3637
+ const result = await engine.recoverInstance('inst-shell');
3638
+ expect(result.status).toBe('failed');
3639
+
3640
+ const state = persistence.state.get('inst-shell');
3641
+ expect(state?.status).toBe('failed');
3642
+ expect(state?.executedSteps[0]?.status).toBe('failed');
3643
+ expect(state?.executedSteps[0]?.error).toContain('not recoverable');
3644
+ });
3645
+
3646
+ it('recoverOrphanedInstances scans the state table, skips terminal, and recovers non-terminal', async () => {
3647
+ const { engine, persistence, sessions, agents } = await harness();
3648
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3649
+
3650
+ // 终态实例(跳过)。
3651
+ await persistence.state.put('inst-done', completedSnapshot('inst-done'));
3652
+ await persistence.state.put('inst-failed', { ...completedSnapshot('inst-failed'), status: 'failed' });
3653
+ await persistence.state.put('inst-aborted', { ...completedSnapshot('inst-aborted'), status: 'aborted' });
3654
+
3655
+ // 非终态实例(恢复):预建宿主 Session 供 agents.resume 后读回。
3656
+ await persistence.state.put('inst-paused', pausedSnapshot('inst-paused'));
3657
+ sessions.create('inst-paused');
3658
+
3659
+ const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
3660
+ expect(result.recovered).toEqual(['inst-paused']);
3661
+ expect(result.errors).toHaveLength(0);
3662
+ // 宿主主代理经 agents.resume 冷恢复(setup 重跑)。
3663
+ expect(agents.setupCount).toBeGreaterThanOrEqual(1);
3664
+ });
3665
+
3666
+ it('recoverOrphanedInstances aggregates a single failing instance without blocking others', async () => {
3667
+ const { engine, persistence, sessions } = await harness();
3668
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3669
+
3670
+ // 一个可恢复(定义存在 + 宿主 Session),一个定义缺失(失败进 errors)。
3671
+ await persistence.state.put('inst-ok', pausedSnapshot('inst-ok'));
3672
+ sessions.create('inst-ok');
3673
+ await persistence.state.put('inst-bad', pausedSnapshot('inst-bad', 'wfl-missing'));
3674
+
3675
+ const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
3676
+ expect(result.recovered).toEqual(['inst-ok']);
3677
+ expect(result.errors.map(item => item.instanceId)).toEqual(['inst-bad']);
3678
+ expect(result.errors[0]?.error).toContain('not found');
3679
+ });
3680
+
3681
+ it('aggregates host resume failure into errors without blocking', async () => {
3682
+ const { engine, persistence, sessions, agents } = await harness();
3683
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3684
+
3685
+ await persistence.state.put('inst-run', pausedSnapshot('inst-run'));
3686
+ sessions.create('inst-run');
3687
+ agents.setupError = new Error('resume boom');
3688
+
3689
+ const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
3690
+ expect(result.recovered).toEqual([]);
3691
+ expect(result.errors.map(item => item.instanceId)).toEqual(['inst-run']);
3692
+ expect(result.errors[0]?.error).toContain('resume boom');
3693
+ });
3694
+
3695
+ it('recovers orphaned instances automatically at Service.init (startup scan)', async () => {
3696
+ const ctx = new Context();
3697
+ const storageDomain = new MemoryStorageDomain();
3698
+ ctx.provide('storageDomain', storageDomain as unknown as DomainFacility);
3699
+ await ctx.plugin(SandorePersistence);
3700
+ const persistence = ctx.sandorePersistence;
3701
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3702
+ await persistence.state.put('inst-init', pausedSnapshot('inst-init'));
3703
+
3704
+ const sessions = new FakeSessionStore();
3705
+ sessions.create('inst-init');
3706
+ const agents = new FakeAgentRegistry(sessions);
3707
+ ctx.provide('sessions', sessions as unknown as SessionStore);
3708
+ ctx.provide('sessionPersistence', {});
3709
+ ctx.provide('agents', agents);
3710
+ ctx.provide('subagents', new FakeSubagents(ctx, (id) => agents.get(id)));
3711
+ ctx.provide('llm', new FakeLlm() as unknown as LlmRuntime);
3712
+ ctx.provide('shell', new FakeShell() as unknown as ShellExecutor);
3713
+ ctx.provide('subprocess', new FakeSubprocess());
3714
+ ctx.provide('userQuestions', new FakeUserQuestions());
3715
+ ctx.provide('sessionTitle', new FakeSessionTitle());
3716
+ ctx.provide('fs', {});
3717
+
3718
+ await ctx.plugin(SandoreEngineRuntime, {});
3719
+
3720
+ // 启动扫描自动冷恢复宿主主代理(agents.resume setup 重跑)+ 实例进内存。
3721
+ expect(agents.setupCount).toBeGreaterThanOrEqual(1);
3722
+ expect(ctx.sandoreEngine.getState('inst-init').status).toBe('paused');
3723
+ });
3724
+
3725
+ it('clears stale pendingInteractions from the snapshot during recovery (defect 9 B / 27)', async () => {
3726
+ const { engine, persistence, sessions } = await harness();
3727
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3728
+
3729
+ await persistence.state.put('inst-stale', stalePendingSnapshot('inst-stale'));
3730
+ sessions.create('inst-stale');
3731
+
3732
+ const result = await engine.recoverInstance('inst-stale');
3733
+ // paused-only 实例不重跑;恢复时 clearAll 清空快照 stale pendingInteractions。
3734
+ expect(result.status).toBe('paused');
3735
+ const state = persistence.state.get('inst-stale');
3736
+ expect(state?.pendingInteractions).toEqual([]);
3737
+ // 回复 stale requestId 抛 INVALID_OPERATION(InteractionManager Maps 已清空,无 ghost 条目)。
3738
+ await expect(engine.replyQuestion('inst-stale', 'req-stale', { answers: [{ id: 'q1', selected: ['A'] }] }))
3739
+ .rejects.toMatchObject({ code: 'INVALID_OPERATION' });
3740
+ });
3741
+
3742
+ it('resumes a between-steps crash without re-running the completed step (defect 9 G)', async () => {
3743
+ const { ctx, engine, persistence, sessions, shell } = await harness();
3744
+ await putDefinition(persistence, 'wfl-linear-shell', linearShellYaml);
3745
+ shell.runHandler = async spec => successResult(spec, spec.command);
3746
+
3747
+ await persistence.state.put('inst-between', betweenStepsSnapshot('inst-between'));
3748
+ sessions.create('inst-between');
3749
+
3750
+ const ended = waitForEnd(ctx);
3751
+ const result = await engine.recoverInstance('inst-between');
3752
+ expect(result.status).toBe('running');
3753
+ await ended;
3754
+
3755
+ const state = persistence.state.get('inst-between');
3756
+ expect(state?.status).toBe('completed');
3757
+ // step-a 不重跑:仅 'exec-a' 一段(无新增 step-a 段 → 非恰好一次语义成立)。
3758
+ const stepA = state?.executedSteps.filter(s => s.stepId === 'step-a') ?? [];
3759
+ expect(stepA).toHaveLength(1);
3760
+ expect(stepA[0]?.id).toBe('exec-a');
3761
+ expect(stepA[0]?.status).toBe('completed');
3762
+ // step-b 从 step-a 反推 DAG next 续跑一次完成。
3763
+ const stepB = state?.executedSteps.filter(s => s.stepId === 'step-b') ?? [];
3764
+ expect(stepB).toHaveLength(1);
3765
+ expect(stepB[0]?.status).toBe('completed');
3766
+ });
3767
+
3768
+ it('marks the instance failed and clears memory when recover() followup throws (defect 11)', async () => {
3769
+ const { ctx, engine, persistence, sessions, subagents } = await harness();
3770
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3771
+
3772
+ await persistence.state.put('inst-run', stepSnapshot('inst-run', 'wfl-agent-fail', agentStepSnapshot('running'), 'running'));
3773
+ sessions.create('inst-run');
3774
+ // 注入 followup 故障:recover() 冷恢复 followup 抛错(缺陷 11 真实可抛场景收敛点)。
3775
+ subagents.followupError = new Error('cold resume boom');
3776
+
3777
+ const ended = waitForEnd(ctx);
3778
+ const result = await engine.recoverInstance('inst-run');
3779
+ // catch 落 failed 终态(对齐 runAndCatch 语义)。
3780
+ expect(result.status).toBe('failed');
3781
+ await ended;
3782
+
3783
+ const state = persistence.state.get('inst-run');
3784
+ expect(state?.status).toBe('failed');
3785
+ // 实例已从内存清除:二次 recoverInstance 读快照(failed 终态)抛 INVALID_OPERATION,
3786
+ // 不卡 running(修复前 followup 抛错传播出 recoverInstance → 实例残留内存 running +
3787
+ // 二次 recoverInstance no-op 永久卡 running)。
3788
+ await expect(engine.recoverInstance('inst-run')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
3789
+ });
3790
+
3791
+ it('preserves extracted pass-through vars across crash recovery so the next step resolves them (defect 15)', async () => {
3792
+ const { ctx, engine, persistence, sessions, shell } = await harness();
3793
+ await putDefinition(persistence, 'wfl-recover-ptv', recoverPtvYaml);
3794
+ // shell 回显命令本身,便于断言经 replacePassThroughVars 替换后的实际命令。
3795
+ shell.runHandler = async spec => successResult(spec, spec.command);
3796
+
3797
+ // 快照:running、无活跃段、无已完成 Step,但 passThroughVars 已含提取值
3798
+ // (模拟前置 Stage 已提取 projectName=sandore 后崩溃,恢复后续跑本 Stage)。
3799
+ // 当前执行段模型:persistState 全量持久化含 passThroughVars → recoverInstance
3800
+ // 全量读回 → StageExecutor.initState 播种提取值(合并顺序修复后 input 优先)→
3801
+ // step-use 的 shell 命令拿到提取值,传递链不断。
3802
+ await persistence.state.put('inst-ptv', extractedPtvSnapshot('inst-ptv'));
3803
+ sessions.create('inst-ptv');
3804
+
3805
+ const ended = waitForEnd(ctx);
3806
+ const result = await engine.recoverInstance('inst-ptv');
3807
+ expect(result.status).toBe('running');
3808
+ await ended;
3809
+
3810
+ const state = persistence.state.get('inst-ptv');
3811
+ expect(state?.status).toBe('completed');
3812
+ // 崩溃恢复保留:step-use 的 shell 命令拿到提取值 'sandore' 而非保留字面量 ${projectName}。
3813
+ expect(shell.specs.map(spec => spec.command)).toEqual(['echo sandore']);
3814
+ // 提取值原样保留在 state.passThroughVars(传递链不断)。
3815
+ expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
3816
+ });
3817
+
3818
+ it('follows up all running subagent steps during multi-running crash recovery (defect 10 multi-path)', async () => {
3819
+ // 并发崩溃恢复:两个 running agent Step 同时存在快照中 → recover() 全部 followup。
3820
+ // 对齐基准恢复 E 步(遍历全部 running/interacting Step 逐一入队)。
3821
+ const multiAgentYaml = `id: wfl-multi-agent
3822
+ name: multi-agent
3823
+ version: "1.0.0"
3824
+ stages:
3825
+ - id: stg-1
3826
+ name: 并行agent
3827
+ steps:
3828
+ - id: step-router
3829
+ branches:
3830
+ - next: step-a
3831
+ - next: step-b
3832
+ - id: step-a
3833
+ branches:
3834
+ - next: step-end
3835
+ - id: step-b
3836
+ branches:
3837
+ - next: step-end
3838
+ - id: step-end
3839
+ steps:
3840
+ - id: step-router
3841
+ - id: step-a
3842
+ command:
3843
+ type: agent
3844
+ value:
3845
+ promptText: "do A"
3846
+ - id: step-b
3847
+ command:
3848
+ type: agent
3849
+ value:
3850
+ promptText: "do B"
3851
+ - id: step-end
3852
+ command:
3853
+ type: shell
3854
+ value:
3855
+ command: "echo end"
3856
+ `;
3857
+ const { ctx, engine, persistence, sessions, subagents, agents, llm } = await harness();
3858
+ await putDefinition(persistence, 'wfl-multi-agent', multiAgentYaml);
3859
+ provideLlmDirectory(llm);
3860
+
3861
+ // 快照:两个 running agent Step(step-a→child-1, step-b→child-2)同时存在。
3862
+ const createdAt = new Date().toISOString();
3863
+ const multiRunningSnapshot: WorkflowTaskState = {
3864
+ taskId: 'inst-multi-run',
3865
+ workflowId: 'wfl-multi-agent',
3866
+ cwd: '/tmp',
3867
+ status: 'running',
3868
+ originalInput: 'do parallel work',
3869
+ currentStageId: 'stg-1',
3870
+ executedSteps: [
3871
+ {
3872
+ id: 'exec-router',
3873
+ stepId: 'step-router',
3874
+ stageId: 'stg-1',
3875
+ name: 'step-router',
3876
+ status: 'completed',
3877
+ visitCount: 1,
3878
+ createdAt,
3879
+ attemptIndex: 0,
3880
+ loopIteration: [],
3881
+ },
3882
+ {
3883
+ id: 'exec-a',
3884
+ stepId: 'step-a',
3885
+ stageId: 'stg-1',
3886
+ name: 'step-a',
3887
+ status: 'running',
3888
+ command: { type: 'agent', value: { promptText: 'do A' } },
3889
+ sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
3890
+ visitCount: 1,
3891
+ createdAt,
3892
+ attemptIndex: 0,
3893
+ },
3894
+ {
3895
+ id: 'exec-b',
3896
+ stepId: 'step-b',
3897
+ stageId: 'stg-1',
3898
+ name: 'step-b',
3899
+ status: 'running',
3900
+ command: { type: 'agent', value: { promptText: 'do B' } },
3901
+ sessionMeta: { childId: 'child-2', synthetic: false, command: 'agent' },
3902
+ visitCount: 1,
3903
+ createdAt,
3904
+ attemptIndex: 0,
3905
+ },
3906
+ ],
3907
+ stageInfos: [{ id: 'stg-1', name: '并行agent', status: 'running' }],
3908
+ pendingInteractions: [],
3909
+ pendingQueue: ['step-end'],
3910
+ visitCounts: { 'step-router': 1, 'step-a': 1, 'step-b': 1 },
3911
+ passThroughVars: {},
3912
+ activeLoopIterationStack: [],
3913
+ activeLoopNodeId: null,
3914
+ activeLoopStack: [],
3915
+ pausedChildIds: [],
3916
+ stageContracts: {},
3917
+ createdAt,
3918
+ updatedAt: createdAt,
3919
+ };
3920
+ await persistence.state.put('inst-multi-run', multiRunningSnapshot);
3921
+ sessions.create('inst-multi-run');
3922
+
3923
+ // 注册两个子代理(冷恢复 followup 后 whenIdle + finalAssistantOutput)。
3924
+ const child1 = new FakeAgent('child-1');
3925
+ child1.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered A' }]));
3926
+ const child2 = new FakeAgent('child-2');
3927
+ child2.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered B' }]));
3928
+ agents.register(child1);
3929
+ agents.register(child2);
3930
+
3931
+ const ended = waitForEnd(ctx);
3932
+ const result = await engine.recoverInstance('inst-multi-run');
3933
+ expect(result.status).toBe('running');
3934
+ await ended;
3935
+
3936
+ // 缺陷 10 多路恢复:两个 running agent Step 全部被 followup(不只是第一个)。
3937
+ const followups = subagents.followups.filter(f => f.parent.id === 'inst-multi-run' || f.childId === 'child-1' || f.childId === 'child-2');
3938
+ expect(followups.length).toBeGreaterThanOrEqual(2);
3939
+ const followedChildIds = new Set(followups.map(f => f.childId));
3940
+ expect(followedChildIds.has('child-1')).toBe(true);
3941
+ expect(followedChildIds.has('child-2')).toBe(true);
3942
+
3943
+ // 恢复后实例完成(两个 agent 续跑 + step-end 执行)。
3944
+ const state = persistence.state.get('inst-multi-run');
3945
+ expect(state?.status).toBe('completed');
3946
+ });
3947
+ });
3948
+
3949
+ /** 单 Stage 单 prompt Step(用于 sendStepMessage 测试:prompt 为真实可续接子代理)。 */
3950
+ const promptOnlyYaml = `id: wfl-prompt-only
3951
+ name: prompt-only
3952
+ version: "1.0.0"
3953
+ stages:
3954
+ - id: stg-1
3955
+ name: 提示
3956
+ steps:
3957
+ - id: step-prompt
3958
+ steps:
3959
+ - id: step-prompt
3960
+ command:
3961
+ type: prompt
3962
+ value:
3963
+ promptText: "do it"
3964
+ `;
3965
+
3966
+ describe('SandoreEngineRuntime sendStepMessage (缺陷 31: @mention 讨论消息封装端点)', () => {
3967
+ it('forwards the message to subagents.followup + persists userMessages kind:message + dual-emits sandore/step-message', async () => {
3968
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
3969
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
3970
+ provideLlmDirectory(llm);
3971
+
3972
+ // 注册挂起 agent:status='running' → 不自动 settle(collectSubagentOutput 挂起等待),
3973
+ // 保持 step-agent running 不进入终态,供 sendStepMessage 测试。
3974
+ const child = new FakeAgent('child-1');
3975
+ child.status = 'running';
3976
+ agents.register(child);
3977
+
3978
+ const messageEvents: Array<{ info: unknown; stepId: string; childId: string; message: string; name: string }> = [];
3979
+ ctx.on('sandore/step-message', (info, stepId, childId, message, name) =>
3980
+ messageEvents.push({ info, stepId, childId, message, name }),
3981
+ );
3982
+
3983
+ const stepStarted = waitForStepStart(ctx);
3984
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
3985
+ await stepStarted;
3986
+ // 等待 onChildStarted 把 sessionMeta 切为 synthetic=false + childId='child-1'。
3987
+ await vi.waitFor(() => {
3988
+ const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-agent');
3989
+ expect(step?.sessionMeta?.synthetic).toBe(false);
3990
+ expect(step?.sessionMeta?.childId).toBe('child-1');
3991
+ });
3992
+
3993
+ await engine.sendStepMessage(run.instanceId, 'step-agent', '请确认参数');
3994
+
3995
+ // followup 收到消息:childId 指向运行中子代理,content 为用户消息文本。
3996
+ expect(subagents.followups).toHaveLength(1);
3997
+ expect(subagents.followups[0]?.childId).toBe('child-1');
3998
+ expect(subagents.followups[0]?.content).toEqual([{ type: 'text', text: '请确认参数' }]);
3999
+ expect(subagents.followups[0]?.options.source).toMatchObject({ kind: 'user' });
4000
+
4001
+ // userMessages 落盘 kind:'message'(非 'input',避免污染 originalInput 语义)。
4002
+ const state = persistence.state.get(run.instanceId);
4003
+ const messages = state?.userMessages?.filter(m => m.kind === 'message') ?? [];
4004
+ expect(messages).toHaveLength(1);
4005
+ expect(messages[0]?.target).toBe('step-agent');
4006
+ expect(messages[0]?.content).toBe('请确认参数');
4007
+
4008
+ // 单发 sandore/step-message:Cordis emit(listener 触发;Round 25 去污染:宿主会话不再承载)。
4009
+ expect(messageEvents).toHaveLength(1);
4010
+ expect(messageEvents[0]?.stepId).toBe('step-agent');
4011
+ expect(messageEvents[0]?.childId).toBe('child-1');
4012
+ expect(messageEvents[0]?.message).toBe('请确认参数');
4013
+ expect(messageEvents[0]?.name).toBe('step-agent');
4014
+
4015
+ // 清理:让挂起的 Step 正常完成(append assistant 输出 + settle completed),令 run 收敛。
4016
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
4017
+ subagents.settleChild('child-1', 'completed');
4018
+ await waitForEnd(ctx);
4019
+ });
4020
+
4021
+ it('forwards a message to a running prompt subagent (决策 D3: prompt 为真实子代理, 缺陷 31)', async () => {
4022
+ const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
4023
+ await putDefinition(persistence, 'wfl-prompt-only', promptOnlyYaml);
4024
+ provideLlmDirectory(llm);
4025
+ // prompt Step 挂起:child status='running' 不自动 settle(collectSubagentOutput 等待),
4026
+ // 保持 step-prompt running,供 sendStepMessage 测试。
4027
+ const child = new FakeAgent('child-1');
4028
+ child.status = 'running';
4029
+ agents.register(child);
4030
+
4031
+ const messageEvents: Array<{ info: unknown; stepId: string; childId: string; message: string; name: string }> = [];
4032
+ ctx.on('sandore/step-message', (info, stepId, childId, message, name) =>
4033
+ messageEvents.push({ info, stepId, childId, message, name }),
4034
+ );
4035
+
4036
+ const stepStarted = waitForStepStart(ctx);
4037
+ const run = await engine.startInstance({ workflowId: 'wfl-prompt-only', originalInput: 'do it', cwd: '/tmp' });
4038
+ await stepStarted;
4039
+ // 等待 onChildStarted 把 sessionMeta 切为 synthetic=false + childId='child-1'。
4040
+ await vi.waitFor(() => {
4041
+ const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-prompt');
4042
+ expect(step?.sessionMeta?.synthetic).toBe(false);
4043
+ expect(step?.sessionMeta?.childId).toBe('child-1');
4044
+ });
4045
+
4046
+ await engine.sendStepMessage(run.instanceId, 'step-prompt', 'hi');
4047
+
4048
+ // prompt 为真实子代理:followup 收到消息(childId 指向运行中子代理)+ userMessages
4049
+ // kind:message 落盘 + 双发 sandore/step-message。
4050
+ expect(subagents.followups).toHaveLength(1);
4051
+ expect(subagents.followups[0]?.childId).toBe('child-1');
4052
+ expect(subagents.followups[0]?.content).toEqual([{ type: 'text', text: 'hi' }]);
4053
+ const state = persistence.state.get(run.instanceId);
4054
+ const messages = state?.userMessages?.filter(m => m.kind === 'message') ?? [];
4055
+ expect(messages).toHaveLength(1);
4056
+ expect(messages[0]?.target).toBe('step-prompt');
4057
+ expect(messages[0]?.content).toBe('hi');
4058
+ expect(messageEvents).toHaveLength(1);
4059
+
4060
+ // 清理:append 输出 + settle completed,令 run 收敛。
4061
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
4062
+ subagents.settleChild('child-1', 'completed');
4063
+ await waitForEnd(ctx);
4064
+ });
4065
+
4066
+ it('rejects a terminal instance with INVALID_OPERATION (缺陷 31)', async () => {
4067
+ const { ctx, engine, persistence, llm, shell, agents } = await harness();
4068
+ await putDefinition(persistence, 'wfl-linear', linearYaml);
4069
+ shell.runHandler = async spec => successResult(spec, 'hello');
4070
+ provideLlmDirectory(llm);
4071
+ llm.streamHandler = () => textChunks('summary');
4072
+ // prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
4073
+ const promptChild = new FakeAgent('child-1');
4074
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
4075
+ agents.register(promptChild);
4076
+
4077
+ const ended = waitForEnd(ctx);
4078
+ const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
4079
+ await ended;
4080
+ expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
4081
+
4082
+ await expect(engine.sendStepMessage(run.instanceId, 'step-abc', 'hi')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
4083
+ });
4084
+
4085
+ it('throws NODE_NOT_FOUND for an unknown instance (缺陷 31)', async () => {
4086
+ const { engine } = await harness();
4087
+ await expect(engine.sendStepMessage('inst-none', 'step-agent', 'hi')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
4088
+ });
4089
+
4090
+ it('throws INVALID_OPERATION for an unknown stepId (缺陷 31)', async () => {
4091
+ const { ctx, engine, persistence, llm, agents, subagents } = await harness();
4092
+ await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
4093
+ provideLlmDirectory(llm);
4094
+
4095
+ const child = new FakeAgent('child-1');
4096
+ child.status = 'running';
4097
+ agents.register(child);
4098
+
4099
+ const stepStarted = waitForStepStart(ctx);
4100
+ const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
4101
+ await stepStarted;
4102
+ await vi.waitFor(() => {
4103
+ const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-agent');
4104
+ expect(step?.sessionMeta?.childId).toBe('child-1');
4105
+ });
4106
+
4107
+ await expect(engine.sendStepMessage(run.instanceId, 'step-missing', 'hi')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
4108
+
4109
+ // 清理:让挂起的 Step 正常完成(append 输出 + settle completed),令 run 收敛。
4110
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
4111
+ subagents.settleChild('child-1', 'completed');
4112
+ await waitForEnd(ctx);
4113
+ });
4114
+ });
4115
+
4116
+ /** 配置 agent Step 可解析的 provider/model 目录(resolveProviderModel L3 兜底)。 */
4117
+ function provideLlmDirectory(llm: FakeLlm): void {
4118
+ llm.providers.push({ id: 'p', name: 'Provider' });
4119
+ llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
4120
+ }
4121
+
4122
+ describe('complex workflow e2e', () => {
4123
+ /**
4124
+ * 复杂工作流 e2e:代码审查与修复流程,覆盖 prompt 命令 + shell 命令 + 条件分支
4125
+ * (shouldExecute)+ 并行分支(无 condition 多 next)+ 循环(loop + breakCondition
4126
+ * shouldBreak + maxIterations)+ 收尾门禁(gates primary/secondary)+ 跨 Stage。
4127
+ *
4128
+ * mock llm.streamHandler 按首条消息文本分发:含 'shouldExecute' → 批量条件分支 JSON
4129
+ * (分支 id 数组);含 'shouldBreak' → 循环跳出 JSON(首次 false 继续、二次 true 跳出);含
4130
+ * 'hasAsk' → 收尾 assess JSON(提取 projectName=sandore);其余 → secondary 门禁
4131
+ * JSON(primary 为纯代码产物校验、无 AI 提示词)。prompt Step(step-finalize)自身
4132
+ * 输出来自可续接子代理会话(决策 D3)。mock shell.runHandler 回显命令对应 stdout。
4133
+ */
4134
+ it('runs the review-fix flow across two stages with branches, parallel, loop, and gates to completed', async () => {
4135
+ const { ctx, engine, persistence, llm, shell, agents, subagents } = await harness();
4136
+ await putDefinition(persistence, 'wfl-complex', complexReviewFixYaml);
4137
+ provideLlmDirectory(llm);
4138
+ // shell.runHandler:按命令回显对应 stdout(便于产物/事件断言)。
4139
+ const stdoutByCommand: Record<string, string> = {
4140
+ 'echo scan': 'scan-output',
4141
+ 'echo fix': 'fix-output',
4142
+ 'echo report': 'report-output',
4143
+ 'echo merge': 'merge-output',
4144
+ 'echo iter': 'iter-output',
4145
+ 'echo stage1-done': 'stage1-done',
4146
+ };
4147
+ shell.runHandler = async spec => successResult(spec, stdoutByCommand[spec.command] ?? 'ok');
4148
+ // step-finalize 为 prompt Step,走可续接子代理(决策 D3):注册真实 childId 并回填输出。
4149
+ const promptChild = new FakeAgent('child-1');
4150
+ promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'final-analysis' }]));
4151
+ agents.register(promptChild);
4152
+ // Phase C secondary 门禁经 startContinuable 建宿主可续接子代理(child-2,与 step
4153
+ // 执行子代理同机制):预注册 FakeAgent 并回填门禁审查输出(gateStatus done)——
4154
+ // 门禁 AI 走子代理 agent loop,不消费 llm.stream。
4155
+ const gateChild = new FakeAgent('child-2');
4156
+ gateChild.session.append(assistantMessageEvent([{ type: 'text', text: '{"gateStatus":"done"}' }]));
4157
+ agents.register(gateChild);
4158
+
4159
+ // 条件分支/循环跳出/收尾各走专用模板(其「## 返回格式」节含对应字段名),
4160
+ // mock 据首条消息文本含哪个字段名分发对应 JSON。循环跳出首次 false、二次 true
4161
+ // (演示 continue 与 break 两条 loopMeta 路由条目均回写)。prompt Step 自身输出
4162
+ // 来自子代理会话(不再消费 llm.stream)。primary 门禁为纯代码产物校验(零 AI),
4163
+ // 无对应 AI 提示词——secondary 门禁经 startContinuable 子代理 settle 审查
4164
+ // (gateStatus done),亦不消费 llm.stream;Phase B 收尾走降级 llm.stream
4165
+ // (compressedOutput 合并 JSON)。
4166
+ let breakCall = 0;
4167
+ llm.streamHandler = (options) => {
4168
+ const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
4169
+ if (text.includes('shouldExecute')) {
4170
+ // Round 20:批量模板——返回满足条件的分支 id 数组(branchId = branch.next)。
4171
+ return textChunks('{"shouldExecute": ["step-fix"]}');
4172
+ }
4173
+ if (text.includes('shouldBreak')) {
4174
+ breakCall += 1;
4175
+ return textChunks(breakCall === 1 ? '{"shouldBreak": false}' : '{"shouldBreak": true}');
4176
+ }
4177
+ if (text.includes('compressedOutput')) {
4178
+ // Phase B 收尾(降级 llm.stream):透传变量提取 + 压缩摘要合并 JSON。
4179
+ return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"finalize 摘要"}');
4180
+ }
4181
+ if (text.includes('hasAsk')) {
4182
+ return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
4183
+ }
4184
+ // 理论不可达(收尾真 fork 后无其他 llm.stream 消费方);兜底返回门禁通过。
4185
+ return textChunks('{"gateStatus":"done"}');
4186
+ };
4187
+
4188
+ const records = recordSandoreEvents(ctx);
4189
+ const ended = waitForEnd(ctx);
4190
+ const run = await engine.startInstance({ workflowId: 'wfl-complex', originalInput: 'do it', cwd: '/tmp' });
4191
+ await ended;
4192
+
4193
+ const state = persistence.state.get(run.instanceId);
4194
+ expect(state?.status).toBe('completed');
4195
+
4196
+ const stepIds = state?.executedSteps.map(step => step.stepId) ?? [];
4197
+
4198
+ // ── 顺序:Stage 1 首步 step-scan → 路由门 step-route(条件评估现场保留)。
4199
+ expect(stepIds[0]).toBe('step-scan');
4200
+ expect(stepIds[1]).toBe('step-route');
4201
+ const routeEntry = state?.executedSteps.find(step => step.stepId === 'step-route');
4202
+ expect(routeEntry?.branchMeta?.status).toBe('completed');
4203
+ expect(routeEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-fix"]}');
4204
+
4205
+ // ── 并行分支(无 condition)+ 条件分支(shouldExecute=true):step-fix 与 step-report
4206
+ // 均执行(并发,Promise.all);两者顺序不假定(mock 同步但并发调度),以 Set 断言
4207
+ // 且确认二者均落在 step-route 之后、step-merge 之前。
4208
+ const parallelPair = new Set(stepIds.slice(2, 4));
4209
+ expect(parallelPair).toEqual(new Set(['step-fix', 'step-report']));
4210
+ expect(stepIds[4]).toBe('step-merge');
4211
+ // 汇合节点仅执行一次(nextBatch dedupe)。
4212
+ expect(stepIds.filter(id => id === 'step-merge')).toHaveLength(1);
4213
+
4214
+ // ── 循环(loop + breakCondition shouldBreak + maxIterations):循环门 step-loop 出现
4215
+ // 两次(continue 透传 loopMeta + break 透传 loopMeta,缺陷 23);循环体 step-iter
4216
+ // 仅执行一次(首趟 continue 后跑一次,第二趟 break 直接跳出未再跑 body)。
4217
+ expect(stepIds[5]).toBe('step-loop');
4218
+ expect(stepIds[6]).toBe('step-iter');
4219
+ expect(stepIds[7]).toBe('step-loop');
4220
+ const loopEntries = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
4221
+ expect(loopEntries).toHaveLength(2);
4222
+ // continue 与 break 两条路由条目均回写 loopMeta(completed) 且携带 shouldBreak 评估现场。
4223
+ expect(loopEntries.every(entry => entry.loopMeta?.status === 'completed')).toBe(true);
4224
+ expect(loopEntries[0]?.loopMeta?.result).toContain('shouldBreak');
4225
+ expect(loopEntries[1]?.loopMeta?.result).toContain('shouldBreak');
4226
+ // continue=false 现场(首趟)、break=true 现场(次趟)。
4227
+ expect(loopEntries[0]?.loopMeta?.result).toBe('{"shouldBreak": false}');
4228
+ expect(loopEntries[1]?.loopMeta?.result).toBe('{"shouldBreak": true}');
4229
+ const iterEntries = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
4230
+ expect(iterEntries).toHaveLength(1);
4231
+
4232
+ // ── 跨 Stage:Stage 1 收尾 step-exit → Stage 2 step-finalize。
4233
+ expect(stepIds[8]).toBe('step-exit');
4234
+ expect(stepIds[9]).toBe('step-finalize');
4235
+ // 两个 Stage 均完成,契约落盘(跨 Stage 推进由 tryAdvanceStage 承接)。
4236
+ expect(state?.stageInfos.map(info => info.status)).toEqual(['completed', 'completed']);
4237
+ expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
4238
+
4239
+ // ── 收尾门禁(gates primary/secondary):step-finalize 的 finalizeMeta 记录
4240
+ // gateResult。primary 为纯代码产物校验(无 outputTo → 通过,零 AI),
4241
+ // secondary 为 AI 门禁审查(显式 enabled:true)→ 两者均通过 → gatesPassed=true。
4242
+ const finalizeStep = state?.executedSteps.find(step => step.stepId === 'step-finalize');
4243
+ expect(finalizeStep?.status).toBe('completed');
4244
+ expect(finalizeStep?.finalizeMeta?.currentPhase).toBe('completed');
4245
+ expect(finalizeStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
4246
+ expect(finalizeStep?.finalizeMeta?.gateResult?.primaryPassed).toBe(true);
4247
+ expect(finalizeStep?.finalizeMeta?.gateResult?.secondaryPassed).toBe(true);
4248
+
4249
+ // Phase C secondary 经 startContinuable 建宿主可续接子代理(与 step 执行子代理同
4250
+ // 机制:agent loop + 工具 + preset 系统提示词;prompt 携带门禁审查要求);
4251
+ // primary 纯代码校验不建子代理。宿主主代理也经 agents.create 创建(startInstance)
4252
+ // → agents.create 记录仅含宿主主代理(门禁不再走 agents.create)。
4253
+ const gateStart = subagents.continuableStarts[subagents.continuableStarts.length - 1];
4254
+ expect(gateStart).toBeDefined();
4255
+ expect(gateStart?.provider).toBe('fork');
4256
+ expect(gateStart?.request.parent.id).toBe(run.instanceId);
4257
+ const gatePrompt = (gateStart?.request.prompt[0] as { type: string; text: string } | undefined)?.text ?? '';
4258
+ expect(gatePrompt).toContain('gateStatus');
4259
+ // gateSessionId = startContinuable 返回的真实 childId(UI 可打开;settle 侧由 DSH
4260
+ // dispose,mock 中仍留在 registry)。
4261
+ const gateSessionId = finalizeStep?.finalizeMeta?.gateResult?.gateSessionId ?? '';
4262
+ expect(gateSessionId).toBe('child-2');
4263
+ expect(agents.get(gateSessionId)).toBeDefined();
4264
+ expect(agents.get(gateSessionId)?.session.events.some(event =>
4265
+ event.type === 'assistant/message'
4266
+ && event.data.message.content.some(block => block.type === 'text' && block.text.includes('gateStatus')),
4267
+ )).toBe(true);
4268
+
4269
+ // ── 产物收集:step-finalize prompt 自身输出为 'final-analysis'(子代理会话输出)。
4270
+ expect(finalizeStep?.output).toEqual([{ type: 'text', text: 'final-analysis' }]);
4271
+
4272
+ // ── 透传变量提取:assess 提取 projectName=sandore 并保留 Step 定义中的 description。
4273
+ expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
4274
+
4275
+ // ── Stage 1 契约产物:step-scan 声明 outputTo(opt-scan),契约 artifacts 含对应条目。
4276
+ const reviewContract = state?.stageContracts['stg-review'];
4277
+ expect(reviewContract?.status).toBe('completed');
4278
+ expect(reviewContract?.artifacts.find(item => item.outputToId === 'opt-scan')?.stepId).toBe('step-scan');
4279
+
4280
+ // ── shell 命令覆盖:6 个 shell Step 各跑一次(step-finalize 为 prompt,不跑 shell)。
4281
+ // 并行分支(step-report)在条件分支(step-fix)之前纳入 nextNodeIds(resolveNextNodes
4282
+ // 先处理无 condition 的并行分支再评估条件分支),故 shell 调用序为 report → fix。
4283
+ expect(shell.specs.map(spec => spec.command)).toEqual([
4284
+ 'echo scan',
4285
+ 'echo report',
4286
+ 'echo fix',
4287
+ 'echo merge',
4288
+ 'echo iter',
4289
+ 'echo stage1-done',
4290
+ ]);
4291
+
4292
+ // ── llm 调用覆盖:条件分支 1 + 循环跳出 2 + Phase A assess 1 + Phase B 重新
4293
+ // assess 1(偏离 2:不复用 Phase A 结果)= 5 次(收尾真 fork 后 Phase B 降级
4294
+ // 走 llm.stream——child-1 无事件源;primary 纯代码校验零 AI;secondary 走宿主
4295
+ // 子代理驱动轮不消费 llm.stream;prompt Step 输出来自子代理会话)。
4296
+ expect(llm.calls).toHaveLength(5);
4297
+
4298
+ // ── 事件序:start → 两 Stage 各 stage-start/step-*/stage-completed → end。
4299
+ expect(records[0]?.name).toBe('sandore/start');
4300
+ expect(records[records.length - 1]?.name).toBe('sandore/end');
4301
+ const stageStarts = records.filter(r => r.name === 'sandore/stage-start').map(r => r.args[1]);
4302
+ expect(stageStarts).toEqual(['stg-review', 'stg-gate']);
4303
+ const stageCompleted = records.filter(r => r.name === 'sandore/stage-completed').map(r => r.args[1]);
4304
+ expect(stageCompleted).toEqual(['stg-review', 'stg-gate']);
4305
+ // 终态 end 为 completed。
4306
+ expect((records[records.length - 1]?.args[1] as { status: string }).status).toBe('completed');
4307
+ });
4308
+ });
4309
+
4310
+ /** 单 prompt Step(未解析透传变量)——Phase B 真 fork 的引擎级集成。 */
4311
+ const forkBPhaseYaml = `id: wfl-fork-b
4312
+ name: fork-b
4313
+ version: "1.0.0"
4314
+ stages:
4315
+ - id: stg-1
4316
+ name: 收尾真fork
4317
+ steps:
4318
+ - id: step-p
4319
+ steps:
4320
+ - id: step-p
4321
+ command:
4322
+ type: prompt
4323
+ value:
4324
+ promptText: "do it"
4325
+ passThroughVars:
4326
+ - name: projectName
4327
+ value: "\${projectName}"
4328
+ description: "项目名"
4329
+ `;
4330
+
4331
+ describe('SandoreEngineRuntime 收尾真 fork(Phase B agents.create 重建宿主子代理)', () => {
4332
+ it('Phase B 真 fork:agents.create 收到 seed=完整事件 + meta.parentSession=宿主主代理 + origin=subagent;透传变量在 Phase B 回写', async () => {
4333
+ const { ctx, engine, persistence, llm, shell, sessions, agents } = await harness();
4334
+ await putDefinition(persistence, 'wfl-fork-b', forkBPhaseYaml);
4335
+ provideLlmDirectory(llm);
4336
+ shell.runHandler = async spec => successResult(spec, 'done');
4337
+ // prompt Step(step-p)走可续接子代理:注册真实 childId 并回填最终输出。
4338
+ const child = new FakeAgent('child-1');
4339
+ child.session.append(assistantMessageEvent([{ type: 'text', text: 'work output' }]));
4340
+ agents.register(child);
4341
+ // 源子代理完整事件(live store 仍可读)→ Phase B 真 fork 的 seed(闭合 turn:4 条事件)。
4342
+ const childSession = sessions.create('child-1');
4343
+ childSession.append('turn/start', { turn: 0 });
4344
+ childSession.append('user/message', { id: 'u1', content: [{ type: 'text', text: 'do it' }] });
4345
+ childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'work output' }]).data);
4346
+ childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
4347
+ // Phase B 真 fork 的评估回复(一个 LLM 调用返回合并 JSON:passThroughVars + compressedOutput)。
4348
+ agents.createFollowupHandler = () => [{ type: 'text', text: '{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"step 压缩摘要"}' }];
4349
+
4350
+ // Phase A 评估临时会话 1 次(completed JSON);Phase B 真 fork 走子代理不消费 llm.stream;
4351
+ // compressStepOutput 已移除(压缩摘要由 Phase B JSON 的 compressedOutput 承担)→ 无额外调用。
4352
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
4353
+
4354
+ const ended = waitForEnd(ctx);
4355
+ const run = await engine.startInstance({ workflowId: 'wfl-fork-b', originalInput: 'do it', cwd: '/tmp' });
4356
+ await ended;
4357
+
4358
+ const state = persistence.state.get(run.instanceId);
4359
+ expect(state?.status).toBe('completed');
4360
+
4361
+ // Phase B 真 fork:agents.create 收到 seed = 完整事件 + meta(parentSession=宿主主代理、
4362
+ // origin='subagent'、seedLength=4、cwd=/tmp)。create 记录序 = [宿主主代理, fork 子代理]。
4363
+ const forkMeta = agents.createMetas[agents.createMetas.length - 1];
4364
+ expect(agents.createSeeds[agents.createSeeds.length - 1]).toEqual(childSession.events);
4365
+ expect(forkMeta).toMatchObject({ cwd: '/tmp', parentSession: run.instanceId, seedLength: 4, origin: 'subagent' });
4366
+ // 驱动一轮:followup 收到收尾处理提示词(含 compressedOutput 格式)、whenIdle 被 await。
4367
+ const forkedAgent = agents.createdAgents[agents.createdAgents.length - 1]!;
4368
+ expect(forkedAgent.followups).toHaveLength(1);
4369
+ const promptText = (forkedAgent.followups[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
4370
+ // 2026-08-25:fork 版不注入参考信息节(上下文在会话里);降级才注入。
4371
+ expect(promptText).not.toContain('<ReferenceInfo>');
4372
+ expect(promptText).toContain('compressedOutput');
4373
+ expect(forkedAgent.whenIdleCount).toBe(1);
4374
+
4375
+ // 透传变量在 Phase B 回写(保留 description)。
4376
+ expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
4377
+ // finalizeMeta.assessResult.forkSessionId = fork 子代理 id(finalize-{stepId}-{random}),评估完 dispose。
4378
+ // Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
4379
+ const step = state?.executedSteps.find(s => s.stepId === 'step-p');
4380
+ expect(step?.finalizeMeta?.assessResult?.forkSessionId).toMatch(UUID_RE);
4381
+ expect(agents.get(step?.finalizeMeta?.assessResult?.forkSessionId ?? '')).toBeUndefined();
4382
+ // 收尾阶段流转落盘:completed 终态(Phase B finalizing → Phase D routing → completed)。
4383
+ expect(step?.finalizeMeta?.currentPhase).toBe('completed');
4384
+ // llm.stream 覆盖:仅 Phase A 临时会话 assess 1 次。**Phase B 真 fork 子代理不消费
4385
+ // llm.stream**(模型调用走 followup 驱动轮);compressStepOutput 已删除(Phase B JSON
4386
+ // 的 compressedOutput 即压缩摘要)→ 无 compress 调用。
4387
+ expect(llm.calls).toHaveLength(1);
4388
+ });
4389
+ });