niceeval 0.6.1 → 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 (296) hide show
  1. package/dist/agents/types.d.ts +67 -5
  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 -1
  9. package/dist/report/built-ins/experiment-comparison.js +116 -10
  10. package/dist/report/built-ins/index.d.ts +1 -0
  11. package/dist/report/built-ins/index.js +1 -1
  12. package/dist/report/components.d.ts +8 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +54 -34
  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 -8
  19. package/dist/report/format.js +27 -12
  20. package/dist/report/index.d.ts +4 -3
  21. package/dist/report/index.js +5 -4
  22. package/dist/report/locale.d.ts +11 -2
  23. package/dist/report/locale.js +23 -5
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +65 -14
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +2 -2
  28. package/dist/report/react/AttemptList.js +5 -6
  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 +2 -1
  34. package/dist/report/react/ExperimentList.js +8 -10
  35. package/dist/report/react/MetricScatter.js +5 -11
  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 +21 -14
  40. package/dist/report/report.d.ts +5 -1
  41. package/dist/report/report.js +6 -2
  42. package/dist/report/text/faces.d.ts +1 -1
  43. package/dist/report/text/faces.js +42 -41
  44. package/dist/report/text/table.js +36 -5
  45. package/dist/report/types.d.ts +39 -21
  46. package/dist/results/types.d.ts +11 -0
  47. package/dist/runner/feedback/sink.d.ts +110 -0
  48. package/dist/runner/types.d.ts +513 -22
  49. package/dist/sandbox/docker.d.ts +23 -2
  50. package/dist/sandbox/e2b.d.ts +15 -1
  51. package/dist/sandbox/errors.d.ts +30 -3
  52. package/dist/sandbox/io-retry.d.ts +17 -0
  53. package/dist/sandbox/registry.d.ts +2 -0
  54. package/dist/sandbox/resolve.d.ts +18 -5
  55. package/dist/sandbox/retry.d.ts +11 -1
  56. package/dist/sandbox/types.d.ts +39 -5
  57. package/dist/sandbox/vercel.d.ts +7 -1
  58. package/dist/scoring/coverage.d.ts +30 -0
  59. package/dist/scoring/display.d.ts +21 -0
  60. package/dist/scoring/display.js +120 -0
  61. package/dist/scoring/types.d.ts +103 -20
  62. package/dist/shared/aggregate.d.ts +1 -0
  63. package/dist/shared/aggregate.js +3 -3
  64. package/dist/shared/types.d.ts +28 -0
  65. package/dist/tty-line.d.ts +0 -4
  66. package/dist/util.d.ts +23 -0
  67. package/docs-site/zh/concepts/adapter.mdx +22 -4
  68. package/docs-site/zh/concepts/experiment.mdx +1 -1
  69. package/docs-site/zh/concepts/overview.mdx +6 -6
  70. package/docs-site/zh/guides/agent-feedback-loop.mdx +28 -26
  71. package/docs-site/zh/guides/authoring.mdx +33 -0
  72. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  73. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  74. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  75. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  76. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  77. package/docs-site/zh/guides/debugging.mdx +210 -0
  78. package/docs-site/zh/guides/experiments.mdx +10 -3
  79. package/docs-site/zh/guides/official-adapters.mdx +26 -2
  80. package/docs-site/zh/guides/publish-report.mdx +30 -16
  81. package/docs-site/zh/guides/report-components.mdx +42 -30
  82. package/docs-site/zh/guides/reporters.mdx +2 -2
  83. package/docs-site/zh/guides/results-data.mdx +17 -9
  84. package/docs-site/zh/guides/runner.mdx +17 -7
  85. package/docs-site/zh/guides/sandbox-agent.mdx +56 -7
  86. package/docs-site/zh/guides/sandbox-providers.mdx +257 -9
  87. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  88. package/docs-site/zh/guides/viewing-results.mdx +79 -36
  89. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  90. package/docs-site/zh/guides/write-send.mdx +17 -1
  91. package/docs-site/zh/index.mdx +1 -1
  92. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  93. package/docs-site/zh/reference/capabilities.mdx +2 -2
  94. package/docs-site/zh/reference/cli.mdx +33 -7
  95. package/docs-site/zh/reference/define-agent.mdx +57 -4
  96. package/docs-site/zh/reference/define-config.mdx +1 -1
  97. package/docs-site/zh/reference/define-eval.mdx +42 -9
  98. package/docs-site/zh/reference/expect.mdx +26 -1
  99. package/package.json +5 -1
  100. package/src/agents/ai-sdk-otel.test.ts +1 -0
  101. package/src/agents/ai-sdk.test.ts +3 -0
  102. package/src/agents/ai-sdk.ts +3 -0
  103. package/src/agents/bub-install-spec.test.ts +34 -0
  104. package/src/agents/bub-install-spec.ts +32 -0
  105. package/src/agents/bub.ts +31 -32
  106. package/src/agents/claude-code.test.ts +130 -9
  107. package/src/agents/claude-code.ts +76 -4
  108. package/src/agents/codex.test.ts +189 -40
  109. package/src/agents/codex.ts +155 -14
  110. package/src/agents/coding-cli-versions.test.ts +15 -0
  111. package/src/agents/coding-cli-versions.ts +3 -0
  112. package/src/agents/index.ts +11 -0
  113. package/src/agents/langgraph.test.ts +204 -0
  114. package/src/agents/langgraph.ts +495 -0
  115. package/src/agents/marketplace.ts +85 -0
  116. package/src/agents/native-config.test.ts +179 -0
  117. package/src/agents/native-config.ts +267 -0
  118. package/src/agents/openai-compat.test.ts +1 -0
  119. package/src/agents/openclaw.test.ts +31 -0
  120. package/src/agents/openclaw.ts +171 -0
  121. package/src/agents/plugin-config.test.ts +1 -0
  122. package/src/agents/sdk-streams.test.ts +79 -0
  123. package/src/agents/sdk-streams.ts +55 -10
  124. package/src/agents/skills.test.ts +1 -0
  125. package/src/agents/streaming.test.ts +3 -9
  126. package/src/agents/types.ts +68 -5
  127. package/src/agents/ui-message-stream.test.ts +3 -0
  128. package/src/cli.ts +411 -108
  129. package/src/context/context.test.ts +51 -12
  130. package/src/context/context.ts +161 -29
  131. package/src/context/session.test.ts +1 -0
  132. package/src/context/session.ts +114 -6
  133. package/src/context/types.ts +30 -12
  134. package/src/define.test.ts +13 -8
  135. package/src/define.ts +25 -4
  136. package/src/expect/index.ts +53 -23
  137. package/src/i18n/en.ts +64 -2
  138. package/src/i18n/zh-CN.ts +65 -3
  139. package/src/o11y/cost.test.ts +1 -0
  140. package/src/o11y/execution-tree.test.ts +1 -20
  141. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  142. package/src/o11y/otlp/parse.test.ts +1 -0
  143. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  144. package/src/o11y/parsers/bub.test.ts +1 -0
  145. package/src/o11y/parsers/claude-code.test.ts +1 -34
  146. package/src/o11y/parsers/openclaw.test.ts +154 -0
  147. package/src/o11y/parsers/openclaw.ts +310 -0
  148. package/src/o11y/prices.json +746 -311
  149. package/src/o11y/tool-names.test.ts +1 -0
  150. package/src/o11y/types.ts +16 -2
  151. package/src/report/aggregate.ts +34 -5
  152. package/src/report/built-in-user-parity.test.tsx +110 -153
  153. package/src/report/built-ins/experiment-comparison.tsx +173 -13
  154. package/src/report/built-ins/index.ts +6 -1
  155. package/src/report/components.tsx +9 -3
  156. package/src/report/compute.ts +70 -40
  157. package/src/report/dual-render.test.tsx +194 -67
  158. package/src/report/flag.ts +30 -2
  159. package/src/report/format.ts +35 -11
  160. package/src/report/index.ts +22 -4
  161. package/src/report/locale.ts +25 -5
  162. package/src/report/metrics.ts +67 -14
  163. package/src/report/primitives.tsx +6 -0
  164. package/src/report/react/AttemptList.tsx +6 -31
  165. package/src/report/react/EvalList.tsx +0 -0
  166. package/src/report/react/ExperimentComparison.tsx +68 -0
  167. package/src/report/react/ExperimentList.tsx +15 -9
  168. package/src/report/react/MetricScatter.tsx +12 -14
  169. package/src/report/react/chart-math.test.ts +85 -0
  170. package/src/report/react/chart-math.ts +101 -22
  171. package/src/report/react/enhance.js +33 -1
  172. package/src/report/react/fixtures.ts +24 -17
  173. package/src/report/react/render.test.tsx +9 -64
  174. package/src/report/react/styles.css +73 -2
  175. package/src/report/report.test.ts +306 -98
  176. package/src/report/report.ts +6 -2
  177. package/src/report/text/faces.ts +47 -43
  178. package/src/report/text/table.ts +42 -5
  179. package/src/report/types.ts +41 -21
  180. package/src/results/annotated-source.test.ts +62 -9
  181. package/src/results/annotated-source.ts +64 -6
  182. package/src/results/attempt-evidence.test.ts +9 -7
  183. package/src/results/attempt-evidence.ts +15 -8
  184. package/src/results/attempt-source.ts +6 -3
  185. package/src/results/copy.ts +145 -55
  186. package/src/results/host-equivalence.test.ts +8 -6
  187. package/src/results/index.ts +2 -0
  188. package/src/results/locator.test.ts +1 -22
  189. package/src/results/open.ts +7 -1
  190. package/src/results/publish.ts +149 -0
  191. package/src/results/results.test.ts +85 -51
  192. package/src/results/truncate.ts +90 -0
  193. package/src/results/types.ts +7 -0
  194. package/src/results/writer.ts +31 -13
  195. package/src/runner/attempt.test.ts +138 -7
  196. package/src/runner/attempt.ts +603 -104
  197. package/src/runner/discover.test.ts +47 -0
  198. package/src/runner/discover.ts +36 -2
  199. package/src/runner/eval-source.test.ts +1 -27
  200. package/src/runner/feedback/agent.test.ts +504 -0
  201. package/src/runner/feedback/agent.ts +409 -0
  202. package/src/runner/feedback/ci.test.ts +562 -0
  203. package/src/runner/feedback/ci.ts +401 -0
  204. package/src/runner/feedback/coordinator.test.ts +317 -0
  205. package/src/runner/feedback/coordinator.ts +397 -0
  206. package/src/runner/feedback/failure.ts +40 -0
  207. package/src/runner/feedback/human.test.ts +616 -0
  208. package/src/runner/feedback/human.ts +535 -0
  209. package/src/runner/feedback/index.ts +66 -0
  210. package/src/runner/feedback/io.ts +78 -0
  211. package/src/runner/feedback/profile.test.ts +50 -0
  212. package/src/runner/feedback/profile.ts +58 -0
  213. package/src/runner/feedback/reducer.test.ts +395 -0
  214. package/src/runner/feedback/reducer.ts +260 -0
  215. package/src/runner/feedback/renderer.ts +82 -0
  216. package/src/runner/feedback/sink.ts +203 -0
  217. package/src/runner/feedback/testing.ts +106 -0
  218. package/src/runner/ledger.test.ts +230 -0
  219. package/src/runner/ledger.ts +329 -0
  220. package/src/runner/report.test.ts +128 -3
  221. package/src/runner/report.ts +33 -9
  222. package/src/runner/reporters/artifacts.ts +8 -2
  223. package/src/runner/reporters/braintrust.test.ts +8 -7
  224. package/src/runner/reporters/braintrust.ts +9 -2
  225. package/src/runner/reporters/index.ts +2 -2
  226. package/src/runner/reporters/json.test.ts +162 -0
  227. package/src/runner/reporters/json.ts +35 -8
  228. package/src/runner/reporters/shared.ts +1 -5
  229. package/src/runner/run.test.ts +760 -3
  230. package/src/runner/run.ts +242 -36
  231. package/src/runner/sandbox-prep.ts +3 -42
  232. package/src/runner/timing.ts +158 -0
  233. package/src/runner/types.ts +518 -22
  234. package/src/sandbox/checkpoint.test.ts +55 -0
  235. package/src/sandbox/checkpoint.ts +29 -8
  236. package/src/sandbox/cli-commands.ts +407 -0
  237. package/src/sandbox/docker.ts +115 -16
  238. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  239. package/src/sandbox/e2b-agent-template.ts +94 -0
  240. package/src/sandbox/e2b.ts +74 -9
  241. package/src/sandbox/errors.ts +111 -4
  242. package/src/sandbox/index.ts +2 -0
  243. package/src/sandbox/io-retry.test.ts +58 -0
  244. package/src/sandbox/io-retry.ts +45 -0
  245. package/src/sandbox/keep-registry.test.ts +86 -0
  246. package/src/sandbox/keep-registry.ts +142 -0
  247. package/src/sandbox/keep.ts +178 -0
  248. package/src/sandbox/paths.test.ts +1 -0
  249. package/src/sandbox/paths.ts +19 -8
  250. package/src/sandbox/registry.ts +20 -3
  251. package/src/sandbox/resolve.ts +76 -11
  252. package/src/sandbox/retry.test.ts +70 -0
  253. package/src/sandbox/retry.ts +46 -4
  254. package/src/sandbox/types.ts +44 -6
  255. package/src/sandbox/vercel.ts +43 -20
  256. package/src/scoring/collector.ts +60 -17
  257. package/src/scoring/coverage.ts +95 -0
  258. package/src/scoring/diff.ts +81 -0
  259. package/src/scoring/display.test.ts +121 -0
  260. package/src/scoring/display.ts +133 -0
  261. package/src/scoring/evidence.test.ts +189 -0
  262. package/src/scoring/judge.test.ts +142 -0
  263. package/src/scoring/judge.ts +15 -18
  264. package/src/scoring/scoped.ts +217 -50
  265. package/src/scoring/types.ts +117 -20
  266. package/src/scoring/verdict.ts +16 -4
  267. package/src/shared/aggregate.ts +3 -2
  268. package/src/shared/types.ts +31 -0
  269. package/src/show/compose.ts +2 -2
  270. package/src/show/index.ts +21 -1
  271. package/src/show/render.ts +619 -104
  272. package/src/show/show.test.ts +235 -19
  273. package/src/tty-line.ts +8 -26
  274. package/src/util.test.ts +1 -0
  275. package/src/util.ts +41 -0
  276. package/src/view/app/components/AttemptModal.tsx +153 -2
  277. package/src/view/app/components/CodeView.tsx +32 -11
  278. package/src/view/app/components/CopyControls.tsx +2 -2
  279. package/src/view/app/i18n.ts +6 -0
  280. package/src/view/app/lib/attempt-route.test.ts +1 -0
  281. package/src/view/app/lib/verdict.ts +7 -9
  282. package/src/view/artifact-serving.test.ts +2 -1
  283. package/src/view/client-dist/app.css +1 -1
  284. package/src/view/client-dist/app.js +17 -17
  285. package/src/view/data.test.ts +1 -0
  286. package/src/view/data.ts +11 -1
  287. package/src/view/index.ts +11 -0
  288. package/src/view/server.ts +2 -0
  289. package/src/view/styles.css +3 -0
  290. package/src/view/view-report.test.ts +6 -5
  291. package/src/runner/reporters/console.ts +0 -70
  292. package/src/runner/reporters/live.test.ts +0 -56
  293. package/src/runner/reporters/live.ts +0 -247
  294. package/src/runner/reporters/quiet.test.ts +0 -66
  295. package/src/runner/reporters/quiet.ts +0 -49
  296. 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"
@@ -23,6 +23,7 @@ import type { TextContext } from "../tree.ts";
23
23
  import type { TableColumn, TableRow } from "../primitives.tsx";
24
24
  import {
25
25
  attemptItemReason,
26
+ experimentDisplayName,
26
27
  formatDurationMs,
27
28
  formatMetricValue,
28
29
  formatPlainNumber,
@@ -30,7 +31,7 @@ import {
30
31
  verdictMark,
31
32
  } from "../format.ts";
32
33
  import { countText, localeText, resolveMetricLabel, type ReportLocale } from "../locale.ts";
33
- import { indentBlock, padDisplay, textBar, wrapDisplay } from "./layout.ts";
34
+ import { indentBlock, padDisplay, stringWidth, textBar, wrapDisplay } from "./layout.ts";
34
35
  import { renderTableText } from "./table.ts";
35
36
  import { renderCharPlot, renderCoordinateTable, type PlotPoint } from "./plot.ts";
36
37
 
@@ -447,7 +448,7 @@ export function deltaText(data: DeltaData, ctx: TextContext): string {
447
448
  // 三面共用的紧凑标记:`locator✓`(判定符紧跟 locator,中间不留空格)。
448
449
  // ExperimentList / EvalList 逐 attempt 只列这一个标记 + 各自的原因/耗时摘要,不重复整段
449
450
  // niceeval show 命令;要看某个 attempt 的完整证据,agent 自己拼 `niceeval show <locator>`——
450
- // 命令模板只在 AttemptList(叶子层)展示完整断言明细时才值得,不在中间层重复。
451
+ // 比较列表都不内联完整断言或命令模板。
451
452
 
452
453
  function locatorBadge(item: { locator: string; verdict: AttemptListItem["verdict"] }): string {
453
454
  return `${item.locator}${verdictMark(item.verdict)}`;
@@ -455,7 +456,7 @@ function locatorBadge(item: { locator: string; verdict: AttemptListItem["verdict
455
456
 
456
457
  // ── ExperimentList ──
457
458
 
458
- function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext): string {
459
+ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
459
460
  const locale = ctx.locale;
460
461
  const compact = ctx.width < 100;
461
462
  const columns: TableColumn[] = [
@@ -471,7 +472,7 @@ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext):
471
472
  const rows: TableRow[] = items.map((item) => ({
472
473
  key: item.experimentId,
473
474
  cells: {
474
- experiment: item.experimentId,
475
+ experiment: experimentDisplayName(item.experimentId, relativeTo),
475
476
  model: item.model ?? localeText(locale, "experimentList.defaultModel"),
476
477
  agent: item.agent,
477
478
  duration: cellText(item.duration),
@@ -483,22 +484,35 @@ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext):
483
484
  }));
484
485
  const metadata = items.flatMap((item) =>
485
486
  wrapDisplay(
486
- `${item.experimentId}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
487
+ `${experimentDisplayName(item.experimentId, relativeTo)}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
487
488
  Math.max(8, ctx.width - 2),
488
489
  ).map((line) => ` ${line}`),
489
490
  );
490
491
  return [renderTableText({ columns, rows, locale }, ctx), metadata.join("\n")].join("\n");
491
492
  }
492
493
 
493
- function experimentDetailTable(item: ExperimentListItem, ctx: TextContext): string {
494
+ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relativeTo?: string): string {
494
495
  const locale = ctx.locale;
495
496
  const columns: TableColumn[] = [
496
497
  { key: "status", header: localeText(locale, "experimentList.status") },
497
498
  { key: "entity", header: localeText(locale, "experimentList.evalAttempt") },
498
- { key: "result", header: localeText(locale, "experimentList.result") },
499
+ // Result 是可扫读的失败预览,不是证据面:两行放不下的以 收口,完整值走 locator 下钻。
500
+ { key: "result", header: localeText(locale, "experimentList.result"), maxLines: 2 },
499
501
  { key: "duration", header: localeText(locale, "experimentList.duration"), align: "right" },
500
502
  { key: "cost", header: localeText(locale, "experimentList.cost"), align: "right" },
501
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);
502
516
  const rows: TableRow[] = item.evalRows.flatMap((row) => {
503
517
  const parent: TableRow = {
504
518
  key: row.evalId,
@@ -506,8 +520,8 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext): stri
506
520
  status: `${verdictMark(row.verdict)} ${localeText(locale, `verdict.${row.verdict}`)}`,
507
521
  entity: row.evalId,
508
522
  result: "",
509
- duration: "",
510
- cost: "",
523
+ duration: localeText(locale, "entityList.average", { value: cellText(row.duration) }),
524
+ cost: localeText(locale, "entityList.average", { value: cellText(row.cost) }),
511
525
  },
512
526
  };
513
527
  const attempts: TableRow[] = row.attempts.map((attempt, index) => ({
@@ -515,7 +529,7 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext): stri
515
529
  cells: {
516
530
  status: ` ${verdictMark(attempt.verdict)}`,
517
531
  entity: `${index === row.attempts.length - 1 ? "└─" : "├─"} ${attempt.locator}`,
518
- result: attemptItemReason(attempt) ?? MISSING_MARK,
532
+ result: attemptItemReason(attempt, resultBudget) ?? MISSING_MARK,
519
533
  duration: attempt.verdict === "skipped" && attempt.durationMs === 0 ? null : formatDurationMs(attempt.durationMs),
520
534
  cost: attempt.costUSD === undefined ? null : formatUSD(attempt.costUSD),
521
535
  },
@@ -525,18 +539,24 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext): stri
525
539
  const flags = item.flags && Object.keys(item.flags).length > 0
526
540
  ? `${localeText(locale, "experimentList.flags")} ${Object.entries(item.flags).map(([key, value]) => `${key}=${String(value)}`).join(" · ")}`
527
541
  : undefined;
528
- return [item.experimentId, flags, renderTableText({ columns, rows, locale }, ctx)].filter(Boolean).join("\n");
542
+ return [experimentDisplayName(item.experimentId, relativeTo), flags, renderTableText({ columns, rows, locale }, ctx)]
543
+ .filter(Boolean)
544
+ .join("\n");
529
545
  }
530
546
 
531
- export function experimentListText(items: ExperimentListItem[], ctx: TextContext): string {
547
+ export function experimentListText(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
532
548
  if (items.length === 0) return localeText(ctx.locale, "attemptList.empty");
533
- return [experimentSummaryTable(items, ctx), ...items.map((item) => experimentDetailTable(item, ctx))].join("\n\n");
549
+ return [
550
+ experimentSummaryTable(items, ctx, relativeTo),
551
+ ...items.map((item) => experimentDetailTable(item, ctx, relativeTo)),
552
+ ].join("\n\n");
534
553
  }
535
554
 
536
555
  // ── EvalList ──
537
556
 
538
- function evalListAttemptLine(item: AttemptListItem): string {
539
- 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));
540
560
  return ` ${locatorBadge(item)}${reason ? ` · ${reason}` : ""}`;
541
561
  }
542
562
 
@@ -548,10 +568,14 @@ export function evalListText(items: EvalListItem[], ctx: TextContext): string {
548
568
  const summary = [
549
569
  localeText(locale, "attemptList.score", { score: cellText(item.score) }),
550
570
  localeText(locale, "overview.attemptsCount", { n: item.attempts.length }),
551
- `${formatDurationMs(item.duration.value ?? 0)} avg`,
552
- 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
+ }),
553
577
  ].join(" · ");
554
- const attemptLines = item.attempts.map(evalListAttemptLine);
578
+ const attemptLines = item.attempts.map((attempt) => evalListAttemptLine(attempt, ctx));
555
579
  return [identity, ` ${summary}`, ...attemptLines].join("\n");
556
580
  });
557
581
  return blocks.join("\n\n");
@@ -559,9 +583,8 @@ export function evalListText(items: EvalListItem[], ctx: TextContext): string {
559
583
 
560
584
  // ── AttemptList ──
561
585
 
562
- /** 一个 AttemptListItem 的完整 text 卡片:判定符 + locator + 身份 + 耗时/成本,
563
- * 然后逐条断言(gate soft 都列,与 web 面的 AttemptRow 同一份材料)。 */
564
- function attemptListItemText(item: AttemptListItem, ctx: TextContext, locale: ReportLocale): string {
586
+ /** Attempt 比较卡片:只显示一条主失败摘要(至多两行终端宽);完整 assertions locator 下钻。 */
587
+ function attemptListItemText(item: AttemptListItem, ctx: TextContext): string {
565
588
  const head = [
566
589
  `${verdictMark(item.verdict)} ${item.locator}`,
567
590
  item.evalId,
@@ -570,34 +593,15 @@ function attemptListItemText(item: AttemptListItem, ctx: TextContext, locale: Re
570
593
  ...(item.costUSD !== undefined ? [formatUSD(item.costUSD)] : []),
571
594
  ].join(" · ");
572
595
  const lines = [head];
573
- if (item.error) {
574
- lines.push(indentBlock(wrapDisplay(item.error, ctx.width - 4).join("\n"), " "));
575
- }
576
- for (const assertion of item.assertions) {
577
- const scoreText =
578
- assertion.threshold !== undefined
579
- ? `${formatPlainNumber(assertion.score)}/${formatPlainNumber(assertion.threshold)}`
580
- : formatPlainNumber(assertion.score);
581
- lines.push(
582
- ` ${assertion.severity} ${assertion.name} · ${localeText(locale, `verdict.${assertion.passed ? "passed" : "failed"}`)}${assertion.severity === "soft" ? ` ${scoreText}` : ""}`,
583
- );
584
- if (assertion.detail) lines.push(indentBlock(wrapDisplay(assertion.detail, ctx.width - 4).join("\n"), " "));
585
- if (assertion.evidence) {
586
- const limit = Math.max(240, ctx.width * 5);
587
- const evidence =
588
- assertion.evidence.length <= limit
589
- ? assertion.evidence
590
- : `${assertion.evidence.slice(0, limit)}… (${assertion.evidence.length - limit} more chars; open ${item.locator} for full evidence)`;
591
- lines.push(indentBlock(wrapDisplay(evidence, ctx.width - 6).join("\n"), " "));
592
- }
593
- }
596
+ const reason = attemptItemReason(item, Math.max(24, ctx.width * 2 - 4));
597
+ if (reason) lines.push(` ${reason}`);
594
598
  return lines.join("\n");
595
599
  }
596
600
 
597
601
  export function attemptListText(items: AttemptListItem[], total: number | undefined, ctx: TextContext): string {
598
602
  const locale = ctx.locale;
599
603
  if (items.length === 0) return localeText(locale, "attemptList.empty");
600
- const blocks = items.map((item) => attemptListItemText(item, ctx, locale));
604
+ const blocks = items.map((item) => attemptListItemText(item, ctx));
601
605
  const remaining = (total ?? items.length) - items.length;
602
606
  if (remaining > 0) blocks.push(localeText(locale, "attemptList.truncatedText", { n: remaining }));
603
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,7 +4,7 @@
4
4
 
5
5
  import type { AttemptHandle, SelectionWarning } from "../results/types.ts";
6
6
  import type { AttemptLocator } from "../results/locator.ts";
7
- import type { AssertionResult, Verdict } from "../types.ts";
7
+ import type { AssertionResult, AttemptError, DiagnosticRecord, Verdict } from "../types.ts";
8
8
  import type { LocalizedLabel, ReportLocale } from "./locale.ts";
9
9
 
10
10
  export type { SelectionWarning };
@@ -31,7 +31,7 @@ export interface MetricAggregate {
31
31
  * 指标:纯函数,吃一个 AttemptHandle 吐一个值(null = 此 attempt 测不了这个指标,
32
32
  * 不进聚合;0 = 测了结果是零,照常进),外加名字、两级聚合方式和渲染提示。
33
33
  * 内置指标与自定义指标是同一个类型,没有特权。name 走字面量泛型:列键锚在指标
34
- * 对象上(`row.cells[passRate.name]`),拼错列名编译不过。
34
+ * 对象上(`row.cells[taskPassRate.name]`),拼错列名编译不过。
35
35
  */
36
36
  export interface Metric<Name extends string = string> {
37
37
  /** MetricColumn.key 与列头的来源;同一次计算里重名是错误。 */
@@ -86,8 +86,24 @@ export interface FlagRef {
86
86
  readonly unit?: string;
87
87
  }
88
88
 
89
- /** 维度槽的输入:内置/自定义维度,或 experiment 声明的 flag。 */
90
- 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;
91
107
 
92
108
  // ───────────────────────── 计算产物(组件 data props)─────────────────────────
93
109
 
@@ -265,7 +281,7 @@ export interface GroupSummaryData {
265
281
  /**
266
282
  * 组的通过率:eval 级折叠计票的 `passed / (passed + failed + errored)`(`skipped` 不进
267
283
  * 分母)——这是旧 `GroupSelector` 卡片的口径,不是 `OverviewData.totals.passRate` 那种
268
- * `computeCell` 两级聚合(两者服务不同问题:「这组题多少算过」vs「整体质量几分」)。
284
+ * `computeCell` 两级聚合(两者服务不同问题:「这组题多少算过」vs「每次运行成功多少」)。
269
285
  * 分母为 0(组内没有任何已跑的 eval)时 `value` 为 `null`,不编 0%。
270
286
  */
271
287
  passRate: MetricCell;
@@ -289,10 +305,11 @@ export interface OverviewData {
289
305
  errored: number;
290
306
  skipped: number;
291
307
  /**
292
- * 通过率的唯一官方口径:`computeCell(passRate, items)`,与 `MetricTable.data(...,
293
- * columns: [passRate])` 同一台两级聚合引擎(题内折叠 perEval、跨题折叠 across,默认都是
308
+ * 默认成功率的唯一官方口径:`computeCell(endToEndPassRate, items)`,与 `MetricTable.data(...,
309
+ * columns: [endToEndPassRate])` 同一台两级聚合引擎(题内折叠 perEval、跨题折叠 across,默认都是
294
310
  * mean)——一道题内多个 attempt 部分通过,贡献的是小数份额而不是二元票。`samples`/`total`
295
- * 是两级聚合口径下的 attempt 计数(`total` 含 skipped,`samples` 不含),不等于上面四个
311
+ * 是两级聚合口径下的 attempt 计数(`total` 含 skipped 与 errored——endToEndPassRate 对
312
+ * errored 记 0、只对 skipped 记 null,`samples` 因此只不含 skipped),不等于上面四个
296
313
  * verdict 计票的任何一个之和。
297
314
  */
298
315
  passRate: MetricCell;
@@ -335,8 +352,11 @@ export interface DeltaData<K extends string = string> {
335
352
  // `EvalListItem.attempts` 的元素,报告作者可以直接把这些嵌套数组喂给 `<AttemptList items={...} />`。
336
353
 
337
354
  /**
338
- * `AttemptList` 一项 = 一个 Attempt:身份、判定、断言、error、耗时、成本和 locator。
339
- * `ExperimentList` / `EvalList` 的下钻数组复用同一个类型,不是各自的精简版。
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」)。
340
360
  */
341
361
  export interface AttemptListItem {
342
362
  evalId: string;
@@ -344,7 +364,10 @@ export interface AttemptListItem {
344
364
  attempt: number;
345
365
  agent: string;
346
366
  verdict: Verdict;
347
- error?: string;
367
+ /** 结构化执行错误(与 `EvalResult.error` 同构):列表只显示 `message` 一层摘要。 */
368
+ error?: AttemptError;
369
+ /** 本 attempt 的有界诊断(teardown / cleanup 失败等,与 verdict 独立);属于下钻详情。 */
370
+ diagnostics?: DiagnosticRecord[];
348
371
  assertions: AssertionResult[];
349
372
  durationMs: number;
350
373
  costUSD?: number;
@@ -352,16 +375,14 @@ export interface AttemptListItem {
352
375
  }
353
376
 
354
377
  /**
355
- * `ExperimentList` 一项里,一个 Eval 的展开行:折叠判定(`foldEvalVerdict`)、失败原因摘要
356
- * (`error` `skipReason` → 未通过的 gate 断言,`reasonFor` 的口径,soft 断言永不进入)
357
- * 该 Eval 内 attempt 的平均耗时/成本(两级聚合引擎在单一 eval 上退化成组内均值),以及这道题
358
- * 的全部 Attempt(升序,供进一步展开到 `AttemptList`)。
378
+ * `ExperimentList` 一项里,一个 Eval 的展开行:折叠判定(`foldEvalVerdict`)、该 Eval 内
379
+ * attempt 的平均耗时/成本(两级聚合引擎在单一 eval 上退化成组内均值),以及这道题的全部
380
+ * Attempt(升序,供进一步展开到 `AttemptList`)。失败原因属于各 Attempt,不在父行挑一轮重复。
359
381
  */
360
382
  export interface ExperimentListEvalRow {
361
383
  evalId: string;
362
384
  /** 折叠判定(任一 attempt 通过则通过,否则取最严重的)。 */
363
385
  verdict: Verdict;
364
- reason?: string;
365
386
  /** 这道题内 attempt 的平均耗时(`computeCell(durationMs, …)`,单一 eval 分组下即均值)。 */
366
387
  duration: MetricCell;
367
388
  /** 这道题内 attempt 的平均成本。 */
@@ -373,7 +394,7 @@ export interface ExperimentListEvalRow {
373
394
  /**
374
395
  * `ExperimentList.data(selection)` 的一项 = 一个 experiment:身份(experimentId/agent/model)、
375
396
  * 声明的 flags、Eval 判定构成(`foldEvalVerdict` 计票,与 view 榜单同一口径)、官方两级聚合
376
- * 汇总指标(passRate/cost/duration/tokens,直接来自 `computeCell`,不现场重算),以及展开到
397
+ * 汇总指标(endToEndPassRate/cost/duration/tokens,直接来自 `computeCell`,不现场重算),以及展开到
377
398
  * 这个 experiment 每道 Eval 的 `evalRows`(按 eval id 升序)。
378
399
  */
379
400
  export interface ExperimentListItem {
@@ -383,7 +404,7 @@ export interface ExperimentListItem {
383
404
  flags?: Record<string, unknown>;
384
405
  /** eval 级折叠计票(foldEvalVerdict 口径,与 `TableRowMeta.verdicts`、view 榜单同一套)。 */
385
406
  verdicts: { passed: number; failed: number; errored: number; skipped: number };
386
- /** 官方两级聚合口径,与 `MetricTable.data(..., columns: [passRate])` 同一台引擎。 */
407
+ /** 官方两级聚合口径(endToEndPassRate),与 `MetricTable.data(..., columns: [endToEndPassRate])` 同一台引擎。 */
387
408
  passRate: MetricCell;
388
409
  cost: MetricCell;
389
410
  duration: MetricCell;
@@ -401,14 +422,13 @@ export interface ExperimentListItem {
401
422
  /**
402
423
  * `EvalList.data(selection)` 的一项 = 一个 `experimentId + evalId`(同一个 Eval 跑在两个
403
424
  * experiment 上是两条不同结果,不合并)。判定、分数(examScore 的两级聚合)、这道题内 attempt
404
- * 的平均耗时/成本,失败原因摘要(与 `ExperimentListEvalRow.reason` 同一口径),外加展开到这道题
405
- * 全部 Attempt`attempts`(按 attempt 序号升序)。
425
+ * 的平均耗时/成本,外加展开到这道题全部 Attempt 的 `attempts`(按 attempt 序号升序)。失败
426
+ * 原因只存在于各 `AttemptListItem`error / assertions,不会在 Eval 父项重复一份。
406
427
  */
407
428
  export interface EvalListItem {
408
429
  evalId: string;
409
430
  experimentId: string;
410
431
  verdict: Verdict;
411
- reason?: string;
412
432
  /** examScore 的两级聚合;单一 eval 分组下即这道题的题级分数。 */
413
433
  score: MetricCell;
414
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
  });