@zhushanwen/subagent-core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +114 -0
  2. package/dist/chunk-3VOERJPJ.js +22 -0
  3. package/dist/chunk-A4IVZWVX.js +31 -0
  4. package/dist/execution/engine/engines/zcode/constants.cjs +54 -0
  5. package/dist/execution/engine/engines/zcode/constants.d.cts +31 -0
  6. package/dist/execution/engine/engines/zcode/constants.d.ts +31 -0
  7. package/dist/execution/engine/engines/zcode/constants.js +22 -0
  8. package/dist/execution/engine/engines/zcode/reader.cjs +276 -0
  9. package/dist/execution/engine/engines/zcode/reader.d.cts +20 -0
  10. package/dist/execution/engine/engines/zcode/reader.d.ts +20 -0
  11. package/dist/execution/engine/engines/zcode/reader.js +239 -0
  12. package/dist/execution/engine/paths.cjs +55 -0
  13. package/dist/execution/engine/paths.d.cts +8 -0
  14. package/dist/execution/engine/paths.d.ts +8 -0
  15. package/dist/execution/engine/paths.js +26 -0
  16. package/dist/execution/relay-env.cjs +62 -0
  17. package/dist/execution/relay-env.d.cts +33 -0
  18. package/dist/execution/relay-env.d.ts +33 -0
  19. package/dist/execution/relay-env.js +20 -0
  20. package/dist/index.cjs +1852 -0
  21. package/dist/index.d.cts +1184 -0
  22. package/dist/index.d.ts +1184 -0
  23. package/dist/index.js +1810 -0
  24. package/dist/types-BxyAidGf.d.cts +875 -0
  25. package/dist/types-BxyAidGf.d.ts +875 -0
  26. package/package.json +99 -0
  27. package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +142 -0
  28. package/src/__tests__/append-system-prompt-assembly.test.ts +242 -0
  29. package/src/__tests__/builtin-workflows-structure.test.ts +180 -0
  30. package/src/__tests__/derive-closed-display-parity.test.ts +93 -0
  31. package/src/__tests__/fr4-get-state-handshake.test.ts +125 -0
  32. package/src/__tests__/fr8-orphan-recovery.test.ts +259 -0
  33. package/src/__tests__/m2-append-content-probe.test.ts +73 -0
  34. package/src/__tests__/manifest-store.test.ts +368 -0
  35. package/src/__tests__/record-store-cache.test.ts +335 -0
  36. package/src/__tests__/record-store-index.test.ts +560 -0
  37. package/src/__tests__/record-store-last-line.test.ts +129 -0
  38. package/src/__tests__/review-fix-loop-script.test.ts +288 -0
  39. package/src/__tests__/review-fix-loop-utils.test.ts +2377 -0
  40. package/src/__tests__/robustness-low-batch1.test.ts +286 -0
  41. package/src/__tests__/robustness-medium-batch1.test.ts +69 -0
  42. package/src/__tests__/robustness-medium-batch2.test.ts +65 -0
  43. package/src/__tests__/robustness-medium-batch3.test.ts +30 -0
  44. package/src/__tests__/robustness-medium-batch4.test.ts +358 -0
  45. package/src/__tests__/session-runner.test.ts +69 -0
  46. package/src/__tests__/smoke.test.ts +9 -0
  47. package/src/__tests__/stream-sink.test.ts +203 -0
  48. package/src/core/__tests__/host-services.test.ts +144 -0
  49. package/src/core/__tests__/logger.test.ts +78 -0
  50. package/src/core/__tests__/notify-ports.test.ts +104 -0
  51. package/src/core/host-services.ts +97 -0
  52. package/src/core/logger.ts +47 -0
  53. package/src/core/notify-ports.ts +126 -0
  54. package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
  55. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  56. package/src/execution/__tests__/agent-result-mapper.test.ts +150 -0
  57. package/src/execution/__tests__/alive-store.test.ts +147 -0
  58. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +490 -0
  59. package/src/execution/__tests__/channel-registry-handshake.test.ts +242 -0
  60. package/src/execution/__tests__/chat-engine-routing.test.ts +610 -0
  61. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  62. package/src/execution/__tests__/concurrency-pool.test.ts +250 -0
  63. package/src/execution/__tests__/config.test.ts +302 -0
  64. package/src/execution/__tests__/delivery-methods.test.ts +424 -0
  65. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  66. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  67. package/src/execution/__tests__/execute-and-await-worktree.test.ts +243 -0
  68. package/src/execution/__tests__/execute-nesting.test.ts +323 -0
  69. package/src/execution/__tests__/execute-options-mapper.test.ts +200 -0
  70. package/src/execution/__tests__/execution-record.test.ts +1394 -0
  71. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  72. package/src/execution/__tests__/finalize-record.test.ts +367 -0
  73. package/src/execution/__tests__/finalized-marker.test.ts +104 -0
  74. package/src/execution/__tests__/format-schema-instruction.test.ts +169 -0
  75. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  76. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  77. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  78. package/src/execution/__tests__/helpers/spawn-mock.ts +242 -0
  79. package/src/execution/__tests__/host-mode.test.ts +87 -0
  80. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  81. package/src/execution/__tests__/lifecycle-manager.test.ts +383 -0
  82. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  83. package/src/execution/__tests__/manifest-parentid.test.ts +117 -0
  84. package/src/execution/__tests__/model-resolver.test.ts +465 -0
  85. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  86. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  87. package/src/execution/__tests__/output-collector.test.ts +358 -0
  88. package/src/execution/__tests__/path-encoding.test.ts +104 -0
  89. package/src/execution/__tests__/pi-invocation.test.ts +134 -0
  90. package/src/execution/__tests__/record-store.test.ts +1057 -0
  91. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  92. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +339 -0
  93. package/src/execution/__tests__/recursive-visibility-env.test.ts +273 -0
  94. package/src/execution/__tests__/relay-env.test.ts +42 -0
  95. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  96. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  97. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  98. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  99. package/src/execution/__tests__/run-spawn-edges.test.ts +687 -0
  100. package/src/execution/__tests__/run-spawn-integration.test.ts +933 -0
  101. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  102. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +196 -0
  103. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  104. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  105. package/src/execution/__tests__/session-file-gc.test.ts +293 -0
  106. package/src/execution/__tests__/session-reconstructor.test.ts +379 -0
  107. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  108. package/src/execution/__tests__/session-runner-schema-env.test.ts +350 -0
  109. package/src/execution/__tests__/spawn-args.test.ts +445 -0
  110. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  111. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  112. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +207 -0
  113. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  114. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  115. package/src/execution/__tests__/stdin-writer.test.ts +462 -0
  116. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  117. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  118. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  119. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  120. package/src/execution/__tests__/subagent-service.test.ts +788 -0
  121. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  122. package/src/execution/__tests__/subprocess-agent-runner.test.ts +612 -0
  123. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  124. package/src/execution/__tests__/timeout-integration.test.ts +615 -0
  125. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  126. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  127. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  128. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  129. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  130. package/src/execution/__tests__/ui-request-handler-factory.test.ts +369 -0
  131. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  132. package/src/execution/__tests__/ui-request-observability.test.ts +113 -0
  133. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  134. package/src/execution/__tests__/worktree-manager.test.ts +633 -0
  135. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +229 -0
  136. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  137. package/src/execution/__tests__/worktree-registry.test.ts +199 -0
  138. package/src/execution/agent-registry.ts +199 -0
  139. package/src/execution/agent-result-mapper.ts +90 -0
  140. package/src/execution/alive-store.ts +119 -0
  141. package/src/execution/argv-mirror.ts +113 -0
  142. package/src/execution/best-effort.ts +38 -0
  143. package/src/execution/channel-registry-access.ts +144 -0
  144. package/src/execution/concurrency-pool.ts +116 -0
  145. package/src/execution/config.ts +165 -0
  146. package/src/execution/dialog-queue.ts +330 -0
  147. package/src/execution/engine/__tests__/common/data-dir.test.ts +78 -0
  148. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  149. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  150. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  151. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  152. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  153. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  154. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  155. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  156. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  157. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  158. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  159. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  160. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  161. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  162. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  163. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  164. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  165. package/src/execution/engine/__tests__/model-prompt.test.ts +197 -0
  166. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  167. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  168. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  169. package/src/execution/engine/common/data-dir.ts +66 -0
  170. package/src/execution/engine/common/errors.ts +183 -0
  171. package/src/execution/engine/common/event-journal.ts +254 -0
  172. package/src/execution/engine/common/journal-replay.ts +62 -0
  173. package/src/execution/engine/common/kill-chain.ts +221 -0
  174. package/src/execution/engine/common/nesting-guard.ts +50 -0
  175. package/src/execution/engine/common/persona-router.ts +108 -0
  176. package/src/execution/engine/common/pool-manager.ts +226 -0
  177. package/src/execution/engine/common/schema-emulation.ts +189 -0
  178. package/src/execution/engine/common/session-view-projection.ts +51 -0
  179. package/src/execution/engine/engine-discovery.ts +65 -0
  180. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  181. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  182. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  183. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  184. package/src/execution/engine/engines/pi/reader.ts +48 -0
  185. package/src/execution/engine/engines/pi/registration.ts +35 -0
  186. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  187. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  188. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  189. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  190. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  191. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  192. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +71 -0
  193. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  194. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  195. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  196. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  197. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  198. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  199. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  200. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  201. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  202. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  203. package/src/execution/engine/host-task-spec.ts +47 -0
  204. package/src/execution/engine/model-prompt.ts +158 -0
  205. package/src/execution/engine/paths.ts +42 -0
  206. package/src/execution/engine/port.ts +153 -0
  207. package/src/execution/engine/registry.ts +133 -0
  208. package/src/execution/engine/routing.ts +218 -0
  209. package/src/execution/engine/types.ts +309 -0
  210. package/src/execution/execute-options-mapper.ts +115 -0
  211. package/src/execution/execution-record.ts +984 -0
  212. package/src/execution/finalize-record.ts +254 -0
  213. package/src/execution/finalized-marker.ts +70 -0
  214. package/src/execution/get-state-handshake.ts +104 -0
  215. package/src/execution/host-mode.ts +56 -0
  216. package/src/execution/idle-gc.ts +47 -0
  217. package/src/execution/lifecycle-manager.ts +513 -0
  218. package/src/execution/lifecycle-predicates.ts +65 -0
  219. package/src/execution/manifest-store.ts +256 -0
  220. package/src/execution/model-config-service.ts +250 -0
  221. package/src/execution/model-resolver.ts +248 -0
  222. package/src/execution/notifier.ts +372 -0
  223. package/src/execution/notify-ledger.ts +580 -0
  224. package/src/execution/output-collector.ts +228 -0
  225. package/src/execution/path-encoding.ts +60 -0
  226. package/src/execution/pi-invocation.ts +120 -0
  227. package/src/execution/record-entry.ts +132 -0
  228. package/src/execution/record-store.ts +1265 -0
  229. package/src/execution/relay-env.ts +37 -0
  230. package/src/execution/session-context-resolver.ts +64 -0
  231. package/src/execution/session-file-gc.ts +120 -0
  232. package/src/execution/session-pending.ts +170 -0
  233. package/src/execution/session-reconstructor.ts +678 -0
  234. package/src/execution/session-runner.ts +1789 -0
  235. package/src/execution/sessions-index.ts +304 -0
  236. package/src/execution/spawn-event-adapter.ts +363 -0
  237. package/src/execution/stdin-writer.ts +198 -0
  238. package/src/execution/stream-sink.ts +126 -0
  239. package/src/execution/subagent-service.ts +2268 -0
  240. package/src/execution/subprocess-agent-runner.ts +317 -0
  241. package/src/execution/temp-prompt.ts +62 -0
  242. package/src/execution/tombstone-store.ts +72 -0
  243. package/src/execution/turn-limiter.ts +102 -0
  244. package/src/execution/types.ts +1023 -0
  245. package/src/execution/ui-channels.ts +216 -0
  246. package/src/execution/ui-interaction-model.ts +48 -0
  247. package/src/execution/ui-request-handler-factory.ts +243 -0
  248. package/src/execution/ui-request-observability.ts +81 -0
  249. package/src/execution/ui-request-queue.ts +184 -0
  250. package/src/execution/worktree-manager.ts +688 -0
  251. package/src/execution/worktree-registry.ts +279 -0
  252. package/src/index.ts +87 -0
  253. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +349 -0
  254. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +202 -0
  255. package/src/orchestration/__tests__/agent-call-stream.test.ts +152 -0
  256. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  257. package/src/orchestration/__tests__/config-loader.test.ts +503 -0
  258. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +809 -0
  259. package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +404 -0
  260. package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
  261. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  262. package/src/orchestration/__tests__/execute-agent-call.test.ts +451 -0
  263. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +600 -0
  264. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  265. package/src/orchestration/__tests__/lifecycle.test.ts +659 -0
  266. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  267. package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +183 -0
  268. package/src/orchestration/__tests__/script-lint.test.ts +831 -0
  269. package/src/orchestration/__tests__/skill-discovery.test.ts +210 -0
  270. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  271. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  272. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  273. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  274. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +563 -0
  275. package/src/orchestration/__tests__/worker-script-builder.test.ts +309 -0
  276. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +129 -0
  277. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +317 -0
  278. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  279. package/src/orchestration/agent-opts-resolver.ts +174 -0
  280. package/src/orchestration/args-validator.ts +127 -0
  281. package/src/orchestration/config-loader.ts +316 -0
  282. package/src/orchestration/error-recovery.ts +1018 -0
  283. package/src/orchestration/execute-agent-call.ts +256 -0
  284. package/src/orchestration/launcher.ts +455 -0
  285. package/src/orchestration/lifecycle.ts +399 -0
  286. package/src/orchestration/models/__tests__/budget.test.ts +307 -0
  287. package/src/orchestration/models/agent-call.ts +83 -0
  288. package/src/orchestration/models/budget.ts +118 -0
  289. package/src/orchestration/models/ports.ts +164 -0
  290. package/src/orchestration/models/run-runtime.ts +104 -0
  291. package/src/orchestration/models/run-spec.ts +83 -0
  292. package/src/orchestration/models/run-state.ts +44 -0
  293. package/src/orchestration/models/trace.ts +183 -0
  294. package/src/orchestration/models/types.ts +301 -0
  295. package/src/orchestration/models/workflow-run.ts +254 -0
  296. package/src/orchestration/models/workflow-script-registry.ts +35 -0
  297. package/src/orchestration/models/workflow-script.ts +117 -0
  298. package/src/orchestration/script-lint.ts +766 -0
  299. package/src/orchestration/skill-discovery.ts +135 -0
  300. package/src/orchestration/worker-handle.ts +115 -0
  301. package/src/orchestration/worker-host.ts +98 -0
  302. package/src/orchestration/worker-script-builder.ts +421 -0
  303. package/src/orchestration/workflow-files.ts +85 -0
  304. package/src/orchestration/workflow-script-registry-impl.ts +133 -0
  305. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  306. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  307. package/src/shared/__tests__/model-ref.test.ts +306 -0
  308. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +288 -0
  309. package/src/shared/__tests__/resource-discovery.test.ts +749 -0
  310. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  311. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  312. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  313. package/src/shared/agent-event.ts +13 -0
  314. package/src/shared/agent-ref.ts +57 -0
  315. package/src/shared/meta-parser.ts +261 -0
  316. package/src/shared/model-ref.ts +286 -0
  317. package/src/shared/resource-discovery.ts +835 -0
  318. package/src/shared/resource-meta.ts +65 -0
  319. package/src/shared/schema-env.ts +44 -0
  320. package/src/shared/schema-jsonify.ts +58 -0
  321. package/src/shared/timer-delay.ts +54 -0
  322. package/src/shared/xml-injection.ts +35 -0
  323. package/workflows/README.md +81 -0
  324. package/workflows/_shared/agent-refs.cjs +40 -0
  325. package/workflows/chain.js +137 -0
  326. package/workflows/map-reduce.js +180 -0
  327. package/workflows/parallel.js +154 -0
  328. package/workflows/review-fix-loop-utils.cjs +1542 -0
  329. package/workflows/review-fix-loop.js +1605 -0
  330. package/workflows/scatter-gather.js +184 -0
@@ -0,0 +1,1394 @@
1
+ // src/__tests__/execution-record.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ completeRecord,
6
+ computeElapsedSeconds,
7
+ createRecord,
8
+ extractLabelFromArgs,
9
+ getAllToolCalls,
10
+ getCurrentActivity,
11
+ getEventLog,
12
+ getFullText,
13
+ getFullTextFrom,
14
+ getTotalUsage,
15
+ markReconstructedStatus,
16
+ nextRoundBaseTurnIndex,
17
+ deriveOutcome,
18
+ projectOutcome,
19
+ project,
20
+ projectLiveProgress,
21
+ snapshot,
22
+ tryTransition,
23
+ updateFromEvent,
24
+ } from "../execution-record.ts";
25
+ import type { AgentResult, ExecutionRecord, SubagentRecord, Turn } from "../types.ts";
26
+ import { toSubagentRecordEntry } from "../record-entry.ts";
27
+
28
+ // ── 常量(与源码 module-private 值对齐,测试用字面量)──
29
+ const TURN_SUMMARY_MAX = 80;
30
+
31
+ // ── 工厂 ──
32
+ function emptyTurn(): Turn {
33
+ return { text: "", thinking: "", toolCalls: [], usageDelta: undefined, closed: false, closedTs: undefined };
34
+ }
35
+
36
+ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
37
+ return {
38
+ id: "test-1",
39
+ agent: "worker",
40
+ model: "test-model",
41
+ thinkingLevel: undefined,
42
+ mode: "sync",
43
+ task: "test task",
44
+ startedAt: 1000,
45
+ status: "running",
46
+ turns: [emptyTurn()],
47
+ turnCount: 0,
48
+ totalTokens: 0,
49
+ lastError: undefined,
50
+ endedAt: undefined,
51
+ result: undefined,
52
+ error: undefined,
53
+ agentResult: undefined,
54
+ controller: undefined,
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ const SAMPLE_RESULT: AgentResult = {
60
+ text: "done",
61
+ turns: 1,
62
+ durationMs: 500,
63
+ success: true,
64
+ sessionId: "sess-1",
65
+ toolCalls: [],
66
+ };
67
+
68
+ // ============================================================
69
+ // createRecord
70
+ // ============================================================
71
+ describe("createRecord", () => {
72
+ it("creates a record with identity fields frozen and defaults", () => {
73
+ const r = createRecord("r1", {
74
+ agent: "reviewer",
75
+ model: "m1",
76
+ thinkingLevel: "high",
77
+ mode: "background",
78
+ task: "review PR",
79
+ startedAt: 2000,
80
+ });
81
+ expect(r.id).toBe("r1");
82
+ expect(r.agent).toBe("reviewer");
83
+ expect(r.model).toBe("m1");
84
+ expect(r.thinkingLevel).toBe("high");
85
+ expect(r.mode).toBe("background");
86
+ expect(r.task).toBe("review PR");
87
+ expect(r.startedAt).toBe(2000);
88
+
89
+ // defaults——turns[] 初始化为 [空 turn],turnCount=0
90
+ expect(r.status).toBe("running");
91
+ expect(r.turns).toHaveLength(1);
92
+ expect(r.turns[0]).toMatchObject({ text: "", thinking: "", toolCalls: [], closed: false });
93
+ expect(r.turnCount).toBe(0);
94
+ expect(r.totalTokens).toBe(0);
95
+ expect(r.lastError).toBeUndefined();
96
+ expect(r.endedAt).toBeUndefined();
97
+ expect(r.result).toBeUndefined();
98
+ expect(r.error).toBeUndefined();
99
+ expect(r.agentResult).toBeUndefined();
100
+ });
101
+
102
+ it("stores controller when provided (background)", () => {
103
+ const controller = new AbortController();
104
+ const r = createRecord("r1", {
105
+ agent: "w", model: "m", mode: "background", task: "t", startedAt: 0, controller,
106
+ });
107
+ expect(r.controller).toBe(controller);
108
+ });
109
+
110
+ it("stores rootSessionId when provided", () => {
111
+ const r = createRecord("r1", {
112
+ agent: "w", model: "m", mode: "sync", task: "t", startedAt: 0, rootSessionId: "sess-A",
113
+ });
114
+ expect(r.rootSessionId).toBe("sess-A");
115
+ });
116
+
117
+ it("defaults rootSessionId to undefined when omitted", () => {
118
+ const r = createRecord("r1", {
119
+ agent: "w", model: "m", mode: "sync", task: "t", startedAt: 0,
120
+ });
121
+ expect(r.rootSessionId).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ // ============================================================
126
+ // updateFromEvent — turns accumulation
127
+ // ============================================================
128
+ describe("updateFromEvent", () => {
129
+ describe("turnCount accumulation", () => {
130
+ it("increments turnCount on turn_end", () => {
131
+ const r = makeRecord();
132
+ updateFromEvent(r, { type: "turn_end", summary: "done" });
133
+ expect(r.turnCount).toBe(1);
134
+ updateFromEvent(r, { type: "turn_end" });
135
+ expect(r.turnCount).toBe(2);
136
+ });
137
+
138
+ it("does not increment turnCount on other events", () => {
139
+ const r = makeRecord();
140
+ updateFromEvent(r, { type: "text_delta", delta: "hi" });
141
+ updateFromEvent(r, { type: "tool_start", toolName: "read" });
142
+ updateFromEvent(r, { type: "message_end" });
143
+ expect(r.turnCount).toBe(0);
144
+ });
145
+ });
146
+
147
+ describe("totalTokens accumulation", () => {
148
+ it("sums all usage fields on message_end", () => {
149
+ const r = makeRecord();
150
+ updateFromEvent(r, {
151
+ type: "message_end",
152
+ usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 },
153
+ });
154
+ expect(r.totalTokens).toBe(38);
155
+ });
156
+
157
+ it("accumulates across multiple message_end events", () => {
158
+ const r = makeRecord();
159
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } });
160
+ updateFromEvent(r, { type: "message_end", usage: { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 } });
161
+ expect(r.totalTokens).toBe(12);
162
+ });
163
+
164
+ it("ignores message_end without usage", () => {
165
+ const r = makeRecord();
166
+ updateFromEvent(r, { type: "message_end" });
167
+ expect(r.totalTokens).toBe(0);
168
+ });
169
+
170
+ it("stores usageDelta on current turn", () => {
171
+ const r = makeRecord();
172
+ updateFromEvent(r, {
173
+ type: "message_end",
174
+ usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 },
175
+ });
176
+ expect(r.turns[0]?.usageDelta).toEqual({ input: 10, output: 20, cacheRead: 5, cacheWrite: 3 });
177
+ });
178
+ });
179
+
180
+ // ============================================================
181
+ // text / thinking accumulation (替代旧 chunking)
182
+ // ============================================================
183
+ describe("text accumulation", () => {
184
+ it("accumulates text_delta into current turn.text (完整内容,非切片)", () => {
185
+ const r = makeRecord();
186
+ updateFromEvent(r, { type: "text_delta", delta: "Hello " });
187
+ updateFromEvent(r, { type: "text_delta", delta: "world" });
188
+ expect(r.turns[0]?.text).toBe("Hello world");
189
+ });
190
+
191
+ it("text accumulation survives long delta (>100 chars, no chunking)", () => {
192
+ const r = makeRecord();
193
+ const longText = "y".repeat(350);
194
+ updateFromEvent(r, { type: "text_delta", delta: longText });
195
+ // 完整存储,不切片——这是收口设计的核心
196
+ expect(r.turns[0]?.text).toBe(longText);
197
+ expect(r.turns).toHaveLength(1);
198
+ });
199
+ });
200
+
201
+ describe("thinking accumulation", () => {
202
+ it("accumulates thinking_delta into current turn.thinking (完整内容)", () => {
203
+ const r = makeRecord();
204
+ updateFromEvent(r, { type: "thinking_delta", delta: "Analyzing " });
205
+ updateFromEvent(r, { type: "thinking_delta", delta: "the problem" });
206
+ expect(r.turns[0]?.thinking).toBe("Analyzing the problem");
207
+ });
208
+ });
209
+
210
+ describe("turn boundary", () => {
211
+ it("turn_end closes current turn; next delta opens new turn", () => {
212
+ const r = makeRecord();
213
+ updateFromEvent(r, { type: "text_delta", delta: "turn 1 text" });
214
+ updateFromEvent(r, { type: "turn_end" });
215
+ expect(r.turns[0]?.closed).toBe(true);
216
+ expect(r.turnCount).toBe(1);
217
+
218
+ // 新 delta 开新 turn
219
+ updateFromEvent(r, { type: "text_delta", delta: "turn 2 text" });
220
+ expect(r.turns).toHaveLength(2);
221
+ expect(r.turns[1]?.closed).toBe(false);
222
+ expect(r.turns[1]?.text).toBe("turn 2 text");
223
+ // turn 1 不受影响
224
+ expect(r.turns[0]?.text).toBe("turn 1 text");
225
+ });
226
+
227
+ it("turn_end closes turn and records closedTs (real wall-clock)", () => {
228
+ const r = makeRecord();
229
+ updateFromEvent(r, { type: "text_delta", delta: "partial" });
230
+ const before = Date.now();
231
+ updateFromEvent(r, { type: "turn_end" });
232
+ const after = Date.now();
233
+ expect(r.turns[0]?.closed).toBe(true);
234
+ expect(r.turns[0]?.closedTs).toBeGreaterThanOrEqual(before);
235
+ expect(r.turns[0]?.closedTs).toBeLessThanOrEqual(after);
236
+ // turn_end 不再覆盖已累积的 text(旧 dead branch 已移除)
237
+ expect(r.turns[0]?.text).toBe("partial");
238
+ expect(r.turnCount).toBe(1);
239
+ });
240
+
241
+ it("turn_end clears lastError (transient error recovery → success)", () => {
242
+ // 瞬态 error 到达后,若 turn 正常闭合,lastError 应清空——
243
+ // 否则 session-runner 会据残留 lastError 把成功误判为 success=false。
244
+ const r = makeRecord();
245
+ updateFromEvent(r, { type: "error", message: "transient" });
246
+ expect(r.lastError).toBe("transient");
247
+ updateFromEvent(r, { type: "turn_end" });
248
+ expect(r.lastError).toBeUndefined();
249
+ });
250
+
251
+ it("turn_end after turn_end: next delta opens 3rd turn (not mutate 2nd)", () => {
252
+ // 连续两次 turn_end 后再发 text_delta,应 push 第 3 个 turn,
253
+ // 而非回填已 closed 的第 2 个空 turn。
254
+ const r = makeRecord();
255
+ updateFromEvent(r, { type: "text_delta", delta: "t1" });
256
+ updateFromEvent(r, { type: "turn_end" });
257
+ updateFromEvent(r, { type: "turn_end" }); // 第 2 个空 turn 立即 closed
258
+ updateFromEvent(r, { type: "text_delta", delta: "t3" });
259
+ expect(r.turns).toHaveLength(3);
260
+ expect(r.turns[0]?.text).toBe("t1");
261
+ expect(r.turns[1]?.text).toBe(""); // 第 2 个空 turn 未被回填
262
+ expect(r.turns[2]?.text).toBe("t3");
263
+ });
264
+ });
265
+
266
+ // ============================================================
267
+ // tool events → turn.toolCalls
268
+ // ============================================================
269
+ describe("tool events", () => {
270
+ it("tool_start pushes a running ToolCall into current turn", () => {
271
+ const r = makeRecord();
272
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a/b/foo.ts" } });
273
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
274
+ expect(r.turns[0]?.toolCalls[0]).toMatchObject({
275
+ toolName: "read",
276
+ _status: "running",
277
+ });
278
+ });
279
+
280
+ it("tool_end matches back running toolCall and sets result/status", () => {
281
+ const r = makeRecord();
282
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "ls" } });
283
+ const result = { content: [{ type: "text", text: "file.ts" }] };
284
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "ls" }, result });
285
+ const tc = r.turns[0]?.toolCalls[0];
286
+ expect(tc).toMatchObject({ toolName: "bash", _status: "done", isError: false });
287
+ expect(tc?.result).toBe(result);
288
+ });
289
+
290
+ it("tool_end sets failed status when isError", () => {
291
+ const r = makeRecord();
292
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "rm" } });
293
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "rm" }, isError: true });
294
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("failed");
295
+ expect(r.turns[0]?.toolCalls[0]?.isError).toBe(true);
296
+ });
297
+
298
+ it("tool_end without matching tool_start pushes a completed ToolCall", () => {
299
+ const r = makeRecord();
300
+ updateFromEvent(r, { type: "tool_end", toolName: "external", args: {} });
301
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
302
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
303
+ });
304
+
305
+ it("LIFO matching: same-name tool twice, tool_end matches last running", () => {
306
+ // 同 turn 内两次 tool_start: bash → tool_end: bash 倒序匹配最后一个 running。
307
+ // 正序匹配会错误地把 result 填到第一个 bash,留下第二个 running。
308
+ const r = makeRecord();
309
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "cmd-a" } });
310
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "cmd-b" } });
311
+ const resultA = { content: [{ type: "text", text: "A" }] };
312
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "cmd-b" }, result: resultA });
313
+ const calls = r.turns[0]?.toolCalls ?? [];
314
+ expect(calls).toHaveLength(2);
315
+ // 第二个 bash(cmd-b)命中 LIFO,标记 done + result=A
316
+ expect(calls[1]?._status).toBe("done");
317
+ expect(calls[1]?.result).toBe(resultA);
318
+ // 第一个 bash(cmd-a)仍未匹配,仍 running
319
+ expect(calls[0]?._status).toBe("running");
320
+ expect(calls[0]?.result).toBeUndefined();
321
+ });
322
+
323
+ it("tool_end without result leaves result undefined (SDK may omit result)", () => {
324
+ // SDK 契约下 tool_end 的 result 可为 undefined——不应抛错,getEventLog 仍正常派生。
325
+ const r = makeRecord();
326
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
327
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
328
+ expect(r.turns[0]?.toolCalls[0]?.result).toBeUndefined();
329
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
330
+ // getEventLog 仍能派生 tool_start/tool_end 对
331
+ const log = getEventLog(r);
332
+ expect(log.map((e) => e.type)).toEqual(["tool_start", "tool_end", "turn_end"].slice(0, 2));
333
+ });
334
+
335
+ it("tool_end matches running toolCall across turns (lagged SDK event)", () => {
336
+ // SDK 在 turn_end 后仍可能补发滞后的 tool_end——跨 turn 扫描兜底,
337
+ // 不误 push 幽灵 ToolCall。
338
+ const r = makeRecord();
339
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
340
+ updateFromEvent(r, { type: "turn_end" }); // turn[0] closed,read 仍 running
341
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
342
+ // 匹配到 turn[0] 的 read(跨 turn 扫描命中),未产生幽灵 ToolCall
343
+ expect(r.turns).toHaveLength(1); // 没有 turn[1]——tool_end 单独不开新 turn
344
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
345
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
346
+ });
347
+ });
348
+
349
+ // ============================================================
350
+ // error event → record.lastError
351
+ // ============================================================
352
+ describe("error event", () => {
353
+ it("stores error message in record.lastError", () => {
354
+ const r = makeRecord();
355
+ updateFromEvent(r, { type: "error", message: "boom" });
356
+ expect(r.lastError).toBe("boom");
357
+ });
358
+
359
+ it("message_end with error field also sets lastError", () => {
360
+ const r = makeRecord();
361
+ updateFromEvent(r, { type: "message_end", error: "provider error" });
362
+ expect(r.lastError).toBe("provider error");
363
+ });
364
+ });
365
+
366
+ // ============================================================
367
+ // compaction (no-op)
368
+ // ============================================================
369
+ describe("compaction", () => {
370
+ it("compaction is a no-op", () => {
371
+ const r = makeRecord();
372
+ updateFromEvent(r, { type: "compaction" });
373
+ expect(r.turns).toHaveLength(1);
374
+ expect(r.turnCount).toBe(0);
375
+ expect(r.totalTokens).toBe(0);
376
+ });
377
+ });
378
+ });
379
+
380
+ // ============================================================
381
+ // getEventLog — 派生事件序列
382
+ // ============================================================
383
+ describe("getEventLog", () => {
384
+ it("returns empty array for fresh record (empty turn, not closed)", () => {
385
+ const r = makeRecord();
386
+ expect(getEventLog(r)).toEqual([]);
387
+ });
388
+
389
+ it("derives tool_start/tool_end pairs from turns[].toolCalls", () => {
390
+ const r = makeRecord();
391
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
392
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/x.ts" } });
393
+ const log = getEventLog(r);
394
+ expect(log.map((e) => e.type)).toEqual(["tool_start", "tool_end"]);
395
+ expect(log[0]).toMatchObject({ type: "tool_start", label: "read x.ts", status: "running" });
396
+ expect(log[1]).toMatchObject({ type: "tool_end", label: "read x.ts", status: "done" });
397
+ });
398
+
399
+ it("running toolCall (no tool_end yet) derives only tool_start", () => {
400
+ const r = makeRecord();
401
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
402
+ const log = getEventLog(r);
403
+ expect(log.map((e) => e.type)).toEqual(["tool_start"]);
404
+ });
405
+
406
+ it("derives turn_end after turn closes (label from turn text)", () => {
407
+ const r = makeRecord();
408
+ updateFromEvent(r, { type: "text_delta", delta: "Result is 42" });
409
+ updateFromEvent(r, { type: "turn_end" });
410
+ const log = getEventLog(r);
411
+ const turnEntry = log.find((e) => e.type === "turn_end");
412
+ expect(turnEntry?.label).toBe("Result is 42");
413
+ });
414
+
415
+ it("turn_end label defaults to 'turn' when turn has no text", () => {
416
+ const r = makeRecord();
417
+ updateFromEvent(r, { type: "turn_end" });
418
+ const log = getEventLog(r);
419
+ const turnEntry = log.find((e) => e.type === "turn_end");
420
+ expect(turnEntry?.label).toBe("turn");
421
+ });
422
+
423
+ it("truncates long turn text to TURN_SUMMARY_MAX in turn_end label", () => {
424
+ const r = makeRecord();
425
+ const longText = "s".repeat(TURN_SUMMARY_MAX + 20);
426
+ updateFromEvent(r, { type: "text_delta", delta: longText });
427
+ updateFromEvent(r, { type: "turn_end" });
428
+ const log = getEventLog(r);
429
+ const turnEntry = log.find((e) => e.type === "turn_end");
430
+ expect(turnEntry?.label.length).toBe(TURN_SUMMARY_MAX);
431
+ });
432
+
433
+ it("appends error entry when record.lastError is set", () => {
434
+ const r = makeRecord();
435
+ updateFromEvent(r, { type: "error", message: "crashed" });
436
+ const log = getEventLog(r);
437
+ expect(log[log.length - 1]).toMatchObject({ type: "error", label: "crashed" });
438
+ });
439
+
440
+ it("multi-turn: events ordered across turns", () => {
441
+ const r = makeRecord();
442
+ // turn 1: tool A + text
443
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
444
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
445
+ updateFromEvent(r, { type: "text_delta", delta: "done turn 1" });
446
+ updateFromEvent(r, { type: "turn_end" });
447
+ // turn 2: tool B
448
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/b.ts" } });
449
+ updateFromEvent(r, { type: "turn_end" });
450
+ const types = getEventLog(r).map((e) => e.type);
451
+ expect(types).toEqual([
452
+ "tool_start", "tool_end", "turn_end", // turn 1
453
+ "tool_start", "turn_end", // turn 2 (tool_start only, no tool_end)
454
+ ]);
455
+ });
456
+
457
+ it("uses real wall-clock ts (tool: startedTs, turn_end: closedTs)", () => {
458
+ // ts 不再是合成 +1,而是真实 Date.now()——消费方可按时序/时长分析。
459
+ const before = Date.now();
460
+ const r = makeRecord({ startedAt: before });
461
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
462
+ const afterToolStart = Date.now();
463
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
464
+ updateFromEvent(r, { type: "turn_end" });
465
+ const afterTurnEnd = Date.now();
466
+ const log = getEventLog(r);
467
+ const toolStartTs = log[0]?.ts;
468
+ const turnEndTs = log[2]?.ts;
469
+ expect(toolStartTs).toBeGreaterThanOrEqual(before);
470
+ expect(toolStartTs).toBeLessThanOrEqual(afterToolStart);
471
+ expect(turnEndTs).toBeGreaterThanOrEqual(afterToolStart);
472
+ expect(turnEndTs).toBeLessThanOrEqual(afterTurnEnd);
473
+ });
474
+ });
475
+
476
+ // ============================================================
477
+ // getCurrentActivity — 派生活动行
478
+ // ============================================================
479
+ describe("getCurrentActivity", () => {
480
+ it("returns undefined when status is not running", () => {
481
+ const r = makeRecord({ status: "done" });
482
+ expect(getCurrentActivity(r)).toBeUndefined();
483
+ });
484
+
485
+ it("returns undefined when turn is closed", () => {
486
+ const r = makeRecord();
487
+ updateFromEvent(r, { type: "turn_end" });
488
+ expect(getCurrentActivity(r)).toBeUndefined();
489
+ });
490
+
491
+ it("prefers running tool over thinking/text", () => {
492
+ const r = makeRecord();
493
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/a.ts" } });
494
+ r.turns[0]!.thinking = "thinking...";
495
+ r.turns[0]!.text = "text...";
496
+ expect(getCurrentActivity(r)).toEqual({ type: "tool", label: "edit a.ts" });
497
+ });
498
+
499
+ it("falls back to thinking when no running tool", () => {
500
+ const r = makeRecord();
501
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
502
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
503
+ r.turns[0]!.thinking = "pondering";
504
+ expect(getCurrentActivity(r)).toEqual({ type: "thinking", label: "pondering" });
505
+ });
506
+
507
+ it("falls back to text when no tool/thinking", () => {
508
+ const r = makeRecord();
509
+ r.turns[0]!.text = "writing output";
510
+ expect(getCurrentActivity(r)).toEqual({ type: "text", label: "writing output" });
511
+ });
512
+
513
+ it("text label takes START not tail fragment (regression: text: } bug)", () => {
514
+ // 原始 bug:compact view 显示流式文本的末尾碎片(如 "text: }")而非开头。
515
+ // getCurrentActivity 必须取 turn.text 开头——本测试在 bug 回归时会失败。
516
+ const r = makeRecord();
517
+ updateFromEvent(r, { type: "text_delta", delta: "Hello world this is the response start" });
518
+ updateFromEvent(r, { type: "text_delta", delta: " ... more content ... }" });
519
+ const activity = getCurrentActivity(r);
520
+ expect(activity?.type).toBe("text");
521
+ // label 以开头而非尾巴开始
522
+ expect(activity?.label.startsWith("Hello world")).toBe(true);
523
+ // 绝不以尾巴碎片开头
524
+ expect(activity?.label.startsWith("}")).toBe(false);
525
+ expect(activity?.label.startsWith(" ... more")).toBe(false);
526
+ });
527
+
528
+ it("truncates text label to ACTIVITY_LABEL_MAX (60)", () => {
529
+ const r = makeRecord();
530
+ const longText = "y".repeat(120);
531
+ r.turns[0]!.text = longText;
532
+ const activity = getCurrentActivity(r);
533
+ expect(activity?.label.length).toBe(60);
534
+ expect(activity?.label).toBe(longText.slice(0, 60));
535
+ });
536
+
537
+ it("returns undefined when idle (empty turn)", () => {
538
+ const r = makeRecord();
539
+ expect(getCurrentActivity(r)).toBeUndefined();
540
+ });
541
+ });
542
+
543
+ // ============================================================
544
+ // getFullText — 聚合文本
545
+ // ============================================================
546
+ describe("getFullText", () => {
547
+ it("returns empty string for fresh record", () => {
548
+ const r = makeRecord();
549
+ expect(getFullText(r)).toBe("");
550
+ });
551
+
552
+ it("returns single turn text", () => {
553
+ const r = makeRecord();
554
+ updateFromEvent(r, { type: "text_delta", delta: "Hello world" });
555
+ expect(getFullText(r)).toBe("Hello world");
556
+ });
557
+
558
+ it("joins multiple turns with double newline", () => {
559
+ const r = makeRecord();
560
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
561
+ updateFromEvent(r, { type: "turn_end" });
562
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
563
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 2");
564
+ });
565
+
566
+ it("skips empty turns", () => {
567
+ const r = makeRecord();
568
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
569
+ updateFromEvent(r, { type: "turn_end" });
570
+ updateFromEvent(r, { type: "turn_end" }); // 空 turn 2
571
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 3" });
572
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 3");
573
+ });
574
+
575
+ it("aggregates multiple text_deltas within a single turn", () => {
576
+ const r = makeRecord();
577
+ updateFromEvent(r, { type: "text_delta", delta: "Hello " });
578
+ updateFromEvent(r, { type: "text_delta", delta: "world" });
579
+ updateFromEvent(r, { type: "text_delta", delta: "!" });
580
+ expect(getFullText(r)).toBe("Hello world!");
581
+ });
582
+ });
583
+
584
+ // ============================================================
585
+ // getFullTextFrom — 增量文本派生(round-base-increment wave1)
586
+ // ============================================================
587
+ describe("getFullTextFrom", () => {
588
+ it("C1TC2: from=0 与 getFullText 逐字节等价(fresh / 单 turn / 多 turn 混空 turn 三形态)", () => {
589
+ // 形态 1:fresh record(turns=[emptyTurn()])
590
+ const fresh = makeRecord();
591
+ expect(getFullTextFrom(fresh, 0)).toBe(getFullText(fresh));
592
+ expect(getFullTextFrom(fresh, 0)).toBe("");
593
+
594
+ // 形态 2:单 turn 有文本
595
+ const single = makeRecord();
596
+ updateFromEvent(single, { type: "text_delta", delta: "Hello world" });
597
+ expect(getFullTextFrom(single, 0)).toBe(getFullText(single));
598
+ expect(getFullTextFrom(single, 0)).toBe("Hello world");
599
+
600
+ // 形态 3:多 turn 混空 turn(closed 空 turn + 空白串 turn + 未闭合尾 turn)
601
+ const mixed = makeRecord();
602
+ updateFromEvent(mixed, { type: "text_delta", delta: "Turn 1" });
603
+ updateFromEvent(mixed, { type: "turn_end" });
604
+ updateFromEvent(mixed, { type: "turn_end" }); // 空 turn 2(closed)
605
+ updateFromEvent(mixed, { type: "text_delta", delta: " " }); // 空白串 turn 3
606
+ updateFromEvent(mixed, { type: "turn_end" });
607
+ updateFromEvent(mixed, { type: "text_delta", delta: "Turn 4" }); // 未闭合尾 turn
608
+ expect(getFullTextFrom(mixed, 0)).toBe(getFullText(mixed));
609
+ expect(getFullTextFrom(mixed, 0)).toBe("Turn 1\n\n \n\nTurn 4");
610
+ });
611
+
612
+ it("C1TC3: 多 turn 单轮增量——from=1 只含 Turn 2,对照全量含 Turn 1\\n\\nTurn 2", () => {
613
+ const r = makeRecord();
614
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
615
+ updateFromEvent(r, { type: "turn_end" });
616
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
617
+ expect(getFullTextFrom(r, 1)).toBe("Turn 2");
618
+ // 对照:全量 vs 增量差异锁定
619
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 2");
620
+ });
621
+
622
+ it("C1TC4: 空文本轮过滤(join 不多出空段)+ 空白串按非空计入增量", () => {
623
+ // turns: ["A"(closed), ""(closed), " "(closed), "B"(open)]
624
+ const r = makeRecord();
625
+ updateFromEvent(r, { type: "text_delta", delta: "A" });
626
+ updateFromEvent(r, { type: "turn_end" });
627
+ updateFromEvent(r, { type: "turn_end" }); // 空 turn(closed)
628
+ updateFromEvent(r, { type: "text_delta", delta: " " });
629
+ updateFromEvent(r, { type: "turn_end" });
630
+ updateFromEvent(r, { type: "text_delta", delta: "B" });
631
+ // from=2 起:空 turn 在 from 之前不参与;" " 按非空处理计入增量(filter 判据与 getFullText 逐字对齐)
632
+ expect(getFullTextFrom(r, 2)).toBe(" \n\nB");
633
+ // 空 turn 过滤:from=0 时不产出空段(join 不多出 \n\n 空段)
634
+ expect(getFullTextFrom(r, 0)).toBe("A\n\n \n\nB");
635
+ });
636
+
637
+ it("C1TC5: from >= turns.length 越界返回空串(不抛错、不回绕)", () => {
638
+ const r = makeRecord();
639
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
640
+ updateFromEvent(r, { type: "turn_end" });
641
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
642
+ expect(getFullTextFrom(r, r.turns.length)).toBe("");
643
+ expect(getFullTextFrom(r, r.turns.length + 5)).toBe("");
644
+ });
645
+
646
+ it("C1TC6: from 为负规范化为 0(与 from=0 / getFullText 等价,无 slice 负偏移语义)", () => {
647
+ const r = makeRecord();
648
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
649
+ updateFromEvent(r, { type: "turn_end" });
650
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
651
+ expect(getFullTextFrom(r, -1)).toBe(getFullTextFrom(r, 0));
652
+ expect(getFullTextFrom(r, -100)).toBe(getFullText(r));
653
+ expect(getFullTextFrom(r, -100)).toBe("Turn 1\n\nTurn 2");
654
+ });
655
+ });
656
+
657
+ // ============================================================
658
+ // nextRoundBaseTurnIndex — 轮次边界推进公式(round-base-increment wave1)
659
+ // ============================================================
660
+ describe("nextRoundBaseTurnIndex", () => {
661
+ it("C1TC7: 全闭合推进 length / fresh record([emptyTurn()] 单未闭合空 turn)返回 0", () => {
662
+ // 全闭合:2 个 closed turn → length
663
+ const r = makeRecord();
664
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
665
+ updateFromEvent(r, { type: "turn_end" });
666
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
667
+ updateFromEvent(r, { type: "turn_end" });
668
+ expect(r.turns.length).toBe(2);
669
+ expect(nextRoundBaseTurnIndex(r)).toBe(2);
670
+
671
+ // fresh 形态(createRecord:turns=[emptyTurn()] 单个未闭合空 turn)→ 0(ES6 重建回退语义)
672
+ const fresh = createRecord("fresh-1", {
673
+ agent: "worker",
674
+ model: "test-model",
675
+ thinkingLevel: undefined,
676
+ mode: "sync",
677
+ task: "t",
678
+ slug: "t",
679
+ startedAt: 1000,
680
+ rootSessionId: "sess-main",
681
+ parentRecordId: undefined,
682
+ depth: 0,
683
+ });
684
+ expect(fresh.turns).toHaveLength(1);
685
+ expect(nextRoundBaseTurnIndex(fresh)).toBe(0);
686
+ });
687
+
688
+ it("C1TC8 ⛔ ES1 探针: 滞后空 turn(防御分支)边界不丢下轮首段文本", () => {
689
+ // 防御分支构造(pi 现事件序下不可达——带 usage 的 message_end 恒先于 turn_end;探针
690
+ // 按防御分支构造仍有效,锁住公式在滞后交错下的正确性,防 pi 未来事件序变化)。
691
+ // 两层 usage 守卫:session-runner.ts 转发层 if (msg?.usage)(bare message_end 不转发)
692
+ // + execution-record.ts 累积层 if (event.usage)(bare message_end 不开 turn)——
693
+ // 故本探针的 message_end 必须携带 usage(如 { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 })才能开出空 turn。
694
+ const r = makeRecord();
695
+ updateFromEvent(r, { type: "text_delta", delta: "R1" });
696
+ updateFromEvent(r, { type: "turn_end" }); // 闭合本轮
697
+ updateFromEvent(r, {
698
+ type: "message_end",
699
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 },
700
+ }); // 滞后 message_end 开新空 turn
701
+ expect(r.turns.length).toBe(2);
702
+ expect(r.turns[1]!.closed).toBe(false);
703
+ expect(r.turns[1]!.text).toBe("");
704
+
705
+ // 推进:滞后空 turn 不计入边界(turns.length - 1,留在下一轮增量内)
706
+ const base = nextRoundBaseTurnIndex(r);
707
+ expect(base).toBe(1);
708
+
709
+ // 新轮 text_delta 复用该空 turn → 文本落在增量范围内,不丢
710
+ updateFromEvent(r, { type: "text_delta", delta: "R2" });
711
+ expect(r.turns.length).toBe(2); // 复用,未新开
712
+ expect(getFullTextFrom(r, base)).toBe("R2");
713
+
714
+ // 对照组:若直用 turns.length 推进,R2 被挤出 slice——被防住的丢文本负面行为锁定
715
+ expect(getFullTextFrom(r, r.turns.length)).toBe("");
716
+ });
717
+
718
+ it("C1TC9: 末 turn 未闭合且非空计入本轮(防御形态公式锁定,R1 观测哨触发条件)", () => {
719
+ // pi 现序不可达(agent_settled 在 agent_end 之后,error/aborted 也先 emit turn_end);
720
+ // 构造该防御形态锁死公式行为:非空文本计入本轮增量、不 defer 到下轮。
721
+ const r = makeRecord();
722
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
723
+ updateFromEvent(r, { type: "turn_end" });
724
+ updateFromEvent(r, { type: "text_delta", delta: "Unclosed tail" }); // 未闭合非空尾 turn
725
+ expect(nextRoundBaseTurnIndex(r)).toBe(r.turns.length);
726
+ expect(nextRoundBaseTurnIndex(r)).toBe(2);
727
+ });
728
+ });
729
+
730
+ // ============================================================
731
+ // getAllToolCalls / getTotalUsage — 聚合派生
732
+ // ============================================================
733
+ describe("getAllToolCalls", () => {
734
+ it("flattens toolCalls across turns", () => {
735
+ const r = makeRecord();
736
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
737
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
738
+ updateFromEvent(r, { type: "turn_end" });
739
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/b.ts" } });
740
+ const calls = getAllToolCalls(r);
741
+ expect(calls).toHaveLength(2);
742
+ expect(calls.map((c) => c.toolName)).toEqual(["read", "edit"]);
743
+ });
744
+
745
+ it("strips internal _status / startedTs (exported shape is clean ToolCall)", () => {
746
+ // 导出的 ToolCall 不应泄漏内部状态机字段(_status / startedTs)——
747
+ // 这些是 execution-record 内部实现细节。
748
+ const r = makeRecord();
749
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
750
+ const calls = getAllToolCalls(r);
751
+ const tc = calls[0];
752
+ expect(tc).toBeDefined();
753
+ // 导出形状只有 4 个语义字段
754
+ expect(Object.keys(tc!).sort()).toEqual(["args", "isError", "result", "toolName"]);
755
+ // 内部字段不存在
756
+ expect((tc as Record<string, unknown>)._status).toBeUndefined();
757
+ expect((tc as Record<string, unknown>).startedTs).toBeUndefined();
758
+ });
759
+ });
760
+
761
+ describe("getTotalUsage", () => {
762
+ it("returns undefined when no usage", () => {
763
+ const r = makeRecord();
764
+ expect(getTotalUsage(r)).toBeUndefined();
765
+ });
766
+
767
+ it("aggregates usageDelta across turns", () => {
768
+ const r = makeRecord();
769
+ updateFromEvent(r, { type: "message_end", usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 } });
770
+ updateFromEvent(r, { type: "turn_end" });
771
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } });
772
+ const usage = getTotalUsage(r);
773
+ expect(usage).toEqual({ input: 11, output: 22, cacheRead: 5, cacheWrite: 3, total: 41, cost: 0 });
774
+ });
775
+
776
+ it("accumulates cost from message_end usage.cost", () => {
777
+ const r = makeRecord();
778
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.5 } });
779
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.25 } });
780
+ const usage = getTotalUsage(r);
781
+ expect(usage?.cost).toBe(0.75);
782
+ });
783
+
784
+ it("accumulates multiple message_end within same turn (no usage loss)", () => {
785
+ // 同 turn 内多次 message_end——usageDelta 按 field 累加(非覆盖),不丢 usage。
786
+ const r = makeRecord();
787
+ updateFromEvent(r, { type: "message_end", usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 } });
788
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } });
789
+ const usage = getTotalUsage(r);
790
+ expect(usage).toEqual({ input: 11, output: 22, cacheRead: 5, cacheWrite: 3, total: 41, cost: 0 });
791
+ });
792
+ });
793
+
794
+ // ============================================================
795
+ // tryTransition — CAS lock
796
+ // ============================================================
797
+ describe("tryTransition", () => {
798
+ it("returns true and sets status when transitioning from running", () => {
799
+ const r = makeRecord({ status: "running" });
800
+ expect(tryTransition(r, "done")).toBe(true);
801
+ expect(r.status).toBe("done");
802
+ });
803
+
804
+ it("returns false when already terminal (done)", () => {
805
+ const r = makeRecord({ status: "done" });
806
+ expect(tryTransition(r, "failed")).toBe(false);
807
+ expect(r.status).toBe("done");
808
+ });
809
+
810
+ it("returns false when already terminal (cancelled)", () => {
811
+ const r = makeRecord({ status: "cancelled" });
812
+ expect(tryTransition(r, "done")).toBe(false);
813
+ });
814
+
815
+ it("returns false when already terminal (failed)", () => {
816
+ const r = makeRecord({ status: "failed" });
817
+ expect(tryTransition(r, "done")).toBe(false);
818
+ });
819
+
820
+ it("first transition wins in concurrent race (running → done beats running → cancelled)", () => {
821
+ const r = makeRecord({ status: "running" });
822
+ expect(tryTransition(r, "done")).toBe(true);
823
+ expect(tryTransition(r, "cancelled")).toBe(false);
824
+ expect(r.status).toBe("done");
825
+ });
826
+
827
+ it("returns false when trying to transition from crashed to done", () => {
828
+ const r = makeRecord({ status: "crashed" });
829
+ expect(tryTransition(r, "done")).toBe(false);
830
+ expect(r.status).toBe("crashed");
831
+ });
832
+ });
833
+
834
+ describe("markReconstructedStatus", () => {
835
+ it("directly sets status without CAS check", () => {
836
+ const r = makeRecord({ status: "running" });
837
+ markReconstructedStatus(r, "crashed");
838
+ expect(r.status).toBe("crashed");
839
+ });
840
+
841
+ it("can overwrite terminal status (bypass CAS)", () => {
842
+ // 重建场景:旧 record 可能已有终态,重建时需要直接覆盖
843
+ const r = makeRecord({ status: "done" });
844
+ markReconstructedStatus(r, "crashed");
845
+ expect(r.status).toBe("crashed");
846
+ });
847
+
848
+ it("can overwrite running status", () => {
849
+ const r = makeRecord({ status: "running" });
850
+ markReconstructedStatus(r, "failed");
851
+ expect(r.status).toBe("failed");
852
+ });
853
+
854
+ it("can set crashed on running record", () => {
855
+ const r = makeRecord({ status: "running" });
856
+ markReconstructedStatus(r, "crashed");
857
+ expect(r.status).toBe("crashed");
858
+ });
859
+
860
+ it("can set crashed on done record (reconstruction override)", () => {
861
+ const r = makeRecord({ status: "done" });
862
+ markReconstructedStatus(r, "crashed");
863
+ expect(r.status).toBe("crashed");
864
+ });
865
+ });
866
+
867
+ // ============================================================
868
+ // completeRecord
869
+ // ============================================================
870
+ describe("completeRecord", () => {
871
+ it("writes outcome fields without resetting turnCount/totalTokens", () => {
872
+ const r = makeRecord({ turnCount: 5, totalTokens: 42 });
873
+ r.status = "done";
874
+ completeRecord(r, SAMPLE_RESULT, "done");
875
+ expect(r.status).toBe("done");
876
+ expect(r.endedAt).toBeTypeOf("number");
877
+ expect(r.agentResult).toBe(SAMPLE_RESULT);
878
+ expect(r.result).toBe("done");
879
+ expect(r.error).toBeUndefined();
880
+ expect(r.turnCount).toBe(5);
881
+ expect(r.totalTokens).toBe(42);
882
+ });
883
+
884
+ it("stores error from result", () => {
885
+ const r = makeRecord();
886
+ r.status = "failed";
887
+ const failedResult: AgentResult = { ...SAMPLE_RESULT, success: false, error: "oops" };
888
+ completeRecord(r, failedResult, "failed");
889
+ expect(r.error).toBe("oops");
890
+ });
891
+
892
+ // ── U3 C-outcome:completeRecord 唯一写入点冻结 outcome ──
893
+
894
+ it("[U3] 唯一写入点冻结 outcome:completed / failed / cancelled", () => {
895
+ const ok = makeRecord();
896
+ ok.status = "closed";
897
+ completeRecord(ok, SAMPLE_RESULT, "closed", "gc");
898
+ expect(ok.outcome).toBe("completed");
899
+ expect(ok.closedReason).toBe("gc");
900
+
901
+ const failed = makeRecord();
902
+ failed.status = "closed";
903
+ const failedResult: AgentResult = { ...SAMPLE_RESULT, success: false, error: "oops" };
904
+ completeRecord(failed, failedResult, "closed", "gc");
905
+ expect(failed.outcome).toBe("failed");
906
+
907
+ // 取消优先于 error(abort 合成 result 可能携带 error,取消语义优先)
908
+ const cancelled = makeRecord();
909
+ cancelled.status = "closed";
910
+ completeRecord(
911
+ cancelled,
912
+ { ...SAMPLE_RESULT, success: false, error: "aborted by user" },
913
+ "closed",
914
+ "cancelled",
915
+ );
916
+ expect(cancelled.outcome).toBe("cancelled");
917
+ });
918
+
919
+ it("[U3][D6 显式取舍] parent-shutdown 合成关闭(error='closed due to ...')→ outcome 'failed'", () => {
920
+ // disposeAllRecords 合成 result 恒写 success:false + error:"closed due to ${reason}"
921
+ // ——语义为「父进程关闭时子 agent 未完成即失败」,选定行为而非疏漏。
922
+ const r = makeRecord();
923
+ r.status = "closed";
924
+ completeRecord(
925
+ r,
926
+ { ...SAMPLE_RESULT, success: false, error: "closed due to parent-shutdown" },
927
+ "closed",
928
+ "parent-shutdown",
929
+ );
930
+ expect(r.outcome).toBe("failed");
931
+ });
932
+
933
+ it("[U3] patchFile 语义不变:completeRecord 不触碰 patchFile/result,仅新增 outcome", () => {
934
+ const r = makeRecord({ patchFile: "/tmp/patches/sa-x.patch" });
935
+ r.status = "closed";
936
+ completeRecord(r, SAMPLE_RESULT, "closed", "gc");
937
+ expect(r.patchFile).toBe("/tmp/patches/sa-x.patch");
938
+ expect(r.result).toBe("done");
939
+ expect(r.outcome).toBe("completed");
940
+ });
941
+ });
942
+
943
+ // ============================================================
944
+ // deriveOutcome / projectOutcome(U3 C-outcome 单一权威派生)
945
+ // ============================================================
946
+ describe("deriveOutcome(单一权威终态派生)", () => {
947
+ it("completed:closed + 无 error(gc / user-close 正常完成)", () => {
948
+ expect(deriveOutcome("gc", undefined)).toBe("completed");
949
+ expect(deriveOutcome("user-close", undefined)).toBe("completed");
950
+ });
951
+
952
+ it("failed:closed + error 非空(gc 失败)", () => {
953
+ expect(deriveOutcome("gc", "spawn EPIPE")).toBe("failed");
954
+ });
955
+
956
+ it("failed:parent-shutdown/parent-fork/parent-new 合成关闭(D6 显式取舍,勿改 cancelled)", () => {
957
+ expect(deriveOutcome("parent-shutdown", "closed due to parent-shutdown")).toBe("failed");
958
+ expect(deriveOutcome("parent-fork", "closed due to parent-fork")).toBe("failed");
959
+ expect(deriveOutcome("parent-new", "closed due to parent-new")).toBe("failed");
960
+ });
961
+
962
+ it("cancelled:取消优先于 error(abort 合成 result 可能带 error)", () => {
963
+ expect(deriveOutcome("cancelled", undefined)).toBe("cancelled");
964
+ expect(deriveOutcome("cancelled", "aborted by user")).toBe("cancelled");
965
+ });
966
+
967
+ it("空串 error 不构成 failed(与旧三处同构的 `record.error &&` truthy 判定逐字对齐)", () => {
968
+ expect(deriveOutcome("gc", "")).toBe("completed");
969
+ });
970
+ });
971
+
972
+ describe("projectOutcome(投影唯一出口)", () => {
973
+ it("running → undefined(终态语义不适用活跃态;即使误带 outcome 也拦截)", () => {
974
+ expect(projectOutcome({ status: "running" })).toBeUndefined();
975
+ expect(projectOutcome({ status: "running", outcome: "completed" })).toBeUndefined();
976
+ });
977
+
978
+ it("closed + 一等 outcome → 直读透传", () => {
979
+ expect(projectOutcome({ status: "closed", outcome: "failed" })).toBe("failed");
980
+ expect(projectOutcome({ status: "closed", outcome: "cancelled" })).toBe("cancelled");
981
+ });
982
+
983
+ it("closed + 无 outcome(存量/重建 record)→ deriveOutcome(closedReason, error) 兑底", () => {
984
+ expect(projectOutcome({ status: "closed", closedReason: "gc", error: "boom" })).toBe("failed");
985
+ expect(projectOutcome({ status: "closed", closedReason: "cancelled" })).toBe("cancelled");
986
+ expect(projectOutcome({ status: "closed", closedReason: "gc" })).toBe("completed");
987
+ // 连 closedReason/error 都缺失的最旧存量:兜底为 completed(与旧 closedReason??'gc'
988
+ // 兑底显示语义一致)
989
+ expect(projectOutcome({ status: "closed" })).toBe("completed");
990
+ });
991
+ });
992
+
993
+ // ============================================================
994
+ // project / snapshot — projections
995
+ // ============================================================
996
+ describe("projections", () => {
997
+ describe("project", () => {
998
+ it("returns SubagentToolDetails with all fields", () => {
999
+ const r = makeRecord({ turnCount: 3, totalTokens: 100 });
1000
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
1001
+ const d = project(r);
1002
+ expect(d.status).toBe("running");
1003
+ expect(d.agent).toBe("worker");
1004
+ expect(d.model).toBe("test-model");
1005
+ expect(d.turns).toBe(3);
1006
+ expect(d.totalTokens).toBe(100);
1007
+ expect(d.eventLog).toHaveLength(1); // tool_start derived
1008
+ expect(d.currentActivity).toEqual({ type: "tool", label: "read x.ts" });
1009
+ });
1010
+
1011
+ it("eventLog is a fresh array each call (派生,非存储引用)", () => {
1012
+ const r = makeRecord();
1013
+ updateFromEvent(r, { type: "tool_start", toolName: "read" });
1014
+ const d1 = project(r);
1015
+ const d2 = project(r);
1016
+ expect(d1.eventLog).not.toBe(d2.eventLog); // 不同数组实例
1017
+ expect(d1.eventLog).toEqual(d2.eventLog); // 内容相同
1018
+ });
1019
+
1020
+ it("currentActivity is undefined when status is not running", () => {
1021
+ const r = makeRecord({ status: "done" });
1022
+ expect(project(r).currentActivity).toBeUndefined();
1023
+ });
1024
+
1025
+ it("outputs mode + sessionFile", () => {
1026
+ const r = makeRecord({ mode: "background", turnCount: 2 });
1027
+ r.sessionFile = "bg-1-abc.jsonl";
1028
+ const d = project(r);
1029
+ expect(d.mode).toBe("background");
1030
+ expect(d.sessionFile).toBe("bg-1-abc.jsonl");
1031
+ });
1032
+
1033
+ it("[U3] project 投影携带 outcome:一等字段直读 / 存量兑底 / running undefined", () => {
1034
+ const done = makeRecord({ status: "closed", outcome: "completed", closedReason: "gc" });
1035
+ expect(project(done).outcome).toBe("completed");
1036
+ const legacy = makeRecord({ status: "closed", closedReason: "gc", error: "boom" });
1037
+ expect(project(legacy).outcome).toBe("failed");
1038
+ expect(project(makeRecord({ status: "running" })).outcome).toBeUndefined();
1039
+ });
1040
+
1041
+ it("sessionFile is undefined when record.sessionFile unset", () => {
1042
+ const r = makeRecord();
1043
+ expect(project(r).sessionFile).toBeUndefined();
1044
+ });
1045
+
1046
+ it("currentActivity prefers tool over thinking over text", () => {
1047
+ const r = makeRecord();
1048
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/a.ts" } });
1049
+ r.turns[0]!.thinking = "thinking...";
1050
+ r.turns[0]!.text = "text...";
1051
+ expect(project(r).currentActivity).toEqual({ type: "tool", label: "edit a.ts" });
1052
+ });
1053
+
1054
+ it("currentActivity falls back to thinking when no running tool", () => {
1055
+ const r = makeRecord();
1056
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
1057
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
1058
+ r.turns[0]!.thinking = "pondering";
1059
+ expect(project(r).currentActivity).toEqual({ type: "thinking", label: "pondering" });
1060
+ });
1061
+
1062
+ it("currentActivity falls back to text when no tool/thinking", () => {
1063
+ const r = makeRecord();
1064
+ r.turns[0]!.text = "writing output";
1065
+ expect(project(r).currentActivity).toEqual({ type: "text", label: "writing output" });
1066
+ });
1067
+
1068
+ it("currentActivity is undefined when idle", () => {
1069
+ const r = makeRecord();
1070
+ expect(project(r).currentActivity).toBeUndefined();
1071
+ });
1072
+ });
1073
+
1074
+ describe("snapshot", () => {
1075
+ it("returns a readonly snapshot with identity + status fields", () => {
1076
+ const r = makeRecord({ turnCount: 2, status: "done", endedAt: 5000, result: "ok" });
1077
+ const s = snapshot(r);
1078
+ expect(s.id).toBe("test-1");
1079
+ expect(s.agent).toBe("worker");
1080
+ expect(s.mode).toBe("sync");
1081
+ expect(s.task).toBe("test task");
1082
+ expect(s.status).toBe("done");
1083
+ expect(s.turns).toBe(2);
1084
+ expect(s.endedAt).toBe(5000);
1085
+ expect(s.result).toBe("ok");
1086
+ });
1087
+
1088
+ it("outputs sessionFile", () => {
1089
+ const r = makeRecord();
1090
+ r.sessionFile = "s.jsonl";
1091
+ expect(snapshot(r).sessionFile).toBe("s.jsonl");
1092
+ });
1093
+
1094
+ it("sessionFile is undefined when unset", () => {
1095
+ const r = makeRecord();
1096
+ expect(snapshot(r).sessionFile).toBeUndefined();
1097
+ });
1098
+ });
1099
+ });
1100
+
1101
+ // ============================================================
1102
+ // extractLabelFromArgs
1103
+ // ============================================================
1104
+ describe("extractLabelFromArgs", () => {
1105
+ it("returns bare toolName for non-object args", () => {
1106
+ expect(extractLabelFromArgs("read", undefined)).toBe("read");
1107
+ expect(extractLabelFromArgs("read", null)).toBe("read");
1108
+ expect(extractLabelFromArgs("read", "string")).toBe("read");
1109
+ });
1110
+
1111
+ it("returns bare toolName when no recognized field", () => {
1112
+ expect(extractLabelFromArgs("custom", { foo: "bar" })).toBe("custom");
1113
+ });
1114
+
1115
+ it("extracts basename from path", () => {
1116
+ expect(extractLabelFromArgs("read", { path: "/home/user/foo.ts" })).toBe("read foo.ts");
1117
+ expect(extractLabelFromArgs("edit", { file_path: "C:\\proj\\bar.js" })).toBe("edit bar.js");
1118
+ expect(extractLabelFromArgs("write", { filePath: "baz.py" })).toBe("write baz.py");
1119
+ });
1120
+
1121
+ it("extracts first line of command for bash", () => {
1122
+ expect(extractLabelFromArgs("bash", { command: "ls -la\necho done" })).toBe("bash ls -la");
1123
+ });
1124
+
1125
+ it("extracts query for web_search", () => {
1126
+ expect(extractLabelFromArgs("web_search", { query: "hello world" })).toBe("web_search hello world");
1127
+ });
1128
+
1129
+ it("extracts url for web_fetch", () => {
1130
+ expect(extractLabelFromArgs("web_fetch", { url: "https://example.com" })).toBe("web_fetch https://example.com");
1131
+ });
1132
+
1133
+ it("truncates long labels to TOOL_LABEL_MAX (TUI column-width stability)", () => {
1134
+ // 设计意图:保持 TUI 列宽稳定,避免 10KB bash 命令撑爆 compact view。
1135
+ const longCmd = "x".repeat(200);
1136
+ const label = extractLabelFromArgs("bash", { command: longCmd });
1137
+ // label = "bash " + 截断到 100 的 command
1138
+ const expectedCmd = "x".repeat(100);
1139
+ expect(label).toBe(`bash ${expectedCmd}`);
1140
+ expect(label.length).toBe("bash ".length + 100);
1141
+ });
1142
+
1143
+ it("truncates long path basename", () => {
1144
+ const longName = "f".repeat(150) + ".ts";
1145
+ const label = extractLabelFromArgs("read", { path: `/dir/${longName}` });
1146
+ // basename 截断到 100
1147
+ expect(label.length).toBe("read ".length + 100);
1148
+ });
1149
+
1150
+ it("truncates long query and url", () => {
1151
+ const longQuery = "q".repeat(150);
1152
+ expect(extractLabelFromArgs("web_search", { query: longQuery }).length).toBe("web_search ".length + 100);
1153
+ const longUrl = "u".repeat(150);
1154
+ expect(extractLabelFromArgs("web_fetch", { url: longUrl }).length).toBe("web_fetch ".length + 100);
1155
+ });
1156
+ });
1157
+
1158
+ // ============================================================
1159
+ // computeElapsedSeconds — 共享 helper
1160
+ // ============================================================
1161
+ describe("computeElapsedSeconds", () => {
1162
+ it("computes floor((endedAt - startedAt) / 1000)", () => {
1163
+ expect(computeElapsedSeconds({ startedAt: 0, endedAt: 1500 })).toBe(1);
1164
+ expect(computeElapsedSeconds({ startedAt: 1000, endedAt: 1599 })).toBe(0);
1165
+ expect(computeElapsedSeconds({ startedAt: 1000, endedAt: 2600 })).toBe(1);
1166
+ });
1167
+
1168
+ it("uses Date.now() when endedAt is undefined (running state)", () => {
1169
+ const startedAt = Date.now() - 3000;
1170
+ const secs = computeElapsedSeconds({ startedAt });
1171
+ // 至少 2 秒(允许调度延迟),不超过 10 秒(防止假死)
1172
+ expect(secs).toBeGreaterThanOrEqual(2);
1173
+ expect(secs).toBeLessThan(10);
1174
+ });
1175
+
1176
+ it("handles identical startedAt/endedAt (0 seconds)", () => {
1177
+ expect(computeElapsedSeconds({ startedAt: 5000, endedAt: 5000 })).toBe(0);
1178
+ });
1179
+ });
1180
+
1181
+ // ============================================================
1182
+ // T3.21: projectLiveProgress 迁移保留(wave-3)
1183
+ // ============================================================
1184
+ describe("projectLiveProgress (T3.21)", () => {
1185
+ it("projects live progress snapshot from running record", () => {
1186
+ const record = makeRecord({
1187
+ mode: "sync",
1188
+ task: "test task",
1189
+ startedAt: 1000,
1190
+ status: "running",
1191
+ turnCount: 2,
1192
+ totalTokens: 500,
1193
+ lastError: undefined,
1194
+ turns: [
1195
+ { ...emptyTurn(), closed: true, text: "turn 1", closedTs: 2000 },
1196
+ { ...emptyTurn(), closed: false, text: "turn 2 in progress" },
1197
+ ],
1198
+ });
1199
+
1200
+ const result = projectLiveProgress(record);
1201
+ expect(result.status).toBe("running");
1202
+ expect(result.turns).toBe(2);
1203
+ expect(result.totalTokens).toBe(500);
1204
+ expect(result.elapsedSeconds).toBeGreaterThanOrEqual(0);
1205
+ expect(result.eventLog).toBeInstanceOf(Array);
1206
+ expect(result.currentActivity).toBeDefined();
1207
+ expect(result.lastError).toBeUndefined();
1208
+ });
1209
+
1210
+ it("projectLiveProgress returns lastError when set", () => {
1211
+ const record = makeRecord({
1212
+ mode: "sync", task: "failing", startedAt: 0, status: "running",
1213
+ lastError: "something went wrong",
1214
+ });
1215
+ const result = projectLiveProgress(record);
1216
+ expect(result.lastError).toBe("something went wrong");
1217
+ });
1218
+ });
1219
+
1220
+ // ============================================================
1221
+ // tool_end running 索引 [perf]
1222
+ // ============================================================
1223
+ // 索引弹尾必须与旧实现「倒序全扫找最后一个 running 同名」语义等价:
1224
+ // 同名并发 toolCall 的 tool_end 交错到达时按 LIFO 配对,配对结果写回正确槽位。
1225
+ describe("tool_end running index [perf]", () => {
1226
+ const start = (record: ExecutionRecord, name: string, args: Record<string, unknown> = {}) =>
1227
+ updateFromEvent(record, { type: "tool_start", toolName: name, args });
1228
+
1229
+ const end = (record: ExecutionRecord, name: string, result: string) =>
1230
+ updateFromEvent(record, { type: "tool_end", toolName: name, result, isError: false });
1231
+
1232
+ it("同名并发两个 toolCall:tool_end LIFO 配对,结果写回各自槽位", () => {
1233
+ const record = makeRecord();
1234
+ start(record, "bash", { cmd: "first" });
1235
+ start(record, "bash", { cmd: "second" });
1236
+
1237
+ // 第一个 end 配对最后 push 的 running 同名(second)——与旧倒序全扫一致
1238
+ end(record, "bash", "result-for-second");
1239
+ const calls = record.turns[0]!.toolCalls;
1240
+ expect(calls).toHaveLength(2);
1241
+ expect(calls[0]).toMatchObject({ args: { cmd: "first" }, _status: "running" });
1242
+ expect(calls[1]).toMatchObject({ result: "result-for-second", _status: "done" });
1243
+
1244
+ // 第二个 end 配对剩下的 first
1245
+ end(record, "bash", "result-for-first");
1246
+ expect(calls[0]).toMatchObject({ result: "result-for-first", _status: "done" });
1247
+ expect(calls[1]).toMatchObject({ result: "result-for-second", _status: "done" });
1248
+ });
1249
+
1250
+ it("索引 miss(无 tool_start 的外部注入 tool_end)→ fallback 全扫后 push 幽灵条目", () => {
1251
+ const record = makeRecord();
1252
+ end(record, "external_tool", "injected");
1253
+ const calls = record.turns[0]!.toolCalls;
1254
+ expect(calls).toHaveLength(1);
1255
+ expect(calls[0]).toMatchObject({ toolName: "external_tool", result: "injected", _status: "done" });
1256
+ });
1257
+
1258
+ it("跨 turn 滞后 tool_end:索引跨 turn 命中(与旧全扫语义一致)", () => {
1259
+ const record = makeRecord();
1260
+ start(record, "read"); // turn 0 内 running
1261
+ updateFromEvent(record, { type: "turn_end" }); // 闭合 turn 0,开 turn 1
1262
+ start(record, "write"); // turn 1
1263
+ end(record, "write", "w-done"); // 正常配对 turn 1
1264
+ end(record, "read", "r-done"); // 滞后 end 配对 turn 0(跨 turn 兜底)
1265
+
1266
+ expect(record.turns[0]!.toolCalls[0]).toMatchObject({ toolName: "read", result: "r-done", _status: "done" });
1267
+ expect(record.turns[1]!.toolCalls[0]).toMatchObject({ toolName: "write", result: "w-done", _status: "done" });
1268
+ });
1269
+ });
1270
+
1271
+ // ============================================================
1272
+ // P4 引擎留痕字段(engine / engineFallback,D9①)
1273
+ // ============================================================
1274
+
1275
+ describe("createRecord 引擎留痕字段(P4)", () => {
1276
+ it("identity 带 engine/engineFallback 时进 record,JSON 序列化保留(持久化语义)", () => {
1277
+ const record = createRecord("sa-engine-1", {
1278
+ agent: "reviewer",
1279
+ model: "p/m",
1280
+ mode: "background",
1281
+ task: "t",
1282
+ slug: "s",
1283
+ startedAt: 1,
1284
+ engine: "pi",
1285
+ engineFallback: { from: "zcode", reason: "engine_probe_failed" },
1286
+ });
1287
+ expect(record.engine).toBe("pi");
1288
+ expect(record.engineFallback).toEqual({ from: "zcode", reason: "engine_probe_failed" });
1289
+ const serialized = JSON.parse(JSON.stringify(record)) as Record<string, unknown>;
1290
+ expect(serialized["engine"]).toBe("pi");
1291
+ expect(serialized["engineFallback"]).toEqual({ from: "zcode", reason: "engine_probe_failed" });
1292
+ });
1293
+
1294
+ it("不传时两字段缺省(存量 record 消费方零影响——序列化后无键产生)", () => {
1295
+ const record = createRecord("sa-engine-2", {
1296
+ agent: "worker",
1297
+ model: "p/m",
1298
+ mode: "background",
1299
+ task: "t",
1300
+ slug: "s",
1301
+ startedAt: 1,
1302
+ });
1303
+ expect(record.engine).toBeUndefined();
1304
+ expect(record.engineFallback).toBeUndefined();
1305
+ const serialized = JSON.parse(JSON.stringify(record)) as Record<string, unknown>;
1306
+ expect("engine" in serialized).toBe(false);
1307
+ expect("engineFallback" in serialized).toBe(false);
1308
+ });
1309
+
1310
+ it("SubagentRecord → entry 投影保留两字段(record-entry 持久化链)", () => {
1311
+ const base: SubagentRecord = {
1312
+ id: "sa-engine-3",
1313
+ agent: "reviewer",
1314
+ task: "t",
1315
+ slug: "s",
1316
+ status: "running",
1317
+ mode: "background",
1318
+ startedAt: 1,
1319
+ rootSessionId: undefined,
1320
+ parentRecordId: undefined,
1321
+ depth: 0,
1322
+ endedAt: undefined,
1323
+ turns: 0,
1324
+ totalTokens: 0,
1325
+ model: "p/m",
1326
+ thinkingLevel: undefined,
1327
+ eventLog: [],
1328
+ displayItems: [],
1329
+ };
1330
+ const entry = toSubagentRecordEntry({ ...base, engine: "pi", engineFallback: { from: "zcode", reason: "engine_probe_failed" } });
1331
+ expect(entry.engine).toBe("pi");
1332
+ expect(entry.engineFallback).toEqual({ from: "zcode", reason: "engine_probe_failed" });
1333
+ // 存量 record(无字段)投影后同样缺省——消费方按 pi 投影,零迁移
1334
+ expect(toSubagentRecordEntry(base).engine).toBeUndefined();
1335
+ });
1336
+ });
1337
+
1338
+ // ============================================================
1339
+ // U1 engine 三字段 entry 往返(engineHandle 贯通)
1340
+ // ============================================================
1341
+ describe("SubagentRecord ↔ subagent-record entry 往返(U1 engineHandle)", () => {
1342
+ const base: SubagentRecord = {
1343
+ id: "sa-engine-4",
1344
+ agent: "reviewer",
1345
+ task: "t",
1346
+ slug: "s",
1347
+ status: "running",
1348
+ mode: "background",
1349
+ startedAt: 1,
1350
+ rootSessionId: undefined,
1351
+ parentRecordId: undefined,
1352
+ depth: 0,
1353
+ endedAt: undefined,
1354
+ turns: 0,
1355
+ totalTokens: 0,
1356
+ model: "p/m",
1357
+ thinkingLevel: undefined,
1358
+ eventLog: [],
1359
+ displayItems: [],
1360
+ };
1361
+ const handle = { sessionRef: { sessionId: "s-1", dbPath: "pool/zcode.db" }, journalPath: "/abs/journal.jsonl", poolKey: "p1" };
1362
+
1363
+ it("record 含三字段 → entry JSON → 解析回 deep equal(sessionRef 键不枚举整体透传)", () => {
1364
+ const entry = toSubagentRecordEntry({ ...base, engine: "zcode", engineFallback: { from: "zcode", reason: "engine_probe_failed" }, engineHandle: handle });
1365
+ const roundTripped = JSON.parse(JSON.stringify(entry));
1366
+ expect(roundTripped).toEqual({
1367
+ v: 1,
1368
+ id: "sa-engine-4",
1369
+ agent: "reviewer",
1370
+ task: "t",
1371
+ slug: "s",
1372
+ status: "running",
1373
+ mode: "background",
1374
+ startedAt: 1,
1375
+ depth: 0,
1376
+ turns: 0,
1377
+ totalTokens: 0,
1378
+ model: "p/m",
1379
+ eventLog: [],
1380
+ displayItems: [],
1381
+ engine: "zcode",
1382
+ engineFallback: { from: "zcode", reason: "engine_probe_failed" },
1383
+ engineHandle: handle,
1384
+ });
1385
+ expect(toSubagentRecordEntry({ ...base, engineHandle: handle }).engineHandle).toEqual(handle);
1386
+ });
1387
+
1388
+ it("record 无三字段 → entry JSON 不含对应键(undefined 经 JSON.stringify 自然省略,存量零迁移)", () => {
1389
+ const serialized = JSON.parse(JSON.stringify(toSubagentRecordEntry(base))) as Record<string, unknown>;
1390
+ expect("engine" in serialized).toBe(false);
1391
+ expect("engineFallback" in serialized).toBe(false);
1392
+ expect("engineHandle" in serialized).toBe(false);
1393
+ });
1394
+ });