@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,1977 @@
1
+ /**
2
+ * finalize-manager.ts(FinalizeAssessor + GateEvaluator + FinalizeManager)的单元测试。
3
+ *
4
+ * 覆盖解析纯函数(parseAssessResult 三级回退 / parseSecondaryGate)、
5
+ * FinalizeAssessor(session 类型 fork 会话 / fork 抛错降级 / shell 临时会话 / ask 载荷)、
6
+ * GateEvaluator(primary 纯代码产物校验 [零 AI] + secondary AI 门禁审查)与
7
+ * finalizeStep 编排(跳过 / ask 中断门禁 / 完整 finalize 的 finalizeMeta 回写 /
8
+ * 透传变量回写保留 refresh)。
9
+ * @module dsh-sandore/microflow/src/finalize-manager.spec
10
+ */
11
+ import { Context } from '@deepseek-ai/cordis';
12
+ import type { ContentBlock, LlmRuntime } from '@deepseek-ai/dsh-llm';
13
+ import type { SessionStore } from '@deepseek-ai/dsh-session';
14
+ import { describe, expect, it } from 'vitest';
15
+
16
+ import { assistantMessageEvent, FakeAgent, FakeAgentRegistry } from '../tests/mocks/agents.ts';
17
+ import { FakeLlm, textChunks } from '../tests/mocks/llm.ts';
18
+ import { FinalizeAssessor, FinalizeManager, GateEvaluator, buildFinalizeRetryHint, buildSecondaryGateCorrectionHint, explainFinalizeResultParse, explainSecondaryGateParse, parseAssessResult, parseSecondaryGate } from './finalize-manager.ts';
19
+ import type { FinalizeManagerConfig } from './finalize-manager.ts';
20
+ import type { FileSystem, FsDirEntry, FsTarget, FsWriteOutcome } from './file-utils.ts';
21
+ import type { PassThroughVarValue } from './stage-executor.ts';
22
+ import type { ContinuableStart, ContinuableStartSpec } from './subagents.ts';
23
+ import type { ExecutedStepDefault } from '../workflow/types';
24
+ import type { FinalizeAssessResult, GateEvalResult, WorkflowStep } from '../workflow/schema';
25
+
26
+ /** 记录 fork 调用并返回可注入 fork 结果的极简会话存储。 */
27
+ class FakeForkedSession {
28
+ constructor(readonly id: string) {}
29
+ }
30
+
31
+ /** 内存 mock SessionStore:仅实现 `fork`,返回 `{ id }` 会话,供 `ctx.provide('sessions', …)` 注入。 */
32
+ class FakeSessionStore {
33
+ readonly forkedSources: string[] = [];
34
+ private counter = 0;
35
+ forkHandler: (source: string) => FakeForkedSession = () => {
36
+ this.counter += 1;
37
+ return new FakeForkedSession(`session-${this.counter}`);
38
+ };
39
+
40
+ fork(source: string): FakeForkedSession {
41
+ this.forkedSources.push(source);
42
+ return this.forkHandler(source);
43
+ }
44
+ }
45
+
46
+ /** 内存文件系统(target 导向两段式协议):`files` 存文件内容,`dirs` 存目录。 */
47
+ class FakeFs implements FileSystem {
48
+ readonly files = new Map<string, string>();
49
+ readonly dirs = new Set<string>();
50
+
51
+ seedFile(path: string, content = ''): void {
52
+ this.files.set(path, content);
53
+ const segments = path.split('/');
54
+ for (let i = 1; i < segments.length; i++) {
55
+ this.dirs.add(segments.slice(0, i).join('/'));
56
+ }
57
+ }
58
+
59
+ async resolve(path: string): Promise<FsTarget> {
60
+ const normalized = path.replace(/\/+$/, '') || '/';
61
+ return { targetKey: normalized, displayPath: normalized };
62
+ }
63
+
64
+ processPath(target: FsTarget): string {
65
+ return target.displayPath;
66
+ }
67
+
68
+ async listDir(target: FsTarget): Promise<FsDirEntry[]> {
69
+ const prefix = target.targetKey === '/' ? '' : `${target.targetKey}/`;
70
+ const names = new Set<string>();
71
+ for (const dir of this.dirs) {
72
+ if (dir.startsWith(prefix)) {
73
+ const name = dir.slice(prefix.length).split('/')[0]!;
74
+ if (name !== '') names.add(name);
75
+ }
76
+ }
77
+ for (const file of this.files.keys()) {
78
+ if (file.startsWith(prefix)) {
79
+ const name = file.slice(prefix.length).split('/')[0]!;
80
+ if (name !== '') names.add(name);
81
+ }
82
+ }
83
+ return [...names].sort().map(name => {
84
+ const full = prefix + name;
85
+ const isDir = this.dirs.has(full);
86
+ return { name, type: isDir ? 'directory' : 'file', target: { targetKey: full, displayPath: full } };
87
+ });
88
+ }
89
+
90
+ async readText(target: FsTarget): Promise<string> {
91
+ const content = this.files.get(target.targetKey);
92
+ if (content === undefined) throw new Error(`FS_NOT_FOUND: ${target.displayPath}`);
93
+ return content;
94
+ }
95
+
96
+ async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
97
+ const before = this.files.get(target.targetKey) ?? null;
98
+ this.seedFile(target.targetKey, content);
99
+ return { operation: before === null ? 'create' : 'update', version: 'v2', before, after: content };
100
+ }
101
+ }
102
+
103
+ /** 记录 startContinuable 调用并返回可注入 childId 的最小 subagents mock。 */
104
+ class FakeGateSubagents {
105
+ /** 每次 `startContinuable` 记录的委派请求(prompt/parent/agentOptions 断言用)。 */
106
+ readonly starts: ContinuableStartSpec[] = [];
107
+ /**
108
+ * `startContinuable` 创建子代理时的回复钩子:返回非空 ContentBlock[] 时预填
109
+ * child.session.events 的 assistant 消息(供 `finalAssistantOutput` 读取——mock 不
110
+ * 后台驱动 agent loop,子代理输出由测试预填)。缺省 undefined → 子代理无输出。
111
+ */
112
+ outputHandler?: () => ContentBlock[] | undefined;
113
+ /** 若设置,`startContinuable` 抛出该错误(模拟 secondary 门禁子代理启动失败)。 */
114
+ startError?: Error;
115
+ /** followup 记录(重试纠正断言用)。 */
116
+ readonly followups: Array<{ childId: SessionId; content: ContentBlock[] }> = [];
117
+ /**
118
+ * followup 时的回复钩子(重试场景:纠正后产出合法 JSON);缺省 undefined → 不追加输出。
119
+ */
120
+ followupHandler?: () => ContentBlock[] | undefined;
121
+
122
+ constructor(private readonly agents: FakeAgentRegistry) {}
123
+
124
+ async followup(_parent: Agent, childId: SessionId, content: ContentBlock[], _options: SubagentFollowupOptions): Promise<MessageId> {
125
+ this.followups.push({ childId, content });
126
+ const agent = this.agents.get(childId);
127
+ const reply = this.followupHandler?.();
128
+ if (agent !== undefined && reply !== undefined && reply.length > 0) {
129
+ agent.session.append(assistantMessageEvent(reply, agent.session.events.length, Date.now()));
130
+ }
131
+ return `msg-${this.followups.length}`;
132
+ }
133
+
134
+ async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
135
+ if (this.startError !== undefined) {
136
+ throw this.startError;
137
+ }
138
+ this.starts.push(spec);
139
+ // 与 FakeSubagents 同形:childId = `child-{index}`(真实 childId 语义,供
140
+ // gateSessionId 断言)。
141
+ const childId = `child-${this.starts.length}`;
142
+ const agent = new FakeAgent(childId);
143
+ const reply = this.outputHandler?.();
144
+ if (reply !== undefined && reply.length > 0) {
145
+ agent.session.append(assistantMessageEvent(reply, 0, Date.now()));
146
+ }
147
+ // 注册进 agents registry:awaitGateOutput 经 ctx.agents.get(childId) 取 child。
148
+ this.agents.register(agent);
149
+ return { childId, messageId: `msg-${this.starts.length}` };
150
+ }
151
+ }
152
+
153
+ /** 组装一条 Step 定义(补齐必填字段 `id`)。 */
154
+ function step(fields: {
155
+ id?: string;
156
+ command?: WorkflowStep['command'];
157
+ passThroughVars?: WorkflowStep['passThroughVars'];
158
+ outputTo?: WorkflowStep['outputTo'];
159
+ gates?: WorkflowStep['gates'];
160
+ } = {}): WorkflowStep {
161
+ return {
162
+ id: fields.id ?? 'step-a',
163
+ ...(fields.command === undefined ? {} : { command: fields.command }),
164
+ ...(fields.passThroughVars === undefined ? {} : { passThroughVars: fields.passThroughVars }),
165
+ ...(fields.outputTo === undefined ? {} : { outputTo: fields.outputTo }),
166
+ ...(fields.gates === undefined ? {} : { gates: fields.gates }),
167
+ };
168
+ }
169
+
170
+ /** 组装一条已执行 Step 快照(补齐必填字段)。 */
171
+ function executedStep(fields: {
172
+ status?: ExecutedStepDefault['status'];
173
+ command?: ExecutedStepDefault['command'];
174
+ } = {}): ExecutedStepDefault {
175
+ return {
176
+ type: 'default',
177
+ id: 'step-a-exec',
178
+ stepId: 'step-a',
179
+ stageId: 'stg-1',
180
+ name: 'Step A',
181
+ status: fields.status ?? 'completed',
182
+ ...(fields.command === undefined ? {} : { command: fields.command }),
183
+ visitCount: 1,
184
+ };
185
+ }
186
+
187
+ /** 组装 finalize-manager 测试环境(注入 mock llm/sessions/fs/agents/subagents 与固定引擎默认 provider/model)。 */
188
+ function harness(config: FinalizeManagerConfig = {}): {
189
+ ctx: Context;
190
+ llm: FakeLlm;
191
+ sessions: FakeSessionStore;
192
+ fs: FakeFs;
193
+ agents: FakeAgentRegistry;
194
+ subagents: FakeGateSubagents;
195
+ assessor: FinalizeAssessor;
196
+ gateEvaluator: GateEvaluator;
197
+ manager: FinalizeManager;
198
+ } {
199
+ const ctx = new Context();
200
+ const llm = new FakeLlm();
201
+ const sessions = new FakeSessionStore();
202
+ const fs = new FakeFs();
203
+ const agents = new FakeAgentRegistry();
204
+ const subagents = new FakeGateSubagents(agents);
205
+ ctx.provide('llm', llm as unknown as LlmRuntime);
206
+ ctx.provide('sessions', sessions as unknown as SessionStore);
207
+ ctx.provide('fs', fs as unknown as FileSystem);
208
+ ctx.provide('agents', agents);
209
+ ctx.provide('subagents', subagents);
210
+ const fullConfig: FinalizeManagerConfig = {
211
+ defaultTimeoutMs: 60000,
212
+ defaultProvider: 'p',
213
+ defaultModel: 'm',
214
+ // secondary 门禁 startContinuable 的 request.parent(最小 Agent 桩;Phase B
215
+ // assessForked 仍走 hostAgentId/createAgent 旧机制,不受本字段影响)。
216
+ hostAgent: new FakeAgent('host-1'),
217
+ ...config,
218
+ };
219
+ return {
220
+ ctx,
221
+ llm,
222
+ sessions,
223
+ fs,
224
+ agents,
225
+ subagents,
226
+ assessor: new FinalizeAssessor(ctx, fullConfig),
227
+ gateEvaluator: new GateEvaluator(ctx, fullConfig),
228
+ manager: new FinalizeManager(ctx, fullConfig),
229
+ };
230
+ }
231
+
232
+ /** DSH 形状的会话事件(seed 截断测试用:仅需 type/seq/time/data 包络)。 */
233
+ type SeedEvent = { readonly type: string; readonly seq: number; readonly time: number; readonly data: unknown };
234
+
235
+ /** 构造一条 `turn/start` / `turn/end` 边界事件。 */
236
+ function boundaryEvent(type: 'turn/start' | 'turn/end', seq: number, data: unknown = {}): SeedEvent {
237
+ return { type, seq, time: 0, data };
238
+ }
239
+
240
+ /** 构造一条 `assistant/message` 事件(text 输出)。 */
241
+ function messageEvent(seq: number, text: string): SeedEvent {
242
+ return {
243
+ type: 'assistant/message',
244
+ seq,
245
+ time: 0,
246
+ data: { turn: 0, step: 0, message: { id: `m-${seq}`, role: 'assistant', content: [{ type: 'text', text }], source: { kind: 'model', provider: 'mock', model: 'mock' } } },
247
+ };
248
+ }
249
+
250
+ describe('parseAssessResult', () => {
251
+ it('extracts hasAsk, passThroughVars and askQuestion from JSON', () => {
252
+ const result = parseAssessResult(
253
+ '{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}',
254
+ );
255
+ expect(result.hasAsk).toBe(false);
256
+ expect(result.passThroughVars).toEqual({ projectName: 'sandore' });
257
+ expect(result.askQuestion).toBeUndefined();
258
+ });
259
+
260
+ it('preserves askQuestion id/question/options fields when hasAsk is true', () => {
261
+ const result = parseAssessResult(
262
+ '{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"选择分支","options":[{"label":"A"}]}]}}',
263
+ );
264
+ expect(result.hasAsk).toBe(true);
265
+ expect(result.askQuestion?.questions[0]).toMatchObject({ id: 'q1', question: '选择分支' });
266
+ expect(result.askQuestion?.questions[0]?.options).toEqual([{ label: 'A' }]);
267
+ });
268
+
269
+ it('falls back to legacy completionType when JSON omits hasAsk (兼容旧输出)', () => {
270
+ const result = parseAssessResult(
271
+ '{"completionType":"ask","passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"选择分支","options":[{"label":"A"}]}]}}',
272
+ );
273
+ expect(result.hasAsk).toBe(true);
274
+ expect(result.askQuestion?.questions[0]).toMatchObject({ id: 'q1', question: '选择分支' });
275
+ expect(result.askQuestion?.questions[0]?.options).toEqual([{ label: 'A' }]);
276
+ });
277
+
278
+ it('falls back to regex hasAsk when JSON is wrapped in prose', () => {
279
+ const result = parseAssessResult('结果如下:\n{"hasAsk": true}\n请查收');
280
+ expect(result.hasAsk).toBe(true);
281
+ expect(result.passThroughVars).toEqual({});
282
+ });
283
+
284
+ it('falls back to legacy regex completionType when JSON is wrapped in prose', () => {
285
+ const result = parseAssessResult('结果如下:\n{"completionType": "ask"}\n请查收');
286
+ expect(result.hasAsk).toBe(true);
287
+ expect(result.passThroughVars).toEqual({});
288
+ });
289
+
290
+ it('falls back to hasAsk=false with empty vars for plain output', () => {
291
+ const result = parseAssessResult(' plain text ');
292
+ expect(result.hasAsk).toBe(false);
293
+ expect(result.passThroughVars).toEqual({});
294
+ });
295
+
296
+ it('preserves askQuestion header/option description/multi_select fields', () => {
297
+ const result = parseAssessResult(
298
+ '{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"选择","header":"请选择","options":[{"label":"A","description":"方案A"},{"label":"B"}],"multi_select":true}]}}',
299
+ );
300
+ expect(result.askQuestion?.questions[0]).toMatchObject({
301
+ id: 'q1',
302
+ question: '选择',
303
+ header: '请选择',
304
+ multi_select: true,
305
+ });
306
+ expect(result.askQuestion?.questions[0]?.options).toEqual([
307
+ { label: 'A', description: '方案A' },
308
+ { label: 'B' },
309
+ ]);
310
+ });
311
+
312
+ // ── 严重缺陷:```json markdown 围栏输出下 askQuestion/passThroughVars 被丢弃 ──
313
+ // 修前裸 JSON.parse 失败 → regex 回退只恢复完成判定、askQuestion 丢失
314
+ // → 假 ask → findAskStep 未命中 → 实例永久卡 interacting。修后围栏剥离再解析。
315
+
316
+ it('parses askQuestion and passThroughVars from a ```json-fenced output', () => {
317
+ const result = parseAssessResult(
318
+ '```json\n{"hasAsk":true,"passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"选择分支","options":[{"label":"A"}]}]}}\n```',
319
+ );
320
+ expect(result.hasAsk).toBe(true);
321
+ expect(result.passThroughVars).toEqual({ projectName: 'sandore' });
322
+ expect(result.askQuestion?.questions[0]).toMatchObject({ id: 'q1', question: '选择分支' });
323
+ expect(result.askQuestion?.questions[0]?.options).toEqual([{ label: 'A' }]);
324
+ });
325
+
326
+ it('parses a fenced JSON block wrapped in surrounding prose', () => {
327
+ const result = parseAssessResult(
328
+ '收尾评估结果:\n```json\n{"hasAsk":false,"passThroughVars":{"a":"b"}}\n```\n请查收',
329
+ );
330
+ expect(result.hasAsk).toBe(false);
331
+ expect(result.passThroughVars).toEqual({ a: 'b' });
332
+ });
333
+
334
+ it('parses compressedOutput from the merged Phase B JSON (Phase A 结果也透传压缩摘要)', () => {
335
+ const result = parseAssessResult(
336
+ '{"hasAsk":false,"passThroughVars":{"projectName":"sandore"},"compressedOutput":"压缩摘要"}',
337
+ );
338
+ expect(result.hasAsk).toBe(false);
339
+ expect(result.passThroughVars).toEqual({ projectName: 'sandore' });
340
+ expect(result.compressedOutput).toBe('压缩摘要');
341
+ });
342
+
343
+ it('omits compressedOutput when the JSON lacks it (缺省 undefined)', () => {
344
+ expect(parseAssessResult('{"hasAsk":false,"passThroughVars":{}}').compressedOutput).toBeUndefined();
345
+ expect(parseAssessResult('非 JSON 输出').compressedOutput).toBeUndefined();
346
+ });
347
+
348
+ it('tolerates an uppercase fence language tag', () => {
349
+ const result = parseAssessResult(
350
+ '```JSON\n{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"Q"}]}}\n```',
351
+ );
352
+ expect(result.hasAsk).toBe(true);
353
+ expect(result.askQuestion?.questions[0]?.id).toBe('q1');
354
+ });
355
+
356
+ it('falls back to hasAsk=false when the fenced block contains invalid JSON', () => {
357
+ const result = parseAssessResult('```json\n{not valid json}\n```');
358
+ expect(result.hasAsk).toBe(false);
359
+ expect(result.passThroughVars).toEqual({});
360
+ expect(result.askQuestion).toBeUndefined();
361
+ });
362
+
363
+ it('keeps regex fallback behavior for unfenced prose without valid JSON', () => {
364
+ const result = parseAssessResult('结果如下:\n这里没有 JSON\n请查收');
365
+ expect(result.hasAsk).toBe(false);
366
+ expect(result.passThroughVars).toEqual({});
367
+ });
368
+ });
369
+
370
+ describe('parseSecondaryGate', () => {
371
+ it('parses gateStatus done as passed for the secondary gate', () => {
372
+ expect(parseSecondaryGate('{"gateStatus":"done"}').passed).toBe(true);
373
+ expect(parseSecondaryGate('{"gateStatus":"error","gateDetail":"needs review"}')).toEqual({
374
+ passed: false,
375
+ detail: 'needs review',
376
+ });
377
+ });
378
+
379
+ it('parses fenced outputs for the secondary gate (fence stripping is shared)', () => {
380
+ expect(parseSecondaryGate('```json\n{"gateStatus":"done"}\n```').passed).toBe(true);
381
+ });
382
+
383
+ // primary 门禁已改为纯代码产物校验(无 AI 输出可解析)——parsePrimaryGate 已删除。
384
+ });
385
+
386
+ describe('FinalizeAssessor.assess(Phase A 临时会话,不 fork)', () => {
387
+ it('does not fork the step session for a session-type step (temp session, eval handle)', async () => {
388
+ const { llm, sessions, assessor } = harness();
389
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
390
+
391
+ const result = await assessor.assess({
392
+ step: step({ command: { type: 'agent', value: { promptText: 'do work' } } }),
393
+ stepSessionId: 'step-session-1',
394
+ context: { input: 'req', output: 'out', artifacts: ['a.md'] },
395
+ });
396
+
397
+ // Phase A 评估不再 fork:sessions.fork 零调用,临时会话句柄(eval-)。
398
+ expect(sessions.forkedSources).toEqual([]);
399
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
400
+ expect(result.hasAsk).toBe(false);
401
+ });
402
+
403
+ it('does not fork for a prompt-type step either (决策 D3: prompt 为真实子代理会话,但 Phase A 临时会话)', async () => {
404
+ const { llm, sessions, assessor } = harness();
405
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
406
+
407
+ const result = await assessor.assess({
408
+ step: step({ command: { type: 'prompt', value: { promptText: 'do work' } } }),
409
+ stepSessionId: 'step-session-1',
410
+ context: { input: 'req', output: 'out' },
411
+ });
412
+
413
+ expect(sessions.forkedSources).toEqual([]);
414
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
415
+ expect(result.hasAsk).toBe(false);
416
+ });
417
+
418
+ it('never calls sessions.fork and leaves the LLM request without a sessionId (temp session)', async () => {
419
+ const { llm, sessions, assessor } = harness();
420
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
421
+
422
+ const result = await assessor.assess({
423
+ step: step({ command: { type: 'agent', value: { promptText: 'do work' } } }),
424
+ stepSessionId: 'missing-session',
425
+ context: { input: 'req', output: 'out' },
426
+ });
427
+
428
+ expect(sessions.forkedSources).toEqual([]);
429
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
430
+ expect(result.errorDetail).toBeUndefined();
431
+ expect(llm.calls[0]?.sessionId).toBeUndefined();
432
+ });
433
+
434
+ it('uses a temp session (no fork) for a shell-type step', async () => {
435
+ const { llm, sessions, assessor } = harness();
436
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
437
+
438
+ const result = await assessor.assess({
439
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
440
+ context: { input: '', output: 'stdout', artifacts: [] },
441
+ });
442
+
443
+ expect(sessions.forkedSources).toHaveLength(0);
444
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
445
+ expect(llm.calls[0]?.sessionId).toBeUndefined();
446
+ });
447
+
448
+ it('includes the subagent return output text in the temp-session prompt (Phase A 输入 = 子代理返回结果)', async () => {
449
+ const { llm, assessor } = harness();
450
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
451
+
452
+ await assessor.assess({
453
+ step: step({ command: { type: 'agent', value: { promptText: 'do work' } } }),
454
+ stepSessionId: 'step-session-1',
455
+ context: { input: 'req', output: 'agent final output', artifacts: ['a.md'] },
456
+ });
457
+
458
+ const prompt = (llm.calls[0]?.messages[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
459
+ // buildFinalizeContext 已含当前 step 子代理返回结果 output 文本 → 评估可见全部 I/O。
460
+ expect(prompt).toContain('agent final output');
461
+ expect(prompt).toContain('<ReferenceInfo>');
462
+ });
463
+
464
+ it('throws FINALIZE_FAILED when the assess stream finishes with error', async () => {
465
+ const { llm, assessor } = harness();
466
+ llm.streamHandler = () => textChunks('', { kind: 'error', failure: { message: 'boom', code: 'E1' } });
467
+
468
+ await expect(
469
+ assessor.assess({
470
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
471
+ context: { input: '', output: '' },
472
+ }),
473
+ ).rejects.toMatchObject({ code: 'FINALIZE_FAILED', message: expect.stringContaining('boom') });
474
+ });
475
+
476
+ it('throws FINALIZE_FAILED when the assess stream is aborted', async () => {
477
+ const { llm, assessor } = harness();
478
+ llm.streamHandler = () => textChunks('', { kind: 'aborted', failure: { message: 'cancelled', code: 'E2' } });
479
+
480
+ await expect(
481
+ assessor.assess({
482
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
483
+ context: { input: '', output: '' },
484
+ }),
485
+ ).rejects.toMatchObject({ code: 'FINALIZE_FAILED', message: expect.stringContaining('cancelled') });
486
+ });
487
+
488
+ it('assess 提示词只判断 ask 需求(Phase A 精简:不含变量提取/输出压缩/完成度检查)', async () => {
489
+ const { llm, assessor } = harness();
490
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
491
+
492
+ await assessor.assess({
493
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
494
+ context: { input: 'req', output: 'out' },
495
+ });
496
+
497
+ const prompt = (llm.calls[0]?.messages[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
498
+ // ask 判断:只判断输出本身是否是在向用户提问,保留 hasAsk 返回格式。
499
+ expect(prompt).toContain('输出是否是在向用户提问');
500
+ expect(prompt).toContain('"hasAsk"');
501
+ // 2026-08-26:参考信息只注入 Output(不注入 Input/Artifacts——判断对象唯一是
502
+ // step 返回的结果文本,输入/上下文会带偏判断)。
503
+ expect(prompt).toContain('<Output>');
504
+ expect(prompt).not.toContain('<Input>');
505
+ expect(prompt).not.toContain('req');
506
+ // 2026-08-25 精简:Phase A 不做透传变量提取/输出压缩(Phase B 职责,不复用 Phase A 结果)。
507
+ expect(prompt).not.toContain('输出压缩');
508
+ expect(prompt).not.toContain('compressedOutput');
509
+ expect(prompt).not.toContain('【任务要求】');
510
+ expect(prompt).not.toContain('透传变量');
511
+ // 不判完成度:不要求判断任务是否真正完成、不要求完成度/质量检查。
512
+ expect(prompt).not.toContain('是否真正完成');
513
+ expect(prompt).not.toContain('已完成');
514
+ expect(prompt).not.toContain('完成度');
515
+ expect(prompt).not.toContain('质量');
516
+ });
517
+ });
518
+
519
+ describe('FinalizeAssessor.assessForked(Phase B 真 fork 子代理)', () => {
520
+ /** 组装完整闭合事件流(turn 0 闭合:start/user/assistant/end)。 */
521
+ function closedEvents(): SeedEvent[] {
522
+ return [
523
+ boundaryEvent('turn/start', 0, { turn: 0 }),
524
+ { type: 'user/message', seq: 1, time: 0, data: { id: 'u1', content: [{ type: 'text', text: 'do it' }] } },
525
+ messageEvent(2, 'step output'),
526
+ boundaryEvent('turn/end', 3, { turn: 0, reason: { kind: 'completed' } }),
527
+ ];
528
+ }
529
+
530
+ /** Phase B 收尾合并 JSON(透传变量 + 压缩摘要)。 */
531
+ function phaseBJson(passThroughVars: string, compressedOutput: string): string {
532
+ return `{"passThroughVars":${passThroughVars},"compressedOutput":"${compressedOutput}"}`;
533
+ }
534
+
535
+ it('replays the full closed-turn events as seed and drives a host subagent round (followup → whenIdle → finalAssistantOutput)', async () => {
536
+ const { llm, agents, assessor } = harness({
537
+ hostAgentId: 'host-1',
538
+ createAgent: options => agents.create(options),
539
+ getStepSessionEvents: async () => closedEvents(),
540
+ });
541
+ agents.createFollowupHandler = () => [{ type: 'text', text: phaseBJson('{"projectName":"sandore"}', '压缩摘要') }];
542
+
543
+ const result = await assessor.assessForked({
544
+ step: step({ command: { type: 'agent', value: { promptText: 'do work' } } }),
545
+ stepSessionId: 'child-1',
546
+ context: { input: 'req', output: 'out' },
547
+ workdir: '/tmp',
548
+ });
549
+
550
+ // agents.create 收到 seed = 完整事件 + meta.parentSession=宿主主代理 + origin='subagent'。
551
+ expect(agents.createSeeds).toHaveLength(1);
552
+ expect(agents.createSeeds[0]).toEqual(closedEvents());
553
+ expect(agents.createMetas[0]).toEqual({ cwd: '/tmp', parentSession: 'host-1', seedLength: 4, origin: 'subagent' });
554
+ // 合成 fork 会话 id:纯 UUID(globalThis.crypto.randomUUID())。
555
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
556
+ // 驱动一轮:followup 收到收尾处理提示词(含 <ReferenceInfo> 与 compressedOutput 格式),whenIdle 被 await。
557
+ const forkedAgent = agents.createdAgents[0]!;
558
+ expect(forkedAgent.followups).toHaveLength(1);
559
+ const promptText = (forkedAgent.followups[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
560
+ // 2026-08-25:fork 版不注入参考信息节(上下文在会话里);降级才注入。
561
+ expect(promptText).not.toContain('<ReferenceInfo>');
562
+ expect(promptText).toContain('compressedOutput');
563
+ // Phase B 专用提示词不含 hasAsk 判断(Phase A 已处理)。
564
+ expect(promptText).not.toContain('hasAsk');
565
+ expect(promptText).not.toContain('是否需要向用户提问');
566
+ // Phase B 提示词含四段式输出模板结构要求(一级标题:任务要求/执行过程/最终成果/未完成与遗留)。
567
+ expect(promptText).toContain('# 任务要求');
568
+ expect(promptText).toContain('# 执行过程');
569
+ expect(promptText).toContain('# 最终成果');
570
+ expect(promptText).toContain('# 未完成与遗留');
571
+ expect(promptText).toContain('```md');
572
+ expect(forkedAgent.whenIdleCount).toBe(1);
573
+ // 一个 LLM 调用返回合并 JSON:passThroughVars + compressedOutput。
574
+ expect(result.passThroughVars).toEqual({ projectName: 'sandore' });
575
+ expect(result.compressedOutput).toBe('压缩摘要');
576
+ // 评估完 dispose:live 移除(持久化记录保留)。
577
+ expect(agents.get(result.forkSessionId)).toBeUndefined();
578
+ // 真 fork 子代理路径不消费 llm.stream(模型调用走子代理 followup)。
579
+ expect(llm.calls).toHaveLength(0);
580
+ });
581
+
582
+ it('truncates the seed to the last closed turn/end when the source has an unclosed tail', async () => {
583
+ const { agents, assessor } = harness({
584
+ hostAgentId: 'host-1',
585
+ createAgent: options => agents.create(options),
586
+ getStepSessionEvents: async () => [
587
+ ...closedEvents(),
588
+ boundaryEvent('turn/start', 4, { turn: 1 }),
589
+ messageEvent(5, 'in-flight output'),
590
+ ],
591
+ });
592
+ agents.createFollowupHandler = () => [{ type: 'text', text: phaseBJson('{}', '摘要') }];
593
+
594
+ const result = await assessor.assessForked({
595
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
596
+ stepSessionId: 'child-1',
597
+ context: { input: 'req', output: 'out' },
598
+ });
599
+
600
+ // seed 截到最后一个闭合 turn/end(seq 0-3),未闭合尾部(turn/start 4 + assistant 5)不进 seed。
601
+ expect(agents.createSeeds[0]).toEqual(closedEvents());
602
+ expect(agents.createMetas[0]?.seedLength).toBe(4);
603
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
604
+ expect(result.compressedOutput).toBe('摘要');
605
+ });
606
+
607
+ it('degrades to a temp session with errorDetail when the source has no closed turn at all', async () => {
608
+ const { llm, agents, assessor } = harness({
609
+ hostAgentId: 'host-1',
610
+ createAgent: options => agents.create(options),
611
+ getStepSessionEvents: async () => [
612
+ boundaryEvent('turn/start', 0, { turn: 0 }),
613
+ messageEvent(1, 'in-flight output'),
614
+ ],
615
+ });
616
+ llm.streamHandler = () => textChunks(phaseBJson('{}', '降级摘要'));
617
+
618
+ const result = await assessor.assessForked({
619
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
620
+ stepSessionId: 'child-1',
621
+ context: { input: 'req', output: 'out' },
622
+ });
623
+
624
+ // 无闭合 turn → 无法构造安全 seed → 降级临时会话(llm.stream 1 次)+ errorDetail(不失败)。
625
+ expect(agents.createSeeds).toHaveLength(0);
626
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
627
+ expect(result.errorDetail).toContain('no closed turn');
628
+ expect(result.compressedOutput).toBe('降级摘要');
629
+ expect(llm.calls).toHaveLength(1);
630
+ });
631
+
632
+ it('degrades to a temp session with errorDetail when agents.create throws', async () => {
633
+ const { llm, agents, assessor } = harness({
634
+ hostAgentId: 'host-1',
635
+ createAgent: options => agents.create(options),
636
+ getStepSessionEvents: async () => closedEvents(),
637
+ });
638
+ agents.createError = new Error('create boom');
639
+ llm.streamHandler = () => textChunks(phaseBJson('{}', '降级摘要'));
640
+
641
+ const result = await assessor.assessForked({
642
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
643
+ stepSessionId: 'child-1',
644
+ context: { input: 'req', output: 'out' },
645
+ });
646
+
647
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
648
+ expect(result.errorDetail).toContain('create boom');
649
+ expect(llm.calls).toHaveLength(1);
650
+ });
651
+
652
+ it('degrades to a temp session with errorDetail when hostAgentId/createAgent are not configured', async () => {
653
+ const { llm, assessor } = harness();
654
+ llm.streamHandler = () => textChunks(phaseBJson('{}', '降级摘要'));
655
+
656
+ const result = await assessor.assessForked({
657
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
658
+ stepSessionId: 'child-1',
659
+ context: { input: 'req', output: 'out' },
660
+ });
661
+
662
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
663
+ expect(result.errorDetail).toContain('not configured');
664
+ expect(llm.calls).toHaveLength(1);
665
+ });
666
+
667
+ it('degrades to a temp session with errorDetail when no event source exists (shell synthetic session)', async () => {
668
+ const { llm, agents, assessor } = harness({
669
+ hostAgentId: 'host-1',
670
+ createAgent: options => agents.create(options),
671
+ getStepSessionEvents: async () => undefined,
672
+ });
673
+ llm.streamHandler = () => textChunks(phaseBJson('{}', '降级摘要'));
674
+
675
+ const result = await assessor.assessForked({
676
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
677
+ stepSessionId: 'step-sh-abcdef01',
678
+ context: { input: '', output: 'stdout' },
679
+ });
680
+
681
+ expect(agents.createSeeds).toHaveLength(0);
682
+ expect(result.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
683
+ expect(result.errorDetail).toContain('no event source');
684
+ expect(llm.calls).toHaveLength(1);
685
+ });
686
+
687
+ it('follows up the same fork subagent with the JSON retry hint when the output is not parseable, then succeeds', async () => {
688
+ const { llm, agents, assessor } = harness({
689
+ hostAgentId: 'host-1',
690
+ createAgent: options => agents.create(options),
691
+ getStepSessionEvents: async () => closedEvents(),
692
+ });
693
+ // 首次返回非 JSON → followup 错误提示重试 → 第二次返回合法合并 JSON。
694
+ let call = 0;
695
+ agents.createFollowupHandler = () => {
696
+ call += 1;
697
+ return call === 1
698
+ ? [{ type: 'text', text: 'not json at all' }]
699
+ : [{ type: 'text', text: phaseBJson('{"projectName":"sandore"}', '重试后摘要') }];
700
+ };
701
+
702
+ const result = await assessor.assessForked({
703
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
704
+ stepSessionId: 'child-1',
705
+ context: { input: 'req', output: 'out' },
706
+ });
707
+
708
+ // 同一个 fork 子代理 followup 两次(首次判断 + 1 次纠正重试)。
709
+ const forkedAgent = agents.createdAgents[0]!;
710
+ expect(forkedAgent.followups).toHaveLength(2);
711
+ const hintText = (forkedAgent.followups[1]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
712
+ // 纠正提示动态携带解析失败原因(首次输出 'not json at all')。
713
+ expect(hintText).toContain('不是合法 JSON');
714
+ expect(hintText).toContain('compressedOutput');
715
+ expect(result.passThroughVars).toEqual({ projectName: 'sandore' });
716
+ expect(result.compressedOutput).toBe('重试后摘要');
717
+ expect(llm.calls).toHaveLength(0);
718
+ });
719
+
720
+ it('explains finalize JSON parse failures with concrete reasons', () => {
721
+ expect(explainFinalizeResultParse('{"passThroughVars": {}, "compressedOutput": "ok"}')).toBeUndefined();
722
+ expect(explainFinalizeResultParse('not json')).toContain('不是合法 JSON');
723
+ expect(explainFinalizeResultParse('{"passThroughVars": {}}')).toContain('缺少 compressedOutput');
724
+ expect(explainFinalizeResultParse('{"compressedOutput": 123}')).toContain('不是字符串');
725
+ });
726
+
727
+ it('buildFinalizeRetryHint carries the concrete parse failure reason', () => {
728
+ const hint = buildFinalizeRetryHint('{"passThroughVars": {}}');
729
+ expect(hint).toContain('缺少 compressedOutput');
730
+ expect(hint).toContain('{"passThroughVars": {}, "compressedOutput": "..."}');
731
+ expect(hint).toContain('```json');
732
+ });
733
+
734
+ it('throws FINALIZE_FAILED when the Phase B JSON stays unparseable after 3 retries (超限报错,不降级)', async () => {
735
+ const { llm, agents, assessor } = harness({
736
+ hostAgentId: 'host-1',
737
+ createAgent: options => agents.create(options),
738
+ getStepSessionEvents: async () => closedEvents(),
739
+ });
740
+ // 始终返回非法 JSON → 首次 + 3 次 followup 重试均解析失败 → 超限报错(Step 落 failed)。
741
+ agents.createFollowupHandler = () => [{ type: 'text', text: 'still not json' }];
742
+
743
+ await expect(assessor.assessForked({
744
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
745
+ stepSessionId: 'child-1',
746
+ context: { input: 'req', output: 'out' },
747
+ })).rejects.toMatchObject({
748
+ code: 'FINALIZE_FAILED',
749
+ message: expect.stringContaining('JSON parse failed after 3 retry'),
750
+ });
751
+
752
+ // 首次 + 3 次重试共 4 次 followup;评估完 dispose(live 移除)。
753
+ const forkedAgent = agents.createdAgents[0]!;
754
+ expect(forkedAgent.followups).toHaveLength(4);
755
+ expect(agents.get(forkedAgent.id)).toBeUndefined();
756
+ expect(llm.calls).toHaveLength(0);
757
+ });
758
+
759
+ it('throws FINALIZE_FAILED when the fork subagent produces no output (JSON 超限报错,不降级)', async () => {
760
+ const { llm, agents, assessor } = harness({
761
+ hostAgentId: 'host-1',
762
+ createAgent: options => agents.create(options),
763
+ // seed 仅含 turn 边界(无 assistant/message)→ followup 无回复时无输出可收集。
764
+ getStepSessionEvents: async () => [
765
+ boundaryEvent('turn/start', 0, { turn: 0 }),
766
+ boundaryEvent('turn/end', 1, { turn: 0, reason: { kind: 'completed' } }),
767
+ ],
768
+ });
769
+ // followup 不追加 assistant 回复 → finalAssistantOutput 无输出 → 重试仍无输出 → 超限报错。
770
+ agents.createFollowupHandler = () => undefined;
771
+
772
+ await expect(assessor.assessForked({
773
+ step: step({ command: { type: 'agent', value: { promptText: 'x' } } }),
774
+ stepSessionId: 'child-1',
775
+ context: { input: 'req', output: 'out' },
776
+ })).rejects.toMatchObject({ code: 'FINALIZE_FAILED', message: expect.stringContaining('JSON parse failed') });
777
+ expect(llm.calls).toHaveLength(0);
778
+ });
779
+ });
780
+
781
+ describe('GateEvaluator', () => {
782
+ // ── primary 门禁 = 纯代码产物校验(零 AI:不跑 llm.stream、不 fork 会话) ──────
783
+
784
+ it('passes the primary gate with pure artifact verification (zero AI calls)', async () => {
785
+ const { llm, fs, gateEvaluator } = harness();
786
+ fs.seedFile('/work/out/report.md');
787
+
788
+ const result = await gateEvaluator.evaluate({
789
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
790
+ context: { input: 'req', output: 'out', artifacts: ['/work/out/report.md'] },
791
+ gates: { primary: { enabled: true } },
792
+ workdir: '/work',
793
+ });
794
+
795
+ expect(result.primaryPassed).toBe(true);
796
+ expect(result.gatesPassed).toBe(true);
797
+ expect(result.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
798
+ expect(llm.calls).toHaveLength(0);
799
+ });
800
+
801
+ it('fails the primary gate when an outputTo file is missing (pure code check, zero AI calls)', async () => {
802
+ const { llm, gateEvaluator } = harness();
803
+
804
+ const result = await gateEvaluator.evaluate({
805
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
806
+ context: { input: 'req', output: 'out' },
807
+ gates: { primary: { enabled: true } },
808
+ workdir: '/work',
809
+ });
810
+
811
+ expect(result.primaryPassed).toBe(false);
812
+ expect(result.gatesPassed).toBe(false);
813
+ expect(result.errorDetail).toContain('missing');
814
+ expect(llm.calls).toHaveLength(0);
815
+ });
816
+
817
+ it('does not create a subagent when only the primary (code) gate runs', async () => {
818
+ const { llm, fs, sessions, agents, gateEvaluator } = harness();
819
+ fs.seedFile('/work/out/report.md');
820
+
821
+ const result = await gateEvaluator.evaluate({
822
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
823
+ context: { input: 'req', output: 'out' },
824
+ gates: { primary: { enabled: true } },
825
+ workdir: '/work',
826
+ stepSessionId: 'step-session-1',
827
+ });
828
+
829
+ // primary 纯代码校验不跑 AI → 不 fork、不建子代理(gateSessionId 为临时句柄占位)。
830
+ expect(sessions.forkedSources).toHaveLength(0);
831
+ expect(agents.createSeeds).toHaveLength(0);
832
+ expect(result.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
833
+ expect(result.gatesPassed).toBe(true);
834
+ expect(llm.calls).toHaveLength(0);
835
+ });
836
+
837
+ // ── secondary 门禁 = AI 门禁审查(显式 enabled:true 才跑;唯一门禁 AI 通道) ──
838
+
839
+ it('creates a host continuable subagent for the secondary gate via startContinuable(prompt 携带门禁提示词 + parent=宿主主代理;whenIdle 后取输出)', async () => {
840
+ const { llm, fs, agents, subagents, gateEvaluator } = harness();
841
+ subagents.outputHandler = () => [{ type: 'text', text: '{"gateStatus":"done"}' }];
842
+ fs.seedFile('/work/out/report.md');
843
+
844
+ const result = await gateEvaluator.evaluate({
845
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
846
+ context: { input: 'req', output: 'out', artifacts: ['/work/out/report.md'] },
847
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
848
+ workdir: '/work',
849
+ stepSessionId: 'step-session-1',
850
+ });
851
+
852
+ // secondary 经 startContinuable 建宿主可续接子代理:provider 缺省 'fork'、label 带
853
+ // step id、parent=宿主主代理(Agent 对象)、agentOptions 承载模型路由。
854
+ expect(subagents.starts).toHaveLength(1);
855
+ const spec = subagents.starts[0]!;
856
+ expect(spec.provider).toBe('fork');
857
+ expect(spec.label).toBe('gate step-a');
858
+ expect(spec.request.parent.id).toBe('host-1');
859
+ expect(spec.request.agentOptions).toEqual({ provider: 'p', model: 'm' });
860
+ // prompt 携带 secondary 门禁提示词(原样注入用户 prompt)。
861
+ const promptText = (spec.request.prompt[0] as { type: string; text: string } | undefined)?.text ?? '';
862
+ expect(promptText).toContain('review');
863
+ // gateSessionId = startContinuable 返回的真实 childId(UI 可打开)。
864
+ expect(result.gateSessionId).toBe('child-1');
865
+ // 输出收集:child.session.events 的 assistant 消息 → whenIdle → finalAssistantOutput →
866
+ // textSummary → parseSecondaryGate。
867
+ const gateAgent = agents.get('child-1')!;
868
+ expect(gateAgent.whenIdleCount).toBe(1);
869
+ expect(result.secondaryPassed).toBe(true);
870
+ expect(result.gatesPassed).toBe(true);
871
+ // 子代理路径不消费 llm.stream(agent loop 内模型调用由子代理自行处理)。
872
+ expect(llm.calls).toHaveLength(0);
873
+ });
874
+
875
+ it('follows up the same gate subagent with a reason-carrying correction hint on unparseable output, then succeeds', async () => {
876
+ const { llm, fs, agents, subagents, gateEvaluator } = harness();
877
+ let call = 0;
878
+ subagents.outputHandler = () => {
879
+ call += 1;
880
+ return call === 1 ? [{ type: 'text', text: 'not json at all' }] : undefined;
881
+ };
882
+ subagents.followupHandler = () => [{ type: 'text', text: '{"gateStatus":"done","gateDetail":"ok"}' }];
883
+ fs.seedFile('/work/out/report.md');
884
+
885
+ const result = await gateEvaluator.evaluate({
886
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
887
+ context: { input: 'req', output: 'out', artifacts: ['/work/out/report.md'] },
888
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
889
+ workdir: '/work',
890
+ stepSessionId: 'step-session-1',
891
+ });
892
+
893
+ // 解析失败 → 同一个门禁子代理 followup 纠正(带失败原因)→ 第二次合法 done。
894
+ expect(subagents.followups).toHaveLength(1);
895
+ const hintText = (subagents.followups[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
896
+ expect(hintText).toContain('不是合法 JSON');
897
+ expect(hintText).toContain('"gateStatus": "done"');
898
+ expect(result.secondaryPassed).toBe(true);
899
+ expect(result.gatesPassed).toBe(true);
900
+ expect(llm.calls).toHaveLength(0);
901
+ });
902
+
903
+ it('explains secondary gate parse failures with concrete reasons', () => {
904
+ expect(explainSecondaryGateParse('{"gateStatus": "done"}')).toBeUndefined();
905
+ expect(explainSecondaryGateParse('{"gateStatus": "error"}')).toBeUndefined();
906
+ expect(explainSecondaryGateParse('not json')).toContain('不是合法 JSON');
907
+ expect(explainSecondaryGateParse('{"gateDetail": "x"}')).toContain('缺少 gateStatus');
908
+ expect(explainSecondaryGateParse('{"gateStatus": "yes"}')).toContain('必须是 done 或 error');
909
+ });
910
+
911
+ it('buildSecondaryGateCorrectionHint carries the concrete parse failure reason', () => {
912
+ const hint = buildSecondaryGateCorrectionHint('{"gateStatus": "yes"}');
913
+ expect(hint).toContain('必须是 done 或 error');
914
+ expect(hint).toContain('"gateStatus": "done"');
915
+ expect(hint).toContain('"gateStatus": "error"');
916
+ expect(hint).toContain('```json');
917
+ });
918
+
919
+ it('degrades to a temp session when the secondary gate startContinuable throws (不失败,记 errorDetail)', async () => {
920
+ const { llm, fs, subagents, gateEvaluator } = harness();
921
+ subagents.startError = new Error('gate start boom');
922
+ llm.streamHandler = () => textChunks('{"gateStatus":"done"}');
923
+ fs.seedFile('/work/out/report.md');
924
+
925
+ const result = await gateEvaluator.evaluate({
926
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
927
+ context: { input: 'req', output: 'out', artifacts: ['/work/out/report.md'] },
928
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
929
+ workdir: '/work',
930
+ stepSessionId: 'missing-session',
931
+ });
932
+
933
+ // 降级一次性临时会话(runStream):gateSessionId 为 UUID 句柄、errorDetail 记原因。
934
+ expect(subagents.starts).toHaveLength(0);
935
+ expect(result.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
936
+ expect(result.errorDetail).toContain('gate start boom');
937
+ expect(llm.calls[0]?.sessionId).toBeUndefined();
938
+ expect(result.gatesPassed).toBe(true);
939
+ });
940
+
941
+ it('degrades to a temp session when hostAgent is not configured (配置缺省 → runStream + errorDetail)', async () => {
942
+ const { llm, fs, subagents, gateEvaluator } = harness({ hostAgent: undefined });
943
+ llm.streamHandler = () => textChunks('{"gateStatus":"done"}');
944
+ fs.seedFile('/work/out/report.md');
945
+
946
+ const result = await gateEvaluator.evaluate({
947
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
948
+ context: { input: 'req', output: 'out' },
949
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
950
+ workdir: '/work',
951
+ });
952
+
953
+ // 未注入 hostAgent → 不建子代理、直接降级临时会话(runStream)+ errorDetail。
954
+ expect(subagents.starts).toHaveLength(0);
955
+ expect(result.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
956
+ expect(result.errorDetail).toContain('hostAgent not configured');
957
+ expect(llm.calls).toHaveLength(1);
958
+ expect(result.gatesPassed).toBe(true);
959
+ });
960
+
961
+ it('records a secondary gate result when secondary is enabled (AI 走门禁子代理、不消费 llm.stream)', async () => {
962
+ const { llm, fs, subagents, gateEvaluator } = harness();
963
+ subagents.outputHandler = () => [{ type: 'text', text: '{"gateStatus":"done"}' }];
964
+ fs.seedFile('/work/out/report.md');
965
+
966
+ const result = await gateEvaluator.evaluate({
967
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
968
+ context: { input: 'req', output: 'out' },
969
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
970
+ workdir: '/work',
971
+ });
972
+
973
+ // primary 纯代码校验(零 AI)+ secondary 门禁子代理(agent loop 内模型调用)
974
+ // → llm.stream 零调用;startContinuable 恰好 1 次。
975
+ expect(subagents.starts).toHaveLength(1);
976
+ expect(llm.calls).toHaveLength(0);
977
+ expect(result.primaryPassed).toBe(true);
978
+ expect(result.secondaryPassed).toBe(true);
979
+ expect(result.gatesPassed).toBe(true);
980
+ });
981
+
982
+ it('secondary 提示词框架:原样注入用户 prompt + 返回格式,不内置完成度/质量检查要求', async () => {
983
+ const { llm, fs, subagents, gateEvaluator } = harness();
984
+ const userPrompt = '核对产物是否满足门控要求';
985
+ subagents.outputHandler = () => [{ type: 'text', text: '{"gateStatus":"done"}' }];
986
+ fs.seedFile('/work/out/report.md');
987
+
988
+ const result = await gateEvaluator.evaluate({
989
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
990
+ context: { input: 'req', output: 'out' },
991
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: userPrompt } },
992
+ workdir: '/work',
993
+ });
994
+
995
+ expect(result.gatesPassed).toBe(true);
996
+ const prompt = (subagents.starts[0]?.request.prompt[0] as { type: string; text: string } | undefined)?.text ?? '';
997
+ // 框架只注入身份 + stepContext + 用户 prompt 原样 + 返回 JSON 格式框架。
998
+ expect(prompt).toContain(userPrompt);
999
+ expect(prompt).toContain('"gateStatus"');
1000
+ // 不内置「检查完成度/质量」要求——完成度判断完全由用户 prompt 决定。
1001
+ expect(prompt).not.toContain('完成度');
1002
+ expect(prompt).not.toContain('质量');
1003
+ expect(prompt).not.toContain('基本完成');
1004
+ expect(prompt).not.toContain('完整门控审查');
1005
+ expect(llm.calls).toHaveLength(0);
1006
+ });
1007
+
1008
+ it('collects the gate output via injected awaitSubagentSettlement (settle output 优先;stopReason=completed)', async () => {
1009
+ const { llm, fs, subagents, gateEvaluator } = harness({
1010
+ awaitSubagentSettlement: async childId => ({
1011
+ childId,
1012
+ stopReason: 'completed',
1013
+ output: [{ type: 'text', text: '{"gateStatus":"done"}' }],
1014
+ }),
1015
+ });
1016
+ fs.seedFile('/work/out/report.md');
1017
+ // child 不预填输出——settle 载荷 output 携带门禁结果(覆盖 finalAssistantOutput 回退)。
1018
+
1019
+ const result = await gateEvaluator.evaluate({
1020
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
1021
+ context: { input: 'req', output: 'out' },
1022
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
1023
+ workdir: '/work',
1024
+ });
1025
+
1026
+ expect(result.gateSessionId).toBe('child-1');
1027
+ expect(result.secondaryPassed).toBe(true);
1028
+ expect(result.gatesPassed).toBe(true);
1029
+ expect(subagents.starts).toHaveLength(1);
1030
+ expect(llm.calls).toHaveLength(0);
1031
+ });
1032
+
1033
+ it('degrades to a temp session when the gate settlement is not completed (error → 进降级,记 errorDetail)', async () => {
1034
+ const { llm, fs, gateEvaluator } = harness({
1035
+ awaitSubagentSettlement: async childId => ({ childId, stopReason: 'error' }),
1036
+ });
1037
+ llm.streamHandler = () => textChunks('{"gateStatus":"done"}');
1038
+ fs.seedFile('/work/out/report.md');
1039
+
1040
+ const result = await gateEvaluator.evaluate({
1041
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
1042
+ context: { input: 'req', output: 'out' },
1043
+ gates: { primary: { enabled: true }, secondary: { enabled: true, prompt: 'review' } },
1044
+ workdir: '/work',
1045
+ });
1046
+
1047
+ // settle stopReason !== 'completed' → awaitGateOutput 抛错 → 降级临时会话。
1048
+ expect(result.errorDetail).toContain('settle error');
1049
+ expect(llm.calls).toHaveLength(1);
1050
+ expect(result.gatesPassed).toBe(true);
1051
+ });
1052
+
1053
+ it('omits primary passed flags when primary is explicitly disabled (only secondary runs)', async () => {
1054
+ const { llm, fs, subagents, gateEvaluator } = harness();
1055
+ subagents.outputHandler = () => [{ type: 'text', text: '{"gateStatus":"done"}' }];
1056
+ fs.seedFile('/work/out/report.md');
1057
+
1058
+ const result = await gateEvaluator.evaluate({
1059
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
1060
+ context: { input: 'req', output: 'out' },
1061
+ gates: { primary: { enabled: false }, secondary: { enabled: true, prompt: 'review' } },
1062
+ workdir: '/work',
1063
+ });
1064
+
1065
+ expect(result.gatesPassed).toBe(true);
1066
+ expect(result.primaryPassed).toBeUndefined();
1067
+ expect(result.secondaryPassed).toBe(true);
1068
+ expect(result.gateDetails).toMatchObject({
1069
+ primary: { enabled: false },
1070
+ secondary: { enabled: true, passed: true, prompt: 'review' },
1071
+ });
1072
+ expect(result.gateDetails?.primary).not.toHaveProperty('passed');
1073
+ expect(subagents.starts).toHaveLength(1);
1074
+ expect(llm.calls).toHaveLength(0);
1075
+ });
1076
+
1077
+ // ── 缺陷 5 次要差异:primary 默认 enabled=true(对齐基准 primary?.enabled ?? true) ──
1078
+ // 修前 sandore primary 默认 enabled=false(=== true 判定);用户只配置 secondary、
1079
+ // 未配置 primary 时基准 primary 默认执行,sandore 默认不执行 → 行为分歧。修后
1080
+ // primary 未配置即视为启用(!== false),与基准对齐。
1081
+
1082
+ it('defaults primary to enabled when only secondary is configured (code check + secondary AI)', async () => {
1083
+ const { llm, fs, subagents, gateEvaluator } = harness();
1084
+ // 未配置 primary,仅配置 secondary → primary 默认启用(纯代码校验,零 AI),
1085
+ // secondary 经 startContinuable 门禁子代理审查(输出 gateStatus:done)。
1086
+ subagents.outputHandler = () => [{ type: 'text', text: '{"gateStatus":"done"}' }];
1087
+ fs.seedFile('/work/out/report.md');
1088
+
1089
+ const result = await gateEvaluator.evaluate({
1090
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
1091
+ context: { input: 'req', output: 'out' },
1092
+ gates: { secondary: { enabled: true, prompt: 'review' } },
1093
+ workdir: '/work',
1094
+ });
1095
+
1096
+ // primary 默认启用:primaryPassed 被置位(非 undefined),gateDetails.primary.enabled=true。
1097
+ expect(result.primaryPassed).toBe(true);
1098
+ expect(result.secondaryPassed).toBe(true);
1099
+ expect(result.gatesPassed).toBe(true);
1100
+ expect(result.gateDetails).toMatchObject({
1101
+ primary: { enabled: true, passed: true },
1102
+ secondary: { enabled: true, passed: true, prompt: 'review' },
1103
+ });
1104
+ expect(subagents.starts).toHaveLength(1);
1105
+ expect(llm.calls).toHaveLength(0);
1106
+ });
1107
+
1108
+ it('defaults primary to enabled when neither primary nor secondary is configured but gates object exists (code-only, zero AI)', async () => {
1109
+ const { llm, fs, gateEvaluator } = harness();
1110
+ // gates: {}(三级合并后存在 gates 对象但 primary/secondary 均未配置)→
1111
+ // primary 默认启用执行纯代码校验、secondary 默认禁用不执行 → 零 AI 调用。
1112
+ fs.seedFile('/work/out/report.md');
1113
+
1114
+ const result = await gateEvaluator.evaluate({
1115
+ step: step({ outputTo: [{ id: 'opt-a', path: 'out/*.md' }] }),
1116
+ context: { input: 'req', output: 'out' },
1117
+ gates: {},
1118
+ workdir: '/work',
1119
+ });
1120
+
1121
+ expect(result.primaryPassed).toBe(true);
1122
+ expect(result.secondaryPassed).toBeUndefined();
1123
+ expect(result.gatesPassed).toBe(true);
1124
+ expect(result.gateDetails).toMatchObject({
1125
+ primary: { enabled: true, passed: true },
1126
+ secondary: { enabled: false },
1127
+ });
1128
+ expect(llm.calls).toHaveLength(0);
1129
+ });
1130
+
1131
+ it('verifies outputTo via regex when a regex is configured', async () => {
1132
+ const { llm, fs, gateEvaluator } = harness();
1133
+ fs.seedFile('/work/report.md');
1134
+
1135
+ const result = await gateEvaluator.evaluate({
1136
+ step: step({ outputTo: [{ id: 'opt-re', regex: 'report\\.md' }] }),
1137
+ context: { input: 'req', output: 'out' },
1138
+ gates: { primary: { enabled: true } },
1139
+ workdir: '/work',
1140
+ });
1141
+
1142
+ expect(result.gatesPassed).toBe(true);
1143
+ expect(llm.calls).toHaveLength(0);
1144
+ });
1145
+
1146
+ it('verifies outputTo via a literal (non-glob) path', async () => {
1147
+ const { llm, fs, gateEvaluator } = harness();
1148
+ fs.seedFile('/work/out/report.md');
1149
+
1150
+ const result = await gateEvaluator.evaluate({
1151
+ step: step({ outputTo: [{ id: 'opt-p', path: 'out/report.md' }] }),
1152
+ context: { input: 'req', output: 'out' },
1153
+ gates: { primary: { enabled: true } },
1154
+ workdir: '/work',
1155
+ });
1156
+
1157
+ expect(result.gatesPassed).toBe(true);
1158
+ expect(llm.calls).toHaveLength(0);
1159
+ });
1160
+
1161
+ it('reports the regex in errorDetail when a regex outputTo is missing', async () => {
1162
+ const { llm, gateEvaluator } = harness();
1163
+
1164
+ const result = await gateEvaluator.evaluate({
1165
+ step: step({ outputTo: [{ id: 'opt-re', regex: 'missing\\.md' }] }),
1166
+ context: { input: 'req', output: 'out' },
1167
+ gates: { primary: { enabled: true } },
1168
+ workdir: '/work',
1169
+ });
1170
+
1171
+ expect(result.gatesPassed).toBe(false);
1172
+ expect(result.errorDetail).toContain('missing\\.md');
1173
+ expect(llm.calls).toHaveLength(0);
1174
+ });
1175
+
1176
+ // ── 缺陷 6:非 glob path 精确存在性检查(替代 findFilesByRegex 误用) ──────────
1177
+
1178
+ it('reports missing for a non-glob literal path when the file is absent and no similar files exist', async () => {
1179
+ const { llm, gateEvaluator } = harness();
1180
+
1181
+ const result = await gateEvaluator.evaluate({
1182
+ step: step({ outputTo: [{ id: 'opt-p', path: 'out/report.md' }] }),
1183
+ context: { input: 'req', output: 'out' },
1184
+ gates: { primary: { enabled: true } },
1185
+ workdir: '/work',
1186
+ });
1187
+
1188
+ expect(result.primaryPassed).toBe(false);
1189
+ expect(result.gatesPassed).toBe(false);
1190
+ expect(result.errorDetail).toContain('out/report.md');
1191
+ expect(llm.calls).toHaveLength(0);
1192
+ });
1193
+
1194
+ it('passes a non-glob literal path with a ./ prefix when the file exists (no false missing)', async () => {
1195
+ const { llm, fs, gateEvaluator } = harness();
1196
+ fs.seedFile('/work/out/report.md');
1197
+
1198
+ const result = await gateEvaluator.evaluate({
1199
+ step: step({ outputTo: [{ id: 'opt-p', path: './out/report.md' }] }),
1200
+ context: { input: 'req', output: 'out' },
1201
+ gates: { primary: { enabled: true } },
1202
+ workdir: '/work',
1203
+ });
1204
+
1205
+ // 旧实现用非锚定 RegExp('./out/report.md'):首字符 `.` 为正则元字符,相对路径
1206
+ // 'out/report.md' 无前导字符 → 确定性误报缺失(门禁恒失败)。精确存在性检查只
1207
+ // 查 join 后的 /work/out/report.md 本身 → 存在即通过。
1208
+ expect(result.gatesPassed).toBe(true);
1209
+ expect(llm.calls).toHaveLength(0);
1210
+ });
1211
+
1212
+ it('still reports missing for a non-glob literal path when a nested similar file would falsely match under regex', async () => {
1213
+ const { llm, fs, gateEvaluator } = harness();
1214
+ // 期望产物 output/result.txt 本身不存在;仅树内存在嵌套同名/相似文件。
1215
+ // 旧实现用非锚定 RegExp(output/result.txt) 全树子串匹配会命中
1216
+ // dist/output/result.txt(含子串 'output/result.txt')→ 该报缺失却放行(漏报)。
1217
+ // 精确存在性检查只查 /work/output/result.txt 本身 → 不被相似文件误命中 → 报缺失。
1218
+ fs.seedFile('/work/dist/output/result.txt');
1219
+ fs.seedFile('/work/output/result.txt.bak');
1220
+ fs.seedFile('/work/result1txt');
1221
+
1222
+ const result = await gateEvaluator.evaluate({
1223
+ step: step({ outputTo: [{ id: 'opt-p', path: 'output/result.txt' }] }),
1224
+ context: { input: 'req', output: 'out' },
1225
+ gates: { primary: { enabled: true } },
1226
+ workdir: '/work',
1227
+ });
1228
+
1229
+ expect(result.gatesPassed).toBe(false);
1230
+ expect(result.errorDetail).toContain('output/result.txt');
1231
+ expect(llm.calls).toHaveLength(0);
1232
+ });
1233
+
1234
+ it('still resolves glob and regex outputTo branches after the non-glob fix (regression)', async () => {
1235
+ const { llm, fs, gateEvaluator } = harness();
1236
+ fs.seedFile('/work/out/report.md');
1237
+ fs.seedFile('/work/report.md');
1238
+
1239
+ const result = await gateEvaluator.evaluate({
1240
+ step: step({
1241
+ outputTo: [
1242
+ { id: 'opt-glob', path: 'out/*.md' },
1243
+ { id: 'opt-re', regex: 'report\\.md' },
1244
+ ],
1245
+ }),
1246
+ context: { input: 'req', output: 'out' },
1247
+ gates: { primary: { enabled: true } },
1248
+ workdir: '/work',
1249
+ });
1250
+
1251
+ // glob 分支仍走 findFilesByGlob(锚定 ^out/[^/]*\.md$)、regex 字段仍走
1252
+ // findFilesByRegex(非锚定全树匹配,语义保留);两者命中即通过。
1253
+ expect(result.gatesPassed).toBe(true);
1254
+ expect(llm.calls).toHaveLength(0);
1255
+ });
1256
+
1257
+ // ── 缺陷 19:isGlobPattern 全特征检测后,regex 写法入 path 字段不再误判 glob ──
1258
+ // 联动缺陷 6(已修复):非 glob → fileExists 精确存在性检查,不走 findFilesByRegex
1259
+ // ——否则 `.*`→`new RegExp('.*')` 匹配一切→门禁恒放行。以下测试确认联动安全。
1260
+
1261
+ it('reports missing for path=.* (no literal file named .*) instead of 恒放行 via findFilesByRegex', async () => {
1262
+ const { llm, fs, gateEvaluator } = harness();
1263
+ // 播种一个普通产物文件(无点文件):若缺陷 6 未修复,`.*` 非glob 会落入
1264
+ // findFilesByRegex → `new RegExp('.*')` 匹配 report.md → 门禁恒放行(危险)。
1265
+ // 缺陷 6 已修复:非glob → fileExists 精确查字面 `.*` → 不存在 → 报 missing。
1266
+ fs.seedFile('/work/report.md');
1267
+
1268
+ const result = await gateEvaluator.evaluate({
1269
+ step: step({ outputTo: [{ id: 'opt-dotstar', path: '.*' }] }),
1270
+ context: { input: 'req', output: 'out' },
1271
+ gates: { primary: { enabled: true } },
1272
+ workdir: '/work',
1273
+ });
1274
+
1275
+ expect(result.primaryPassed).toBe(false);
1276
+ expect(result.gatesPassed).toBe(false);
1277
+ expect(result.errorDetail).toContain('.*');
1278
+ expect(llm.calls).toHaveLength(0);
1279
+ });
1280
+
1281
+ it('reports missing for path=[^a]* and does not falsely pass via globToRegex silent match', async () => {
1282
+ const { llm, fs, gateEvaluator } = harness();
1283
+ // 播种一个修前 globToRegex 会静默误命中的文件:globToRegex('[^a]*')=^\[\^a\][^/]*$
1284
+ // 匹配字面 `[^a]` 前缀 → 误命中 `[^a]note.md` → 门禁假放行(静默错误匹配)。
1285
+ // 修后:`[^a]*` 命中 `[^` 特征 → 非glob → fileExists 精确查字面 `[^a]*` → 不存在 → 报 missing。
1286
+ fs.seedFile('/work/[^a]note.md');
1287
+
1288
+ const result = await gateEvaluator.evaluate({
1289
+ step: step({ outputTo: [{ id: 'opt-negclass', path: '[^a]*' }] }),
1290
+ context: { input: 'req', output: 'out' },
1291
+ gates: { primary: { enabled: true } },
1292
+ workdir: '/work',
1293
+ });
1294
+
1295
+ expect(result.primaryPassed).toBe(false);
1296
+ expect(result.gatesPassed).toBe(false);
1297
+ expect(result.errorDetail).toContain('[^a]*');
1298
+ expect(llm.calls).toHaveLength(0);
1299
+ });
1300
+
1301
+ // ── 缺陷 34:outputTo path/regex 含 ${xxx} 透传变量占位符的解析与延迟 ──────────
1302
+
1303
+ it('passes the gate when a ${xxx} path resolves via vars and the file exists', async () => {
1304
+ const { llm, fs, gateEvaluator } = harness();
1305
+ fs.seedFile('/work/artifacts/my-project/report.md');
1306
+
1307
+ const result = await gateEvaluator.evaluate({
1308
+ step: step({ outputTo: [{ id: 'opt-var', path: 'artifacts/${projectName}/report.md' }] }),
1309
+ context: { input: 'req', output: 'out' },
1310
+ gates: { primary: { enabled: true } },
1311
+ workdir: '/work',
1312
+ vars: { projectName: 'my-project' },
1313
+ });
1314
+
1315
+ expect(result.gatesPassed).toBe(true);
1316
+ expect(llm.calls).toHaveLength(0);
1317
+ });
1318
+
1319
+ it('reports missing when a ${xxx} path resolves via vars but the file is absent', async () => {
1320
+ const { llm, gateEvaluator } = harness();
1321
+
1322
+ const result = await gateEvaluator.evaluate({
1323
+ step: step({ outputTo: [{ id: 'opt-var', path: 'artifacts/${projectName}/report.md' }] }),
1324
+ context: { input: 'req', output: 'out' },
1325
+ gates: { primary: { enabled: true } },
1326
+ workdir: '/work',
1327
+ vars: { projectName: 'my-project' },
1328
+ });
1329
+
1330
+ expect(result.gatesPassed).toBe(false);
1331
+ // 报缺失的 pattern 是解析后的字面路径(含真实变量值),便于定位。
1332
+ expect(result.errorDetail).toContain('artifacts/my-project/report.md');
1333
+ expect(llm.calls).toHaveLength(0);
1334
+ });
1335
+
1336
+ it('reports missing when vars lack the variable (deferred literal ${xxx} never matches)', async () => {
1337
+ const { llm, gateEvaluator } = harness();
1338
+ // 修前原实现:${projectName} 原样入 findFilesByRegex → new RegExp('${projectName}')
1339
+ // 的 `$` 行尾锚 + 花括号字面量永不匹配 → 确定性报 missing。resolveOutputToVars
1340
+ // 缺省 vars={} 时 deferred=true,字面占位路径走 fileExists 不命中 → 同样报 missing。
1341
+ const result = await gateEvaluator.evaluate({
1342
+ step: step({ outputTo: [{ id: 'opt-var', path: 'artifacts/${projectName}/report.md' }] }),
1343
+ context: { input: 'req', output: 'out' },
1344
+ gates: { primary: { enabled: true } },
1345
+ workdir: '/work',
1346
+ // 不传 vars(缺省 {})→ ${projectName} 未解析 → deferred → 字面不命中。
1347
+ });
1348
+
1349
+ expect(result.gatesPassed).toBe(false);
1350
+ expect(result.errorDetail).toContain('artifacts/${projectName}/report.md');
1351
+ expect(llm.calls).toHaveLength(0);
1352
+ });
1353
+
1354
+ it('passes the gate when a ${xxx} regex resolves via vars and a file matches', async () => {
1355
+ const { llm, fs, gateEvaluator } = harness();
1356
+ fs.seedFile('/work/out/my-project.md');
1357
+
1358
+ const result = await gateEvaluator.evaluate({
1359
+ step: step({ outputTo: [{ id: 'opt-re', regex: '^out/${projectName}\\.md$' }] }),
1360
+ context: { input: 'req', output: 'out' },
1361
+ gates: { primary: { enabled: true } },
1362
+ workdir: '/work',
1363
+ vars: { projectName: 'my-project' },
1364
+ });
1365
+
1366
+ expect(result.gatesPassed).toBe(true);
1367
+ expect(llm.calls).toHaveLength(0);
1368
+ });
1369
+ });
1370
+
1371
+ describe('FinalizeManager.finalizeStep', () => {
1372
+ it('skips finalize with zero AI calls when there are no vars and no gates', async () => {
1373
+ const { llm, manager } = harness();
1374
+
1375
+ const outcome = await manager.finalizeStep({
1376
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
1377
+ executedStep: executedStep(),
1378
+ context: { input: '', output: '' },
1379
+ workdir: '/work',
1380
+ });
1381
+
1382
+ expect(outcome).toEqual({ type: 'done' });
1383
+ expect(llm.calls).toHaveLength(0);
1384
+ });
1385
+
1386
+ // ── 缺陷 5 次要差异:hasEnabledGates 短路(primary 默认 enabled=true 下的跳过语义) ──
1387
+
1388
+ it('skips finalize (zero AI calls) when gates is undefined (three-level all unconfigured)', async () => {
1389
+ // mergeGates 三级均未配置 → 返回 undefined → hasEnabledGates(undefined)=false → 跳过。
1390
+ // 显式传 undefined gates 锁定「三级全未配置时不跑门禁」语义(修后仍成立)。
1391
+ const { llm, manager } = harness();
1392
+
1393
+ const outcome = await manager.finalizeStep({
1394
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
1395
+ executedStep: executedStep(),
1396
+ context: { input: '', output: '' },
1397
+ workdir: '/work',
1398
+ gates: undefined,
1399
+ });
1400
+
1401
+ expect(outcome).toEqual({ type: 'done' });
1402
+ expect(llm.calls).toHaveLength(0);
1403
+ });
1404
+
1405
+ it('skips finalize (zero AI calls) when both primary and secondary are explicitly disabled', async () => {
1406
+ // gates 对象存在但 primary.enabled=false 且 secondary.enabled=false →
1407
+ // hasEnabledGates:primary?.enabled !== false → false;secondary?.enabled === true → false
1408
+ // → 短路返回 false → 跳过门禁(零 AI 调用)。锁定 !== false 语义下显式禁用仍跳过。
1409
+ const { llm, manager } = harness();
1410
+
1411
+ const outcome = await manager.finalizeStep({
1412
+ step: step({ command: { type: 'shell', value: { command: 'echo hi' } } }),
1413
+ executedStep: executedStep(),
1414
+ context: { input: '', output: '' },
1415
+ workdir: '/work',
1416
+ gates: { primary: { enabled: false }, secondary: { enabled: false } },
1417
+ });
1418
+
1419
+ expect(outcome).toEqual({ type: 'done' });
1420
+ expect(llm.calls).toHaveLength(0);
1421
+ });
1422
+
1423
+ it('marks the step interacting and skips the gate when assess returns ask', async () => {
1424
+ const { llm, manager } = harness();
1425
+ llm.streamHandler = () =>
1426
+ textChunks('{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"选择分支","options":[{"label":"A"}]}]}}');
1427
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1428
+
1429
+ const outcome = await manager.finalizeStep({
1430
+ step: step({
1431
+ command: { type: 'prompt', value: { promptText: 'x' } },
1432
+ passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
1433
+ }),
1434
+ executedStep: executed,
1435
+ stepSessionId: 's1',
1436
+ context: { input: 'req', output: 'out' },
1437
+ workdir: '/work',
1438
+ gates: { primary: { enabled: true } },
1439
+ });
1440
+
1441
+ expect(outcome.type).toBe('ask');
1442
+ expect(executed.status).toBe('interacting');
1443
+ expect(llm.calls).toHaveLength(1);
1444
+ });
1445
+
1446
+ // ── 假 ask 防御:ask 判定但无 askQuestion 载荷 → 回落 completed(不置 interacting) ──
1447
+ // 修前 hasAsk=true 但 askQuestion===undefined 时仍置 status:'ask' →
1448
+ // findAskStep 未命中 → 实例永久卡 interacting、UI 无选择框。
1449
+
1450
+ it('treats ask without askQuestion payload as completed instead of fake ask', async () => {
1451
+ const { llm, manager } = harness();
1452
+ llm.streamHandler = () => textChunks('{"hasAsk":true,"passThroughVars":{}}');
1453
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1454
+
1455
+ const outcome = await manager.finalizeStep({
1456
+ step: step({
1457
+ command: { type: 'prompt', value: { promptText: 'x' } },
1458
+ passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
1459
+ }),
1460
+ executedStep: executed,
1461
+ stepSessionId: 's1',
1462
+ context: { input: 'req', output: 'out' },
1463
+ workdir: '/work',
1464
+ });
1465
+
1466
+ expect(outcome.type).toBe('done');
1467
+ expect(executed.status).toBe('completed');
1468
+ expect(executed.finalizeMeta?.currentPhase).toBe('completed');
1469
+ expect(executed.finalizeMeta?.assessResult?.passThroughVars).toEqual({});
1470
+ });
1471
+
1472
+ it('treats a fenced ask-without-question output as completed too (fence + fake-ask combined)', async () => {
1473
+ const { llm, manager } = harness();
1474
+ llm.streamHandler = () =>
1475
+ textChunks('```json\n{"hasAsk":true,"passThroughVars":{}}\n```');
1476
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1477
+
1478
+ const outcome = await manager.finalizeStep({
1479
+ step: step({
1480
+ command: { type: 'prompt', value: { promptText: 'x' } },
1481
+ passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
1482
+ }),
1483
+ executedStep: executed,
1484
+ stepSessionId: 's1',
1485
+ context: { input: 'req', output: 'out' },
1486
+ workdir: '/work',
1487
+ });
1488
+
1489
+ expect(outcome.type).toBe('done');
1490
+ expect(executed.status).toBe('completed');
1491
+ expect(executed.finalizeMeta?.currentPhase).toBe('completed');
1492
+ });
1493
+
1494
+ it('writes back extracted passThroughVars preserving the refresh flag', async () => {
1495
+ const { llm, manager } = harness();
1496
+ llm.streamHandler = () => textChunks('{"completionType":"completed","passThroughVars":{"projectName":"sandore"},"compressedOutput":"摘要"}');
1497
+ const passThroughVars: Record<string, PassThroughVarValue> = {};
1498
+
1499
+ await manager.finalizeStep({
1500
+ step: step({
1501
+ command: { type: 'prompt', value: { promptText: 'x' } },
1502
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project', refresh: true }],
1503
+ }),
1504
+ executedStep: executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1505
+ stepSessionId: 's1',
1506
+ context: { input: 'req', output: 'out' },
1507
+ workdir: '/work',
1508
+ passThroughVars,
1509
+ });
1510
+
1511
+ expect(passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project', refresh: true });
1512
+ });
1513
+
1514
+ it('records a complete finalizeMeta with currentPhase/assessSessionId/assessResult/gateResult', async () => {
1515
+ const { llm, fs, manager } = harness();
1516
+ // primary 纯代码校验(零 AI):只有 assess 一次 LLM 调用(无门禁 AI)。
1517
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
1518
+ fs.seedFile('/work/out/report.md');
1519
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1520
+
1521
+ const outcome = await manager.finalizeStep({
1522
+ step: step({
1523
+ command: { type: 'prompt', value: { promptText: 'x' } },
1524
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1525
+ outputTo: [{ id: 'opt-a', path: 'out/*.md' }],
1526
+ }),
1527
+ executedStep: executed,
1528
+ stepSessionId: 's1',
1529
+ context: { input: 'req', output: 'out' },
1530
+ workdir: '/work',
1531
+ gates: { primary: { enabled: true } },
1532
+ });
1533
+
1534
+ expect(outcome.type).toBe('done');
1535
+ expect(executed.finalizeMeta?.currentPhase).toBe('completed');
1536
+ expect(executed.finalizeMeta?.assessResult).toBeDefined();
1537
+ // Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
1538
+ expect(executed.finalizeMeta?.assessResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
1539
+ expect(executed.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
1540
+ // Phase C 仅 primary 纯代码校验(无 secondary AI)→ 不建子代理,
1541
+ // gateSessionId 为临时句柄占位(schema 强约束)。
1542
+ expect(executed.finalizeMeta?.gateResult?.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
1543
+ // Phase A(临时会话)+ Phase B(降级临时会话)= 2 次 LLM 调用。
1544
+ expect(llm.calls).toHaveLength(2);
1545
+ });
1546
+
1547
+ it('returns error with missing-file detail when the gate fails on outputTo', async () => {
1548
+ const { llm, manager } = harness();
1549
+ // primary 纯代码校验(零 AI):只有 assess 一次 LLM 调用;门禁报 missing 走代码校验。
1550
+ llm.streamHandler = () => textChunks('{"completionType":"completed","passThroughVars":{},"compressedOutput":"摘要"}');
1551
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1552
+
1553
+ const outcome = await manager.finalizeStep({
1554
+ step: step({
1555
+ command: { type: 'prompt', value: { promptText: 'x' } },
1556
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1557
+ outputTo: [{ id: 'opt-a', path: 'out/*.md' }],
1558
+ }),
1559
+ executedStep: executed,
1560
+ stepSessionId: 's1',
1561
+ context: { input: 'req', output: 'out' },
1562
+ workdir: '/work',
1563
+ gates: { primary: { enabled: true } },
1564
+ });
1565
+
1566
+ expect(outcome).toMatchObject({
1567
+ type: 'error',
1568
+ errorDetail: expect.stringContaining('missing'),
1569
+ });
1570
+ });
1571
+
1572
+ it('does not write back extracted vars that are not declared on the step', async () => {
1573
+ const { llm, manager } = harness();
1574
+ llm.streamHandler = () =>
1575
+ textChunks('{"completionType":"completed","passThroughVars":{"undeclared":"x","projectName":"sandore"},"compressedOutput":"摘要"}');
1576
+ const passThroughVars: Record<string, PassThroughVarValue> = {};
1577
+
1578
+ await manager.finalizeStep({
1579
+ step: step({
1580
+ command: { type: 'prompt', value: { promptText: 'x' } },
1581
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1582
+ }),
1583
+ executedStep: executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1584
+ stepSessionId: 's1',
1585
+ context: { input: 'req', output: 'out' },
1586
+ workdir: '/work',
1587
+ passThroughVars,
1588
+ });
1589
+
1590
+ expect(passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
1591
+ expect(passThroughVars['undeclared']).toBeUndefined();
1592
+ });
1593
+
1594
+ // ── 缺陷 34:自引用变量在门禁时解析(vars = 累加器 + 当次 assess 提取值合并) ──
1595
+
1596
+ it('resolves a self-referential ${xxx} outputTo at gate time using merged vars (file exists → done)', async () => {
1597
+ // 场景:Step 声明 projectName(值 ${projectName} 占位)并在自身 outputTo 引用它。
1598
+ // assess 提取 projectName=my-project → 合并进 vars → 门禁 verifyArtifacts 解析
1599
+ // artifacts/${projectName}/report.md → artifacts/my-project/report.md 命中文件 → 通过。
1600
+ // primary 纯代码校验(零 AI):只有 assess 一次 LLM 调用。
1601
+ const { llm, fs, manager } = harness();
1602
+ llm.streamHandler = () => textChunks('{"completionType":"completed","passThroughVars":{"projectName":"my-project"},"compressedOutput":"摘要"}');
1603
+ fs.seedFile('/work/artifacts/my-project/report.md');
1604
+ const passThroughVars: Record<string, PassThroughVarValue> = {
1605
+ projectName: { value: '${projectName}', description: 'project' },
1606
+ };
1607
+
1608
+ const outcome = await manager.finalizeStep({
1609
+ step: step({
1610
+ command: { type: 'prompt', value: { promptText: 'x' } },
1611
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1612
+ outputTo: [{ id: 'opt-var', path: 'artifacts/${projectName}/report.md' }],
1613
+ }),
1614
+ executedStep: executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1615
+ stepSessionId: 's1',
1616
+ context: { input: 'req', output: 'out' },
1617
+ workdir: '/work',
1618
+ gates: { primary: { enabled: true } },
1619
+ passThroughVars,
1620
+ });
1621
+
1622
+ expect(outcome.type).toBe('done');
1623
+ expect(outcome.gateResult?.gatesPassed).toBe(true);
1624
+ // 回写后累加器值已更新(persistFinalize 写回 extracted 值)。
1625
+ expect(passThroughVars['projectName']).toEqual({ value: 'my-project', description: 'project' });
1626
+ // Phase A(临时会话)+ Phase B(降级临时会话)= 2 次 LLM 调用。
1627
+ expect(llm.calls).toHaveLength(2);
1628
+ });
1629
+
1630
+ it('reports missing at gate time when a self-referential ${xxx} outputTo resolves but the file is absent', async () => {
1631
+ // 同上,但文件缺失 → verifyArtifacts 解析后命中字面 artifacts/my-project/report.md
1632
+ // 不存在 → 报 missing → gatesPassed=false → outcome.type=error。
1633
+ const { llm, manager } = harness();
1634
+ llm.streamHandler = () => textChunks('{"completionType":"completed","passThroughVars":{"projectName":"my-project"},"compressedOutput":"摘要"}');
1635
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1636
+
1637
+ const outcome = await manager.finalizeStep({
1638
+ step: step({
1639
+ command: { type: 'prompt', value: { promptText: 'x' } },
1640
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1641
+ outputTo: [{ id: 'opt-var', path: 'artifacts/${projectName}/report.md' }],
1642
+ }),
1643
+ executedStep: executed,
1644
+ stepSessionId: 's1',
1645
+ context: { input: 'req', output: 'out' },
1646
+ workdir: '/work',
1647
+ gates: { primary: { enabled: true } },
1648
+ });
1649
+
1650
+ expect(outcome).toMatchObject({
1651
+ type: 'error',
1652
+ errorDetail: expect.stringContaining('artifacts/my-project/report.md'),
1653
+ });
1654
+ });
1655
+ });
1656
+
1657
+ describe('FinalizeManager 3 阶段拆分(assessOnly / evaluateGates / commitFinalize,5 阶段收尾)', () => {
1658
+ it('assessOnly 只跑评估并返回 hasAsk(不跑门禁、不回写 finalizeMeta)', async () => {
1659
+ const { llm, manager } = harness();
1660
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
1661
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1662
+
1663
+ const assessResult = await manager.assessOnly({
1664
+ step: step({
1665
+ command: { type: 'prompt', value: { promptText: 'x' } },
1666
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1667
+ }),
1668
+ executedStep: executed,
1669
+ stepSessionId: 's1',
1670
+ context: { input: 'req', output: 'out' },
1671
+ workdir: '/work',
1672
+ });
1673
+
1674
+ expect(assessResult.hasAsk).toBe(false);
1675
+ expect(assessResult.passThroughVars).toEqual({ projectName: 'sandore' });
1676
+ expect(llm.calls).toHaveLength(1);
1677
+ // Phase A 不回写任何状态。
1678
+ expect(executed.finalizeMeta).toBeUndefined();
1679
+ });
1680
+
1681
+ it('assessOnly 假 ask 防御:hasAsk 但无 askQuestion 载荷回落 hasAsk=false', async () => {
1682
+ const { llm, manager } = harness();
1683
+ llm.streamHandler = () => textChunks('{"hasAsk":true,"passThroughVars":{}}');
1684
+
1685
+ const assessResult = await manager.assessOnly({
1686
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1687
+ executedStep: executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1688
+ stepSessionId: 's1',
1689
+ context: { input: 'req', output: 'out' },
1690
+ workdir: '/work',
1691
+ });
1692
+
1693
+ expect(assessResult.hasAsk).toBe(false);
1694
+ });
1695
+
1696
+ it('evaluateGates 只跑门禁(primary 纯代码校验,零 AI)并返回 gateResult(不回写状态)', async () => {
1697
+ const { llm, fs, manager } = harness();
1698
+ fs.seedFile('/work/out/report.md');
1699
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1700
+
1701
+ const assessResult: FinalizeAssessResult = {
1702
+ hasAsk: false,
1703
+ passThroughVars: {},
1704
+ forkSessionId: 'session-1',
1705
+ assessmentRaw: '',
1706
+ };
1707
+ const gateResult = await manager.evaluateGates({
1708
+ step: step({
1709
+ command: { type: 'prompt', value: { promptText: 'x' } },
1710
+ outputTo: [{ id: 'opt-a', path: 'out/*.md' }],
1711
+ }),
1712
+ executedStep: executed,
1713
+ stepSessionId: 's1',
1714
+ context: { input: 'req', output: 'out' },
1715
+ workdir: '/work',
1716
+ gates: { primary: { enabled: true } },
1717
+ }, assessResult);
1718
+
1719
+ // primary 纯代码校验:产物存在 → 通过;无 secondary → 零 AI、不 fork 门禁会话。
1720
+ expect(gateResult.gatesPassed).toBe(true);
1721
+ expect(gateResult.primaryPassed).toBe(true);
1722
+ expect(gateResult.gateSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
1723
+ expect(llm.calls).toHaveLength(0);
1724
+ expect(executed.finalizeMeta).toBeUndefined();
1725
+ });
1726
+
1727
+ it('commitFinalize 组装 finalizeMeta(currentPhase + assessSessionId/gateSessionId)并回写透传变量', async () => {
1728
+ const { llm, manager } = harness();
1729
+ llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
1730
+ const passThroughVars: Record<string, PassThroughVarValue> = {};
1731
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1732
+ const assessResult = await manager.assessOnly({
1733
+ step: step({
1734
+ command: { type: 'prompt', value: { promptText: 'x' } },
1735
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1736
+ }),
1737
+ executedStep: executed,
1738
+ stepSessionId: 's1',
1739
+ context: { input: 'req', output: 'out' },
1740
+ workdir: '/work',
1741
+ passThroughVars,
1742
+ });
1743
+
1744
+ await manager.commitFinalize({
1745
+ step: step({
1746
+ command: { type: 'prompt', value: { promptText: 'x' } },
1747
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1748
+ }),
1749
+ executedStep: executed,
1750
+ stepSessionId: 's1',
1751
+ context: { input: 'req', output: 'out' },
1752
+ workdir: '/work',
1753
+ passThroughVars,
1754
+ }, assessResult, undefined, 'finalizing');
1755
+
1756
+ // Phase B 收尾:透传变量已回写(保留 refresh)。
1757
+ expect(passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
1758
+ // finalizeMeta:currentPhase='finalizing';Round 17 顶层 assessSessionId 已删除,
1759
+ // sessionId 从 assessResult.forkSessionId 内读。
1760
+ expect(executed.finalizeMeta?.currentPhase).toBe('finalizing');
1761
+ expect(executed.finalizeMeta?.assessResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
1762
+ expect(executed.finalizeMeta?.assessResult?.passThroughVars).toEqual({ projectName: 'sandore' });
1763
+ expect(executed.finalizeMeta?.gateResult).toBeUndefined();
1764
+ });
1765
+
1766
+ it('commitFinalize 触发 onPhaseChange 回调(step + childId + phase 透传,供引擎双发 sandore/step-phase)', async () => {
1767
+ const { manager } = harness();
1768
+ const phases: Array<{ stepId: string; childId: string | undefined; phase: string }> = [];
1769
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1770
+ // 收尾阶段事件 childId 取执行段 sessionMeta.childId(真实子代理或合成占位)。
1771
+ executed.sessionMeta = { childId: 'child-1', synthetic: false, command: 'prompt' };
1772
+
1773
+ await manager.commitFinalize({
1774
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1775
+ executedStep: executed,
1776
+ stepSessionId: 's1',
1777
+ context: { input: 'req', output: 'out' },
1778
+ workdir: '/work',
1779
+ onPhaseChange: (step, childId, phase) => phases.push({ stepId: step.id, childId, phase }),
1780
+ }, undefined, undefined, 'completed');
1781
+
1782
+ expect(phases).toEqual([{ stepId: 'step-a', childId: 'child-1', phase: 'completed' }]);
1783
+ // 回调在 finalizeMeta 写入后触发(阶段已落盘)。
1784
+ expect(executed.finalizeMeta?.currentPhase).toBe('completed');
1785
+ });
1786
+
1787
+ it('Phase A ask 保底中断的 finalizeMeta 不记录顶层 assessSessionId(Phase A 临时会话不持久化,4.6/4.1)', async () => {
1788
+ const { llm, manager } = harness();
1789
+ llm.streamHandler = () =>
1790
+ textChunks('{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"确认继续?"}]}}');
1791
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1792
+
1793
+ const outcome = await manager.finalizeStep({
1794
+ step: step({
1795
+ command: { type: 'prompt', value: { promptText: 'x' } },
1796
+ passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
1797
+ }),
1798
+ executedStep: executed,
1799
+ stepSessionId: 's1',
1800
+ context: { input: 'req', output: 'out' },
1801
+ workdir: '/work',
1802
+ });
1803
+
1804
+ expect(outcome.type).toBe('ask');
1805
+ expect(executed.status).toBe('interacting');
1806
+ // Phase A 中断快照:currentPhase='ask-detect';顶层 assessSessionId 不持久化
1807
+ // (计划:Phase A 临时会话用完即删、Phase B 收尾才落 assessSessionId)。
1808
+ expect(executed.finalizeMeta?.currentPhase).toBe('ask-detect');
1809
+ expect(executed.finalizeMeta?.assessSessionId).toBeUndefined();
1810
+ // askResult 随快照落 state(findAskStep 依赖 askQuestion;forkSessionId 为 schema 强约束)。
1811
+ expect(executed.finalizeMeta?.askResult?.hasAsk).toBe(true);
1812
+ expect(executed.finalizeMeta?.askResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
1813
+ expect(executed.finalizeMeta?.assessResult).toBeUndefined();
1814
+ });
1815
+
1816
+ // ── commitFinalize 透传变量写回门控:仅在 Phase B('finalizing')执行 ──────────
1817
+
1818
+ it('commitFinalize 透传变量写回仅发生在 Phase B(finalizing)——Phase A(ask-detect)不写回', async () => {
1819
+ const { manager } = harness();
1820
+ const passThroughVars: Record<string, PassThroughVarValue> = {};
1821
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1822
+ const request = {
1823
+ step: step({
1824
+ command: { type: 'prompt', value: { promptText: 'x' } },
1825
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1826
+ }),
1827
+ executedStep: executed,
1828
+ stepSessionId: 's1',
1829
+ context: { input: 'req', output: 'out' },
1830
+ workdir: '/work',
1831
+ passThroughVars,
1832
+ };
1833
+ const assessResult: FinalizeAssessResult = {
1834
+ hasAsk: true,
1835
+ passThroughVars: { projectName: 'sandore' },
1836
+ forkSessionId: 'eval-1234abcd',
1837
+ assessmentRaw: '{"hasAsk":true,"passThroughVars":{"projectName":"sandore"}}',
1838
+ };
1839
+
1840
+ // Phase A(ask-detect)调用:finalizeMeta 落盘但不回写透传变量。
1841
+ await manager.commitFinalize(request, assessResult, undefined, 'ask-detect', { recordAssessSession: false });
1842
+ expect(passThroughVars['projectName']).toBeUndefined();
1843
+ expect(executed.finalizeMeta?.currentPhase).toBe('ask-detect');
1844
+
1845
+ // Phase B(finalizing)调用:透传变量回写。
1846
+ await manager.commitFinalize(request, assessResult, undefined, 'finalizing');
1847
+ expect(passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
1848
+ expect(executed.finalizeMeta?.currentPhase).toBe('finalizing');
1849
+ });
1850
+
1851
+ it('commitFinalize gating/completed 终态不再重复回写透传变量(Phase B 已写,幂等重写不覆盖累加器)', async () => {
1852
+ const { manager } = harness();
1853
+ const passThroughVars: Record<string, PassThroughVarValue> = {
1854
+ projectName: { value: 'sandore', description: 'project' },
1855
+ };
1856
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1857
+ const request = {
1858
+ step: step({
1859
+ command: { type: 'prompt', value: { promptText: 'x' } },
1860
+ passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
1861
+ }),
1862
+ executedStep: executed,
1863
+ stepSessionId: 's1',
1864
+ context: { input: 'req', output: 'out' },
1865
+ workdir: '/work',
1866
+ passThroughVars,
1867
+ };
1868
+ const assessResult: FinalizeAssessResult = {
1869
+ hasAsk: false,
1870
+ passThroughVars: {},
1871
+ forkSessionId: 'eval-1234abcd',
1872
+ assessmentRaw: '{"hasAsk":false,"passThroughVars":{}}',
1873
+ };
1874
+
1875
+ // gating/completed 终态提交不触发写回(写回仅 finalizing;避免终态覆盖已落账值)。
1876
+ await manager.commitFinalize(request, assessResult, undefined, 'gating');
1877
+ await manager.commitFinalize(request, assessResult, undefined, 'completed');
1878
+ expect(passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
1879
+ });
1880
+
1881
+ // ── Bug 1:commitFinalize previousPhase 记录阶段流转痕迹 ──────────
1882
+
1883
+ it('commitFinalize records previousPhase when currentPhase changes (routing 时 previousPhase=finalizing)', async () => {
1884
+ const { manager } = harness();
1885
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1886
+ const request = {
1887
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1888
+ executedStep: executed,
1889
+ stepSessionId: 's1',
1890
+ context: { input: 'req', output: 'out' },
1891
+ workdir: '/work',
1892
+ };
1893
+ const assessResult: FinalizeAssessResult = {
1894
+ hasAsk: false,
1895
+ passThroughVars: {},
1896
+ forkSessionId: 'eval-1234abcd',
1897
+ assessmentRaw: '',
1898
+ };
1899
+
1900
+ // Phase B(finalizing):首次写入,无 previousPhase。
1901
+ await manager.commitFinalize(request, assessResult, undefined, 'finalizing');
1902
+ expect(executed.finalizeMeta?.currentPhase).toBe('finalizing');
1903
+ expect(executed.finalizeMeta?.previousPhase).toBeUndefined();
1904
+
1905
+ // Phase D(routing):previousPhase = 'finalizing'。
1906
+ await manager.commitFinalize(request, assessResult, undefined, 'routing');
1907
+ expect(executed.finalizeMeta?.currentPhase).toBe('routing');
1908
+ expect(executed.finalizeMeta?.previousPhase).toBe('finalizing');
1909
+
1910
+ // completed:previousPhase = 'routing'。
1911
+ await manager.commitFinalize(request, assessResult, undefined, 'completed');
1912
+ expect(executed.finalizeMeta?.currentPhase).toBe('completed');
1913
+ expect(executed.finalizeMeta?.previousPhase).toBe('routing');
1914
+ });
1915
+
1916
+ // ── Round 7:commitFinalize onPersist/onPhaseChange 执行顺序 + 中间态同步 ──
1917
+
1918
+ it('commitFinalize executes onPersist before onPhaseChange (Round 7 顺序保证)', async () => {
1919
+ const { manager } = harness();
1920
+ const callOrder: string[] = [];
1921
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1922
+ executed.sessionMeta = { childId: 'child-1', synthetic: false, command: 'prompt' };
1923
+
1924
+ await manager.commitFinalize({
1925
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1926
+ executedStep: executed,
1927
+ stepSessionId: 's1',
1928
+ context: { input: 'req', output: 'out' },
1929
+ workdir: '/work',
1930
+ onPersist: async () => { callOrder.push('onPersist'); },
1931
+ onPhaseChange: () => { callOrder.push('onPhaseChange'); },
1932
+ }, undefined, undefined, 'finalizing');
1933
+
1934
+ expect(callOrder).toEqual(['onPersist', 'onPhaseChange']);
1935
+ });
1936
+
1937
+ it('commitFinalize onPersist receives executedStep with updated finalizeMeta (Round 7 中间态同步)', async () => {
1938
+ const { manager } = harness();
1939
+ let persistedStep: ExecutedStepDefault | undefined;
1940
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1941
+ executed.sessionMeta = { childId: 'child-1', synthetic: false, command: 'prompt' };
1942
+
1943
+ await manager.commitFinalize({
1944
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1945
+ executedStep: executed,
1946
+ stepSessionId: 's1',
1947
+ context: { input: 'req', output: 'out' },
1948
+ workdir: '/work',
1949
+ onPersist: async (step) => { persistedStep = step; },
1950
+ }, undefined, undefined, 'finalizing');
1951
+
1952
+ expect(persistedStep).toBeDefined();
1953
+ expect(persistedStep?.finalizeMeta?.currentPhase).toBe('finalizing');
1954
+ });
1955
+
1956
+ it('commitFinalize onPhaseChange sees finalizeMeta already synced via onPersist (Round 7 事件前中间态就绪)', async () => {
1957
+ const { manager } = harness();
1958
+ let phaseAtEventTime: string | undefined;
1959
+ const executed = executedStep({ command: { type: 'prompt', value: { promptText: 'x' } } });
1960
+ executed.sessionMeta = { childId: 'child-1', synthetic: false, command: 'prompt' };
1961
+
1962
+ // 模拟引擎侧 onPersist:将 executedStep upsert 进 active.state(此处用同步回调模拟)。
1963
+ let activeStateSynced = false;
1964
+ await manager.commitFinalize({
1965
+ step: step({ command: { type: 'prompt', value: { promptText: 'x' } } }),
1966
+ executedStep: executed,
1967
+ stepSessionId: 's1',
1968
+ context: { input: 'req', output: 'out' },
1969
+ workdir: '/work',
1970
+ onPersist: async () => { activeStateSynced = true; },
1971
+ onPhaseChange: () => { phaseAtEventTime = activeStateSynced ? 'synced' : 'not-synced'; },
1972
+ }, undefined, undefined, 'gating');
1973
+
1974
+ expect(phaseAtEventTime).toBe('synced');
1975
+ expect(executed.finalizeMeta?.currentPhase).toBe('gating');
1976
+ });
1977
+ });