niceeval 0.10.0 → 0.10.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 (53) hide show
  1. package/dist/report/components/attempt-detail/AttemptConversation.d.ts +5 -1
  2. package/dist/report/components/attempt-detail/AttemptConversation.js +42 -2
  3. package/dist/report/components/attempt-detail/AttemptSource.js +83 -13
  4. package/dist/report/components/attempt-detail/compute.js +44 -1
  5. package/dist/report/components/attempt-detail/faces.js +4 -0
  6. package/dist/report/components/attempt-detail/index.d.ts +2 -2
  7. package/dist/report/components/attempt-detail/index.js +24 -4
  8. package/dist/report/components/entity-lists/ExperimentList.d.ts +1 -2
  9. package/dist/report/components/entity-lists/ExperimentList.js +6 -5
  10. package/dist/report/components/entity-lists/faces.d.ts +1 -1
  11. package/dist/report/components/entity-lists/faces.js +10 -9
  12. package/dist/report/components/entity-lists/index.d.ts +5 -7
  13. package/dist/report/components/entity-lists/index.js +7 -3
  14. package/dist/report/components/metric-views/MetricScatter.js +2 -38
  15. package/dist/report/index.d.ts +1 -1
  16. package/dist/report/model/format.d.ts +5 -5
  17. package/dist/report/model/format.js +34 -8
  18. package/dist/report/model/types.d.ts +16 -2
  19. package/dist/report/react/index.d.ts +1 -1
  20. package/dist/runner/types.d.ts +2 -1
  21. package/dist/sandbox/e2b.d.ts +0 -1
  22. package/docs-site/zh/examples/integrations/ai-sdk-v7.mdx +5 -5
  23. package/docs-site/zh/explanation/runner.mdx +6 -2
  24. package/docs-site/zh/reference/cli.mdx +2 -2
  25. package/docs-site/zh/reference/report-components.mdx +3 -1
  26. package/docs-site/zh/tutorials/experiments.mdx +1 -2
  27. package/docs-site/zh/tutorials/viewing-results.mdx +12 -9
  28. package/docs-site/zh/tutorials/write-experiment.mdx +1 -3
  29. package/package.json +3 -4
  30. package/src/cli.ts +4 -2
  31. package/src/o11y/prices.json +176 -99
  32. package/src/report/assets/styles.css +822 -0
  33. package/src/report/components/attempt-detail/AttemptConversation.tsx +55 -7
  34. package/src/report/components/attempt-detail/AttemptSource.tsx +186 -42
  35. package/src/report/components/attempt-detail/attempt-components.test.tsx +82 -4
  36. package/src/report/components/attempt-detail/compute.ts +50 -1
  37. package/src/report/components/attempt-detail/faces.ts +3 -0
  38. package/src/report/components/attempt-detail/index.tsx +33 -16
  39. package/src/report/components/attempt-detail/validate.test.ts +19 -2
  40. package/src/report/components/entity-lists/ExperimentList.tsx +13 -10
  41. package/src/report/components/entity-lists/faces.ts +10 -9
  42. package/src/report/components/entity-lists/index.tsx +7 -10
  43. package/src/report/components/metric-views/MetricScatter.tsx +2 -38
  44. package/src/report/components/render.test.tsx +19 -9
  45. package/src/report/index.ts +2 -0
  46. package/src/report/model/format.ts +33 -8
  47. package/src/report/model/types.ts +18 -2
  48. package/src/report/react/index.tsx +2 -0
  49. package/src/report/runtime/dual-render.test.tsx +43 -10
  50. package/src/runner/types.ts +2 -1
  51. package/src/sandbox/e2b-reconcile.test.ts +125 -0
  52. package/src/sandbox/e2b.ts +38 -2
  53. package/src/view/view-report.test.ts +3 -1
@@ -6,7 +6,7 @@
6
6
  import type { ReactElement } from "react";
7
7
  import type { AttemptLocator } from "../../../results/locator.ts";
8
8
  import type { AttemptListItem, ExperimentListEvalRow, ExperimentListItem } from "../../model/types.ts";
9
- import { experimentDisplayName } from "../../model/format.ts";
9
+ import { shortestUniqueLabels } from "../../model/format.ts";
10
10
  import { DEFAULT_REPORT_LOCALE, localeText, type ReportLocale } from "../../model/locale.ts";
11
11
  import { AttemptLocatorBadge, failureSummaryText } from "./AttemptList.tsx";
12
12
  import { MetricCellView } from "../cell.tsx";
@@ -122,23 +122,21 @@ function Flags({ flags, locale }: { flags: Record<string, unknown> | undefined;
122
122
 
123
123
  function ExperimentRow({
124
124
  item,
125
+ label,
125
126
  attemptHref,
126
127
  locale,
127
- relativeTo,
128
128
  }: {
129
129
  item: ExperimentListItem;
130
+ label: string;
130
131
  attemptHref?: (locator: AttemptLocator) => string;
131
132
  locale: ReportLocale;
132
- relativeTo?: string;
133
133
  }): ReactElement {
134
134
  return (
135
135
  <details className="nre-experiment-entry">
136
136
  <summary className="nre-experiment-summary">
137
- {/* relativeTo 只影响显示末段;完整 id 仍是排序 / 过滤 / 折叠的键,系列色跟随 agent */}
137
+ {/* label 是当前列表里的最短唯一后缀;完整 id 仍是排序 / 过滤 / 折叠的键,系列色跟随 agent */}
138
138
  <span className="nre-experiment-name" data-sort-value={item.experimentId}>
139
- <b className={cx("nre-experiment-id", "nre-key")}>
140
- {experimentDisplayName(item.experimentId, relativeTo)}
141
- </b>
139
+ <b className={cx("nre-experiment-id", "nre-key")}>{label}</b>
142
140
  <small>
143
141
  {localeText(locale, "overview.evalsCount", { n: item.evals })}
144
142
  {item.attempts > item.evals ? ` · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })}` : ""}
@@ -184,15 +182,14 @@ export function ExperimentList({
184
182
  filter = false,
185
183
  className,
186
184
  locale = DEFAULT_REPORT_LOCALE,
187
- relativeTo,
188
185
  }: {
189
186
  data: readonly ExperimentListItem[];
190
187
  attemptHref?: (locator: AttemptLocator) => string;
191
188
  filter?: boolean;
192
189
  className?: string;
193
190
  locale?: ReportLocale;
194
- relativeTo?: string;
195
191
  }): ReactElement {
192
+ const experimentLabels = shortestUniqueLabels(data.map((item) => item.experimentId));
196
193
  const labels = [
197
194
  localeText(locale, "experimentList.experiment"),
198
195
  localeText(locale, "table.model"),
@@ -221,7 +218,13 @@ export function ExperimentList({
221
218
  </div>
222
219
  {data.length === 0 && <p className="nre-experiment-list-empty">{localeText(locale, "attemptList.empty")}</p>}
223
220
  {data.map((item) => (
224
- <ExperimentRow key={item.experimentId} item={item} attemptHref={attemptHref} locale={locale} relativeTo={relativeTo} />
221
+ <ExperimentRow
222
+ key={item.experimentId}
223
+ item={item}
224
+ label={experimentLabels.get(item.experimentId) ?? item.experimentId}
225
+ attemptHref={attemptHref}
226
+ locale={locale}
227
+ />
225
228
  ))}
226
229
  </div>
227
230
  );
@@ -8,10 +8,10 @@ import type { AttemptListItem, EvalListItem, ExperimentListItem } from "../../mo
8
8
  import type { TextContext } from "../../definition/tree.ts";
9
9
  import type { TableColumn, TableRow } from "../../definition/primitives.tsx";
10
10
  import {
11
- experimentDisplayName,
12
11
  fitFailureSummary,
13
12
  formatDurationMs,
14
13
  formatUSD,
14
+ shortestUniqueLabels,
15
15
  verdictMark,
16
16
  } from "../../model/format.ts";
17
17
  import { countText, localeText, type ReportLocale } from "../../model/locale.ts";
@@ -47,7 +47,7 @@ function attemptReasonText(item: AttemptListItem, locale: ReportLocale, maxChars
47
47
  function experimentSummaryTable(
48
48
  items: readonly ExperimentListItem[],
49
49
  ctx: TextContext,
50
- relativeTo?: string,
50
+ labels: Map<string, string>,
51
51
  ): string {
52
52
  const locale = ctx.locale;
53
53
  const compact = ctx.width < 100;
@@ -64,7 +64,7 @@ function experimentSummaryTable(
64
64
  const rows: TableRow[] = items.map((item) => ({
65
65
  key: item.experimentId,
66
66
  cells: {
67
- experiment: experimentDisplayName(item.experimentId, relativeTo),
67
+ experiment: labels.get(item.experimentId) ?? item.experimentId,
68
68
  model: item.model ?? localeText(locale, "experimentList.defaultModel"),
69
69
  agent: item.agent,
70
70
  duration: cellText(item.durationMs, locale),
@@ -76,14 +76,14 @@ function experimentSummaryTable(
76
76
  }));
77
77
  const metadata = items.flatMap((item) =>
78
78
  wrapDisplay(
79
- `${experimentDisplayName(item.experimentId, relativeTo)}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
79
+ `${labels.get(item.experimentId) ?? item.experimentId}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
80
80
  Math.max(8, ctx.width - 2),
81
81
  ).map((line) => ` ${line}`),
82
82
  );
83
83
  return [renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx), metadata.join("\n")].join("\n");
84
84
  }
85
85
 
86
- function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relativeTo?: string): string {
86
+ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, label: string): string {
87
87
  const locale = ctx.locale;
88
88
  const columns: TableColumn[] = [
89
89
  { key: "status", header: localeText(locale, "experimentList.status") },
@@ -135,7 +135,7 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
135
135
  .join(" · ")}`
136
136
  : undefined;
137
137
  return [
138
- experimentDisplayName(item.experimentId, relativeTo),
138
+ label,
139
139
  flags,
140
140
  renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx),
141
141
  ]
@@ -143,11 +143,12 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
143
143
  .join("\n");
144
144
  }
145
145
 
146
- export function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
146
+ export function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext): string {
147
147
  if (items.length === 0) return localeText(ctx.locale, "attemptList.empty");
148
+ const labels = shortestUniqueLabels(items.map((item) => item.experimentId));
148
149
  return [
149
- experimentSummaryTable(items, ctx, relativeTo),
150
- ...items.map((item) => experimentDetailTable(item, ctx, relativeTo)),
150
+ experimentSummaryTable(items, ctx, labels),
151
+ ...items.map((item) => experimentDetailTable(item, ctx, labels.get(item.experimentId) ?? item.experimentId)),
151
152
  ].join("\n\n");
152
153
  }
153
154
 
@@ -102,20 +102,18 @@ export type ExperimentListProps = DataProps<
102
102
  EntityListChrome & {
103
103
  /** web 面在比较表前显示实验过滤框;text 面忽略。 */
104
104
  filter?: boolean;
105
- /**
106
- * 可选父路径:两面的行标签去掉与它相同的前缀,只显示 experiment id 末段。自定义报告
107
- * 显式传入使用;默认 `ExperimentComparison` 不传,完整 id 始终可见。完整 id 仍是
108
- * 排序 / 着色 / 过滤 / 折叠的键。
109
- */
110
- relativeTo?: string;
111
105
  }
112
106
  >;
113
107
 
114
- /** 实验列表:每项一个 experiment,固定八列比较表 + 展开到 Eval / Attempt。 */
108
+ /**
109
+ * 实验列表:每项一个 experiment,固定八列比较表 + 展开到 Eval / Attempt。行标签是
110
+ * experiment id 在当前列表里的最短唯一后缀(与 MetricScatter 点标签同一算法,重名逐步
111
+ * 加长到能区分为止);完整 id 不受影响,仍是排序 / 过滤 / 折叠的键。
112
+ */
115
113
  export const ExperimentList = makeDataComponent<
116
114
  readonly ExperimentListItem[],
117
115
  Record<never, never>,
118
- EntityListChrome & { filter?: boolean; relativeTo?: string }
116
+ EntityListChrome & { filter?: boolean }
119
117
  >({
120
118
  name: "ExperimentList",
121
119
  dataFnName: "experimentListData",
@@ -127,13 +125,12 @@ export const ExperimentList = makeDataComponent<
127
125
  <ExperimentListWeb
128
126
  data={props.data}
129
127
  filter={props.filter}
130
- relativeTo={props.relativeTo}
131
128
  locale={props.locale ?? ctx.locale}
132
129
  attemptHref={hrefOf(props, ctx)}
133
130
  className={props.className}
134
131
  />
135
132
  ),
136
- text: (props, ctx) => experimentListText(props.data, ctx, props.relativeTo),
133
+ text: (props, ctx) => experimentListText(props.data, ctx),
137
134
  }) as unknown as ReportComponent<ExperimentListProps>;
138
135
 
139
136
  export type EvalListProps = DataProps<readonly EvalListItem[], Record<never, never>, EntityListChrome>;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import type { ReactElement } from "react";
14
14
  import type { MetricColumn, ScatterData } from "../../model/types.ts";
15
- import { formatMetricValue } from "../../model/format.ts";
15
+ import { formatMetricValue, shortestUniqueLabels } from "../../model/format.ts";
16
16
  import { DEFAULT_REPORT_LOCALE, countText, localeText, resolveLocalizedText, resolveMetricLabel, type ReportLocale } from "../../model/locale.ts";
17
17
  import { niceTicks, placePointLabels } from "./chart-math.ts";
18
18
  import { colorIndicesForKeys } from "../../assets/colors.ts";
@@ -36,42 +36,6 @@ interface DrawablePoint {
36
36
  py: number;
37
37
  }
38
38
 
39
- /**
40
- * 点的直接标签:末段在当前 data 中唯一才缩成末段;重名时逐步加长为能区分它们的最短
41
- * 路径后缀(完整 id 与两轴值仍进 <title>)。
42
- */
43
- function pointLabels(keys: readonly string[]): Map<string, string> {
44
- const segsOf = (key: string) => key.split("/").filter(Boolean);
45
- const depth = new Map<string, number>(keys.map((key) => [key, 1]));
46
- for (;;) {
47
- const byLabel = new Map<string, string[]>();
48
- for (const key of keys) {
49
- const segs = segsOf(key);
50
- const label = segs.slice(-Math.min(depth.get(key)!, segs.length)).join("/") || key;
51
- byLabel.set(label, [...(byLabel.get(label) ?? []), key]);
52
- }
53
- let grew = false;
54
- for (const group of byLabel.values()) {
55
- if (group.length < 2) continue;
56
- for (const key of group) {
57
- const segs = segsOf(key);
58
- if (depth.get(key)! < segs.length) {
59
- depth.set(key, depth.get(key)! + 1);
60
- grew = true;
61
- }
62
- }
63
- }
64
- if (!grew) {
65
- const out = new Map<string, string>();
66
- for (const key of keys) {
67
- const segs = segsOf(key);
68
- out.set(key, segs.slice(-Math.min(depth.get(key)!, segs.length)).join("/") || key);
69
- }
70
- return out;
71
- }
72
- }
73
- }
74
-
75
39
  /**
76
40
  * 一根轴:niceTicks 撑出整齐的值域,值 → 像素做线性映射。轴方向跟随指标的 `better`:
77
41
  * `better: "lower"` 的轴反向渲染(值大的一端在左 / 下),「更好」因此恒指向右与上;
@@ -143,7 +107,7 @@ export function MetricScatter({
143
107
  MARGIN.top,
144
108
  data.y.better === "lower",
145
109
  );
146
- const labelByKey = pointLabels(drawableRows.map((r) => r.key));
110
+ const labelByKey = shortestUniqueLabels(drawableRows.map((r) => r.key));
147
111
  // 方向提示恒为「越靠右上越好」,仅当两轴都声明 better 时显示——任一轴未声明,
148
112
  // 组件不猜「更好」朝哪边,整图无提示。
149
113
  const showBetterHint = data.x.better !== undefined && data.y.better !== undefined;
@@ -390,9 +390,11 @@ describe("ExperimentList", () => {
390
390
  expect(html).toContain("2 evals");
391
391
  });
392
392
 
393
- it("主行:身份、agent/model、官方两级聚合指标与 eval 级判定构成", () => {
394
- expect(html).toContain("compare/bub");
395
- expect(html).toContain("compare/codex");
393
+ it("主行:身份(最短唯一后缀)、agent/model、官方两级聚合指标与 eval 级判定构成;完整 id 仍是排序键", () => {
394
+ expect(html).toContain(">bub</b>");
395
+ expect(html).toContain(">codex</b>");
396
+ expect(html).toContain('data-sort-value="compare/bub"');
397
+ expect(html).toContain('data-sort-value="compare/codex"');
396
398
  expect(html).toContain("gpt-5.4");
397
399
  expect(html).toContain("50%"); // endToEndPassRate.display
398
400
  expect(html).toContain("1 passed · 1 failed");
@@ -416,12 +418,20 @@ describe("ExperimentList", () => {
416
418
  expect(html.match(/gate: roots-correct/g)).toHaveLength(2);
417
419
  });
418
420
 
419
- it("relativeTo 去掉组前缀只显示末段;data-sort-value 仍是完整 id", () => {
420
- const relative = renderToStaticMarkup(<ExperimentList data={experimentListItems} relativeTo="compare" />);
421
- expect(relative).toContain(">bub</b>");
422
- expect(relative).toContain('data-sort-value="compare/bub"');
423
- // 不传 relativeTo 显示完整 id
424
- expect(html).toContain(">compare/bub</b>");
421
+ it("行标签默认缩成当前列表里的最短唯一后缀;末段重名时逐步加长到能区分,data-sort-value 仍是完整 id", () => {
422
+ // 两个不同 experiment 末段撞名(a/run b/run 都叫 run)时,两行都要加长到能区分为止。
423
+ const dupes = renderToStaticMarkup(
424
+ <ExperimentList
425
+ data={[
426
+ { ...experimentListItems[0]!, experimentId: "compare/a/run" },
427
+ { ...experimentListItems[1]!, experimentId: "compare/b/run" },
428
+ ]}
429
+ />,
430
+ );
431
+ expect(dupes).toContain(">a/run</b>");
432
+ expect(dupes).toContain(">b/run</b>");
433
+ expect(dupes).toContain('data-sort-value="compare/a/run"');
434
+ expect(dupes).toContain('data-sort-value="compare/b/run"');
425
435
  });
426
436
 
427
437
  it("零 JS 靠原生 <details>,无 <script>", () => {
@@ -271,6 +271,8 @@ export type {
271
271
  AttemptErrorData,
272
272
  AttemptFixPromptData,
273
273
  AttemptSourceData,
274
+ AttemptSourceLineData,
275
+ AttemptSourceTurn,
274
276
  AttemptSummaryData,
275
277
  AttemptTimelineData,
276
278
  AttemptTraceData,
@@ -6,16 +6,41 @@ import type { Verdict } from "../../types.ts";
6
6
  import { DISPLAY_LOCALES, type LocalizedText, type ReportLocale } from "./locale.ts";
7
7
 
8
8
  /**
9
- * experiment 行的显示名:给了父路径 `relativeTo` 且它确是前缀,就去掉 `relativeTo + "/"`,
10
- * 只留 id 末段——供自定义报告在已知父路径的上下文里显式调用,避免每行重复文件夹名。默认
11
- * `ExperimentComparison` 不传 `relativeTo`,完整 id 始终可见。不给 `relativeTo`、或它不是
12
- * 前缀时原样返回完整 id。完整 id 仍是排序 / 着色 / 折叠的键,调用方不要拿这个显示名当身份用。
9
+ * 一组 id 的显示名:每个 id 缩成在这组里唯一的最短路径后缀,重名逐步加长到能区分为止
10
+ * (与 `MetricScatter` 点标签同一算法,两处共用本函数以保证同一份 experiment id 在散点和
11
+ * 列表里缩成同一个显示名)。单个 id、或所有 id 深度不同时也照常缩到各自的最短唯一后缀。
12
+ * 完整 id 不受影响,调用方仍用它做排序 / 过滤 / 折叠的身份键,这里只产出显示名。
13
13
  */
14
- export function experimentDisplayName(experimentId: string, relativeTo?: string): string {
15
- if (relativeTo && experimentId.startsWith(`${relativeTo}/`)) {
16
- return experimentId.slice(relativeTo.length + 1);
14
+ export function shortestUniqueLabels(ids: readonly string[]): Map<string, string> {
15
+ const segsOf = (id: string) => id.split("/").filter(Boolean);
16
+ const depth = new Map<string, number>(ids.map((id) => [id, 1]));
17
+ for (;;) {
18
+ const byLabel = new Map<string, string[]>();
19
+ for (const id of ids) {
20
+ const segs = segsOf(id);
21
+ const label = segs.slice(-Math.min(depth.get(id)!, segs.length)).join("/") || id;
22
+ byLabel.set(label, [...(byLabel.get(label) ?? []), id]);
23
+ }
24
+ let grew = false;
25
+ for (const group of byLabel.values()) {
26
+ if (group.length < 2) continue;
27
+ for (const id of group) {
28
+ const segs = segsOf(id);
29
+ if (depth.get(id)! < segs.length) {
30
+ depth.set(id, depth.get(id)! + 1);
31
+ grew = true;
32
+ }
33
+ }
34
+ }
35
+ if (!grew) {
36
+ const out = new Map<string, string>();
37
+ for (const id of ids) {
38
+ const segs = segsOf(id);
39
+ out.set(id, segs.slice(-Math.min(depth.get(id)!, segs.length)).join("/") || id);
40
+ }
41
+ return out;
42
+ }
17
43
  }
18
- return experimentId;
19
44
  }
20
45
 
21
46
  /** 一位小数、去掉无意义的 ".0" 尾巴。 */
@@ -484,13 +484,29 @@ export interface AttemptAssertionsData {
484
484
  passedGroups: { group: string; items: AssertionResult[] }[];
485
485
  }
486
486
 
487
- /** `AttemptSource` data:AnnotatedEvalSource 的展示投影;没有 source 时 null。 */
487
+ /** `AttemptSource` 源码行内的一轮执行:send 头事实 + 标准事件流归并出的完整回复。 */
488
+ export interface AttemptSourceTurn {
489
+ label: string;
490
+ status: "completed" | "failed" | "waiting";
491
+ durationMs?: number;
492
+ sentText: string;
493
+ replies: AttemptConversationReply[];
494
+ }
495
+
496
+ /** AnnotatedSourceLine 加上 web 源码视图需要的行内执行轮。 */
497
+ export interface AttemptSourceLineData extends AnnotatedSourceLine {
498
+ turns: AttemptSourceTurn[];
499
+ }
500
+
501
+ /** `AttemptSource` 的 data:AnnotatedEvalSource + 按 loc 投影的标准事件流;没有 source 时 null。 */
488
502
  export interface AttemptSourceData {
489
503
  /** text 面拼 `niceeval show <locator> --source` 下钻命令用;web 面不需要。 */
490
504
  locator: AttemptLocator;
491
505
  sourcePath: string;
492
- lines: AnnotatedSourceLine[];
506
+ lines: AttemptSourceLineData[];
493
507
  unmapped: AssertionResult[];
508
+ /** 没有 loc、指向其它文件或越界的轮次;不能静默丢弃,放在源码块末尾。 */
509
+ unlocatedTurns: AttemptSourceTurn[];
494
510
  summary: AnnotatedEvalSourceSummary;
495
511
  }
496
512
 
@@ -55,6 +55,8 @@ export type {
55
55
  AttemptListItem,
56
56
  AttemptLocator,
57
57
  AttemptSourceData,
58
+ AttemptSourceLineData,
59
+ AttemptSourceTurn,
58
60
  AttemptSummaryData,
59
61
  AttemptTimelineData,
60
62
  AttemptTraceData,
@@ -521,13 +521,13 @@ describe("text/web 双面同源", () => {
521
521
  res("algebra/retry", "passed", { attempt: 1 }),
522
522
  ],
523
523
  });
524
- const text = await renderTreeText(<ExperimentList relativeTo="grp" />, scopeOf([s]), 160);
524
+ const text = await renderTreeText(<ExperimentList />, scopeOf([s]), 160);
525
525
  expect(text.match(/algebra\/retry/g)).toHaveLength(1); // Eval 父行只出现一次
526
526
  expect(text).toContain("├─");
527
527
  expect(text).toContain("└─");
528
528
  // 失败摘要只在 Attempt 子行(父行不复述)
529
529
  expect(text.match(/equals\(42\)/g)).toHaveLength(1);
530
- // relativeTo:行标签只显示末段
530
+ // 行标签默认缩成最短唯一后缀
531
531
  expect(text).toContain("exp-a");
532
532
  });
533
533
  });
@@ -1276,18 +1276,21 @@ describe("内建报告", () => {
1276
1276
  );
1277
1277
  });
1278
1278
 
1279
- it("首页 web/text 两面都展示完整 Scope,不同深度目录的 experiment 同屏且显示完整 id;无组选择器或组索引", async () => {
1280
- const g1 = snap({ experimentId: "compare/a", results: [res("q", "passed")] });
1281
- const g2 = snap({ experimentId: "dev/b", results: [res("q", "failed")] });
1279
+ it("首页 web/text 两面都展示完整 Scope,不同深度目录的 experiment 同屏且行标签缩成最短唯一后缀;无组选择器或组索引", async () => {
1280
+ const g1 = snap({ experimentId: "compare/alpha-exp", results: [res("q", "passed")] });
1281
+ const g2 = snap({ experimentId: "dev/beta-exp", results: [res("q", "failed")] });
1282
1282
  const ctx = { scope: scopeOf([g1, g2]), results: resultsOf([g1, g2]) };
1283
1283
 
1284
1284
  const text = await renderReportToText(builtInReport, ctx, { width: 120 });
1285
1285
  // Hero text 面:标题行(回退链终点)+ 最后运行 meta 行在页首
1286
1286
  expect(text.split("\n")[0]).toBe("Eval Results");
1287
1287
  expect(text).toContain("Last run");
1288
- // 完整 Scope 直接展示:两个不同路径的 experiment 都可见,不是组索引 + 单组查看命令
1289
- expect(text).toContain("compare/a");
1290
- expect(text).toContain("dev/b");
1288
+ // 完整 Scope 直接展示:两个不同路径的 experiment 都可见,不是组索引 + 单组查看命令;
1289
+ // 行标签是各自末段(两个 id 互不撞名,不必加长),不带父路径。
1290
+ expect(text).toContain("alpha-exp");
1291
+ expect(text).toContain("beta-exp");
1292
+ expect(text).not.toContain("compare/alpha-exp");
1293
+ expect(text).not.toContain("dev/beta-exp");
1291
1294
  expect(text).not.toContain("niceeval show --exp compare");
1292
1295
  expect(text).not.toContain("niceeval show --exp dev");
1293
1296
  expect(text).not.toContain("niceeval exp compare");
@@ -1296,8 +1299,11 @@ describe("内建报告", () => {
1296
1299
  const html = await renderReportToStaticHtml(builtInReport, ctx);
1297
1300
  expect(html).not.toContain('role="tablist"');
1298
1301
  expect(html).not.toContain("data-nre-experiment-group");
1299
- expect(html).toContain("compare/a");
1300
- expect(html).toContain("dev/b");
1302
+ expect(html).toContain(">alpha-exp</b>");
1303
+ expect(html).toContain(">beta-exp</b>");
1304
+ // 完整 id 仍是排序键,只是不再重复出现在显示文字里
1305
+ expect(html).toContain('data-sort-value="compare/alpha-exp"');
1306
+ expect(html).toContain('data-sort-value="dev/beta-exp"');
1301
1307
  });
1302
1308
 
1303
1309
  it("0/1/多 experiment 均可渲染;0 个时三个叶子组件各自显示自己的空态", async () => {
@@ -1431,6 +1437,33 @@ describe("ExperimentComparison(组合组件)", () => {
1431
1437
  const [, explicitScatterEl] = await resolveComparisonChildren(<ExperimentComparison series={label("memory")} />, all);
1432
1438
  expect((explicitScatterEl.props.data as { seriesDimension?: string }).seriesDimension).toBe("memory");
1433
1439
  });
1440
+
1441
+ it("ExperimentList 行标签默认缩成 Scope 内的最短唯一后缀,不改变 ScopeSummary / MetricScatter 输出或排序键", async () => {
1442
+ const a = snap({ experimentId: "compare/a", agent: "bub", results: [res("q", "passed")] });
1443
+ const b = snap({ experimentId: "compare/b", agent: "codex", results: [res("q", "failed")] });
1444
+ const ctx = { scope: scopeOf([a, b]), results: resultsOf([a, b]) };
1445
+
1446
+ const definition = defineReport(<ExperimentComparison />);
1447
+ const html = await renderReportToStaticHtml(definition, ctx);
1448
+ expect(html).toContain(">a</b>");
1449
+ expect(html).toContain(">b</b>");
1450
+ expect(html).toContain('data-sort-value="compare/a"');
1451
+ expect(html).toContain('data-sort-value="compare/b"');
1452
+
1453
+ // 换成末段撞名的一对(a/run、b/run 都叫 run)时两行都要加长到能区分为止,不是随口断言。
1454
+ const dupeA = snap({ experimentId: "compare/x/run", agent: "bub", results: [res("q", "passed")] });
1455
+ const dupeB = snap({ experimentId: "compare/y/run", agent: "codex", results: [res("q", "failed")] });
1456
+ const dupeHtml = await renderReportToStaticHtml(definition, { scope: scopeOf([dupeA, dupeB]), results: resultsOf([dupeA, dupeB]) });
1457
+ expect(dupeHtml).toContain(">x/run</b>");
1458
+ expect(dupeHtml).toContain(">y/run</b>");
1459
+
1460
+ // ScopeSummary / MetricScatter 完全不受行标签缩短影响
1461
+ const [summaryEl, scatterEl] = await resolveComparisonChildren(<ExperimentComparison />, [a, b]);
1462
+ expect(summaryEl.props.data).toEqual(await scopeSummaryData([a, b]));
1463
+ expect(scatterEl.props.data).toEqual(
1464
+ await metricScatterData([a, b], { points: "experiment", series: "agent", x: costUSD, y: endToEndPassRate }),
1465
+ );
1466
+ });
1434
1467
  });
1435
1468
 
1436
1469
  // ───────────────────────── extends 与内建视图集合 ─────────────────────────
@@ -422,7 +422,8 @@ export interface ExperimentDef {
422
422
  labels?: Record<string, string | number>;
423
423
  /** 同一 eval 重复跑几次(结果各计一条 attempt);省略/CLI `--runs` 覆盖时默认 1。 */
424
424
  runs?: number;
425
- /** 一次重复(runs > 1)里某次 attempt 失败后是否跳过剩余重复;省略默认 true(提前退出省钱)。 */
425
+ /** 一次重复(runs > 1)里某次 attempt 通过后是否跳过剩余重复;省略默认 false(`runs` 跑满、测完整通过率),
426
+ * 显式打开用于「只想知道能不能过」的省钱场景。 */
426
427
  earlyExit?: boolean;
427
428
  /**
428
429
  * 这个实验覆盖哪些 eval:`"*"` 全部、字符串数组按 id 前缀、或自定义谓词(逐条收到发现并扇出后的
@@ -0,0 +1,125 @@
1
+ // 覆盖 reconcileProvision 对账逻辑,不打真实 e2b API——mock "e2b" 整个模块。
2
+ // 背景:线上跑分撞见过 nextItems() 之后 `for...of` 抛 "X is not iterable" 的一次,
3
+ // 没能稳定复现出确切成因;这里补的是"对账这条路径本身撞上瞬时错误该怎么办"这层契约,
4
+ // 不是去复现那次具体的 SDK 行为。
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ class FakeNotFoundError extends Error {}
8
+ class FakeRateLimitError extends Error {}
9
+ class FakeCommandExitError extends Error {}
10
+
11
+ const listMock = vi.fn();
12
+ const killMock = vi.fn();
13
+
14
+ vi.mock("e2b", () => ({
15
+ Sandbox: { list: (...args: unknown[]) => listMock(...args), kill: (...args: unknown[]) => killMock(...args) },
16
+ NotFoundError: FakeNotFoundError,
17
+ RateLimitError: FakeRateLimitError,
18
+ CommandExitError: FakeCommandExitError,
19
+ }));
20
+
21
+ function fakePaginator(pages: Array<() => Promise<unknown[]>>) {
22
+ let i = 0;
23
+ return {
24
+ get hasNext() {
25
+ return i < pages.length;
26
+ },
27
+ nextItems: async () => {
28
+ const page = pages[i];
29
+ i += 1;
30
+ return page();
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("reconcileProvision", () => {
36
+ beforeEach(() => {
37
+ listMock.mockReset();
38
+ killMock.mockReset();
39
+ });
40
+
41
+ it("kills every sandbox found under the provision token, across pages", async () => {
42
+ const { reconcileProvision } = await import("./e2b.ts");
43
+ listMock.mockReturnValue(
44
+ fakePaginator([
45
+ async () => [{ sandboxId: "a" }],
46
+ async () => [{ sandboxId: "b" }],
47
+ ]),
48
+ );
49
+ killMock.mockResolvedValue(undefined);
50
+
51
+ await reconcileProvision("tok");
52
+
53
+ expect(killMock).toHaveBeenCalledTimes(2);
54
+ expect(killMock.mock.calls.map((c) => c[0])).toEqual(["a", "b"]);
55
+ });
56
+
57
+ it("treats a NotFound on kill as already-reconciled, not an error", async () => {
58
+ const { reconcileProvision } = await import("./e2b.ts");
59
+ listMock.mockReturnValue(fakePaginator([async () => [{ sandboxId: "gone" }]]));
60
+ killMock.mockRejectedValue(new FakeNotFoundError("gone"));
61
+
62
+ await expect(reconcileProvision("tok")).resolves.toBeUndefined();
63
+ });
64
+
65
+ it("retries nextItems() on a transient (ambiguous) error and still succeeds", async () => {
66
+ vi.useFakeTimers();
67
+ try {
68
+ const { reconcileProvision } = await import("./e2b.ts");
69
+ let calls = 0;
70
+ let done = false;
71
+ listMock.mockReturnValue({
72
+ get hasNext() {
73
+ return !done;
74
+ },
75
+ nextItems: vi.fn(async () => {
76
+ calls += 1;
77
+ if (calls === 1) throw new Error("fetch failed");
78
+ done = true;
79
+ return [];
80
+ }),
81
+ });
82
+
83
+ const promise = reconcileProvision("tok");
84
+ await vi.runAllTimersAsync();
85
+ await promise;
86
+ expect(calls).toBe(2);
87
+ } finally {
88
+ vi.useRealTimers();
89
+ }
90
+ });
91
+
92
+ it("does not swallow a nextItems() result that isn't an array", async () => {
93
+ const { reconcileProvision } = await import("./e2b.ts");
94
+ listMock.mockReturnValue({ hasNext: true, nextItems: vi.fn().mockResolvedValue(undefined) });
95
+
96
+ await expect(reconcileProvision("tok")).rejects.toThrow(/not an array|SandboxInfo/);
97
+ });
98
+
99
+ it("gives up after repeated transient nextItems() failures instead of retrying forever", async () => {
100
+ vi.useFakeTimers();
101
+ try {
102
+ const { reconcileProvision } = await import("./e2b.ts");
103
+ const err = new Error("fetch failed");
104
+ listMock.mockReturnValue({
105
+ hasNext: true,
106
+ nextItems: vi.fn().mockRejectedValue(err),
107
+ });
108
+
109
+ const assertion = expect(reconcileProvision("tok")).rejects.toBe(err);
110
+ await vi.runAllTimersAsync();
111
+ await assertion;
112
+ } finally {
113
+ vi.useRealTimers();
114
+ }
115
+ });
116
+
117
+ it("propagates a non-retryable kill error immediately", async () => {
118
+ const { reconcileProvision } = await import("./e2b.ts");
119
+ listMock.mockReturnValue(fakePaginator([async () => [{ sandboxId: "a" }]]));
120
+ const err = new Error("permission denied");
121
+ killMock.mockRejectedValue(err);
122
+
123
+ await expect(reconcileProvision("tok")).rejects.toBe(err);
124
+ });
125
+ });