niceeval 0.5.4 → 0.6.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 (342) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +5 -5
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +2 -2
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -1
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +3 -3
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  146. package/docs-site/zh/guides/{sandbox-backends.mdx → sandbox-providers.mdx} +11 -11
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +9 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +12 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +129 -42
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +9 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +54 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/control-flow.ts +1 -1
  178. package/src/context/session.test.ts +92 -1
  179. package/src/context/session.ts +23 -1
  180. package/src/context/types.ts +4 -4
  181. package/src/define.test.ts +8 -8
  182. package/src/define.ts +11 -11
  183. package/src/expect/index.ts +5 -5
  184. package/src/i18n/en.ts +34 -15
  185. package/src/i18n/zh-CN.ts +37 -21
  186. package/src/index.ts +1 -1
  187. package/src/o11y/execution-tree.test.ts +452 -0
  188. package/src/o11y/execution-tree.ts +367 -0
  189. package/src/o11y/otlp/receiver.ts +1 -1
  190. package/src/o11y/otlp/select.ts +7 -3
  191. package/src/o11y/parsers/claude-code.test.ts +175 -0
  192. package/src/o11y/parsers/claude-code.ts +32 -0
  193. package/src/o11y/types.ts +6 -1
  194. package/src/report/aggregate.ts +26 -20
  195. package/src/report/built-in-user-parity.test.tsx +643 -0
  196. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  197. package/src/report/built-ins/index.ts +2 -0
  198. package/src/report/components.tsx +223 -50
  199. package/src/report/compute.ts +358 -53
  200. package/src/report/dual-render.test.tsx +1125 -0
  201. package/src/report/flag.ts +1 -1
  202. package/src/report/format.ts +50 -2
  203. package/src/report/index.ts +48 -14
  204. package/src/report/load.ts +1 -1
  205. package/src/report/locale.ts +206 -0
  206. package/src/report/metrics.ts +39 -15
  207. package/src/report/primitives.tsx +100 -2
  208. package/src/report/react/AttemptList.tsx +115 -0
  209. package/src/report/react/DeltaTable.tsx +9 -6
  210. package/src/report/react/EvalList.tsx +0 -0
  211. package/src/report/react/ExperimentList.tsx +108 -0
  212. package/src/report/react/GroupSummary.tsx +66 -0
  213. package/src/report/react/MetricBars.tsx +24 -13
  214. package/src/report/react/MetricLine.tsx +21 -21
  215. package/src/report/react/MetricMatrix.tsx +8 -4
  216. package/src/report/react/MetricScatter.tsx +136 -96
  217. package/src/report/react/MetricTable.tsx +115 -15
  218. package/src/report/react/RunOverview.tsx +48 -26
  219. package/src/report/react/Scoreboard.tsx +15 -7
  220. package/src/report/react/cell.tsx +18 -9
  221. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  222. package/src/report/react/colors.ts +24 -15
  223. package/src/report/react/enhance.js +129 -0
  224. package/src/report/react/fixtures.ts +163 -34
  225. package/src/report/react/format.ts +1 -1
  226. package/src/report/react/index.tsx +22 -6
  227. package/src/report/react/render.test.tsx +164 -31
  228. package/src/report/react/styles.css +405 -213
  229. package/src/report/report.test.ts +403 -94
  230. package/src/report/report.ts +29 -11
  231. package/src/report/text/faces.ts +303 -95
  232. package/src/report/text/layout.ts +76 -11
  233. package/src/report/text/table.ts +98 -0
  234. package/src/report/tree.ts +103 -20
  235. package/src/report/types.ts +196 -25
  236. package/src/report/web.ts +46 -19
  237. package/src/results/annotated-source.test.ts +146 -0
  238. package/src/results/annotated-source.ts +128 -0
  239. package/src/results/attempt-evidence.test.ts +158 -0
  240. package/src/results/attempt-evidence.ts +158 -0
  241. package/src/results/attempt-source.ts +48 -0
  242. package/src/results/copy.ts +154 -139
  243. package/src/results/format.ts +54 -47
  244. package/src/results/host-equivalence.test.ts +527 -0
  245. package/src/results/index.ts +56 -14
  246. package/src/results/locator.test.ts +198 -0
  247. package/src/results/locator.ts +0 -0
  248. package/src/results/open.ts +392 -175
  249. package/src/results/results.test.ts +943 -494
  250. package/src/results/select.ts +129 -23
  251. package/src/results/skipped-notice.ts +0 -0
  252. package/src/results/source-hash.ts +28 -0
  253. package/src/results/types.ts +98 -62
  254. package/src/results/writer.ts +304 -176
  255. package/src/runner/attempt.test.ts +170 -0
  256. package/src/runner/attempt.ts +48 -13
  257. package/src/runner/discover.test.ts +65 -0
  258. package/src/runner/discover.ts +6 -2
  259. package/src/runner/eval-source.test.ts +121 -0
  260. package/src/runner/eval-source.ts +45 -0
  261. package/src/runner/fingerprint.ts +54 -1
  262. package/src/runner/remote-sandbox.ts +1 -1
  263. package/src/runner/report.test.ts +6 -8
  264. package/src/runner/report.ts +5 -4
  265. package/src/runner/reporters/artifacts.ts +27 -41
  266. package/src/runner/reporters/braintrust.test.ts +3 -3
  267. package/src/runner/reporters/braintrust.ts +1 -1
  268. package/src/runner/reporters/console.ts +6 -6
  269. package/src/runner/reporters/json.ts +4 -4
  270. package/src/runner/reporters/live.test.ts +56 -0
  271. package/src/runner/reporters/live.ts +36 -10
  272. package/src/runner/reporters/quiet.test.ts +66 -0
  273. package/src/runner/reporters/quiet.ts +49 -0
  274. package/src/runner/reporters/shared.ts +5 -5
  275. package/src/runner/reporters/table.ts +17 -17
  276. package/src/runner/run.ts +61 -102
  277. package/src/runner/types.ts +43 -32
  278. package/src/sandbox/checkpoint.ts +1 -1
  279. package/src/sandbox/docker.ts +15 -2
  280. package/src/sandbox/e2b.ts +11 -5
  281. package/src/sandbox/errors.ts +13 -0
  282. package/src/sandbox/index.ts +3 -3
  283. package/src/sandbox/local-files.ts +1 -1
  284. package/src/sandbox/registry.ts +6 -5
  285. package/src/sandbox/resolve.ts +38 -23
  286. package/src/sandbox/retry.test.ts +68 -0
  287. package/src/sandbox/retry.ts +44 -0
  288. package/src/sandbox/types.ts +24 -24
  289. package/src/sandbox/vercel.ts +17 -3
  290. package/src/scoring/collector.ts +3 -3
  291. package/src/scoring/judge.ts +2 -2
  292. package/src/scoring/scoped.ts +13 -1
  293. package/src/scoring/types.ts +7 -7
  294. package/src/scoring/verdict.ts +4 -4
  295. package/src/shared/aggregate.ts +23 -3
  296. package/src/shared/types.ts +2 -2
  297. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  298. package/src/show/compose.ts +12 -132
  299. package/src/show/index.ts +125 -94
  300. package/src/show/render.ts +660 -160
  301. package/src/show/show.test.ts +472 -253
  302. package/src/source-loc.ts +1 -1
  303. package/src/tty-line.ts +32 -0
  304. package/src/types.ts +2 -2
  305. package/src/util.test.ts +21 -1
  306. package/src/util.ts +5 -1
  307. package/src/view/app/App.tsx +33 -198
  308. package/src/view/app/components/AttemptModal.tsx +6 -6
  309. package/src/view/app/components/CodeView.tsx +2 -2
  310. package/src/view/app/components/CopyControls.tsx +32 -36
  311. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  312. package/src/view/app/components/ui/badge.tsx +1 -1
  313. package/src/view/app/components/ui/dialog.tsx +2 -2
  314. package/src/view/app/i18n.ts +40 -31
  315. package/src/view/app/lib/artifact-url.ts +1 -1
  316. package/src/view/app/lib/attempt-route.test.ts +37 -47
  317. package/src/view/app/lib/attempt-route.ts +26 -26
  318. package/src/view/app/lib/rows.ts +9 -112
  319. package/src/view/app/lib/verdict.ts +25 -0
  320. package/src/view/app/main.tsx +9 -12
  321. package/src/view/app/pages/RunsPage.tsx +4 -4
  322. package/src/view/app/pages/TracesPage.tsx +3 -3
  323. package/src/view/app/shared.ts +0 -1
  324. package/src/view/app/types.ts +6 -51
  325. package/src/view/artifact-serving.test.ts +136 -0
  326. package/src/view/client-dist/app.css +1 -1
  327. package/src/view/client-dist/app.js +21 -21
  328. package/src/view/data.test.ts +214 -143
  329. package/src/view/data.ts +166 -120
  330. package/src/view/index.ts +23 -11
  331. package/src/view/server.ts +52 -17
  332. package/src/view/shared/types.ts +22 -31
  333. package/src/view/styles.css +117 -327
  334. package/src/view/view-report.test.ts +146 -98
  335. package/src/report/default-report.tsx +0 -222
  336. package/src/report/dual-face.test.tsx +0 -527
  337. package/src/report/react/CaseList.tsx +0 -70
  338. package/src/view/app/components/CostScoreChart.tsx +0 -147
  339. package/src/view/app/components/ExperimentTable.tsx +0 -284
  340. package/src/view/app/components/GroupSelector.tsx +0 -61
  341. package/src/view/app/components/primitives.tsx +0 -43
  342. package/src/view/app/lib/outcome.ts +0 -82
@@ -1,26 +1,27 @@
1
1
  // niceeval show 终端宿主的测试(行为规范:docs-site/zh/guides/viewing-results.mdx;
2
- // 组合语义:docs/reports.md「宿主输入的组合语义」)。覆盖:
3
- // - 榜单合成口径:每 experiment × eval 取最新判决,局部重跑从更早 run 补齐,头部标注合成自几个 run;
4
- // - 前缀过滤收窄选集,覆盖警告分母 = 已知并集 ∩ 范围;
2
+ // 组合语义:docs/feature/reports/architecture.md「Selection 是计算入口」)。覆盖:
3
+ // - 榜单合成口径:每 experiment × eval 取最新判定,局部重跑从更早快照补齐,头部标注合成自几个快照;
4
+ // - 前缀过滤收窄 Selection,覆盖警告分母 = 已知并集 ∩ 范围;
5
5
  // - --history 时间轴只列真实执行,resume 携带的复印件不占行;
6
- // - --report 装载(合法 / 非法默认导出 / 文件缺失)、位置前缀收窄注入选集、attemptCommand 下钻;
6
+ // - --report 装载(合法 / 非法默认导出 / 文件缺失)、位置前缀收窄注入 Selection、attemptCommand 下钻;
7
7
  // - 互斥:--history 与 --report;
8
- // - 单 eval 详情与 --transcript 证据切面的输出形态。
8
+ // - 单 eval 详情、`@<locator>` 精确定位与 --eval / --execution / --diff 证据切面的输出形态。
9
+ //
10
+ // fixture 直接写新布局(<expDir>/<snapDir>/snapshot.json + <evalId>/a<n>/result.json),
11
+ // 依据是 docs/feature/results/architecture.md 的稳定磁盘契约,不经 writer 运行时 API(避免与并行重写的
12
+ // niceeval/results 写入面签名耦合)。
9
13
 
10
- import { afterEach, describe, expect, it } from "vitest";
14
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
11
15
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
12
16
  import { tmpdir } from "node:os";
13
17
  import { join } from "node:path";
14
- import {
15
- RESULTS_FORMAT,
16
- RESULTS_SCHEMA_VERSION,
17
- createRunWriter,
18
- openResults,
19
- type EvalResult,
20
- type RunSummary,
21
- } from "../results/index.ts";
22
- import { composeShowSelection, evalHistory } from "./compose.ts";
18
+ import { createHash } from "node:crypto";
19
+ import { openResults } from "../results/index.ts";
20
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION, type EvalResult, type Verdict } from "../types.ts";
21
+ import { selectCurrentResults } from "../results/select.ts";
22
+ import { evalHistory } from "./compose.ts";
23
23
  import { runShow, type ShowFlags } from "./index.ts";
24
+ import { stringWidth } from "../report/text/layout.ts";
24
25
 
25
26
  // ───────────────────────── fixture 工具 ─────────────────────────
26
27
 
@@ -34,40 +35,72 @@ afterEach(async () => {
34
35
  await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
35
36
  });
36
37
 
37
- function res(over: Partial<EvalResult> & Pick<EvalResult, "id">): EvalResult {
38
- return {
39
- agent: "bub",
40
- outcome: "passed",
41
- attempt: 0,
42
- durationMs: 1000,
43
- assertions: [],
44
- ...over,
45
- };
38
+ // show 的报告 chrome 跟随 CLI 界面语言(detectLocale);本文件的断言按英文写,
39
+ // 固定 en 让用例不随宿主机 LANG 漂移;zh-CN 传递单独一条用例覆盖。
40
+ let langBackup: string | undefined;
41
+ beforeAll(() => {
42
+ langBackup = process.env.NICEEVAL_LANG;
43
+ process.env.NICEEVAL_LANG = "en";
44
+ });
45
+ afterAll(() => {
46
+ if (langBackup === undefined) delete process.env.NICEEVAL_LANG;
47
+ else process.env.NICEEVAL_LANG = langBackup;
48
+ });
49
+
50
+ /** 一条 attempt 的最小 fixture;字段照 docs/feature/results/architecture.md 的 AttemptRecord。 */
51
+ type AttemptFixture = Pick<EvalResult, "id" | "verdict"> &
52
+ Partial<
53
+ Pick<
54
+ EvalResult,
55
+ "attempt" | "durationMs" | "assertions" | "estimatedCostUSD" | "usage" | "error" | "startedAt" | "artifactBase" | "hasEvents" | "hasTrace"
56
+ >
57
+ >;
58
+
59
+ function res(id: string, verdict: Verdict, extra: Partial<AttemptFixture> = {}): AttemptFixture {
60
+ return { id, verdict, attempt: 0, durationMs: 1000, assertions: [], ...extra };
61
+ }
62
+
63
+ /** 实验目录名的清洗:与 docs/feature/results/architecture.md 一致(/ 与非 [\w.@-] 换成 _)。 */
64
+ function cleanDirName(id: string): string {
65
+ return id.replace(/[^\w.@-]/g, "_");
66
+ }
67
+
68
+ interface SnapshotOpts {
69
+ experimentId: string;
70
+ agent?: string;
71
+ model?: string;
72
+ startedAt: string;
73
+ completedAt?: string;
74
+ knownEvalIds?: string[];
46
75
  }
47
76
 
48
- function summaryOf(results: EvalResult[], over: Partial<RunSummary> = {}): RunSummary {
49
- const count = (o: EvalResult["outcome"]) => results.filter((r) => r.outcome === o).length;
50
- return {
77
+ /** 写一份新布局快照:snapshot.json + attempt result.json。返回快照目录绝对路径。 */
78
+ async function writeSnapshot(
79
+ root: string,
80
+ snapDirName: string,
81
+ opts: SnapshotOpts,
82
+ results: AttemptFixture[],
83
+ ): Promise<string> {
84
+ const dir = join(root, cleanDirName(opts.experimentId), snapDirName);
85
+ await mkdir(dir, { recursive: true });
86
+ const meta = {
51
87
  format: RESULTS_FORMAT,
52
88
  schemaVersion: RESULTS_SCHEMA_VERSION,
53
89
  producer: { name: "niceeval", version: "0.4.6" },
54
- agent: results[0]?.agent ?? "bub",
55
- startedAt: "2026-07-08T10:00:00.000Z",
56
- completedAt: "2026-07-08T10:10:00.000Z",
57
- passed: count("passed"),
58
- failed: count("failed"),
59
- skipped: count("skipped"),
60
- errored: count("errored"),
61
- durationMs: 60_000,
62
- results,
63
- ...over,
90
+ experimentId: opts.experimentId,
91
+ agent: opts.agent ?? "bub",
92
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
93
+ startedAt: opts.startedAt,
94
+ completedAt: opts.completedAt ?? opts.startedAt,
95
+ ...(opts.knownEvalIds ? { knownEvalIds: opts.knownEvalIds } : {}),
64
96
  };
65
- }
66
-
67
- async function writeRun(root: string, dirName: string, summary: RunSummary): Promise<void> {
68
- const dir = join(root, dirName);
69
- await mkdir(dir, { recursive: true });
70
- await writeFile(join(dir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
97
+ await writeFile(join(dir, "snapshot.json"), JSON.stringify(meta, null, 2), "utf-8");
98
+ for (const r of results) {
99
+ const attemptDir = join(dir, r.id, `a${r.attempt ?? 0}`);
100
+ await mkdir(attemptDir, { recursive: true });
101
+ await writeFile(join(attemptDir, "result.json"), JSON.stringify(r, null, 2), "utf-8");
102
+ }
103
+ return dir;
71
104
  }
72
105
 
73
106
  interface Captured {
@@ -76,89 +109,114 @@ interface Captured {
76
109
  code: number;
77
110
  }
78
111
 
79
- async function show(root: string, patterns: string[], flags: ShowFlags = {}): Promise<Captured> {
112
+ async function show(root: string, patterns: string[], flags: ShowFlags = {}, width = 100): Promise<Captured> {
80
113
  let out = "";
81
114
  let err = "";
82
115
  const code = await runShow(root, patterns, { run: root, ...flags }, {
83
116
  out: (s) => (out += s),
84
117
  err: (s) => (err += s),
85
- width: 100,
118
+ width,
86
119
  now: Date.parse("2026-07-09T10:01:00.000Z"),
87
120
  });
88
121
  return { out, err, code };
89
122
  }
90
123
 
91
- /** 两个 run:老 run 全量(a ✓ b ✓),新 run 只重跑 b(✗)—— 榜单该跨 run 合成。 */
124
+ /** 两个快照:老快照全量(a ✓ b ✓),新快照只重跑 b(✗)—— 榜单该跨快照合成。 */
92
125
  async function seedComposedRoot(): Promise<string> {
93
126
  const root = await makeRoot();
94
- await writeRun(
95
- root,
96
- "2026-07-08T10-00-00-000Z",
97
- summaryOf(
98
- [
99
- res({ id: "weather/brooklyn", experimentId: "compare/bub", startedAt: "2026-07-08T10:00:01.000Z" }),
100
- res({ id: "fixtures/button", experimentId: "compare/bub", startedAt: "2026-07-08T10:00:02.000Z" }),
101
- ],
102
- { startedAt: "2026-07-08T10:00:00.000Z" },
103
- ),
104
- );
105
- await writeRun(
106
- root,
107
- "2026-07-09T10-00-00-000Z",
108
- summaryOf(
109
- [
110
- res({
111
- id: "fixtures/button",
112
- experimentId: "compare/bub",
113
- outcome: "failed",
114
- startedAt: "2026-07-09T10:00:01.000Z",
115
- assertions: [
116
- {
117
- name: 'fileChanged("src/components/Button.tsx")',
118
- severity: "gate",
119
- score: 0,
120
- passed: false,
121
- detail: "file was not modified",
122
- },
123
- ],
124
- }),
127
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
128
+ res("weather/brooklyn", "passed"),
129
+ res("fixtures/button", "passed"),
130
+ ]);
131
+ await writeSnapshot(root, "2026-07-09T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-09T10:00:00.000Z" }, [
132
+ res("fixtures/button", "failed", {
133
+ assertions: [
134
+ {
135
+ name: 'fileChanged("src/components/Button.tsx")',
136
+ severity: "gate",
137
+ score: 0,
138
+ passed: false,
139
+ detail: "file was not modified",
140
+ },
125
141
  ],
126
- { startedAt: "2026-07-09T10:00:00.000Z" },
127
- ),
128
- );
142
+ }),
143
+ ]);
129
144
  return root;
130
145
  }
131
146
 
132
147
  // ───────────────────────── 榜单合成口径 ─────────────────────────
133
148
 
134
- describe("榜单:跨 run 合成的现刻水位", () => {
135
- it("局部重跑不撕榜单:另一题从更早 run 补齐,头部如实标注合成自 2 个 run", async () => {
149
+ describe("榜单:跨快照合成的现刻水位(show 专用 attempt 表)", () => {
150
+ it("局部重跑不撕榜单:另一题从更早快照补齐,单实验不出现比较空态", async () => {
136
151
  const root = await seedComposedRoot();
137
152
  const { out, code } = await show(root, []);
138
153
  expect(code).toBe(0);
139
- expect(out).toContain("Current verdicts · 1 experiment · composed from 2 runs");
140
- expect(out).toContain("latest 2026-07-09T10-00-00-000Z");
141
- // eval 级折叠计票:2 题里 1 题通过;两题都在(没有 partial-coverage 撕榜)
142
- expect(out).toContain("1/2");
143
- expect(out).toContain("50%");
144
- expect(out).not.toContain("verdicts cover");
145
- // Failing 清单:新判决的 fixtures/button,带失败断言与下钻命令
146
- expect(out).toContain("Failing:");
147
- expect(out).toContain("✗ fixtures/button");
148
- expect(out).toContain('gate fileChanged("src/components/Button.tsx")');
149
- expect(out).toContain(" niceeval show fixtures/button");
150
- expect(out).not.toContain("✗ weather/brooklyn");
151
- });
152
-
153
- it("合成选集:每 experiment × eval 取最新判决;compose 不产生残缺警告", async () => {
154
+ expect(out).not.toContain("No data to plot");
155
+ expect(out).not.toContain("At least 2 experiments");
156
+ expect(out).not.toContain("COMPARISON");
157
+ expect(out).toContain("EXPERIMENT compare/bub · bub");
158
+ expect(out).toContain("SUMMARY 1 passed · 1 failed · 0 errored · 0 skipped · 2 attempts");
159
+ expect(out).toMatch(/STATUS\s+EVAL\s+ATTEMPT\s+RESULT\s+DURATION\s+COST/);
160
+ expect(out).toMatch(/✗ failed\s+fixtures\/button\s+@1[0-9a-z]{7}\s+file was not modified/);
161
+ expect(out).toMatch(/✓ passed\s+weather\/brooklyn\s+@1[0-9a-z]{7}\s+—/);
162
+ expect(out).not.toMatch(/@1[0-9a-z]{7}[✓✗]/);
163
+ expect(out).not.toContain("[E,X,");
164
+ expect(out).toContain("DRILL DOWN niceeval show @<attempt>");
165
+ });
166
+
167
+ it("裸 show 使用稳定表头,不受自定义报告 locale chrome 影响", async () => {
168
+ const root = await seedComposedRoot();
169
+ process.env.NICEEVAL_LANG = "zh-CN";
170
+ try {
171
+ const { out, code } = await show(root, []);
172
+ expect(code).toBe(0);
173
+ expect(out).toMatch(/STATUS\s+EVAL\s+ATTEMPT\s+RESULT\s+DURATION\s+COST/);
174
+ expect(out).toContain("1 passed · 1 failed");
175
+ expect(out).not.toContain("没有可绘制的数据");
176
+ expect(out).toContain("compare/bub");
177
+ } finally {
178
+ process.env.NICEEVAL_LANG = "en";
179
+ }
180
+ });
181
+
182
+ it("窄终端截断长 eval 与原因,每一行都不超过终端显示宽度", async () => {
183
+ const root = await seedComposedRoot();
184
+ const { out, code } = await show(root, [], {}, 60);
185
+ expect(code).toBe(0);
186
+ expect(out).toContain("…");
187
+ for (const line of out.trimEnd().split("\n")) {
188
+ expect(stringWidth(line), line).toBeLessThanOrEqual(60);
189
+ }
190
+ });
191
+
192
+ it("多个 experiment 先显示紧凑比较表,再逐 experiment 显示 attempt 表", async () => {
193
+ const root = await makeRoot();
194
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/a", agent: "codex", model: "mini", startedAt: "2026-07-08T10:00:00.000Z" }, [
195
+ res("q1", "passed", { estimatedCostUSD: 0.1 }),
196
+ res("q2", "failed", { estimatedCostUSD: 0.2 }),
197
+ ]);
198
+ await writeSnapshot(root, "2026-07-08T11-00-00-000Z", { experimentId: "compare/b", agent: "claude", model: "large", startedAt: "2026-07-08T11:00:00.000Z" }, [
199
+ res("q1", "passed", { estimatedCostUSD: 0.3 }),
200
+ res("q2", "passed", { estimatedCostUSD: 0.3 }),
201
+ ]);
202
+ const { out, code } = await show(root, []);
203
+ expect(code).toBe(0);
204
+ expect(out).toMatch(/COMPARISON\nEXPERIMENT\s+AGENT\s+MODEL\s+PASS\s+FAILED\s+ERROR\s+SKIP\s+DURATION\s+COST/);
205
+ expect(out).toContain("50% 1/2");
206
+ expect(out).toContain("100% 2/2");
207
+ expect(out.match(/^EXPERIMENT compare\//gm)).toHaveLength(2);
208
+ expect(out).not.toContain("At least 2 experiments");
209
+ });
210
+
211
+ it("合成 Selection:每 experiment × eval 取最新判定;compose 不产生残缺警告", async () => {
154
212
  const root = await seedComposedRoot();
155
213
  const results = await openResults(root);
156
- const selection = composeShowSelection(results);
214
+ const selection = selectCurrentResults(results);
157
215
  expect(selection.snapshots).toHaveLength(1);
158
216
  const evals = selection.snapshots[0].evals.map((e) => e.id).sort();
159
217
  expect(evals).toEqual(["fixtures/button", "weather/brooklyn"]);
160
218
  const button = selection.snapshots[0].evals.find((e) => e.id === "fixtures/button")!;
161
- expect(button.attempts[0].result.outcome).toBe("failed"); // 新 run 的判决赢
219
+ expect(button.attempts[0].result.verdict).toBe("failed"); // 新快照的判定赢
162
220
  expect(selection.warnings).toEqual([]);
163
221
  // 对照:results.latest() 的最新快照是残缺的(这正是宿主要合成的原因)
164
222
  expect(results.latest().warnings.some((w) => w.kind === "partial-coverage")).toBe(true);
@@ -168,32 +226,28 @@ describe("榜单:跨 run 合成的现刻水位", () => {
168
226
  // ───────────────────────── 前缀过滤 ─────────────────────────
169
227
 
170
228
  describe("位置前缀收窄", () => {
171
- it("前缀收窄选集覆盖的 eval;覆盖警告分母 = 已知并集 ∩ 范围", async () => {
229
+ it("前缀收窄 Selection 覆盖的 eval;覆盖警告分母 = 已知并集 ∩ 范围", async () => {
172
230
  const root = await makeRoot();
173
- await writeRun(
231
+ await writeSnapshot(
174
232
  root,
175
233
  "2026-07-08T10-00-00-000Z",
176
- summaryOf(
177
- [
178
- res({ id: "weather/brooklyn", experimentId: "compare/bub", startedAt: "2026-07-08T10:00:01.000Z" }),
179
- res({ id: "algebra/quadratic", experimentId: "compare/bub", startedAt: "2026-07-08T10:00:02.000Z" }),
180
- ],
181
- {
182
- startedAt: "2026-07-08T10:00:00.000Z",
183
- // 已知并集包含一道从未落盘的题:algebra 范围外,不该刷 weather 范围的屏
184
- snapshots: { "compare/bub": { knownEvalIds: ["weather/brooklyn", "weather/queens", "algebra/quadratic"] } },
185
- },
186
- ),
234
+ {
235
+ experimentId: "compare/bub",
236
+ startedAt: "2026-07-08T10:00:00.000Z",
237
+ // 已知并集包含一道从未落盘的题:algebra 范围外,不该刷 weather 范围的屏
238
+ knownEvalIds: ["weather/brooklyn", "weather/queens", "algebra/quadratic"],
239
+ },
240
+ [res("weather/brooklyn", "passed"), res("algebra/quadratic", "passed")],
187
241
  );
188
242
  const results = await openResults(root);
189
243
 
190
- const weather = composeShowSelection(results, { patterns: ["weather"] });
244
+ const weather = selectCurrentResults(results, { patterns: ["weather"] });
191
245
  expect(weather.snapshots[0].evals.map((e) => e.id)).toEqual(["weather/brooklyn"]);
192
246
  // 分母 = {weather/brooklyn, weather/queens}:缺 queens → 1/2;algebra 的缺口不进来
193
247
  const coverage = weather.warnings.find((w) => w.kind === "partial-coverage");
194
248
  expect(coverage).toMatchObject({ covered: 1, total: 2 });
195
249
 
196
- const algebra = composeShowSelection(results, { patterns: ["algebra"] });
250
+ const algebra = selectCurrentResults(results, { patterns: ["algebra"] });
197
251
  expect(algebra.warnings.filter((w) => w.kind === "partial-coverage")).toEqual([]);
198
252
  });
199
253
 
@@ -211,52 +265,36 @@ describe("位置前缀收窄", () => {
211
265
  describe("单 eval 详情", () => {
212
266
  it("attempt 行 + 断言明细 + artifacts 路径 + 下钻提示", async () => {
213
267
  const root = await makeRoot();
214
- await writeRun(
268
+ await writeSnapshot(
215
269
  root,
216
270
  "2026-07-09T10-00-00-000Z",
217
- summaryOf(
218
- [
219
- res({
220
- id: "weather/brooklyn",
221
- experimentId: "compare/codex",
222
- agent: "codex",
223
- outcome: "failed",
224
- attempt: 0,
225
- startedAt: "2026-07-09T10:00:01.000Z",
226
- durationMs: 40_000,
227
- }),
228
- res({
229
- id: "weather/brooklyn",
230
- experimentId: "compare/codex",
231
- agent: "codex",
232
- outcome: "failed",
233
- attempt: 1,
234
- startedAt: "2026-07-09T10:00:42.000Z",
235
- durationMs: 41_000,
236
- usage: { inputTokens: 12_000, outputTokens: 300 },
237
- estimatedCostUSD: 0.04,
238
- artifactsDir: "artifacts/compare__codex__weather__brooklyn__1",
239
- assertions: [
240
- {
241
- name: 'calledTool("get_weather")',
242
- severity: "gate",
243
- score: 0,
244
- passed: false,
245
- detail: "tool was never called",
246
- },
247
- { name: "succeeded()", severity: "gate", score: 1, passed: true },
248
- {
249
- name: 'judge("回答基于实时数据")',
250
- severity: "soft",
251
- score: 0.2,
252
- passed: false,
253
- detail: "reply invents a temperature without any tool call",
254
- },
255
- ],
256
- }),
257
- ],
258
- { agent: "codex", startedAt: "2026-07-09T10:00:00.000Z" },
259
- ),
271
+ { experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-09T10:00:00.000Z" },
272
+ [
273
+ res("weather/brooklyn", "failed", { attempt: 0, durationMs: 40_000 }),
274
+ res("weather/brooklyn", "failed", {
275
+ attempt: 1,
276
+ durationMs: 41_000,
277
+ usage: { inputTokens: 12_000, outputTokens: 300 },
278
+ estimatedCostUSD: 0.04,
279
+ assertions: [
280
+ {
281
+ name: 'calledTool("get_weather")',
282
+ severity: "gate",
283
+ score: 0,
284
+ passed: false,
285
+ detail: "tool was never called",
286
+ },
287
+ { name: "succeeded()", severity: "gate", score: 1, passed: true },
288
+ {
289
+ name: 'judge("回答基于实时数据")',
290
+ severity: "soft",
291
+ score: 0.2,
292
+ passed: false,
293
+ detail: "reply invents a temperature without any tool call",
294
+ },
295
+ ],
296
+ }),
297
+ ],
260
298
  );
261
299
  const { out, code } = await show(root, ["weather/brooklyn"]);
262
300
  expect(code).toBe(0);
@@ -269,63 +307,44 @@ describe("单 eval 详情", () => {
269
307
  expect(out).toContain('✗ gate calledTool("get_weather") — tool was never called');
270
308
  expect(out).toContain("✓ gate succeeded()");
271
309
  expect(out).toContain('✗ soft judge("回答基于实时数据") — 0.2/1: reply invents a temperature');
272
- expect(out).toContain("artifacts/compare__codex__weather__brooklyn__1/");
273
- expect(out).toContain("next: niceeval show weather/brooklyn --transcript | --trace | --diff");
274
- });
275
-
276
- it("--attempt 指定不存在的 attempt:直说可用序号", async () => {
277
- const root = await seedComposedRoot();
278
- const { err, code } = await show(root, ["fixtures/button"], { attempt: 5 });
279
- expect(code).toBe(1);
280
- expect(err).toContain("Attempt 5 not found for fixtures/button");
310
+ // artifacts 路径 = <experiment-dir>/<snapshot-dir>/<evalId>/a<n>(root 相对)
311
+ expect(out).toContain("compare_codex/2026-07-09T10-00-00-000Z/weather/brooklyn/a1");
312
+ // 每 experiment 一行都带紧凑索引(locator + 失败原因),详情块再补一条精确的 attempt locator
313
+ expect(out).toMatch(/✗ failed\s+2 attempts.*@1[0-9a-z]{7}.*gate calledTool/);
314
+ expect(out).toMatch(/attempt locator: @1[0-9a-z]{7}/);
315
+ expect(out).toMatch(/next: niceeval show @1[0-9a-z]{7} \[--eval\|--execution\|--diff\]/);
281
316
  });
282
317
  });
283
318
 
284
319
  // ───────────────────────── --history:复印件不占行 ─────────────────────────
285
320
 
286
321
  describe("--history 时间轴", () => {
287
- /** run1 真实执行;run2 resume 携带同一判决(身份键相同的复印件)+ 新题真实执行。 */
322
+ /** 快照1 真实执行;快照2 resume 携带同一判定(身份键相同的复印件)+ 新题真实执行。 */
288
323
  async function seedHistoryRoot(): Promise<string> {
289
324
  const root = await makeRoot();
290
- const original = res({
291
- id: "weather/brooklyn",
292
- experimentId: "compare/bub",
293
- outcome: "passed",
294
- startedAt: "2026-07-07T09:00:01.000Z",
295
- estimatedCostUSD: 0.03,
296
- artifactsDir: "artifacts/compare__bub__weather__brooklyn__0",
297
- });
298
- await writeRun(
325
+ await writeSnapshot(
299
326
  root,
300
327
  "2026-07-07T09-00-00-000Z",
301
- summaryOf([original], { startedAt: "2026-07-07T09:00:00.000Z" }),
328
+ { experimentId: "compare/bub", startedAt: "2026-07-07T09:00:00.000Z" },
329
+ [res("weather/brooklyn", "passed", { estimatedCostUSD: 0.03 })],
302
330
  );
303
- // 复印件:同 id / attempt / startedAt,artifactBase 指回原 run(resume 合入的形状)
304
- const { artifactsDir: _dropped, ...carriedBase } = original;
305
- const carried: EvalResult = {
306
- ...carriedBase,
307
- artifactBase: "2026-07-07T09-00-00-000Z/artifacts/compare__bub__weather__brooklyn__0",
308
- };
309
- await writeRun(
331
+ // 复印件:同 id / attempt / startedAt(锚定原快照的 startedAt),artifactBase 指回原快照。
332
+ await writeSnapshot(
310
333
  root,
311
334
  "2026-07-09T10-00-00-000Z",
312
- summaryOf(
313
- [
314
- carried,
315
- res({
316
- id: "weather/brooklyn",
317
- experimentId: "compare/bub",
318
- outcome: "failed",
319
- attempt: 1,
320
- startedAt: "2026-07-09T10:00:05.000Z",
321
- estimatedCostUSD: 0.04,
322
- assertions: [
323
- { name: 'calledTool("get_weather")', severity: "gate", score: 0, passed: false },
324
- ],
325
- }),
326
- ],
327
- { startedAt: "2026-07-09T10:00:00.000Z" },
328
- ),
335
+ { experimentId: "compare/bub", startedAt: "2026-07-09T10:00:00.000Z" },
336
+ [
337
+ res("weather/brooklyn", "passed", {
338
+ estimatedCostUSD: 0.03,
339
+ startedAt: "2026-07-07T09:00:00.000Z",
340
+ artifactBase: "compare_bub/2026-07-07T09-00-00-000Z/weather/brooklyn/a0",
341
+ }),
342
+ res("weather/brooklyn", "failed", {
343
+ attempt: 1,
344
+ estimatedCostUSD: 0.04,
345
+ assertions: [{ name: 'calledTool("get_weather")', severity: "gate", score: 0, passed: false }],
346
+ }),
347
+ ],
329
348
  );
330
349
  return root;
331
350
  }
@@ -335,18 +354,18 @@ describe("--history 时间轴", () => {
335
354
  const results = await openResults(root);
336
355
  const exp = results.experiments.find((e) => e.id === "compare/bub")!;
337
356
  const rows = evalHistory(exp, "weather/brooklyn");
338
- // run2 里复印件被识别,真实执行只有:run1 的 passed + run2 的 failed(新 attempt)
357
+ // 快照2 里复印件被识别,真实执行只有:快照1 的 passed + 快照2 的 failed(新 attempt)
339
358
  expect(rows).toHaveLength(2);
340
- expect(rows[0]).toMatchObject({ outcome: "failed", attempts: 1, costUSD: 0.04 });
359
+ expect(rows[0]).toMatchObject({ verdict: "failed", attempts: 1, costUSD: 0.04 });
341
360
  expect(rows[0].failedAssertion).toBe('gate calledTool("get_weather")');
342
- expect(rows[1]).toMatchObject({ outcome: "passed", attempts: 1, costUSD: 0.03 });
361
+ expect(rows[1]).toMatchObject({ verdict: "passed", attempts: 1, costUSD: 0.03 });
343
362
 
344
363
  const { out, code } = await show(root, ["weather/brooklyn"], { history: true });
345
364
  expect(code).toBe(0);
346
365
  expect(out).toContain("compare/bub · 2 runs · passed 1/2");
347
366
  expect(out).toContain("2026-07-09T10-00");
348
367
  expect(out).toContain("2026-07-07T09-00");
349
- // 复印件那份 passed 判决只出现一行(run1),不在 run2 再占一行
368
+ // 复印件那份 passed 判定只出现一行(快照1),不在快照2 再占一行
350
369
  expect(out.match(/✓ passed/g)).toHaveLength(1);
351
370
  });
352
371
 
@@ -355,7 +374,7 @@ describe("--history 时间轴", () => {
355
374
  const { out, code } = await show(root, [], { history: true });
356
375
  expect(code).toBe(0);
357
376
  expect(out).toContain("compare/bub · 2 runs");
358
- // 每个快照一行(run2 折叠含携带的 passed 复印件,任一轮通过 → passed)
377
+ // 每个快照一行(快照2 折叠含携带的 passed 复印件,任一轮通过 → passed)
359
378
  expect(out.match(/1\/1 passed/g)).toHaveLength(2);
360
379
  });
361
380
  });
@@ -373,7 +392,7 @@ describe("--report 装载", () => {
373
392
  "const Custom = () => null;",
374
393
  "Custom[FACES] = {",
375
394
  " web: () => null,",
376
- " text: (props, ctx) => `CUSTOM ${props.evals} · drill ${ctx.attemptCommand(props.ref)}`,",
395
+ " text: (props, ctx) => `CUSTOM ${props.evals} · drill ${ctx.attemptCommand(props.locator)}`,",
377
396
  "};",
378
397
  "export default {",
379
398
  ' [Symbol.for("niceeval.report.definition")]: true,',
@@ -381,7 +400,7 @@ describe("--report 装载", () => {
381
400
  " type: Custom,",
382
401
  " props: {",
383
402
  " evals: ctx.selection.snapshots.flatMap((s) => s.evals.map((e) => e.id)).sort().join(\",\"),",
384
- " ref: ctx.selection.snapshots[0].evals[0].attempts[0].ref,",
403
+ " locator: ctx.selection.snapshots[0].evals[0].attempts[0].locator,",
385
404
  " },",
386
405
  " }),",
387
406
  "};",
@@ -392,7 +411,7 @@ describe("--report 装载", () => {
392
411
  return path;
393
412
  }
394
413
 
395
- it("装载 + 注入选集 + attemptCommand 下钻命令", async () => {
414
+ it("装载 + 注入 Selection + attemptCommand 下钻命令", async () => {
396
415
  const root = await seedComposedRoot();
397
416
  const report = await writeReportFile(root);
398
417
  const { out, code } = await show(root, [], { report });
@@ -401,7 +420,7 @@ describe("--report 装载", () => {
401
420
  expect(out).toContain("drill niceeval show");
402
421
  });
403
422
 
404
- it("位置前缀对 --report 生效:收窄注入选集覆盖的 eval", async () => {
423
+ it("位置前缀对 --report 生效:收窄注入 Selection 覆盖的 eval", async () => {
405
424
  const root = await seedComposedRoot();
406
425
  const report = await writeReportFile(root);
407
426
  const { out, code } = await show(root, ["weather"], { report });
@@ -409,18 +428,16 @@ describe("--report 装载", () => {
409
428
  expect(out).toContain("CUSTOM weather/brooklyn ·");
410
429
  });
411
430
 
412
- it("--experiment 让选集只留该实验", async () => {
431
+ it("--experiment 让 Selection 只留该实验", async () => {
413
432
  const root = await seedComposedRoot();
414
- await writeRun(
433
+ await writeSnapshot(
415
434
  root,
416
435
  "2026-07-09T11-00-00-000Z",
417
- summaryOf(
418
- [res({ id: "weather/brooklyn", experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-09T11:00:01.000Z" })],
419
- { agent: "codex", startedAt: "2026-07-09T11:00:00.000Z" },
420
- ),
436
+ { experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-09T11:00:00.000Z" },
437
+ [res("weather/brooklyn", "passed")],
421
438
  );
422
439
  const results = await openResults(root);
423
- const selection = composeShowSelection(results, { experiment: "compare/codex" });
440
+ const selection = selectCurrentResults(results, { experiment: "compare/codex" });
424
441
  expect(selection.snapshots.map((s) => s.experimentId)).toEqual(["compare/codex"]);
425
442
  });
426
443
 
@@ -454,52 +471,254 @@ describe("--report 装载", () => {
454
471
  const { out, code } = await show(root, ["fixtures/button"], { report, diff: true });
455
472
  expect(code).toBe(0);
456
473
  expect(out).not.toContain("CUSTOM");
457
- expect(out).toContain("no diff recorded"); // fixture 没有 diff 工件:如实说缺
474
+ expect(out).toContain("no diff recorded"); // fixture 没有 diff artifact:如实说缺
458
475
  });
459
476
  });
460
477
 
461
- // ───────────────────────── 证据切面:--transcript ─────────────────────────
478
+ // ───────────────────────── 证据切面:--execution ─────────────────────────
462
479
 
463
- describe("--transcript", () => {
464
- it("逐轮对话 + 截断标注(事件数 · 工具调用数 · 原始工件路径)", async () => {
480
+ describe("--execution", () => {
481
+ it("标准事件流(消息);没有 OTel 时如实标 timing unavailable", async () => {
465
482
  const root = await makeRoot();
466
- const writer = await createRunWriter(root, { producer: { name: "niceeval", version: "0.0.0" } });
467
- const snap = writer.snapshot({
468
- experiment: "compare/codex",
469
- agent: "codex",
470
- startedAt: "2026-07-09T10:00:00.000Z",
471
- });
472
- await snap.writeAttempt(
473
- {
474
- id: "weather/brooklyn",
475
- outcome: "failed",
476
- attempt: 2,
477
- durationMs: 41_000,
478
- assertions: [],
479
- },
480
- {
481
- events: [
482
- { type: "message", role: "user", text: "布鲁克林今天天气怎么样?" },
483
- { type: "message", role: "assistant", text: "布鲁克林今天大约 24°C,晴。" },
484
- ],
485
- },
483
+ const dir = await writeSnapshot(
484
+ root,
485
+ "2026-07-09T10-00-00-000Z",
486
+ { experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-09T10:00:00.000Z" },
487
+ [res("weather/brooklyn", "failed", { attempt: 2, durationMs: 41_000, hasEvents: true })],
486
488
  );
487
- await writer.finish();
489
+ await writeFile(
490
+ join(dir, "weather/brooklyn/a2/events.json"),
491
+ JSON.stringify([
492
+ { type: "message", role: "user", text: "布鲁克林今天天气怎么样?" },
493
+ { type: "message", role: "assistant", text: "布鲁克林今天大约 24°C,晴。" },
494
+ ]),
495
+ "utf-8",
496
+ );
497
+
498
+ const { out, code } = await show(root, ["weather/brooklyn"], { execution: true });
499
+ expect(code).toBe(0);
500
+ expect(out).toMatch(/^@1[0-9a-z]{7} · weather\/brooklyn · compare\/codex · failed/m);
501
+ expect(out).toMatch(/USER\s+布鲁克林今天天气怎么样?/);
502
+ expect(out).toMatch(/ASSISTANT\s+布鲁克林今天大约 24°C,晴。/);
503
+ expect(out).toContain("timing unavailable · OTel trace was not collected");
504
+ expect(out).toContain("full events: ");
505
+ expect(out).toContain("events.json");
506
+ });
488
507
 
489
- const { out, code } = await show(root, ["weather/brooklyn"], { transcript: true });
508
+ it("execution === null 时如实说没有事件记录", async () => {
509
+ const root = await makeRoot();
510
+ await writeSnapshot(
511
+ root,
512
+ "2026-07-08T10-00-00-000Z",
513
+ { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" },
514
+ [res("weather/brooklyn", "passed")],
515
+ );
516
+ const { out, code } = await show(root, ["weather/brooklyn"], { execution: true });
490
517
  expect(code).toBe(0);
491
- expect(out).toContain("attempt 3 · compare/codex · failed");
492
- expect(out).toContain("[user]");
493
- expect(out).toContain("布鲁克林今天天气怎么样?");
494
- expect(out).toContain("[assistant]");
495
- expect(out).toContain("(2 events · no tool calls · full stream: ");
496
- expect(out).toContain("events.json)");
518
+ expect(out).toContain("no events recorded for this attempt");
497
519
  });
498
520
 
499
- it("多个 eval 匹配时证据切面报错:先收窄到单个 eval", async () => {
521
+ it("不把未关联的 telemetry spans 混进 Agent 执行记录", async () => {
522
+ const root = await makeRoot();
523
+ const dir = await writeSnapshot(root, "2026-07-09T10-00-00-000Z", { experimentId: "compare/codex", startedAt: "2026-07-09T10:00:00.000Z" }, [
524
+ res("weather/brooklyn", "failed", { hasEvents: true, hasTrace: true }),
525
+ ]);
526
+ await writeFile(join(dir, "weather/brooklyn/a0/events.json"), JSON.stringify([{ type: "message", role: "user", text: "hello" }]), "utf-8");
527
+ await writeFile(join(dir, "weather/brooklyn/a0/trace.json"), JSON.stringify([{ traceId: "t", spanId: "s", name: "sdk.internal", startMs: 1, endMs: 2 }]), "utf-8");
528
+ const { out } = await show(root, ["weather/brooklyn"], { execution: true });
529
+ expect(out).toContain("USER");
530
+ expect(out).not.toMatch(/telemetry\s+sdk\.internal/);
531
+ expect(out).toContain("unlinked telemetry spans omitted");
532
+ });
533
+
534
+ it("多个 eval 匹配时证据切面报错:给紧凑索引(locator + 失败原因)而不是只报个数", async () => {
500
535
  const root = await seedComposedRoot();
501
- const { err, code } = await show(root, [], { transcript: true });
536
+ const { err, code } = await show(root, [], { execution: true });
537
+ expect(code).toBe(1);
538
+ expect(err).toContain("matched 2 evals");
539
+ expect(err).toMatch(/✗ fixtures\/button\s+@1[0-9a-z]{7}/);
540
+ expect(err).toContain('gate fileChanged("src/components/Button.tsx")');
541
+ });
542
+ });
543
+
544
+ // ───────────────────────── 证据切面:--eval ─────────────────────────
545
+
546
+ describe("--eval", () => {
547
+ it("evalSource === null 时如实说源码未捕获", async () => {
548
+ const root = await makeRoot();
549
+ await writeSnapshot(
550
+ root,
551
+ "2026-07-08T10-00-00-000Z",
552
+ { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" },
553
+ [res("weather/brooklyn", "failed", { assertions: [{ name: "succeeded()", severity: "gate", score: 0, passed: false }] })],
554
+ );
555
+ const { out, code } = await show(root, ["weather/brooklyn"], { eval: true });
556
+ expect(code).toBe(0);
557
+ expect(out).toContain("eval source unavailable for this attempt");
558
+ });
559
+
560
+ it("有捕获的源码时:源码行标回断言,unmapped 断言单独成段,断言计票摘要", async () => {
561
+ const root = await makeRoot();
562
+ const dir = await writeSnapshot(
563
+ root,
564
+ "2026-07-08T10-00-00-000Z",
565
+ { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" },
566
+ [
567
+ res("weather/brooklyn", "failed", {
568
+ assertions: [
569
+ {
570
+ name: 'calledTool("get_weather")',
571
+ severity: "gate",
572
+ score: 0,
573
+ passed: false,
574
+ detail: "tool was never called",
575
+ loc: { file: "evals/weather/brooklyn.eval.ts", line: 2 },
576
+ },
577
+ { name: "unlocated()", severity: "soft", score: 0.5, passed: false, detail: "no loc on this one" },
578
+ ],
579
+ }),
580
+ ],
581
+ );
582
+ const attemptDir = join(dir, "weather/brooklyn/a0");
583
+ const content = 'defineEval({\n turn.calledTool("get_weather");\n});\n';
584
+ const sha = createHash("sha256").update(content).digest("hex");
585
+ await writeFile(join(attemptDir, "sources.json"), JSON.stringify([{ path: "evals/weather/brooklyn.eval.ts", sha256: sha }]), "utf-8");
586
+ await mkdir(join(dir, "sources"), { recursive: true });
587
+ await writeFile(join(dir, "sources", `${sha}.json`), JSON.stringify({ content }), "utf-8");
588
+
589
+ const { out, code } = await show(root, ["weather/brooklyn"], { eval: true });
590
+ expect(code).toBe(0);
591
+ expect(out).toContain(`eval source: evals/weather/brooklyn.eval.ts · sha256:${sha.slice(0, 8)}`);
592
+ // 源码行的缩进是语义的一部分,渲染必须原样保留(不按词重排折叠掉这两个空格)。
593
+ expect(out).toMatch(/2✗ {3}turn\.calledTool\("get_weather"\);/);
594
+ expect(out).toContain('gate · calledTool("get_weather") · tool was never called');
595
+ expect(out).toContain("unmapped assertions (1, no source location):");
596
+ expect(out).toContain("unlocated()");
597
+ expect(out).toContain("assertions: 1 gate failed · 1 soft below target");
598
+ });
599
+
600
+ it("源码行标注保留 group 与值断言的 expected/received", async () => {
601
+ const root = await makeRoot();
602
+ const dir = await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
603
+ res("manager", "failed", { assertions: [{ name: "equals(4)", severity: "gate", score: 0, passed: false, evidence: "1", group: "Issue 15193", loc: { file: "evals/manager.eval.ts", line: 1 } }] }),
604
+ ]);
605
+ const attemptDir = join(dir, "manager/a0");
606
+ const content = "t.check(actual, equals(expected));\n";
607
+ const sha = createHash("sha256").update(content).digest("hex");
608
+ await writeFile(join(attemptDir, "sources.json"), JSON.stringify([{ path: "evals/manager.eval.ts", sha256: sha }]), "utf-8");
609
+ await mkdir(join(dir, "sources"), { recursive: true });
610
+ await writeFile(join(dir, "sources", `${sha}.json`), JSON.stringify({ content }), "utf-8");
611
+ const { out } = await show(root, ["manager"], { eval: true });
612
+ expect(out).toContain("gate · Issue 15193 · equals(4) · expected 4 · received 1");
613
+ });
614
+ });
615
+
616
+ // ───────────────────────── @<locator> ─────────────────────────
617
+
618
+ describe("show @<locator>", () => {
619
+ it("默认页直接解释失败断言的 group、期望值、实际值和源码位置", async () => {
620
+ const root = await makeRoot();
621
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
622
+ res("manager", "failed", { assertions: [{ name: "equals(4)", severity: "gate", score: 0, passed: false, evidence: "1", group: "Issue 15193", loc: { file: "evals/manager.eval.ts", line: 40, column: 11 } }] }),
623
+ ]);
624
+ const results = await openResults(root);
625
+ const locator = results.experiments[0]!.latest.evals[0]!.attempts[0]!.locator!;
626
+ const { out } = await show(root, [locator]);
627
+ expect(out).toContain("gate · Issue 15193");
628
+ expect(out).toContain("assertion: equals(4)");
629
+ expect(out).toContain("expected: 4");
630
+ expect(out).toContain("received: 1");
631
+ expect(out).toContain("source: evals/manager.eval.ts:40:11");
632
+ });
633
+ it("解析到对应 attempt,渲染紧凑全景(不当成 eval id 前缀匹配)", async () => {
634
+ const root = await makeRoot();
635
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
636
+ res("weather/brooklyn", "passed"),
637
+ ]);
638
+ const results = await openResults(root);
639
+ const attempt = results.experiments[0]!.latest.evals[0]!.attempts[0]!;
640
+ const locator = attempt.locator!;
641
+ expect(locator).toMatch(/^@1[0-9a-z]{7}$/);
642
+
643
+ const { out, code } = await show(root, [locator]);
644
+ expect(code).toBe(0);
645
+ expect(out).toContain(`${locator} · weather/brooklyn · compare/bub · passed`);
646
+ expect(out).toContain("attempt 1 · ");
647
+ expect(out).toContain("execution: unavailable (no events recorded for this attempt)");
648
+ expect(out).toContain("changes: diff unavailable");
649
+ expect(out).not.toContain("evidence:");
650
+ expect(out).not.toContain("available:");
651
+ });
652
+
653
+ it("available 只逐行列出实际存在的证据命令,不打印能力字母或合并式 next", async () => {
654
+ const root = await makeRoot();
655
+ const dir = await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
656
+ res("weather/brooklyn", "failed", { hasEvents: true }),
657
+ ]);
658
+ const attemptDir = join(dir, "weather/brooklyn/a0");
659
+ const content = "t.check(false, equals(true));\n";
660
+ const sha = createHash("sha256").update(content).digest("hex");
661
+ await writeFile(join(attemptDir, "sources.json"), JSON.stringify([{ path: "evals/weather.eval.ts", sha256: sha }]), "utf-8");
662
+ await mkdir(join(dir, "sources"), { recursive: true });
663
+ await writeFile(join(dir, "sources", `${sha}.json`), JSON.stringify({ content }), "utf-8");
664
+ await writeFile(join(attemptDir, "events.json"), JSON.stringify([{ type: "message", role: "user", text: "hello" }]), "utf-8");
665
+ const results = await openResults(root);
666
+ const locator = results.experiments[0]!.latest.evals[0]!.attempts[0]!.locator!;
667
+
668
+ const { out, code } = await show(root, [locator]);
669
+ expect(code).toBe(0);
670
+ expect(out).toContain(`available:\n niceeval show ${locator} --eval\n niceeval show ${locator} --execution`);
671
+ expect(out).not.toContain(`niceeval show ${locator} --diff`);
672
+ expect(out).not.toContain("evidence:");
673
+ expect(out).not.toContain("next:");
674
+ });
675
+
676
+ it("配 --execution 走证据切面,不是紧凑全景", async () => {
677
+ const root = await makeRoot();
678
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
679
+ res("weather/brooklyn", "passed"),
680
+ ]);
681
+ const results = await openResults(root);
682
+ const locator = results.experiments[0]!.latest.evals[0]!.attempts[0]!.locator!;
683
+
684
+ const { out, code } = await show(root, [locator], { execution: true });
685
+ expect(code).toBe(0);
686
+ expect(out).toContain(`${locator} · weather/brooklyn · compare/bub · passed`);
687
+ expect(out).toContain("no events recorded for this attempt");
688
+ // 紧凑全景独有的行不应该出现在证据切面输出里
689
+ expect(out).not.toContain("available:");
690
+ });
691
+
692
+ it("语法不对的 locator 报「not a valid attempt locator」,退出码 1,不崩", async () => {
693
+ const root = await makeRoot();
694
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
695
+ res("weather/brooklyn", "passed"),
696
+ ]);
697
+ const { err, code } = await show(root, ["@not-valid"]);
698
+ expect(code).toBe(1);
699
+ expect(err).toContain("not a valid attempt locator");
700
+ });
701
+
702
+ it("语法合法但索引里没有的 locator 报「No attempt found」,退出码 1,不崩", async () => {
703
+ const root = await makeRoot();
704
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
705
+ res("weather/brooklyn", "passed"),
706
+ ]);
707
+ const { err, code } = await show(root, ["@1nosuch1"]);
708
+ expect(code).toBe(1);
709
+ expect(err).toContain("No attempt found");
710
+ });
711
+
712
+ it("locator 与其它位置参数混用时报错,不静默只取第一个", async () => {
713
+ const root = await makeRoot();
714
+ await writeSnapshot(root, "2026-07-08T10-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-08T10:00:00.000Z" }, [
715
+ res("weather/brooklyn", "passed"),
716
+ ]);
717
+ const results = await openResults(root);
718
+ const locator = results.experiments[0]!.latest.evals[0]!.attempts[0]!.locator!;
719
+
720
+ const { err, code } = await show(root, [locator, "weather/brooklyn"]);
502
721
  expect(code).toBe(1);
503
- expect(err).toContain("Narrow to a single eval id");
722
+ expect(err).toContain("must be the only positional argument");
504
723
  });
505
724
  });