niceeval 0.6.0 → 0.6.2

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 (314) hide show
  1. package/dist/agents/types.d.ts +72 -6
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
  9. package/dist/report/built-ins/experiment-comparison.js +119 -0
  10. package/dist/report/built-ins/index.d.ts +2 -1
  11. package/dist/report/built-ins/index.js +2 -2
  12. package/dist/report/components.d.ts +10 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +68 -66
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -14
  19. package/dist/report/format.js +28 -30
  20. package/dist/report/index.d.ts +5 -4
  21. package/dist/report/index.js +6 -5
  22. package/dist/report/locale.d.ts +23 -3
  23. package/dist/report/locale.js +47 -6
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +66 -15
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +4 -4
  28. package/dist/report/react/AttemptList.js +8 -10
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +4 -2
  34. package/dist/report/react/ExperimentList.js +57 -7
  35. package/dist/report/react/MetricScatter.js +6 -16
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +30 -18
  40. package/dist/report/react/format.d.ts +1 -1
  41. package/dist/report/react/format.js +1 -1
  42. package/dist/report/react/index.d.ts +1 -1
  43. package/dist/report/report.d.ts +5 -1
  44. package/dist/report/report.js +6 -2
  45. package/dist/report/text/faces.d.ts +1 -1
  46. package/dist/report/text/faces.js +100 -61
  47. package/dist/report/text/table.js +36 -5
  48. package/dist/report/types.d.ts +40 -34
  49. package/dist/results/types.d.ts +11 -0
  50. package/dist/runner/feedback/sink.d.ts +110 -0
  51. package/dist/runner/types.d.ts +513 -22
  52. package/dist/sandbox/docker.d.ts +23 -2
  53. package/dist/sandbox/e2b.d.ts +15 -1
  54. package/dist/sandbox/errors.d.ts +30 -3
  55. package/dist/sandbox/io-retry.d.ts +17 -0
  56. package/dist/sandbox/registry.d.ts +2 -0
  57. package/dist/sandbox/resolve.d.ts +18 -5
  58. package/dist/sandbox/retry.d.ts +11 -1
  59. package/dist/sandbox/types.d.ts +39 -5
  60. package/dist/sandbox/vercel.d.ts +7 -1
  61. package/dist/scoring/coverage.d.ts +30 -0
  62. package/dist/scoring/display.d.ts +21 -0
  63. package/dist/scoring/display.js +120 -0
  64. package/dist/scoring/types.d.ts +103 -20
  65. package/dist/shared/aggregate.d.ts +1 -0
  66. package/dist/shared/aggregate.js +3 -3
  67. package/dist/shared/types.d.ts +28 -0
  68. package/dist/tty-line.d.ts +0 -4
  69. package/dist/util.d.ts +23 -0
  70. package/docs-site/zh/concepts/adapter.mdx +24 -6
  71. package/docs-site/zh/concepts/assert.mdx +11 -10
  72. package/docs-site/zh/concepts/evals.mdx +7 -6
  73. package/docs-site/zh/concepts/experiment.mdx +1 -1
  74. package/docs-site/zh/concepts/overview.mdx +7 -7
  75. package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
  76. package/docs-site/zh/guides/authoring.mdx +33 -0
  77. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  78. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  79. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  80. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  81. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  82. package/docs-site/zh/guides/debugging.mdx +210 -0
  83. package/docs-site/zh/guides/experiments.mdx +10 -3
  84. package/docs-site/zh/guides/fixtures.mdx +3 -1
  85. package/docs-site/zh/guides/official-adapters.mdx +27 -3
  86. package/docs-site/zh/guides/publish-report.mdx +30 -16
  87. package/docs-site/zh/guides/report-components.mdx +49 -37
  88. package/docs-site/zh/guides/reporters.mdx +2 -2
  89. package/docs-site/zh/guides/results-data.mdx +42 -8
  90. package/docs-site/zh/guides/runner.mdx +17 -7
  91. package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
  92. package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
  93. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  94. package/docs-site/zh/guides/viewing-results.mdx +85 -41
  95. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  96. package/docs-site/zh/guides/write-send.mdx +19 -2
  97. package/docs-site/zh/index.mdx +1 -1
  98. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  99. package/docs-site/zh/reference/capabilities.mdx +2 -2
  100. package/docs-site/zh/reference/cli.mdx +35 -9
  101. package/docs-site/zh/reference/define-agent.mdx +60 -5
  102. package/docs-site/zh/reference/define-config.mdx +1 -1
  103. package/docs-site/zh/reference/define-eval.mdx +42 -9
  104. package/docs-site/zh/reference/events.mdx +2 -2
  105. package/docs-site/zh/reference/expect.mdx +36 -6
  106. package/package.json +5 -1
  107. package/src/agents/ai-sdk-otel.test.ts +1 -0
  108. package/src/agents/ai-sdk.test.ts +3 -0
  109. package/src/agents/ai-sdk.ts +3 -0
  110. package/src/agents/bub-install-spec.test.ts +34 -0
  111. package/src/agents/bub-install-spec.ts +32 -0
  112. package/src/agents/bub.ts +31 -32
  113. package/src/agents/claude-code.test.ts +130 -9
  114. package/src/agents/claude-code.ts +76 -4
  115. package/src/agents/codex.test.ts +189 -40
  116. package/src/agents/codex.ts +155 -14
  117. package/src/agents/coding-cli-versions.test.ts +15 -0
  118. package/src/agents/coding-cli-versions.ts +3 -0
  119. package/src/agents/index.ts +11 -0
  120. package/src/agents/langgraph.test.ts +204 -0
  121. package/src/agents/langgraph.ts +495 -0
  122. package/src/agents/marketplace.ts +85 -0
  123. package/src/agents/native-config.test.ts +179 -0
  124. package/src/agents/native-config.ts +267 -0
  125. package/src/agents/openai-compat.test.ts +1 -0
  126. package/src/agents/openclaw.test.ts +31 -0
  127. package/src/agents/openclaw.ts +171 -0
  128. package/src/agents/plugin-config.test.ts +1 -0
  129. package/src/agents/sdk-streams.test.ts +79 -0
  130. package/src/agents/sdk-streams.ts +55 -10
  131. package/src/agents/skills.test.ts +1 -0
  132. package/src/agents/streaming.test.ts +3 -9
  133. package/src/agents/types.ts +73 -6
  134. package/src/agents/ui-message-stream.test.ts +3 -0
  135. package/src/cli.ts +411 -108
  136. package/src/context/context.test.ts +51 -12
  137. package/src/context/context.ts +161 -29
  138. package/src/context/session.test.ts +1 -0
  139. package/src/context/session.ts +114 -6
  140. package/src/context/types.ts +30 -12
  141. package/src/define.test.ts +13 -8
  142. package/src/define.ts +25 -4
  143. package/src/expect/index.ts +53 -23
  144. package/src/i18n/en.ts +65 -4
  145. package/src/i18n/zh-CN.ts +66 -4
  146. package/src/o11y/cost.test.ts +1 -0
  147. package/src/o11y/execution-tree.test.ts +1 -20
  148. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  149. package/src/o11y/otlp/parse.test.ts +1 -0
  150. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  151. package/src/o11y/parsers/bub.test.ts +1 -0
  152. package/src/o11y/parsers/claude-code.test.ts +1 -34
  153. package/src/o11y/parsers/openclaw.test.ts +154 -0
  154. package/src/o11y/parsers/openclaw.ts +310 -0
  155. package/src/o11y/prices.json +746 -311
  156. package/src/o11y/tool-names.test.ts +1 -0
  157. package/src/o11y/types.ts +16 -2
  158. package/src/report/aggregate.ts +34 -5
  159. package/src/report/built-in-user-parity.test.tsx +127 -173
  160. package/src/report/built-ins/experiment-comparison.tsx +179 -0
  161. package/src/report/built-ins/index.ts +7 -2
  162. package/src/report/components.tsx +11 -3
  163. package/src/report/compute.ts +80 -74
  164. package/src/report/dual-render.test.tsx +222 -91
  165. package/src/report/flag.ts +30 -2
  166. package/src/report/format.ts +36 -27
  167. package/src/report/index.ts +23 -6
  168. package/src/report/locale.ts +49 -6
  169. package/src/report/metrics.ts +68 -15
  170. package/src/report/primitives.tsx +6 -0
  171. package/src/report/react/AttemptList.tsx +9 -36
  172. package/src/report/react/EvalList.tsx +0 -0
  173. package/src/report/react/ExperimentComparison.tsx +68 -0
  174. package/src/report/react/ExperimentList.tsx +173 -55
  175. package/src/report/react/MetricScatter.tsx +13 -25
  176. package/src/report/react/chart-math.test.ts +85 -0
  177. package/src/report/react/chart-math.ts +101 -22
  178. package/src/report/react/enhance.js +72 -1
  179. package/src/report/react/fixtures.ts +34 -21
  180. package/src/report/react/format.ts +1 -1
  181. package/src/report/react/index.tsx +0 -1
  182. package/src/report/react/render.test.tsx +30 -69
  183. package/src/report/react/styles.css +112 -14
  184. package/src/report/report.test.ts +308 -105
  185. package/src/report/report.ts +6 -2
  186. package/src/report/text/faces.ts +111 -67
  187. package/src/report/text/table.ts +42 -5
  188. package/src/report/types.ts +42 -34
  189. package/src/results/annotated-source.test.ts +62 -9
  190. package/src/results/annotated-source.ts +64 -6
  191. package/src/results/attempt-evidence.test.ts +9 -7
  192. package/src/results/attempt-evidence.ts +15 -8
  193. package/src/results/attempt-source.ts +6 -3
  194. package/src/results/copy.ts +145 -55
  195. package/src/results/host-equivalence.test.ts +11 -9
  196. package/src/results/index.ts +2 -0
  197. package/src/results/locator.test.ts +1 -22
  198. package/src/results/open.ts +7 -1
  199. package/src/results/publish.ts +149 -0
  200. package/src/results/results.test.ts +85 -51
  201. package/src/results/truncate.ts +90 -0
  202. package/src/results/types.ts +7 -0
  203. package/src/results/writer.ts +31 -13
  204. package/src/runner/attempt.test.ts +138 -7
  205. package/src/runner/attempt.ts +603 -104
  206. package/src/runner/discover.test.ts +47 -0
  207. package/src/runner/discover.ts +36 -2
  208. package/src/runner/eval-source.test.ts +1 -27
  209. package/src/runner/feedback/agent.test.ts +504 -0
  210. package/src/runner/feedback/agent.ts +409 -0
  211. package/src/runner/feedback/ci.test.ts +562 -0
  212. package/src/runner/feedback/ci.ts +401 -0
  213. package/src/runner/feedback/coordinator.test.ts +317 -0
  214. package/src/runner/feedback/coordinator.ts +397 -0
  215. package/src/runner/feedback/failure.ts +40 -0
  216. package/src/runner/feedback/human.test.ts +616 -0
  217. package/src/runner/feedback/human.ts +535 -0
  218. package/src/runner/feedback/index.ts +66 -0
  219. package/src/runner/feedback/io.ts +78 -0
  220. package/src/runner/feedback/profile.test.ts +50 -0
  221. package/src/runner/feedback/profile.ts +58 -0
  222. package/src/runner/feedback/reducer.test.ts +395 -0
  223. package/src/runner/feedback/reducer.ts +260 -0
  224. package/src/runner/feedback/renderer.ts +82 -0
  225. package/src/runner/feedback/sink.ts +203 -0
  226. package/src/runner/feedback/testing.ts +106 -0
  227. package/src/runner/ledger.test.ts +230 -0
  228. package/src/runner/ledger.ts +329 -0
  229. package/src/runner/report.test.ts +128 -3
  230. package/src/runner/report.ts +33 -9
  231. package/src/runner/reporters/artifacts.ts +8 -2
  232. package/src/runner/reporters/braintrust.test.ts +8 -7
  233. package/src/runner/reporters/braintrust.ts +9 -2
  234. package/src/runner/reporters/index.ts +2 -2
  235. package/src/runner/reporters/json.test.ts +162 -0
  236. package/src/runner/reporters/json.ts +35 -8
  237. package/src/runner/reporters/shared.ts +1 -5
  238. package/src/runner/run.test.ts +760 -3
  239. package/src/runner/run.ts +242 -36
  240. package/src/runner/sandbox-prep.ts +3 -42
  241. package/src/runner/timing.ts +158 -0
  242. package/src/runner/types.ts +518 -22
  243. package/src/sandbox/checkpoint.test.ts +55 -0
  244. package/src/sandbox/checkpoint.ts +29 -8
  245. package/src/sandbox/cli-commands.ts +407 -0
  246. package/src/sandbox/docker.ts +115 -16
  247. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  248. package/src/sandbox/e2b-agent-template.ts +94 -0
  249. package/src/sandbox/e2b.ts +74 -9
  250. package/src/sandbox/errors.ts +111 -4
  251. package/src/sandbox/index.ts +2 -0
  252. package/src/sandbox/io-retry.test.ts +58 -0
  253. package/src/sandbox/io-retry.ts +45 -0
  254. package/src/sandbox/keep-registry.test.ts +86 -0
  255. package/src/sandbox/keep-registry.ts +142 -0
  256. package/src/sandbox/keep.ts +178 -0
  257. package/src/sandbox/paths.test.ts +1 -0
  258. package/src/sandbox/paths.ts +19 -8
  259. package/src/sandbox/registry.ts +20 -3
  260. package/src/sandbox/resolve.ts +76 -11
  261. package/src/sandbox/retry.test.ts +70 -0
  262. package/src/sandbox/retry.ts +46 -4
  263. package/src/sandbox/types.ts +44 -6
  264. package/src/sandbox/vercel.ts +43 -20
  265. package/src/scoring/collector.ts +60 -17
  266. package/src/scoring/coverage.ts +95 -0
  267. package/src/scoring/diff.ts +81 -0
  268. package/src/scoring/display.test.ts +121 -0
  269. package/src/scoring/display.ts +133 -0
  270. package/src/scoring/evidence.test.ts +189 -0
  271. package/src/scoring/judge.test.ts +142 -0
  272. package/src/scoring/judge.ts +15 -18
  273. package/src/scoring/scoped.ts +217 -50
  274. package/src/scoring/types.ts +117 -20
  275. package/src/scoring/verdict.ts +16 -4
  276. package/src/shared/aggregate.ts +3 -2
  277. package/src/shared/types.ts +31 -0
  278. package/src/show/compose.ts +2 -2
  279. package/src/show/index.ts +29 -16
  280. package/src/show/render.ts +626 -308
  281. package/src/show/show.test.ts +251 -36
  282. package/src/tty-line.ts +8 -26
  283. package/src/util.test.ts +1 -0
  284. package/src/util.ts +41 -0
  285. package/src/view/app/components/AttemptModal.tsx +153 -2
  286. package/src/view/app/components/CodeView.tsx +32 -11
  287. package/src/view/app/components/CopyControls.tsx +2 -2
  288. package/src/view/app/i18n.ts +6 -0
  289. package/src/view/app/lib/attempt-route.test.ts +1 -0
  290. package/src/view/app/lib/verdict.ts +7 -9
  291. package/src/view/artifact-serving.test.ts +2 -1
  292. package/src/view/client-dist/app.css +1 -1
  293. package/src/view/client-dist/app.js +17 -17
  294. package/src/view/data.test.ts +2 -1
  295. package/src/view/data.ts +17 -7
  296. package/src/view/index.ts +12 -1
  297. package/src/view/server.ts +2 -0
  298. package/src/view/shared/types.ts +1 -1
  299. package/src/view/styles.css +3 -0
  300. package/src/view/view-report.test.ts +11 -10
  301. package/dist/o11y/execution-tree.d.ts +0 -103
  302. package/dist/o11y/otlp/select.d.ts +0 -22
  303. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
  304. package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
  305. package/dist/results/annotated-source.d.ts +0 -61
  306. package/dist/results/attempt-evidence.d.ts +0 -69
  307. package/dist/results/attempt-source.d.ts +0 -15
  308. package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
  309. package/src/runner/reporters/console.ts +0 -70
  310. package/src/runner/reporters/live.test.ts +0 -56
  311. package/src/runner/reporters/live.ts +0 -247
  312. package/src/runner/reporters/quiet.test.ts +0 -66
  313. package/src/runner/reporters/quiet.ts +0 -49
  314. package/src/runner/reporters/table.ts +0 -277
@@ -44,10 +44,14 @@ export function defineReport(
44
44
  return { build, [REPORT_DEFINITION]: true };
45
45
  }
46
46
 
47
- /** 宿主装载报告文件时用:默认导出是不是 defineReport 的产物。 */
47
+ /**
48
+ * 宿主装载报告文件时用:默认导出是不是 ReportDefinition。defineReport 的产物是普通对象;
49
+ * 「组件兼报告」(把 defineReport 产物 Object.assign 到双面组件上)是挂了 build 面的
50
+ * 可调用函数,同样算数——判据只看 build 面与标记,不看宿主形态。
51
+ */
48
52
  export function isReportDefinition(value: unknown): value is ReportDefinition {
49
53
  return (
50
- typeof value === "object" &&
54
+ (typeof value === "object" || typeof value === "function") &&
51
55
  value !== null &&
52
56
  (value as Partial<ReportDefinition>)[REPORT_DEFINITION] === true &&
53
57
  typeof (value as Partial<ReportDefinition>).build === "function"
@@ -9,7 +9,6 @@ import type {
9
9
  AttemptListItem,
10
10
  DeltaData,
11
11
  EvalListItem,
12
- ExperimentListEvalRow,
13
12
  ExperimentListItem,
14
13
  GroupSummaryData,
15
14
  LineData,
@@ -24,7 +23,7 @@ import type { TextContext } from "../tree.ts";
24
23
  import type { TableColumn, TableRow } from "../primitives.tsx";
25
24
  import {
26
25
  attemptItemReason,
27
- capabilityBadge,
26
+ experimentDisplayName,
28
27
  formatDurationMs,
29
28
  formatMetricValue,
30
29
  formatPlainNumber,
@@ -32,7 +31,7 @@ import {
32
31
  verdictMark,
33
32
  } from "../format.ts";
34
33
  import { countText, localeText, resolveMetricLabel, type ReportLocale } from "../locale.ts";
35
- import { indentBlock, padDisplay, textBar, wrapDisplay } from "./layout.ts";
34
+ import { indentBlock, padDisplay, stringWidth, textBar, wrapDisplay } from "./layout.ts";
36
35
  import { renderTableText } from "./table.ts";
37
36
  import { renderCharPlot, renderCoordinateTable, type PlotPoint } from "./plot.ts";
38
37
 
@@ -325,11 +324,6 @@ export function scatterText(data: ScatterData, ctx: TextContext): string {
325
324
  if (drawable.length === 0) {
326
325
  return [localeText(locale, "scatter.noData", axes), ...footnotes].join("\n");
327
326
  }
328
- // 恰好 1 个可画点:成本 × 通过率的比较至少要两个实验,单点不成图。
329
- if (drawable.length === 1) {
330
- return [localeText(locale, "scatter.needTwo", axes), ...footnotes].join("\n");
331
- }
332
-
333
327
  // 点太密排不下时降级为坐标表,不硬挤
334
328
  if (drawable.length > POINT_MARKS.length || ctx.width < 44) {
335
329
  const table = renderCoordinateTable(
@@ -451,51 +445,118 @@ export function deltaText(data: DeltaData, ctx: TextContext): string {
451
445
 
452
446
  // ───────────────────────── 实体列表(ExperimentList / EvalList / AttemptList)─────────────────────────
453
447
  //
454
- // 三面共用的紧凑标记:`locator✓[E,X,⏱]`(判定符紧跟 locator,证据能力方括号紧跟判定符,
455
- // 中间不留空格)——docs-site/zh/guides/report-components.mdx「终端输出形成反馈闭环」定的形态。
448
+ // 三面共用的紧凑标记:`locator✓`(判定符紧跟 locator,中间不留空格)。
456
449
  // ExperimentList / EvalList 逐 attempt 只列这一个标记 + 各自的原因/耗时摘要,不重复整段
457
450
  // niceeval show 命令;要看某个 attempt 的完整证据,agent 自己拼 `niceeval show <locator>`——
458
- // 命令模板只在 AttemptList(叶子层)展示完整断言明细时才值得,不在中间层重复。
451
+ // 比较列表都不内联完整断言或命令模板。
459
452
 
460
- function locatorBadge(item: { locator: string; verdict: AttemptListItem["verdict"]; capabilities: AttemptListItem["capabilities"] }): string {
461
- return `${item.locator}${verdictMark(item.verdict)}${capabilityBadge(item.capabilities)}`;
453
+ function locatorBadge(item: { locator: string; verdict: AttemptListItem["verdict"] }): string {
454
+ return `${item.locator}${verdictMark(item.verdict)}`;
462
455
  }
463
456
 
464
457
  // ── ExperimentList ──
465
458
 
466
- function experimentListEvalLine(row: ExperimentListEvalRow): string {
467
- const badges = row.attempts.map(locatorBadge).join(" ");
468
- const trailer =
469
- row.verdict === "passed"
470
- ? [formatDurationMs(row.duration.value ?? 0), row.cost.value === null ? undefined : formatUSD(row.cost.value)]
471
- .filter((s): s is string => s !== undefined)
472
- .join(" · ")
473
- : (row.reason ?? "");
474
- return ` ${verdictMark(row.verdict)} ${row.evalId} ${badges} ${trailer}`;
459
+ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
460
+ const locale = ctx.locale;
461
+ const compact = ctx.width < 100;
462
+ const columns: TableColumn[] = [
463
+ { key: "experiment", header: compact && locale === "en" ? "Exp." : localeText(locale, "experimentList.experiment") },
464
+ { key: "model", header: localeText(locale, "table.model") },
465
+ { key: "agent", header: localeText(locale, "table.agent") },
466
+ { key: "duration", header: compact && locale === "en" ? "Avg" : localeText(locale, "experimentList.avgDuration"), align: "right" },
467
+ { key: "passRate", header: compact && locale === "en" ? "Pass" : localeText(locale, "experimentList.passRate"), align: "right" },
468
+ { key: "result", header: localeText(locale, "experimentList.result") },
469
+ { key: "tokens", header: localeText(locale, "experimentList.tokens"), align: "right" },
470
+ { key: "cost", header: compact && locale === "en" ? "Cost" : localeText(locale, "experimentList.estimatedCost"), align: "right" },
471
+ ];
472
+ const rows: TableRow[] = items.map((item) => ({
473
+ key: item.experimentId,
474
+ cells: {
475
+ experiment: experimentDisplayName(item.experimentId, relativeTo),
476
+ model: item.model ?? localeText(locale, "experimentList.defaultModel"),
477
+ agent: item.agent,
478
+ duration: cellText(item.duration),
479
+ passRate: cellText(item.passRate),
480
+ result: verdictTallyText(item.verdicts, locale),
481
+ tokens: cellText(item.tokens),
482
+ cost: cellText(item.cost),
483
+ },
484
+ }));
485
+ const metadata = items.flatMap((item) =>
486
+ wrapDisplay(
487
+ `${experimentDisplayName(item.experimentId, relativeTo)}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
488
+ Math.max(8, ctx.width - 2),
489
+ ).map((line) => ` ${line}`),
490
+ );
491
+ return [renderTableText({ columns, rows, locale }, ctx), metadata.join("\n")].join("\n");
475
492
  }
476
493
 
477
- export function experimentListText(items: ExperimentListItem[], ctx: TextContext): string {
494
+ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relativeTo?: string): string {
478
495
  const locale = ctx.locale;
479
- if (items.length === 0) return localeText(locale, "attemptList.empty");
480
- const blocks = items.map((item) => {
481
- const identity = item.model ? `${item.experimentId} · ${item.agent} · ${item.model}` : `${item.experimentId} · ${item.agent}`;
482
- const summary = [
483
- `${localeText(locale, "overview.passRate")} ${cellText(item.passRate)}`,
484
- verdictTallyText(item.verdicts, locale),
485
- localeText(locale, "overview.attemptsCount", { n: item.attempts }),
486
- formatDurationMs(item.duration.value ?? 0),
487
- item.cost.value === null ? missingText(locale) : formatUSD(item.cost.value),
488
- ].join(" · ");
489
- const evalLines = item.evalRows.map(experimentListEvalLine);
490
- return [identity, ` ${summary}`, ...evalLines].join("\n");
496
+ const columns: TableColumn[] = [
497
+ { key: "status", header: localeText(locale, "experimentList.status") },
498
+ { key: "entity", header: localeText(locale, "experimentList.evalAttempt") },
499
+ // Result 是可扫读的失败预览,不是证据面:两行放不下的以 … 收口,完整值走 locator 下钻。
500
+ { key: "result", header: localeText(locale, "experimentList.result"), maxLines: 2 },
501
+ { key: "duration", header: localeText(locale, "experimentList.duration"), align: "right" },
502
+ { key: "cost", header: localeText(locale, "experimentList.cost"), align: "right" },
503
+ ];
504
+ // Result 的字符预算 两行 × 它能分到的列宽(总宽减其它列的自然宽与列距)。这里只做
505
+ // 优先级让位(标题先截、received 最后截)的粗预算;精确的按宽度收口由列的 maxLines 兜底。
506
+ const statusWidth = Math.max(
507
+ stringWidth(localeText(locale, "experimentList.status")),
508
+ ...item.evalRows.map((row) => stringWidth(`${verdictMark(row.verdict)} ${localeText(locale, `verdict.${row.verdict}`)}`)),
509
+ );
510
+ const entityWidth = Math.max(
511
+ stringWidth(localeText(locale, "experimentList.evalAttempt")),
512
+ ...item.evalRows.flatMap((row) => [stringWidth(row.evalId), ...row.attempts.map((a) => stringWidth(a.locator) + 3)]),
513
+ );
514
+ const fixedWidth = statusWidth + entityWidth + 8 /* duration */ + 6 /* cost */ + 3 * 4; /* 4 段列距 */
515
+ const resultBudget = Math.max(24, (ctx.width - fixedWidth) * 2);
516
+ const rows: TableRow[] = item.evalRows.flatMap((row) => {
517
+ const parent: TableRow = {
518
+ key: row.evalId,
519
+ cells: {
520
+ status: `${verdictMark(row.verdict)} ${localeText(locale, `verdict.${row.verdict}`)}`,
521
+ entity: row.evalId,
522
+ result: "",
523
+ duration: localeText(locale, "entityList.average", { value: cellText(row.duration) }),
524
+ cost: localeText(locale, "entityList.average", { value: cellText(row.cost) }),
525
+ },
526
+ };
527
+ const attempts: TableRow[] = row.attempts.map((attempt, index) => ({
528
+ key: attempt.locator,
529
+ cells: {
530
+ status: ` ${verdictMark(attempt.verdict)}`,
531
+ entity: `${index === row.attempts.length - 1 ? "└─" : "├─"} ${attempt.locator}`,
532
+ result: attemptItemReason(attempt, resultBudget) ?? MISSING_MARK,
533
+ duration: attempt.verdict === "skipped" && attempt.durationMs === 0 ? null : formatDurationMs(attempt.durationMs),
534
+ cost: attempt.costUSD === undefined ? null : formatUSD(attempt.costUSD),
535
+ },
536
+ }));
537
+ return [parent, ...attempts];
491
538
  });
492
- return blocks.join("\n\n");
539
+ const flags = item.flags && Object.keys(item.flags).length > 0
540
+ ? `${localeText(locale, "experimentList.flags")} ${Object.entries(item.flags).map(([key, value]) => `${key}=${String(value)}`).join(" · ")}`
541
+ : undefined;
542
+ return [experimentDisplayName(item.experimentId, relativeTo), flags, renderTableText({ columns, rows, locale }, ctx)]
543
+ .filter(Boolean)
544
+ .join("\n");
545
+ }
546
+
547
+ export function experimentListText(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
548
+ if (items.length === 0) return localeText(ctx.locale, "attemptList.empty");
549
+ return [
550
+ experimentSummaryTable(items, ctx, relativeTo),
551
+ ...items.map((item) => experimentDetailTable(item, ctx, relativeTo)),
552
+ ].join("\n\n");
493
553
  }
494
554
 
495
555
  // ── EvalList ──
496
556
 
497
- function evalListAttemptLine(item: AttemptListItem): string {
498
- const reason = attemptItemReason(item);
557
+ function evalListAttemptLine(item: AttemptListItem, ctx: TextContext): string {
558
+ // 行式列表同守「Result 最多两行」:预算 = 两行终端宽,超出由 fit 投影按优先级让位。
559
+ const reason = attemptItemReason(item, Math.max(24, ctx.width * 2 - stringWidth(locatorBadge(item)) - 6));
499
560
  return ` ${locatorBadge(item)}${reason ? ` · ${reason}` : ""}`;
500
561
  }
501
562
 
@@ -507,10 +568,14 @@ export function evalListText(items: EvalListItem[], ctx: TextContext): string {
507
568
  const summary = [
508
569
  localeText(locale, "attemptList.score", { score: cellText(item.score) }),
509
570
  localeText(locale, "overview.attemptsCount", { n: item.attempts.length }),
510
- `${formatDurationMs(item.duration.value ?? 0)} avg`,
511
- item.cost.value === null ? `${missingText(locale)} avg` : `${formatUSD(item.cost.value)} avg`,
571
+ localeText(locale, "entityList.average", {
572
+ value: item.duration.value === null ? missingText(locale) : formatDurationMs(item.duration.value),
573
+ }),
574
+ localeText(locale, "entityList.average", {
575
+ value: item.cost.value === null ? missingText(locale) : formatUSD(item.cost.value),
576
+ }),
512
577
  ].join(" · ");
513
- const attemptLines = item.attempts.map(evalListAttemptLine);
578
+ const attemptLines = item.attempts.map((attempt) => evalListAttemptLine(attempt, ctx));
514
579
  return [identity, ` ${summary}`, ...attemptLines].join("\n");
515
580
  });
516
581
  return blocks.join("\n\n");
@@ -518,46 +583,25 @@ export function evalListText(items: EvalListItem[], ctx: TextContext): string {
518
583
 
519
584
  // ── AttemptList ──
520
585
 
521
- /** 一个 AttemptListItem 的完整 text 卡片:判定符 + locator + 身份 + 耗时/成本 + 证据能力,
522
- * 然后逐条断言(gate soft 都列,与 web 面的 AttemptRow 同一份材料)。 */
523
- function attemptListItemText(item: AttemptListItem, ctx: TextContext, locale: ReportLocale): string {
586
+ /** Attempt 比较卡片:只显示一条主失败摘要(至多两行终端宽);完整 assertions locator 下钻。 */
587
+ function attemptListItemText(item: AttemptListItem, ctx: TextContext): string {
524
588
  const head = [
525
589
  `${verdictMark(item.verdict)} ${item.locator}`,
526
590
  item.evalId,
527
591
  item.experimentId,
528
592
  formatDurationMs(item.durationMs),
529
593
  ...(item.costUSD !== undefined ? [formatUSD(item.costUSD)] : []),
530
- ...(capabilityBadge(item.capabilities) ? [capabilityBadge(item.capabilities)] : []),
531
594
  ].join(" · ");
532
595
  const lines = [head];
533
- if (item.error) {
534
- lines.push(indentBlock(wrapDisplay(item.error, ctx.width - 4).join("\n"), " "));
535
- }
536
- for (const assertion of item.assertions) {
537
- const scoreText =
538
- assertion.threshold !== undefined
539
- ? `${formatPlainNumber(assertion.score)}/${formatPlainNumber(assertion.threshold)}`
540
- : formatPlainNumber(assertion.score);
541
- lines.push(
542
- ` ${assertion.severity} ${assertion.name} · ${localeText(locale, `verdict.${assertion.passed ? "passed" : "failed"}`)}${assertion.severity === "soft" ? ` ${scoreText}` : ""}`,
543
- );
544
- if (assertion.detail) lines.push(indentBlock(wrapDisplay(assertion.detail, ctx.width - 4).join("\n"), " "));
545
- if (assertion.evidence) {
546
- const limit = Math.max(240, ctx.width * 5);
547
- const evidence =
548
- assertion.evidence.length <= limit
549
- ? assertion.evidence
550
- : `${assertion.evidence.slice(0, limit)}… (${assertion.evidence.length - limit} more chars; open ${item.locator} for full evidence)`;
551
- lines.push(indentBlock(wrapDisplay(evidence, ctx.width - 6).join("\n"), " "));
552
- }
553
- }
596
+ const reason = attemptItemReason(item, Math.max(24, ctx.width * 2 - 4));
597
+ if (reason) lines.push(` ${reason}`);
554
598
  return lines.join("\n");
555
599
  }
556
600
 
557
601
  export function attemptListText(items: AttemptListItem[], total: number | undefined, ctx: TextContext): string {
558
602
  const locale = ctx.locale;
559
603
  if (items.length === 0) return localeText(locale, "attemptList.empty");
560
- const blocks = items.map((item) => attemptListItemText(item, ctx, locale));
604
+ const blocks = items.map((item) => attemptListItemText(item, ctx));
561
605
  const remaining = (total ?? items.length) - items.length;
562
606
  if (remaining > 0) blocks.push(localeText(locale, "attemptList.truncatedText", { n: remaining }));
563
607
  return blocks.join("\n\n");
@@ -9,7 +9,7 @@
9
9
  import type { TableProps, TableRow } from "../primitives.tsx";
10
10
  import type { TextContext } from "../tree.ts";
11
11
  import { countText, localeText } from "../locale.ts";
12
- import { renderAlignedRows, stringWidth, wrapDisplay, type ColumnAlign } from "./layout.ts";
12
+ import { charDisplayWidth, renderAlignedRows, stringWidth, wrapDisplay, type ColumnAlign } from "./layout.ts";
13
13
 
14
14
  const MISSING_MARK = "—";
15
15
  /** 列间距,与 renderAlignedRows 的 join(" ") 一致。 */
@@ -56,12 +56,43 @@ function fitWidths(
56
56
  return { widths, hidden };
57
57
  }
58
58
 
59
- /** 逻辑行 物理行:每格折到自己的列宽,行高取最高的那格,矮格补空串。 */
60
- function toPhysicalRows(cells: readonly string[], widths: readonly number[]): string[][] {
59
+ /** 按显示宽度截到 width(不够放 `…` 时原样返回)。 */
60
+ function truncateDisplay(text: string, width: number): string {
61
+ if (stringWidth(text) <= width) return text;
62
+ let out = "";
63
+ let used = 0;
64
+ for (const ch of text) {
65
+ const w = charDisplayWidth(ch.codePointAt(0)!);
66
+ if (used + w > width) break;
67
+ out += ch;
68
+ used += w;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** 列的 maxLines 收口:超出的行丢弃,最后一行以 `…` 如实标注被收口。 */
74
+ function clampCellLines(lines: string[], maxLines: number | undefined, width: number): string[] {
75
+ if (maxLines === undefined || lines.length <= maxLines) return lines;
76
+ const kept = lines.slice(0, Math.max(1, maxLines));
77
+ const last = kept[kept.length - 1]!;
78
+ kept[kept.length - 1] = `${truncateDisplay(last, Math.max(1, width - 1))}…`;
79
+ return kept;
80
+ }
81
+
82
+ /** 逻辑行 → 物理行:每格折到自己的列宽,列带 maxLines 就收口,行高取最高的那格,矮格补空串。 */
83
+ function toPhysicalRows(
84
+ cells: readonly string[],
85
+ widths: readonly number[],
86
+ maxLines: readonly (number | undefined)[],
87
+ ): string[][] {
61
88
  const wrapped = cells.map((cell, c) =>
62
89
  // 放得下就原样保留:wrapDisplay 会把连续空格折成一个,而 DeltaTable 的
63
90
  // "50% → 62% +12pp" 这类格子内含固定间距 —— 不折行时一个字节都不能动。
64
- !cell.includes("\n") && stringWidth(cell) <= widths[c] ? [cell] : wrapDisplay(cell, widths[c]),
91
+ clampCellLines(
92
+ !cell.includes("\n") && stringWidth(cell) <= widths[c] ? [cell] : wrapDisplay(cell, widths[c]),
93
+ maxLines[c],
94
+ widths[c]!,
95
+ ),
65
96
  );
66
97
  const height = Math.max(...wrapped.map((lines) => lines.length), 1);
67
98
  const out: string[][] = [];
@@ -92,7 +123,13 @@ export function renderTableText(props: TableProps, ctx: TextContext): string {
92
123
  const natural = header.map((_, c) => Math.max(...matrix.map((row) => stringWidth(row[c] ?? ""))));
93
124
  const { widths, hidden } = fitWidths(natural, align, ctx.width);
94
125
 
95
- const physical = matrix.flatMap((row) => toPhysicalRows(row.slice(0, widths.length), widths));
126
+ const maxLines: (number | undefined)[] = props.columns.map((column) => column.maxLines);
127
+ if (hasLocator) maxLines.push(undefined);
128
+ // 表头不参与 maxLines 收口:表头是自己写的短词,收口只服务数据格。
129
+ const physical = [
130
+ ...toPhysicalRows(header.slice(0, widths.length), widths, widths.map(() => undefined)),
131
+ ...body.flatMap((row) => toPhysicalRows(row.slice(0, widths.length), widths, maxLines)),
132
+ ];
96
133
  const table = renderAlignedRows(physical, align);
97
134
  return hidden > 0 ? `${table}\n${countText(locale, "table.columnsHidden", hidden)}` : table;
98
135
  }
@@ -4,12 +4,11 @@
4
4
 
5
5
  import type { AttemptHandle, SelectionWarning } from "../results/types.ts";
6
6
  import type { AttemptLocator } from "../results/locator.ts";
7
- import type { AttemptEvidenceCapabilities } from "../results/attempt-evidence.ts";
8
- import type { AssertionResult, Verdict } from "../types.ts";
7
+ import type { AssertionResult, AttemptError, DiagnosticRecord, Verdict } from "../types.ts";
9
8
  import type { LocalizedLabel, ReportLocale } from "./locale.ts";
10
9
 
11
10
  export type { SelectionWarning };
12
- export type { AttemptLocator, AttemptEvidenceCapabilities };
11
+ export type { AttemptLocator };
13
12
  export type { LocalizedLabel, ReportLocale };
14
13
 
15
14
  // ───────────────────────── 指标与聚合 ─────────────────────────
@@ -32,7 +31,7 @@ export interface MetricAggregate {
32
31
  * 指标:纯函数,吃一个 AttemptHandle 吐一个值(null = 此 attempt 测不了这个指标,
33
32
  * 不进聚合;0 = 测了结果是零,照常进),外加名字、两级聚合方式和渲染提示。
34
33
  * 内置指标与自定义指标是同一个类型,没有特权。name 走字面量泛型:列键锚在指标
35
- * 对象上(`row.cells[passRate.name]`),拼错列名编译不过。
34
+ * 对象上(`row.cells[taskPassRate.name]`),拼错列名编译不过。
36
35
  */
37
36
  export interface Metric<Name extends string = string> {
38
37
  /** MetricColumn.key 与列头的来源;同一次计算里重名是错误。 */
@@ -87,8 +86,24 @@ export interface FlagRef {
87
86
  readonly unit?: string;
88
87
  }
89
88
 
90
- /** 维度槽的输入:内置/自定义维度,或 experiment 声明的 flag。 */
91
- export type DimensionInput = Dimension | FlagRef;
89
+ /**
90
+ * config() 的产物:把顶层运行配置(快照 `ExperimentRunInfo` 投影的字段全集,外加桥接到
91
+ * 快照顶层权威字段的 `model` / `agent` 两个键)当维度或轴,槽位用法与 {@link FlagRef} 一致。
92
+ * 未投影的值不猜:分组如实归「(unset)」,作轴不画点、注脚报数。
93
+ */
94
+ export interface ConfigRef {
95
+ readonly kind: "config";
96
+ readonly name: string;
97
+ /** 组标签 / 轴标签;函数形态把投影值折成组名。 */
98
+ readonly label?: string | ((value: string | number | boolean) => string);
99
+ readonly unit?: string;
100
+ }
101
+
102
+ /** MetricLine 的 x 轴输入:experiment 声明的 flag,或顶层运行配置(config())。 */
103
+ export type AxisInput = FlagRef | ConfigRef;
104
+
105
+ /** 维度槽的输入:内置/自定义维度、experiment 声明的 flag,或顶层运行配置(config())。 */
106
+ export type DimensionInput = Dimension | FlagRef | ConfigRef;
92
107
 
93
108
  // ───────────────────────── 计算产物(组件 data props)─────────────────────────
94
109
 
@@ -266,7 +281,7 @@ export interface GroupSummaryData {
266
281
  /**
267
282
  * 组的通过率:eval 级折叠计票的 `passed / (passed + failed + errored)`(`skipped` 不进
268
283
  * 分母)——这是旧 `GroupSelector` 卡片的口径,不是 `OverviewData.totals.passRate` 那种
269
- * `computeCell` 两级聚合(两者服务不同问题:「这组题多少算过」vs「整体质量几分」)。
284
+ * `computeCell` 两级聚合(两者服务不同问题:「这组题多少算过」vs「每次运行成功多少」)。
270
285
  * 分母为 0(组内没有任何已跑的 eval)时 `value` 为 `null`,不编 0%。
271
286
  */
272
287
  passRate: MetricCell;
@@ -290,10 +305,11 @@ export interface OverviewData {
290
305
  errored: number;
291
306
  skipped: number;
292
307
  /**
293
- * 通过率的唯一官方口径:`computeCell(passRate, items)`,与 `MetricTable.data(...,
294
- * columns: [passRate])` 同一台两级聚合引擎(题内折叠 perEval、跨题折叠 across,默认都是
308
+ * 默认成功率的唯一官方口径:`computeCell(endToEndPassRate, items)`,与 `MetricTable.data(...,
309
+ * columns: [endToEndPassRate])` 同一台两级聚合引擎(题内折叠 perEval、跨题折叠 across,默认都是
295
310
  * mean)——一道题内多个 attempt 部分通过,贡献的是小数份额而不是二元票。`samples`/`total`
296
- * 是两级聚合口径下的 attempt 计数(`total` 含 skipped,`samples` 不含),不等于上面四个
311
+ * 是两级聚合口径下的 attempt 计数(`total` 含 skipped 与 errored——endToEndPassRate 对
312
+ * errored 记 0、只对 skipped 记 null,`samples` 因此只不含 skipped),不等于上面四个
297
313
  * verdict 计票的任何一个之和。
298
314
  */
299
315
  passRate: MetricCell;
@@ -336,10 +352,11 @@ export interface DeltaData<K extends string = string> {
336
352
  // `EvalListItem.attempts` 的元素,报告作者可以直接把这些嵌套数组喂给 `<AttemptList items={...} />`。
337
353
 
338
354
  /**
339
- * `AttemptList` 一项 = 一个 Attempt:身份、判定、断言、error、耗时、成本、locator,外加
340
- * 证据能力标记(有没有保存的 Eval 源码 / 执行事件 / OTel 计时 / diff——与 `AttemptEvidence.capabilities`
341
- * 同一个类型,不重新发明四个布尔位的含义)。`ExperimentList` / `EvalList` 的下钻数组复用同一个
342
- * 类型,不是各自的精简版。
355
+ * `AttemptList` 一项 = 一个 Attempt:身份、判定、断言、结构化 error、diagnostics、耗时、
356
+ * 成本和 locator。`ExperimentList` / `EvalList` 的下钻数组复用同一个类型,不是各自的精简版。
357
+ * 渲染面只显示一条主失败断言摘要或 error 的一层摘要;完整 assertions、cause / stack
358
+ * diagnostics 属于 locator 下钻详情,不塞进比较列表,但随数据携带 —— `AttemptList.data` 的 `redact`
359
+ * 钩子覆盖它们的自由文本(见 docs/feature/reports/library.md「AttemptList」)。
343
360
  */
344
361
  export interface AttemptListItem {
345
362
  evalId: string;
@@ -347,33 +364,25 @@ export interface AttemptListItem {
347
364
  attempt: number;
348
365
  agent: string;
349
366
  verdict: Verdict;
350
- error?: string;
367
+ /** 结构化执行错误(与 `EvalResult.error` 同构):列表只显示 `message` 一层摘要。 */
368
+ error?: AttemptError;
369
+ /** 本 attempt 的有界诊断(teardown / cleanup 失败等,与 verdict 独立);属于下钻详情。 */
370
+ diagnostics?: DiagnosticRecord[];
351
371
  assertions: AssertionResult[];
352
372
  durationMs: number;
353
373
  costUSD?: number;
354
374
  locator: AttemptLocator;
355
- /**
356
- * 证据能力标记:`eval`(运行时 Eval 源码已保存)、`execution`(执行事件非空)、
357
- * `timing`(执行事件 + 这次运行接入过 OTel)、`diff`(工作区有文件改动)。逐位定义与
358
- * `AttemptEvidence.capabilities` 完全一致——这里复用同一个类型,不重复声明四个布尔位的门槛。
359
- * 计算函数从 `EvalResult` 上已有的 `hasEvents` / `hasSources` / `hasTrace` 摘要位与一次
360
- * `attempt.diff()` 懒加载算出,不对每个 item 调用完整的 `loadAttemptEvidence`
361
- * (那还会额外装配 Eval 源码标注与 ExecutionTree,这里只要四个布尔位)。
362
- */
363
- capabilities: AttemptEvidenceCapabilities;
364
375
  }
365
376
 
366
377
  /**
367
- * `ExperimentList` 一项里,一个 Eval 的展开行:折叠判定(`foldEvalVerdict`)、失败原因摘要
368
- * (`error` `skipReason` → 未通过的 gate 断言,`reasonFor` 的口径,soft 断言永不进入)
369
- * 该 Eval 内 attempt 的平均耗时/成本(两级聚合引擎在单一 eval 上退化成组内均值),以及这道题
370
- * 的全部 Attempt(升序,供进一步展开到 `AttemptList`)。
378
+ * `ExperimentList` 一项里,一个 Eval 的展开行:折叠判定(`foldEvalVerdict`)、该 Eval 内
379
+ * attempt 的平均耗时/成本(两级聚合引擎在单一 eval 上退化成组内均值),以及这道题的全部
380
+ * Attempt(升序,供进一步展开到 `AttemptList`)。失败原因属于各 Attempt,不在父行挑一轮重复。
371
381
  */
372
382
  export interface ExperimentListEvalRow {
373
383
  evalId: string;
374
384
  /** 折叠判定(任一 attempt 通过则通过,否则取最严重的)。 */
375
385
  verdict: Verdict;
376
- reason?: string;
377
386
  /** 这道题内 attempt 的平均耗时(`computeCell(durationMs, …)`,单一 eval 分组下即均值)。 */
378
387
  duration: MetricCell;
379
388
  /** 这道题内 attempt 的平均成本。 */
@@ -385,7 +394,7 @@ export interface ExperimentListEvalRow {
385
394
  /**
386
395
  * `ExperimentList.data(selection)` 的一项 = 一个 experiment:身份(experimentId/agent/model)、
387
396
  * 声明的 flags、Eval 判定构成(`foldEvalVerdict` 计票,与 view 榜单同一口径)、官方两级聚合
388
- * 汇总指标(passRate/cost/duration/tokens,直接来自 `computeCell`,不现场重算),以及展开到
397
+ * 汇总指标(endToEndPassRate/cost/duration/tokens,直接来自 `computeCell`,不现场重算),以及展开到
389
398
  * 这个 experiment 每道 Eval 的 `evalRows`(按 eval id 升序)。
390
399
  */
391
400
  export interface ExperimentListItem {
@@ -395,7 +404,7 @@ export interface ExperimentListItem {
395
404
  flags?: Record<string, unknown>;
396
405
  /** eval 级折叠计票(foldEvalVerdict 口径,与 `TableRowMeta.verdicts`、view 榜单同一套)。 */
397
406
  verdicts: { passed: number; failed: number; errored: number; skipped: number };
398
- /** 官方两级聚合口径,与 `MetricTable.data(..., columns: [passRate])` 同一台引擎。 */
407
+ /** 官方两级聚合口径(endToEndPassRate),与 `MetricTable.data(..., columns: [endToEndPassRate])` 同一台引擎。 */
399
408
  passRate: MetricCell;
400
409
  cost: MetricCell;
401
410
  duration: MetricCell;
@@ -413,14 +422,13 @@ export interface ExperimentListItem {
413
422
  /**
414
423
  * `EvalList.data(selection)` 的一项 = 一个 `experimentId + evalId`(同一个 Eval 跑在两个
415
424
  * experiment 上是两条不同结果,不合并)。判定、分数(examScore 的两级聚合)、这道题内 attempt
416
- * 的平均耗时/成本,失败原因摘要(与 `ExperimentListEvalRow.reason` 同一口径),外加展开到这道题
417
- * 全部 Attempt`attempts`(按 attempt 序号升序)。
425
+ * 的平均耗时/成本,外加展开到这道题全部 Attempt 的 `attempts`(按 attempt 序号升序)。失败
426
+ * 原因只存在于各 `AttemptListItem`error / assertions,不会在 Eval 父项重复一份。
418
427
  */
419
428
  export interface EvalListItem {
420
429
  evalId: string;
421
430
  experimentId: string;
422
431
  verdict: Verdict;
423
- reason?: string;
424
432
  /** examScore 的两级聚合;单一 eval 分组下即这道题的题级分数。 */
425
433
  score: MetricCell;
426
434
  duration: MetricCell;
@@ -1,10 +1,11 @@
1
+ // cases: docs/engineering/unit-tests/results/cases.md
1
2
  // buildAnnotatedEvalSource 的单测(定稿见 docs/concepts.md「标注 Eval 源码」)。
2
3
  // 覆盖:同一行多条断言、无 loc 断言进 unmapped、loc 指向别的文件/越界行也进 unmapped
3
4
  // (never silently dropped 的边界情况)、空断言数组、summary 计数、哈希与归一化行为。
4
5
 
5
6
  import { describe, expect, it } from "vitest";
6
- import type { AssertionResult } from "../types.ts";
7
- import { buildAnnotatedEvalSource } from "./annotated-source.ts";
7
+ import type { AssertionResult, PhaseTiming, StreamEvent } from "../types.ts";
8
+ import { buildAnnotatedEvalSource, deriveSendAnnotations, type SendAnnotation } from "./annotated-source.ts";
8
9
  import { hashEvalSource, normalizeEvalSource } from "./source-hash.ts";
9
10
 
10
11
  const SOURCE_PATH = "evals/weather.eval.ts";
@@ -13,9 +14,9 @@ function assertion(over: Partial<AssertionResult> & Pick<AssertionResult, "name"
13
14
  return {
14
15
  severity: "soft",
15
16
  score: 1,
16
- passed: true,
17
+ outcome: "passed" as const,
17
18
  ...over,
18
- };
19
+ } as AssertionResult;
19
20
  }
20
21
 
21
22
  describe("buildAnnotatedEvalSource", () => {
@@ -94,10 +95,10 @@ describe("buildAnnotatedEvalSource", () => {
94
95
  it("computes summary counts across passed/failed and gate/soft, mapped and unmapped alike", () => {
95
96
  const content = "line 1\nline 2\n";
96
97
  const assertions = [
97
- assertion({ name: "a", severity: "gate", passed: true, loc: { file: SOURCE_PATH, line: 1 } }),
98
- assertion({ name: "b", severity: "gate", passed: false, loc: { file: SOURCE_PATH, line: 1 } }),
99
- assertion({ name: "c", severity: "soft", passed: true, loc: { file: SOURCE_PATH, line: 2 } }),
100
- assertion({ name: "d", severity: "soft", passed: false }), // unmapped
98
+ assertion({ name: "a", severity: "gate", outcome: "passed" as const, loc: { file: SOURCE_PATH, line: 1 } }),
99
+ assertion({ name: "b", severity: "gate", outcome: "failed" as const, loc: { file: SOURCE_PATH, line: 1 } }),
100
+ assertion({ name: "c", severity: "soft", outcome: "passed" as const, loc: { file: SOURCE_PATH, line: 2 } }),
101
+ assertion({ name: "d", severity: "soft", outcome: "failed" as const }), // unmapped
101
102
  ];
102
103
 
103
104
  const model = buildAnnotatedEvalSource({ path: SOURCE_PATH, content }, assertions);
@@ -129,7 +130,7 @@ describe("buildAnnotatedEvalSource", () => {
129
130
 
130
131
  it("treats an empty source file as a single empty line", () => {
131
132
  const model = buildAnnotatedEvalSource({ path: SOURCE_PATH, content: "" }, []);
132
- expect(model.lines).toEqual([{ line: 1, text: "", assertions: [] }]);
133
+ expect(model.lines).toEqual([{ line: 1, text: "", assertions: [], sends: [] }]);
133
134
  });
134
135
 
135
136
  it("sourceSha256 matches hashEvalSource(normalizeEvalSource(content)) and is stable across CRLF/LF", () => {
@@ -143,4 +144,56 @@ describe("buildAnnotatedEvalSource", () => {
143
144
  expect(modelLf.sourceSha256).toBe(modelCrlf.sourceSha256);
144
145
  expect(modelLf.lines.map((l) => l.text)).toEqual(modelCrlf.lines.map((l) => l.text));
145
146
  });
147
+
148
+ it("send 标注按 loc 落到对应行,一行多轮逐轮保留;别的文件或越界行直接丢(全量面在 --execution)", () => {
149
+ const content = "await t.send('a');\nawait t.send('b');\n";
150
+ const sends: SendAnnotation[] = [
151
+ { label: "s1/t1", status: "completed", durationMs: 1200, loc: { file: SOURCE_PATH, line: 1, column: 9 } },
152
+ { label: "s1/t2", status: "failed", durationMs: 800, loc: { file: SOURCE_PATH, line: 1, column: 9 } },
153
+ { label: "s1/t3", status: "completed", loc: { file: "evals/other.eval.ts", line: 1 } },
154
+ { label: "s1/t4", status: "completed", loc: { file: SOURCE_PATH, line: 99 } },
155
+ ];
156
+ const model = buildAnnotatedEvalSource({ path: SOURCE_PATH, content }, [], sends);
157
+ expect(model.lines[0]!.sends.map((s) => s.label)).toEqual(["s1/t1", "s1/t2"]);
158
+ expect(model.lines[1]!.sends).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("deriveSendAnnotations", () => {
163
+ it("第 i 条用户消息配第 i 个 turn 节点(与 --execution 分轮同一规则);无 loc 的轮不产出", () => {
164
+ const events: StreamEvent[] = [
165
+ { type: "message", role: "user", text: "first", loc: { file: SOURCE_PATH, line: 3, column: 5 } },
166
+ { type: "message", role: "assistant", text: "reply" },
167
+ { type: "message", role: "user", text: "second (no loc)" },
168
+ { type: "message", role: "user", text: "third", loc: { file: SOURCE_PATH, line: 9, column: 5 } },
169
+ ];
170
+ const phases: PhaseTiming[] = [{
171
+ name: "eval.run" as PhaseTiming["name"],
172
+ durationMs: 5000,
173
+ children: [
174
+ { id: "n1", kind: "command", label: "git", startOffsetMs: 0, durationMs: 10 },
175
+ { id: "n2", kind: "turn", label: "s1/t1", startOffsetMs: 10, durationMs: 1500 },
176
+ { id: "n3", kind: "turn", label: "s1/t2", startOffsetMs: 1510, durationMs: 900, failed: true },
177
+ { id: "n4", kind: "turn", label: "s1/t3", startOffsetMs: 2410, durationMs: 300 },
178
+ ],
179
+ }];
180
+
181
+ const sends = deriveSendAnnotations(events, phases);
182
+ expect(sends).toEqual([
183
+ { label: "s1/t1", status: "completed", durationMs: 1500, loc: { file: SOURCE_PATH, line: 3, column: 5 } },
184
+ // 第二条用户消息没有 loc → 不产出;第三条配第 3 个 turn 节点,不因跳过而错位
185
+ { label: "s1/t3", status: "completed", durationMs: 300, loc: { file: SOURCE_PATH, line: 9, column: 5 } },
186
+ ]);
187
+ });
188
+
189
+ it("时间树缺 turn 节点时回退 t<i> 标签、无墙钟;没有事件时为空", () => {
190
+ const events: StreamEvent[] = [
191
+ { type: "message", role: "user", text: "only", loc: { file: SOURCE_PATH, line: 1 } },
192
+ ];
193
+ expect(deriveSendAnnotations(events, undefined)).toEqual([
194
+ { label: "t1", status: "completed", loc: { file: SOURCE_PATH, line: 1 } },
195
+ ]);
196
+ expect(deriveSendAnnotations(null, undefined)).toEqual([]);
197
+ expect(deriveSendAnnotations([], undefined)).toEqual([]);
198
+ });
146
199
  });