niceeval 0.6.1 → 0.7.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 (378) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +69 -7
  4. package/dist/context/types.d.ts +32 -12
  5. package/dist/i18n/en.d.ts +54 -0
  6. package/dist/i18n/zh-CN.d.ts +57 -3
  7. package/dist/o11y/types.d.ts +16 -2
  8. package/dist/report/aggregate.d.ts +32 -24
  9. package/dist/report/aggregate.js +158 -50
  10. package/dist/report/built-in/index.d.ts +2 -0
  11. package/dist/report/built-in/index.js +8 -0
  12. package/dist/report/components.d.ts +93 -160
  13. package/dist/report/components.js +377 -114
  14. package/dist/report/compute.d.ts +87 -81
  15. package/dist/report/compute.js +597 -417
  16. package/dist/report/flag.d.ts +32 -6
  17. package/dist/report/flag.js +92 -4
  18. package/dist/report/format.d.ts +19 -11
  19. package/dist/report/format.js +30 -13
  20. package/dist/report/index.d.ts +16 -16
  21. package/dist/report/index.js +20 -21
  22. package/dist/report/load.js +3 -2
  23. package/dist/report/locale.d.ts +57 -33
  24. package/dist/report/locale.js +122 -56
  25. package/dist/report/metrics.d.ts +23 -4
  26. package/dist/report/metrics.js +110 -25
  27. package/dist/report/primitives.d.ts +48 -15
  28. package/dist/report/primitives.js +135 -26
  29. package/dist/report/react/AttemptList.d.ts +9 -7
  30. package/dist/report/react/AttemptList.js +17 -10
  31. package/dist/report/react/DeltaTable.js +19 -18
  32. package/dist/report/react/EvalList.d.ts +4 -4
  33. package/dist/report/react/EvalList.js +0 -0
  34. package/dist/report/react/ExperimentComparison.d.ts +10 -0
  35. package/dist/report/react/ExperimentComparison.js +12 -0
  36. package/dist/report/react/ExperimentList.d.ts +4 -3
  37. package/dist/report/react/ExperimentList.js +17 -18
  38. package/dist/report/react/MetricBars.js +5 -4
  39. package/dist/report/react/MetricLine.js +12 -5
  40. package/dist/report/react/MetricMatrix.js +1 -1
  41. package/dist/report/react/MetricScatter.js +59 -28
  42. package/dist/report/react/MetricTable.js +2 -12
  43. package/dist/report/react/ScopeSummary.d.ts +10 -0
  44. package/dist/report/react/ScopeSummary.js +17 -0
  45. package/dist/report/react/Scoreboard.js +6 -6
  46. package/dist/report/react/cell.js +2 -2
  47. package/dist/report/react/chart-math.d.ts +23 -6
  48. package/dist/report/react/chart-math.js +71 -19
  49. package/dist/report/react/fixtures.d.ts +5 -9
  50. package/dist/report/react/fixtures.js +110 -147
  51. package/dist/report/react/index.d.ts +15 -5
  52. package/dist/report/react/index.js +18 -7
  53. package/dist/report/report.d.ts +137 -16
  54. package/dist/report/report.js +259 -28
  55. package/dist/report/text/faces.d.ts +17 -19
  56. package/dist/report/text/faces.js +253 -184
  57. package/dist/report/text/plot.js +1 -1
  58. package/dist/report/text/table.js +38 -7
  59. package/dist/report/tree.d.ts +90 -40
  60. package/dist/report/tree.js +252 -94
  61. package/dist/report/types.d.ts +247 -284
  62. package/dist/report/types.js +4 -3
  63. package/dist/report/web.d.ts +21 -5
  64. package/dist/report/web.js +42 -16
  65. package/dist/results/select.d.ts +38 -16
  66. package/dist/results/select.js +73 -25
  67. package/dist/results/types.d.ts +49 -14
  68. package/dist/runner/feedback/sink.d.ts +110 -0
  69. package/dist/runner/types.d.ts +513 -22
  70. package/dist/sandbox/docker.d.ts +23 -2
  71. package/dist/sandbox/e2b.d.ts +15 -1
  72. package/dist/sandbox/errors.d.ts +30 -3
  73. package/dist/sandbox/io-retry.d.ts +17 -0
  74. package/dist/sandbox/registry.d.ts +2 -0
  75. package/dist/sandbox/resolve.d.ts +18 -5
  76. package/dist/sandbox/retry.d.ts +11 -1
  77. package/dist/sandbox/types.d.ts +39 -5
  78. package/dist/sandbox/vercel.d.ts +7 -1
  79. package/dist/scoring/coverage.d.ts +30 -0
  80. package/dist/scoring/display.d.ts +21 -0
  81. package/dist/scoring/display.js +120 -0
  82. package/dist/scoring/types.d.ts +103 -20
  83. package/dist/shared/aggregate.d.ts +4 -2
  84. package/dist/shared/aggregate.js +8 -7
  85. package/dist/shared/types.d.ts +28 -0
  86. package/dist/tty-line.d.ts +0 -4
  87. package/dist/util.d.ts +23 -0
  88. package/docs-site/zh/README.md +44 -0
  89. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  90. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  91. package/docs-site/zh/examples/index.mdx +50 -0
  92. package/docs-site/zh/{concepts → explanation}/adapter.mdx +31 -13
  93. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  94. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  95. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  96. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  97. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  98. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  99. package/docs-site/zh/{concepts → explanation}/overview.mdx +11 -11
  100. package/docs-site/zh/{guides → explanation}/runner.mdx +18 -8
  101. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  102. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +35 -33
  103. package/docs-site/zh/{guides → how-to}/authoring.mdx +35 -2
  104. package/docs-site/zh/{guides → how-to}/ci-integration.mdx +23 -12
  105. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  106. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +47 -21
  107. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +34 -39
  108. package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +25 -3
  109. package/docs-site/zh/{guides → how-to}/experiments.mdx +12 -5
  110. package/docs-site/zh/how-to/publish-report.mdx +105 -0
  111. package/docs-site/zh/{guides → how-to}/reporters.mdx +2 -2
  112. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +56 -7
  113. package/docs-site/zh/how-to/sandbox-providers.mdx +350 -0
  114. package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +4 -4
  115. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +82 -39
  116. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +6 -4
  117. package/docs-site/zh/{guides → how-to}/write-send.mdx +30 -14
  118. package/docs-site/zh/index.mdx +24 -26
  119. package/docs-site/zh/introduction.mdx +8 -8
  120. package/docs-site/zh/reference/builtin-agents.mdx +32 -5
  121. package/docs-site/zh/reference/capabilities.mdx +8 -8
  122. package/docs-site/zh/reference/cli.mdx +40 -12
  123. package/docs-site/zh/reference/define-agent.mdx +58 -5
  124. package/docs-site/zh/reference/define-config.mdx +1 -1
  125. package/docs-site/zh/reference/define-eval.mdx +42 -9
  126. package/docs-site/zh/reference/events.mdx +3 -3
  127. package/docs-site/zh/reference/expect.mdx +26 -1
  128. package/docs-site/zh/{guides → reference}/official-adapters.mdx +32 -8
  129. package/docs-site/zh/{guides → reference}/report-components.mdx +45 -33
  130. package/docs-site/zh/{guides → reference}/results-data.mdx +21 -13
  131. package/docs-site/zh/troubleshooting/debug-sandbox.mdx +57 -0
  132. package/docs-site/zh/troubleshooting/debugging.mdx +212 -0
  133. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  134. package/package.json +10 -2
  135. package/src/agents/ai-sdk-otel.test.ts +1 -0
  136. package/src/agents/ai-sdk.test.ts +3 -0
  137. package/src/agents/ai-sdk.ts +3 -0
  138. package/src/agents/bub-install-spec.test.ts +34 -0
  139. package/src/agents/bub-install-spec.ts +32 -0
  140. package/src/agents/bub.ts +31 -32
  141. package/src/agents/claude-code.test.ts +130 -9
  142. package/src/agents/claude-code.ts +76 -4
  143. package/src/agents/codex.test.ts +189 -40
  144. package/src/agents/codex.ts +155 -14
  145. package/src/agents/coding-cli-versions.test.ts +15 -0
  146. package/src/agents/coding-cli-versions.ts +3 -0
  147. package/src/agents/index.ts +13 -2
  148. package/src/agents/langgraph.test.ts +204 -0
  149. package/src/agents/langgraph.ts +495 -0
  150. package/src/agents/marketplace.ts +85 -0
  151. package/src/agents/native-config.test.ts +179 -0
  152. package/src/agents/native-config.ts +267 -0
  153. package/src/agents/openai-compat.test.ts +1 -0
  154. package/src/agents/openai-compat.ts +1 -1
  155. package/src/agents/openclaw.test.ts +31 -0
  156. package/src/agents/openclaw.ts +171 -0
  157. package/src/agents/plugin-config.test.ts +1 -0
  158. package/src/agents/sdk-streams.test.ts +79 -0
  159. package/src/agents/sdk-streams.ts +55 -10
  160. package/src/agents/skills.test.ts +1 -0
  161. package/src/agents/streaming.test.ts +3 -9
  162. package/src/agents/streaming.ts +2 -2
  163. package/src/agents/types.ts +71 -8
  164. package/src/agents/ui-message-stream.test.ts +3 -0
  165. package/src/cli.ts +446 -124
  166. package/src/context/context.test.ts +51 -12
  167. package/src/context/context.ts +162 -30
  168. package/src/context/session.test.ts +2 -1
  169. package/src/context/session.ts +115 -7
  170. package/src/context/types.ts +30 -12
  171. package/src/define.test.ts +13 -8
  172. package/src/define.ts +25 -4
  173. package/src/expect/index.ts +53 -23
  174. package/src/i18n/en.ts +81 -17
  175. package/src/i18n/zh-CN.ts +80 -17
  176. package/src/o11y/cost.test.ts +1 -0
  177. package/src/o11y/execution-tree.test.ts +1 -20
  178. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  179. package/src/o11y/otlp/parse.test.ts +1 -0
  180. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  181. package/src/o11y/parsers/bub.test.ts +1 -0
  182. package/src/o11y/parsers/claude-code.test.ts +1 -34
  183. package/src/o11y/parsers/openclaw.test.ts +154 -0
  184. package/src/o11y/parsers/openclaw.ts +310 -0
  185. package/src/o11y/prices.json +746 -311
  186. package/src/o11y/tool-names.test.ts +1 -0
  187. package/src/o11y/types.ts +16 -2
  188. package/src/report/aggregate.ts +178 -61
  189. package/src/report/built-in/index.tsx +9 -0
  190. package/src/report/components.tsx +625 -279
  191. package/src/report/compute.ts +723 -491
  192. package/src/report/dual-render.test.tsx +741 -1024
  193. package/src/report/flag.ts +104 -12
  194. package/src/report/format.ts +32 -12
  195. package/src/report/index.ts +119 -46
  196. package/src/report/load.ts +3 -2
  197. package/src/report/locale.ts +136 -65
  198. package/src/report/metrics.ts +108 -25
  199. package/src/report/primitives.tsx +196 -45
  200. package/src/report/react/AttemptList.tsx +30 -43
  201. package/src/report/react/DeltaTable.tsx +63 -45
  202. package/src/report/react/EvalList.tsx +0 -0
  203. package/src/report/react/ExperimentComparison.tsx +73 -0
  204. package/src/report/react/ExperimentList.tsx +50 -32
  205. package/src/report/react/MetricBars.tsx +5 -4
  206. package/src/report/react/MetricLine.tsx +13 -8
  207. package/src/report/react/MetricMatrix.tsx +2 -2
  208. package/src/report/react/MetricScatter.tsx +86 -34
  209. package/src/report/react/MetricTable.tsx +4 -76
  210. package/src/report/react/ScopeSummary.tsx +86 -0
  211. package/src/report/react/Scoreboard.tsx +28 -10
  212. package/src/report/react/cell.tsx +2 -2
  213. package/src/report/react/chart-math.test.ts +85 -0
  214. package/src/report/react/chart-math.ts +101 -22
  215. package/src/report/react/enhance.js +89 -5
  216. package/src/report/react/fixtures.ts +114 -154
  217. package/src/report/react/index.tsx +24 -39
  218. package/src/report/react/render.test.tsx +138 -158
  219. package/src/report/react/styles.css +243 -82
  220. package/src/report/report.test.ts +779 -841
  221. package/src/report/report.ts +423 -41
  222. package/src/report/text/faces.ts +290 -193
  223. package/src/report/text/plot.ts +1 -1
  224. package/src/report/text/table.ts +44 -7
  225. package/src/report/tree.ts +362 -104
  226. package/src/report/types.ts +261 -271
  227. package/src/report/web.ts +63 -20
  228. package/src/results/annotated-source.test.ts +62 -9
  229. package/src/results/annotated-source.ts +64 -6
  230. package/src/results/attempt-evidence.test.ts +13 -11
  231. package/src/results/attempt-evidence.ts +20 -13
  232. package/src/results/attempt-source.ts +6 -3
  233. package/src/results/copy.ts +150 -60
  234. package/src/results/host-equivalence.test.ts +34 -20
  235. package/src/results/index.ts +12 -4
  236. package/src/results/locator.test.ts +1 -22
  237. package/src/results/open.ts +15 -5
  238. package/src/results/publish.ts +149 -0
  239. package/src/results/results.test.ts +89 -54
  240. package/src/results/select.ts +104 -34
  241. package/src/results/truncate.ts +90 -0
  242. package/src/results/types.ts +43 -14
  243. package/src/results/writer.ts +31 -13
  244. package/src/runner/attempt.test.ts +138 -7
  245. package/src/runner/attempt.ts +603 -104
  246. package/src/runner/discover.test.ts +47 -0
  247. package/src/runner/discover.ts +36 -2
  248. package/src/runner/eval-source.test.ts +1 -27
  249. package/src/runner/feedback/agent.test.ts +504 -0
  250. package/src/runner/feedback/agent.ts +409 -0
  251. package/src/runner/feedback/ci.test.ts +562 -0
  252. package/src/runner/feedback/ci.ts +401 -0
  253. package/src/runner/feedback/coordinator.test.ts +317 -0
  254. package/src/runner/feedback/coordinator.ts +397 -0
  255. package/src/runner/feedback/failure.ts +40 -0
  256. package/src/runner/feedback/human.test.ts +616 -0
  257. package/src/runner/feedback/human.ts +535 -0
  258. package/src/runner/feedback/index.ts +66 -0
  259. package/src/runner/feedback/io.ts +78 -0
  260. package/src/runner/feedback/profile.test.ts +50 -0
  261. package/src/runner/feedback/profile.ts +58 -0
  262. package/src/runner/feedback/reducer.test.ts +395 -0
  263. package/src/runner/feedback/reducer.ts +260 -0
  264. package/src/runner/feedback/renderer.ts +82 -0
  265. package/src/runner/feedback/sink.ts +203 -0
  266. package/src/runner/feedback/testing.ts +106 -0
  267. package/src/runner/ledger.test.ts +230 -0
  268. package/src/runner/ledger.ts +329 -0
  269. package/src/runner/report.test.ts +128 -3
  270. package/src/runner/report.ts +33 -9
  271. package/src/runner/reporters/artifacts.ts +8 -2
  272. package/src/runner/reporters/braintrust.test.ts +8 -7
  273. package/src/runner/reporters/braintrust.ts +9 -2
  274. package/src/runner/reporters/index.ts +2 -2
  275. package/src/runner/reporters/json.test.ts +162 -0
  276. package/src/runner/reporters/json.ts +35 -8
  277. package/src/runner/reporters/shared.ts +1 -5
  278. package/src/runner/run.test.ts +760 -3
  279. package/src/runner/run.ts +243 -37
  280. package/src/runner/sandbox-prep.ts +3 -42
  281. package/src/runner/timing.ts +158 -0
  282. package/src/runner/types.ts +518 -22
  283. package/src/sandbox/checkpoint.test.ts +55 -0
  284. package/src/sandbox/checkpoint.ts +29 -8
  285. package/src/sandbox/cli-commands.ts +407 -0
  286. package/src/sandbox/docker.ts +115 -16
  287. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  288. package/src/sandbox/e2b-agent-template.ts +94 -0
  289. package/src/sandbox/e2b.ts +74 -9
  290. package/src/sandbox/errors.ts +111 -4
  291. package/src/sandbox/index.ts +2 -0
  292. package/src/sandbox/io-retry.test.ts +58 -0
  293. package/src/sandbox/io-retry.ts +45 -0
  294. package/src/sandbox/keep-registry.test.ts +86 -0
  295. package/src/sandbox/keep-registry.ts +142 -0
  296. package/src/sandbox/keep.ts +178 -0
  297. package/src/sandbox/paths.test.ts +1 -0
  298. package/src/sandbox/paths.ts +19 -8
  299. package/src/sandbox/registry.ts +20 -3
  300. package/src/sandbox/resolve.ts +76 -11
  301. package/src/sandbox/retry.test.ts +70 -0
  302. package/src/sandbox/retry.ts +46 -4
  303. package/src/sandbox/types.ts +44 -6
  304. package/src/sandbox/vercel.ts +43 -20
  305. package/src/scoring/collector.ts +60 -17
  306. package/src/scoring/coverage.ts +95 -0
  307. package/src/scoring/diff.ts +81 -0
  308. package/src/scoring/display.test.ts +121 -0
  309. package/src/scoring/display.ts +133 -0
  310. package/src/scoring/evidence.test.ts +189 -0
  311. package/src/scoring/judge.test.ts +142 -0
  312. package/src/scoring/judge.ts +15 -18
  313. package/src/scoring/scoped.ts +217 -50
  314. package/src/scoring/types.ts +117 -20
  315. package/src/scoring/verdict.ts +16 -4
  316. package/src/shared/aggregate.ts +8 -6
  317. package/src/shared/types.ts +31 -0
  318. package/src/show/compose.ts +50 -67
  319. package/src/show/index.ts +127 -56
  320. package/src/show/render.ts +662 -131
  321. package/src/show/report-host.test.ts +188 -0
  322. package/src/show/report-host.ts +375 -0
  323. package/src/show/show.test.ts +320 -54
  324. package/src/tty-line.ts +8 -26
  325. package/src/util.test.ts +1 -0
  326. package/src/util.ts +41 -0
  327. package/src/view/app/App.test.tsx +69 -0
  328. package/src/view/app/App.tsx +144 -48
  329. package/src/view/app/components/AttemptModal.tsx +423 -11
  330. package/src/view/app/components/CodeView.tsx +41 -14
  331. package/src/view/app/components/CopyControls.tsx +2 -2
  332. package/src/view/app/i18n.ts +37 -17
  333. package/src/view/app/lib/attempt-route.test.ts +1 -0
  334. package/src/view/app/lib/verdict.ts +7 -9
  335. package/src/view/app/main.tsx +13 -8
  336. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  337. package/src/view/app/types.ts +4 -1
  338. package/src/view/artifact-serving.test.ts +2 -1
  339. package/src/view/client-dist/app.css +1 -1
  340. package/src/view/client-dist/app.js +17 -17
  341. package/src/view/data.test.ts +10 -3
  342. package/src/view/data.ts +155 -49
  343. package/src/view/index.ts +56 -41
  344. package/src/view/server.ts +37 -15
  345. package/src/view/shared/types.ts +34 -5
  346. package/src/view/styles.css +227 -0
  347. package/src/view/view-report.test.ts +167 -62
  348. package/dist/report/built-ins/experiment-comparison.d.ts +0 -1
  349. package/dist/report/built-ins/experiment-comparison.js +0 -13
  350. package/dist/report/built-ins/index.d.ts +0 -1
  351. package/dist/report/built-ins/index.js +0 -2
  352. package/dist/report/react/GroupSummary.d.ts +0 -8
  353. package/dist/report/react/GroupSummary.js +0 -8
  354. package/dist/report/react/RunOverview.d.ts +0 -8
  355. package/dist/report/react/RunOverview.js +0 -12
  356. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  357. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  358. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  359. package/docs-site/zh/example/showcase.mdx +0 -39
  360. package/docs-site/zh/guides/publish-report.mdx +0 -91
  361. package/docs-site/zh/guides/sandbox-providers.mdx +0 -102
  362. package/src/report/built-in-user-parity.test.tsx +0 -640
  363. package/src/report/built-ins/experiment-comparison.tsx +0 -19
  364. package/src/report/built-ins/index.ts +0 -2
  365. package/src/report/react/GroupSummary.tsx +0 -66
  366. package/src/report/react/RunOverview.tsx +0 -109
  367. package/src/runner/reporters/console.ts +0 -70
  368. package/src/runner/reporters/live.test.ts +0 -56
  369. package/src/runner/reporters/live.ts +0 -247
  370. package/src/runner/reporters/quiet.test.ts +0 -66
  371. package/src/runner/reporters/quiet.ts +0 -49
  372. package/src/runner/reporters/table.ts +0 -277
  373. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  374. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  375. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  376. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  377. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  378. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
@@ -1,3 +1,4 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
1
2
  // discoverEvals 的 eval 源码捕获:同一文件(数组默认导出)共享一份 CapturedEvalSource,
2
3
  // 内容/路径/哈希与 captureEvalSource() 直接调出来的一致(定稿见 docs/concepts.md「标注 Eval 源码」)。
3
4
 
@@ -48,6 +49,52 @@ describe("discoverEvals · 源码捕获", () => {
48
49
  expect(evals[0]!.source.sha256).toHaveLength(64);
49
50
  });
50
51
 
52
+ it("keyed record 默认导出:业务 key 进入 id,按 key 字典序稳定排列并共享源码引用", async () => {
53
+ const root = await makeRoot();
54
+ await mkdir(join(root, "evals"), { recursive: true });
55
+ const file = join(root, "evals", "issues.eval.ts");
56
+ await writeFile(
57
+ file,
58
+ "export default {\n '25901': { test() {} },\n '15193': { test() {} },\n};\n",
59
+ "utf-8",
60
+ );
61
+
62
+ const evals = await discoverEvals(root);
63
+ expect(evals.map((e) => e.id)).toEqual(["issues/15193", "issues/25901"]);
64
+ expect(evals[0]!.source).toBe(evals[1]!.source);
65
+ });
66
+
67
+ it("空 keyed record 合法且不产生 eval", async () => {
68
+ const root = await makeRoot();
69
+ await mkdir(join(root, "evals"), { recursive: true });
70
+ await writeFile(join(root, "evals", "empty.eval.ts"), "export default {};\n", "utf-8");
71
+
72
+ await expect(discoverEvals(root)).resolves.toEqual([]);
73
+ });
74
+
75
+ it.each(["", ".", "..", "a/b", "a\\b", "line\nbreak"])(
76
+ "keyed record 拒绝非法业务 key %j",
77
+ async (key) => {
78
+ const root = await makeRoot();
79
+ await mkdir(join(root, "evals"), { recursive: true });
80
+ await writeFile(
81
+ join(root, "evals", "bad.eval.ts"),
82
+ `export default Object.fromEntries([[${JSON.stringify(key)}, { test() {} }]]);\n`,
83
+ "utf-8",
84
+ );
85
+
86
+ await expect(discoverEvals(root)).rejects.toThrow(/Invalid keyed eval dataset key/);
87
+ },
88
+ );
89
+
90
+ it("keyed record 的每个值都必须是 EvalDef", async () => {
91
+ const root = await makeRoot();
92
+ await mkdir(join(root, "evals"), { recursive: true });
93
+ await writeFile(join(root, "evals", "bad-value.eval.ts"), "export default { issue: {} };\n", "utf-8");
94
+
95
+ await expect(discoverEvals(root)).rejects.toThrow(/must map to an EvalDef with test/);
96
+ });
97
+
51
98
  it("CRLF 源码归一化后哈希与 LF 版本一致(discovery 侧与 collectSources/annotated-source 共用归一化)", async () => {
52
99
  const rootLf = await makeRoot();
53
100
  const rootCrlf = await makeRoot();
@@ -1,4 +1,4 @@
1
- // 发现:扫 evals/ 找 *.eval.ts(默认导出 EvalDef 或数组),扫 experiments/ 找实验。
1
+ // 发现:扫 evals/ 找 *.eval.ts(默认导出 EvalDef、数组或 keyed record),扫 experiments/ 找实验。
2
2
  // 路径即身份:id 从相对路径推导,排序保证稳定。
3
3
 
4
4
  import { readdir } from "node:fs/promises";
@@ -38,7 +38,9 @@ export async function discoverEvals(root: string): Promise<DiscoveredEval[]> {
38
38
  const files = (await walkFiles(dir, (n) => n.endsWith(".eval.ts") || n.endsWith(".eval.tsx"))).sort();
39
39
  const out: DiscoveredEval[] = [];
40
40
  for (const file of files) {
41
- const mod = (await import(pathToFileURL(file).href)) as { default?: EvalDef | EvalDef[] };
41
+ const mod = (await import(pathToFileURL(file).href)) as {
42
+ default?: EvalDef | EvalDef[] | Record<string, EvalDef>;
43
+ };
42
44
  const def = mod.default;
43
45
  if (!def) continue;
44
46
  const baseId = relative(dir, file).replace(/\.eval\.tsx?$/, "").split(sep).join("/");
@@ -48,6 +50,18 @@ export async function discoverEvals(root: string): Promise<DiscoveredEval[]> {
48
50
  const source = await captureEvalSource(file, { root });
49
51
  if (Array.isArray(def)) {
50
52
  def.forEach((d, i) => out.push({ ...d, id: `${baseId}/${pad4(i)}`, baseDir, sourcePath: file, source }));
53
+ } else if (!isEvalDef(def)) {
54
+ const dataset = def;
55
+ for (const key of Object.keys(dataset).sort()) {
56
+ assertDatasetKey(key, file);
57
+ const d = dataset[key];
58
+ if (!d || typeof d.test !== "function") {
59
+ throw new Error(
60
+ `Invalid keyed eval dataset export in ${file}: key ${JSON.stringify(key)} must map to an EvalDef with test().`,
61
+ );
62
+ }
63
+ out.push({ ...d, id: `${baseId}/${key}`, baseDir, sourcePath: file, source });
64
+ }
51
65
  } else {
52
66
  out.push({ ...def, id: baseId, baseDir, sourcePath: file, source });
53
67
  }
@@ -55,6 +69,26 @@ export async function discoverEvals(root: string): Promise<DiscoveredEval[]> {
55
69
  return out;
56
70
  }
57
71
 
72
+ function isEvalDef(value: EvalDef | Record<string, EvalDef>): value is EvalDef {
73
+ return typeof (value as EvalDef).test === "function";
74
+ }
75
+
76
+ function assertDatasetKey(key: string, file: string): void {
77
+ if (
78
+ key.length === 0 ||
79
+ key === "." ||
80
+ key === ".." ||
81
+ key.includes("/") ||
82
+ key.includes("\\") ||
83
+ /[\u0000-\u001f\u007f]/.test(key)
84
+ ) {
85
+ throw new Error(
86
+ `Invalid keyed eval dataset key ${JSON.stringify(key)} in ${file}: ` +
87
+ "keys must be non-empty path segments; '.', '..', '/', '\\', and control characters are not allowed.",
88
+ );
89
+ }
90
+ }
91
+
58
92
  export async function discoverExperiments(root: string): Promise<DiscoveredExperiment[]> {
59
93
  const dir = join(root, "experiments");
60
94
  const files = (await walkFiles(dir, (n) => n.endsWith(".ts") && !n.endsWith(".d.ts"))).sort();
@@ -1,3 +1,4 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
1
2
  // captureEvalSource 的单测(定稿见 docs/feature/results/architecture.md「sources.json」)。
2
3
  // 覆盖:哈希确定性、path 相对 root 计算、CRLF/BOM 归一化行为、与 results/source-hash.ts
3
4
  // 算法保持一致(两处哈希必须逐字节相同,见 eval-source.ts 顶部注释)。
@@ -20,19 +21,6 @@ afterEach(async () => {
20
21
  });
21
22
 
22
23
  describe("captureEvalSource", () => {
23
- it("computes a deterministic sha256 for the same file content", async () => {
24
- const root = await makeRoot();
25
- await mkdir(join(root, "evals"), { recursive: true });
26
- const file = join(root, "evals", "weather.eval.ts");
27
- await writeFile(file, "export default { id: 'weather' };\n", "utf-8");
28
-
29
- const first = await captureEvalSource(file, { root });
30
- const second = await captureEvalSource(file, { root });
31
-
32
- expect(first.sha256).toBe(second.sha256);
33
- expect(first.sha256).toMatch(/^[0-9a-f]{64}$/);
34
- });
35
-
36
24
  it("returns a path relative to the given root, using forward slashes", async () => {
37
25
  const root = await makeRoot();
38
26
  await mkdir(join(root, "evals", "nested"), { recursive: true });
@@ -104,18 +92,4 @@ describe("captureEvalSource", () => {
104
92
  expect(captured.content).toBe(normalizeEvalSource(raw));
105
93
  expect(captured.sha256).toBe(hashEvalSource(normalizeEvalSource(raw)));
106
94
  });
107
-
108
- it("rejects two files with different content by producing different hashes", async () => {
109
- const root = await makeRoot();
110
- await mkdir(join(root, "evals"), { recursive: true });
111
- const fileA = join(root, "evals", "a.eval.ts");
112
- const fileB = join(root, "evals", "b.eval.ts");
113
- await writeFile(fileA, "content a\n", "utf-8");
114
- await writeFile(fileB, "content b\n", "utf-8");
115
-
116
- const a = await captureEvalSource(fileA, { root });
117
- const b = await captureEvalSource(fileB, { root });
118
-
119
- expect(a.sha256).not.toBe(b.sha256);
120
- });
121
95
  });
@@ -0,0 +1,504 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
2
+ // Agent renderer 测试:全部经真实 FeedbackCoordinator + createFakeFeedbackIO 驱动(不
3
+ // monkey-patch process/Date/setInterval),断言具体文本而不是整段 snapshot diff——逐条对应
4
+ // plan section E 的 checklist 与 docs/feature/experiments/cli.md「AI agent 怎么用」的例子。
5
+ //
6
+ // 几个测试直接用 cli.md 给出的字面文本做断言(不只是结构性检查),因为这些例子是这个
7
+ // renderer 唯一的书面契约来源——如果哪天悄悄改动了字段名/顺序,这里应该红。
8
+
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+ import { createFeedbackCoordinator, type FeedbackCoordinator } from "./coordinator.ts";
11
+ import { createFakeFeedbackIO, type FakeFeedbackIO } from "./testing.ts";
12
+ import { activeFeedbackSinkCount } from "./sink.ts";
13
+ import { createAgentRenderer, renderAgentPlanEnvelope } from "./agent.ts";
14
+ import type { AttemptLocator } from "../../results/locator.ts";
15
+ import type { AttemptRef, RunCompletion, RunFeedbackPlan, RunSummary } from "../types.ts";
16
+
17
+ function locator(id: string): AttemptLocator {
18
+ return `@1${id}` as AttemptLocator;
19
+ }
20
+
21
+ function ref(evalId: string, attempt = 0, experimentId = "compare/bub-e2b"): AttemptRef {
22
+ return { experimentId, evalId, attempt };
23
+ }
24
+
25
+ function plan(overrides: Partial<RunFeedbackPlan["shape"]> = {}, reused = 0): RunFeedbackPlan {
26
+ return {
27
+ shape: { evals: 1, configs: 1, totalRuns: 1, maxConcurrency: 1, ...overrides },
28
+ reused,
29
+ reusedFailures: [],
30
+ };
31
+ }
32
+
33
+ function summary(overrides: Partial<RunSummary> = {}): RunSummary {
34
+ return {
35
+ agent: "codex",
36
+ startedAt: "2026-07-13T00:00:00.000Z",
37
+ completedAt: "2026-07-13T00:03:21.000Z",
38
+ passed: 1,
39
+ failed: 0,
40
+ skipped: 0,
41
+ errored: 0,
42
+ durationMs: 201_000,
43
+ results: [],
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function completion(overrides: Partial<RunCompletion> = {}): RunCompletion {
49
+ return { status: "complete", unstarted: 0, earlyExitUnstarted: 0, reporterErrors: [], ...overrides };
50
+ }
51
+
52
+ function flush(): Promise<void> {
53
+ return new Promise((resolve) => setTimeout(resolve, 0));
54
+ }
55
+
56
+ /** ANSI escape 起始字节;agent 输出必须永远不出现。 */
57
+ const ESC = "\x1B[";
58
+
59
+ function setup(): { fake: FakeFeedbackIO; coordinator: FeedbackCoordinator } {
60
+ const fake = createFakeFeedbackIO({ stderr: { isTTY: false }, stdout: { isTTY: false } });
61
+ const renderer = createAgentRenderer({ io: fake.io });
62
+ const coordinator = createFeedbackCoordinator({ profile: "agent", renderer, io: fake.io, tickIntervalMs: 250 });
63
+ return { fake, coordinator };
64
+ }
65
+
66
+ afterEach(() => {
67
+ expect(activeFeedbackSinkCount()).toBe(0);
68
+ });
69
+
70
+ // ───────────────────────── 固定 ASCII envelope:无 ANSI、字段转义 ─────────────────────────
71
+
72
+ describe("固定 ASCII envelope,不依赖 locale", () => {
73
+ it("全程(checkpoint + handoff)不出现任何 ANSI 控制字符", async () => {
74
+ const { fake, coordinator } = setup();
75
+ coordinator.start(plan({ totalRuns: 1 }));
76
+ coordinator.diagnostic({ key: "x", severity: "warning", message: "has a space" });
77
+ await coordinator.finish({
78
+ summary: summary({ passed: 0, failed: 1 }),
79
+ completion: completion(),
80
+ paths: [".niceeval/compare/a/2026"],
81
+ });
82
+ const all = fake.stderr.writes.join("") + fake.stdout.writes.join("");
83
+ expect(all).not.toContain(ESC);
84
+ });
85
+
86
+ it("字段值含空格时用 JSON string 转义,不含空格时保持裸 token", async () => {
87
+ const { fake, coordinator } = setup();
88
+ coordinator.start(plan());
89
+ coordinator.diagnostic({ key: "no-spaces", severity: "warning", message: "cold index" });
90
+ await flush();
91
+ const text = fake.stderr.writes.join("");
92
+ expect(text).toContain('message="cold index"'); // 含空格 → JSON 转义
93
+ expect(text).toContain("key=no-spaces"); // 不含空格 → 裸 token,不加引号
94
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
95
+ });
96
+ });
97
+
98
+ // ───────────────────────── start 立即追加 / 30 秒空闲 heartbeat / 永久事件重置时钟 ─────────────────────────
99
+
100
+ describe("start 立即追加;仅连续 30 秒无永久事件才 heartbeat;failure 后重置心跳时钟", () => {
101
+ it("cli.md 给出的四行 progress/failure 例子:字面文本逐行复现", async () => {
102
+ const { fake, coordinator } = setup();
103
+ coordinator.start(plan({ totalRuns: 5 }, 1)); // total=5 reused=1 → queued=4
104
+
105
+ // 调度已经把 4 个非携入 attempt 派发出去(running=4 queued=0)。
106
+ for (let i = 0; i < 4; i++) {
107
+ coordinator.emit({ type: "attempt:start", at: 0, identity: ref(`memory/eval-${i}`), who: "compare/bub-e2b", phase: "eval.run" });
108
+ }
109
+ fake.advance(250); // t=250:第一次 tick,无条件打印,不等 30 秒
110
+ await flush();
111
+
112
+ // 30s 之前有一个 attempt 正常完成(不打印,只改变 running/completed)。
113
+ coordinator.emit({ type: "attempt:complete", at: 250, identity: ref("memory/eval-0"), who: "compare/bub-e2b", verdict: "passed" });
114
+ fake.advance(30_000); // t=30250:距首条 checkpoint(t=250)满 30s → 触发第二条
115
+ await flush();
116
+
117
+ // 30s~75s 之间:一个 attempt 正常完成(静默)+ 一个 attempt 失败(立即追加 locator)。
118
+ coordinator.emit({ type: "attempt:complete", at: 30_250, identity: ref("memory/eval-1"), who: "compare/bub-e2b", verdict: "passed" });
119
+ fake.advance(14_750); // t=45000(250 的整数倍,与 tick 网格对齐)
120
+ coordinator.emit({ type: "attempt:complete", at: 45_000, identity: ref("memory/commit0-cachetool"), who: "compare/bub-e2b", verdict: "failed" });
121
+ coordinator.emit({
122
+ type: "failure",
123
+ at: 45_000,
124
+ locator: locator("7m2k9p"),
125
+ identity: ref("memory/commit0-cachetool"),
126
+ who: "compare/bub-e2b",
127
+ verdict: "failed",
128
+ reason: "failed",
129
+ assertion: {
130
+ severity: "gate",
131
+ assertion: "Issue 15193: selected proposal matches the accepted proposal",
132
+ matcher: "equals(4)",
133
+ expected: "4",
134
+ received: "3",
135
+ additionalFailures: 0,
136
+ },
137
+ });
138
+ await flush();
139
+ fake.advance(30_000); // t=75000:距 failure(t=45000)满 30s → 触发第三条(网格对齐,不需要多等)
140
+ await flush();
141
+
142
+ const lines = fake.stderr.writes.map((w) => w.trimEnd()).filter((w) => w.startsWith("NICEEVAL"));
143
+ expect(lines).toEqual([
144
+ "NICEEVAL progress elapsed=0s total=5 reused=1 running=4 queued=0 completed=0",
145
+ "NICEEVAL progress elapsed=30s total=5 reused=1 running=3 queued=0 completed=1",
146
+ "NICEEVAL failure locator=@17m2k9p eval=memory/commit0-cachetool experiment=compare/bub-e2b verdict=failed",
147
+ "NICEEVAL progress elapsed=75s total=5 reused=1 running=1 queued=0 completed=3",
148
+ ]);
149
+
150
+ await coordinator.finish({ summary: summary({ passed: 2, failed: 1 }), completion: completion(), paths: [] });
151
+ });
152
+
153
+ it("29.9 秒无永久事件不 heartbeat,满 30 秒才追加一条", async () => {
154
+ const { fake, coordinator } = setup();
155
+ coordinator.start(plan({ totalRuns: 1 }));
156
+ fake.advance(250); // 第一次 tick,立即打印
157
+ await flush();
158
+ const afterFirst = fake.stderr.writes.length;
159
+
160
+ fake.advance(29_500); // 累计 29750,距首条不到 30s
161
+ await flush();
162
+ expect(fake.stderr.writes.length).toBe(afterFirst);
163
+
164
+ fake.advance(500); // 累计 30250,满 30s
165
+ await flush();
166
+ expect(fake.stderr.writes.length).toBe(afterFirst + 1);
167
+
168
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
169
+ });
170
+
171
+ it("errored 的 checkpoint 用 'error' 命令词,并携带 phase 字段", async () => {
172
+ const { fake, coordinator } = setup();
173
+ coordinator.start(plan({ totalRuns: 1 }));
174
+ coordinator.emit({
175
+ type: "failure",
176
+ at: 0,
177
+ locator: locator("2h8m4k1"),
178
+ identity: ref("memory/agent-029-use-cache", 0, "compare/claude-e2b"),
179
+ who: "compare/claude-e2b",
180
+ verdict: "errored",
181
+ reason: "sandbox-rate-limit: E2B sandbox allocation failed after 5 attempts",
182
+ phase: "sandbox.create",
183
+ });
184
+ await flush();
185
+ const text = fake.stderr.writes.join("");
186
+ expect(text).toContain(
187
+ "NICEEVAL error locator=@12h8m4k1 eval=memory/agent-029-use-cache experiment=compare/claude-e2b phase=sandbox.create verdict=errored",
188
+ );
189
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
190
+ });
191
+ });
192
+
193
+ // ───────────────────────── 不输出 active phase / waiting 明细 / passed / raw progress / 表格 ─────────────────────────
194
+
195
+ describe("不输出 active phase、waiting 明细、passed result、raw progress 或表格", () => {
196
+ it("lifecycle 事件(start/phase/progress/complete)不产生任何可见输出", async () => {
197
+ const { fake, coordinator } = setup();
198
+ coordinator.start(plan({ totalRuns: 2 }));
199
+ coordinator.emit({ type: "attempt:start", at: 0, identity: ref("memory/a"), who: "compare/bub-e2b", phase: "eval.run" });
200
+ coordinator.emit({ type: "attempt:phase", at: 1, identity: ref("memory/a"), phase: "agent.setup" });
201
+ coordinator.emit({ type: "attempt:progress", at: 2, identity: ref("memory/a"), detail: "tool: shell" });
202
+ coordinator.emit({ type: "attempt:complete", at: 3, identity: ref("memory/a"), who: "compare/bub-e2b", verdict: "passed" });
203
+ fake.advance(250);
204
+ await flush();
205
+ const text = fake.stderr.writes.join("");
206
+ expect(text).not.toContain("agent-setup");
207
+ expect(text).not.toContain("agent setup");
208
+ expect(text).not.toContain("tool: shell");
209
+ expect(text).not.toContain("passed"); // passed attempt 本身不逐条打印
210
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
211
+ });
212
+ });
213
+
214
+ // ───────────────────────── failure/error 展开上限 5 条 + suppressed ─────────────────────────
215
+
216
+ describe("failed/errored 立即输出 locator;默认最多展开 5 条,之后输出 suppressed 总数", () => {
217
+ it("12 条 failure 只展开 5 条 checkpoint,第 6 条给一次 suppressed 提示,完整结果不丢", async () => {
218
+ const { fake, coordinator } = setup();
219
+ coordinator.start(plan({ totalRuns: 12 }));
220
+ for (let i = 0; i < 12; i++) {
221
+ coordinator.emit({
222
+ type: "failure",
223
+ at: i,
224
+ locator: locator(`f${i}`),
225
+ identity: ref(`memory/eval-${i}`),
226
+ who: `who-${i}`,
227
+ verdict: "failed",
228
+ reason: "gate: cache tool not used",
229
+ });
230
+ }
231
+ await flush();
232
+ expect(coordinator.state.failures).toHaveLength(12); // reducer 完整保留,不因展开上限丢数据
233
+
234
+ const text = fake.stderr.writes.join("");
235
+ for (let i = 0; i < 5; i++) expect(text).toContain(`memory/eval-${i}`);
236
+ expect(text).not.toContain("memory/eval-5");
237
+ expect(text).not.toContain("memory/eval-11");
238
+ expect(text).toContain("failures-suppressed");
239
+ // suppressed 提示只出现一次(不是每条越限失败都重复一遍)。
240
+ expect(text.split("failures-suppressed").length - 1).toBe(1);
241
+
242
+ await coordinator.finish({
243
+ summary: summary({ passed: 0, failed: 12 }),
244
+ completion: completion(),
245
+ paths: [],
246
+ });
247
+ });
248
+ });
249
+
250
+ // ───────────────────────── 最终 stdout handoff:有界、逐条一层原因 + show 下钻 ─────────────────────────
251
+
252
+ describe("最终 stdout handoff:status/summary/快照/最多 5 个失败/show 下钻命令", () => {
253
+ it("cli.md 的 RESULT 例子:字面文本逐行复现", async () => {
254
+ const { fake, coordinator } = setup();
255
+ coordinator.start(plan({ totalRuns: 5 }, 1));
256
+ coordinator.emit({
257
+ type: "failure",
258
+ at: 0,
259
+ locator: locator("7m2k9p"),
260
+ identity: ref("memory/commit0-cachetool"),
261
+ who: "compare/bub-e2b",
262
+ verdict: "failed",
263
+ reason: "failed",
264
+ assertion: {
265
+ severity: "gate",
266
+ assertion: "Issue 15193: selected proposal matches the accepted proposal",
267
+ matcher: "equals(4)",
268
+ expected: "4",
269
+ received: "3",
270
+ additionalFailures: 0,
271
+ },
272
+ });
273
+ await coordinator.finish({
274
+ summary: summary({ passed: 4, failed: 1, errored: 0 }),
275
+ completion: completion(),
276
+ paths: [".niceeval/compare/bub-e2b/<snapshot>", ".niceeval/compare/codex/<snapshot>"],
277
+ });
278
+
279
+ const expected = [
280
+ "NICEEVAL RESULT failed",
281
+ "summary: 4 passed, 1 failed, 0 errored (1 reused)",
282
+ "snapshots:",
283
+ " - .niceeval/compare/bub-e2b/<snapshot>",
284
+ " - .niceeval/compare/codex/<snapshot>",
285
+ "failures:",
286
+ " - @17m2k9p memory/commit0-cachetool [compare/bub-e2b]",
287
+ " gate: Issue 15193: selected proposal matches the accepted proposal",
288
+ " equals(4) · expected 4 · received 3",
289
+ "next:",
290
+ " niceeval show @17m2k9p",
291
+ " niceeval show @17m2k9p --eval",
292
+ " niceeval show @17m2k9p --execution",
293
+ " niceeval show @17m2k9p --diff",
294
+ "",
295
+ ].join("\n");
296
+ expect(fake.stdout.writes.join("")).toBe(expected);
297
+ // handoff 完全在 stdout;checkpoint(failure 行)完全在 stderr,两者不混流。
298
+ expect(fake.stderr.writes.join("")).not.toContain("RESULT");
299
+ expect(fake.stdout.writes.join("")).not.toContain("NICEEVAL progress");
300
+ });
301
+
302
+ it("全部通过时不留空的 failures/next 区块", async () => {
303
+ const { fake, coordinator } = setup();
304
+ coordinator.start(plan({ totalRuns: 1 }));
305
+ await coordinator.finish({
306
+ summary: summary({ passed: 1, failed: 0, errored: 0 }),
307
+ completion: completion(),
308
+ paths: [".niceeval/compare/a/2026"],
309
+ });
310
+ const text = fake.stdout.writes.join("");
311
+ expect(text).toContain("NICEEVAL RESULT passed");
312
+ expect(text).not.toContain("failures:");
313
+ expect(text).not.toContain("next:");
314
+ });
315
+
316
+ it("incomplete/interrupted 时状态词优先于 verdict 计数,summary 带 unstarted", async () => {
317
+ const { fake, coordinator } = setup();
318
+ coordinator.start(plan({ totalRuns: 5 }));
319
+ await coordinator.finish({
320
+ summary: summary({ passed: 3, failed: 0, errored: 0 }),
321
+ completion: completion({ status: "incomplete", unstarted: 2 }),
322
+ paths: [],
323
+ });
324
+ const text = fake.stdout.writes.join("");
325
+ expect(text).toContain("NICEEVAL RESULT incomplete");
326
+ expect(text).toContain("summary: 3 passed, 0 failed, 0 errored (0 reused, 2 unstarted)");
327
+ });
328
+
329
+ // required reporter(默认 artifacts、显式 --json/--junit)写失败必须让 handoff 判红,即便
330
+ // 全部 attempt 都通过——退出码已经因此非零(见 computeCiExitCode 对 reporterErrors 的同一
331
+ // 条判断),handoff 不能反过来印一个会被误读成"全绿"的 passed(与 ci.ts 的
332
+ // resultStatusWord() 同一契约)。
333
+ it("required reporter 写失败时即便全部 attempt 通过,也报 failed 而不是 passed", async () => {
334
+ const { fake, coordinator } = setup();
335
+ coordinator.start(plan({ totalRuns: 1 }));
336
+ await coordinator.finish({
337
+ summary: summary({ passed: 1, failed: 0, errored: 0 }),
338
+ completion: completion({
339
+ reporterErrors: [{ reporter: "json", required: true, message: "EEXIST: mkdir failed" }],
340
+ }),
341
+ paths: [],
342
+ });
343
+ const text = fake.stdout.writes.join("");
344
+ expect(text).toContain("NICEEVAL RESULT failed");
345
+ expect(text).not.toContain("NICEEVAL RESULT passed");
346
+ });
347
+
348
+ // best-effort reporter(用户 config.reporters)写失败只折成 diagnostic,不影响退出码——
349
+ // handoff 同样不应该判红,否则和实际退出码矛盾。
350
+ it("非 required reporter 写失败不影响 handoff 判定,仍报 passed", async () => {
351
+ const { fake, coordinator } = setup();
352
+ coordinator.start(plan({ totalRuns: 1 }));
353
+ await coordinator.finish({
354
+ summary: summary({ passed: 1, failed: 0, errored: 0 }),
355
+ completion: completion({
356
+ reporterErrors: [{ reporter: "config-reporter-0", required: false, message: "network timeout" }],
357
+ }),
358
+ paths: [],
359
+ });
360
+ const text = fake.stdout.writes.join("");
361
+ expect(text).toContain("NICEEVAL RESULT passed");
362
+ expect(text).not.toContain("NICEEVAL RESULT failed");
363
+ });
364
+
365
+ it("handoff 不内联 transcript/trace/diff——只给 locator 与 show 下钻命令", async () => {
366
+ const { fake, coordinator } = setup();
367
+ coordinator.start(plan({ totalRuns: 1 }));
368
+ coordinator.emit({
369
+ type: "failure",
370
+ at: 0,
371
+ locator: locator("x"),
372
+ identity: ref("memory/a"),
373
+ who: "compare/bub-e2b",
374
+ verdict: "failed",
375
+ reason: "gate: something failed with a long human explanation",
376
+ });
377
+ await coordinator.finish({ summary: summary({ passed: 0, failed: 1 }), completion: completion(), paths: [] });
378
+ const text = fake.stdout.writes.join("");
379
+ expect(text).not.toContain("transcript");
380
+ expect(text).not.toContain("trace");
381
+ // "diff" 只应该以 "--diff" 下钻命令的形式出现一次,不应该有第二处(内联 diff 内容)。
382
+ expect(text.split("diff").length - 1).toBe(1);
383
+ expect(text).toContain("niceeval show @1x");
384
+ });
385
+
386
+ it("失败超过 5 条时:'N total, showing 5' + 只展开前 5 条 + 一次 suppressed footer", async () => {
387
+ const { fake, coordinator } = setup();
388
+ coordinator.start(plan({ totalRuns: 12 }));
389
+ for (let i = 0; i < 12; i++) {
390
+ coordinator.emit({
391
+ type: "failure",
392
+ at: i,
393
+ locator: locator(`f${i}`),
394
+ identity: ref(`memory/eval-${i}`),
395
+ who: `who-${i}`,
396
+ verdict: "failed",
397
+ reason: "gate: cache tool not used",
398
+ });
399
+ }
400
+ await coordinator.finish({
401
+ summary: summary({ passed: 0, failed: 12 }),
402
+ completion: completion(),
403
+ paths: [".niceeval/compare/a/2026", ".niceeval/compare/b/2026"],
404
+ });
405
+ const text = fake.stdout.writes.join("");
406
+ expect(text).toContain("failures: 12 total, showing 5");
407
+ for (let i = 0; i < 5; i++) expect(text).toContain(`memory/eval-${i}`);
408
+ expect(text).not.toContain("memory/eval-5 ");
409
+ expect(text).toContain("… 7 more; inspect the JSON result or run `niceeval view compare`");
410
+ });
411
+
412
+ it("快照路径超过上限时折叠成 '… N more'", async () => {
413
+ const { fake, coordinator } = setup();
414
+ coordinator.start(plan({ totalRuns: 1 }));
415
+ const paths = Array.from({ length: 8 }, (_, i) => `.niceeval/compare/agent-${i}/2026`);
416
+ await coordinator.finish({ summary: summary(), completion: completion(), paths });
417
+ const text = fake.stdout.writes.join("");
418
+ expect(text).toContain("agent-0");
419
+ expect(text).not.toContain("agent-7");
420
+ expect(text).toContain("… 3 more");
421
+ });
422
+ });
423
+
424
+ // ───────────────────────── budget-exhausted / interrupted / reporter-error:去重后追加一次 ─────────────────────────
425
+
426
+ describe("diagnostic 类永久事件去重后只追加一次", () => {
427
+ it("同一 budget-exhausted experimentId 触发 3 次只打印第一次", async () => {
428
+ const { fake, coordinator } = setup();
429
+ coordinator.start(plan({ totalRuns: 3 }));
430
+ for (let i = 0; i < 3; i++) {
431
+ coordinator.emit({ type: "budget-exhausted", at: i, experimentId: "regression/codex", spent: 25.31, unstarted: i + 1 });
432
+ }
433
+ await flush();
434
+ const text = fake.stderr.writes.join("");
435
+ expect(text.split("budget_exhausted").length - 1).toBe(1);
436
+ expect(text).toContain("NICEEVAL budget_exhausted experiment=regression/codex spent=25.31 unstarted=1");
437
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
438
+ });
439
+
440
+ it("interrupted 只追加一次", async () => {
441
+ const { fake, coordinator } = setup();
442
+ coordinator.start(plan({ totalRuns: 1 }));
443
+ coordinator.interrupted();
444
+ await flush();
445
+ const text = fake.stderr.writes.join("");
446
+ expect(text).toContain("NICEEVAL interrupted elapsed=0s");
447
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
448
+ });
449
+
450
+ it("reporter-error 去重后追加一次,携带 required 字段", async () => {
451
+ const { fake, coordinator } = setup();
452
+ coordinator.start(plan({ totalRuns: 1 }));
453
+ coordinator.reporterError({ reporter: "artifacts", required: true, message: "EACCES: permission denied" });
454
+ coordinator.reporterError({ reporter: "artifacts", required: true, message: "EACCES: permission denied" });
455
+ await flush();
456
+ const text = fake.stderr.writes.join("");
457
+ expect(text.split("reporter_error").length - 1).toBe(1);
458
+ expect(text).toContain('NICEEVAL reporter_error reporter=artifacts required=true message="EACCES: permission denied"');
459
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
460
+ });
461
+ });
462
+
463
+ // ───────────────────────── `--dry --output agent`:稳定 PLAN envelope ─────────────────────────
464
+
465
+ describe("`--dry --output agent`:不经 coordinator,直接渲染 PLAN envelope", () => {
466
+ it("cli.md 的 PLAN 例子:5 个组合展开 2 行 + '… 3 more'", () => {
467
+ const text = renderAgentPlanEnvelope({
468
+ total: 5,
469
+ evals: 1,
470
+ configs: 5,
471
+ runs: 1,
472
+ rows: [
473
+ { label: "compare/bub-e2b", evalId: "memory/commit0-cachetool" },
474
+ { label: "compare/codex", evalId: "memory/commit0-cachetool" },
475
+ { label: "compare/claude-e2b", evalId: "memory/commit0-cachetool" },
476
+ { label: "compare/deepseek", evalId: "memory/commit0-cachetool" },
477
+ { label: "compare/gpt-5-1", evalId: "memory/commit0-cachetool" },
478
+ ],
479
+ });
480
+ const lines = text.split("\n");
481
+ expect(lines[0]).toBe("NICEEVAL PLAN total=5 evals=1 configs=5 runs=1");
482
+ expect(lines[1]).toContain("compare/bub-e2b");
483
+ expect(lines[1]).toContain("memory/commit0-cachetool");
484
+ expect(lines[2]).toContain("compare/codex");
485
+ expect(lines.at(-1)).toBe("… 3 more");
486
+ expect(lines).toHaveLength(4);
487
+ });
488
+
489
+ it("不超过上限时不折叠,也不出现任何 ANSI", () => {
490
+ const text = renderAgentPlanEnvelope({
491
+ total: 2,
492
+ evals: 2,
493
+ configs: 1,
494
+ runs: 1,
495
+ rows: [
496
+ { label: "compare/bub-e2b", evalId: "memory/a" },
497
+ { label: "compare/bub-e2b", evalId: "memory/b" },
498
+ ],
499
+ });
500
+ expect(text).not.toContain(ESC);
501
+ expect(text).not.toContain("more");
502
+ expect(text.split("\n")).toHaveLength(3);
503
+ });
504
+ });