niceeval 0.10.3-canary.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/agents/types.d.ts +5 -4
  2. package/dist/context/turn-errors.d.ts +27 -23
  3. package/dist/i18n/en.d.ts +14 -0
  4. package/dist/i18n/en.js +15 -1
  5. package/dist/i18n/zh-CN.d.ts +15 -1
  6. package/dist/i18n/zh-CN.js +15 -1
  7. package/dist/o11y/derive.js +28 -24
  8. package/dist/o11y/types.d.ts +8 -5
  9. package/dist/report/components/attempt-detail/UsageTable.js +4 -7
  10. package/dist/report/components/attempt-detail/compute.d.ts +2 -2
  11. package/dist/report/components/attempt-detail/compute.js +2 -6
  12. package/dist/report/components/attempt-detail/faces.js +7 -7
  13. package/dist/report/components/attempt-detail/index.js +0 -3
  14. package/dist/report/components/entity-lists/EvalList.js +0 -0
  15. package/dist/report/components/metric-views/compute.js +1 -1
  16. package/dist/report/model/types.d.ts +3 -4
  17. package/dist/results/locator.js +0 -0
  18. package/dist/results/select.d.ts +6 -0
  19. package/dist/results/select.js +8 -0
  20. package/dist/runner/feedback/sink.d.ts +26 -1
  21. package/dist/runner/fingerprint.d.ts +23 -0
  22. package/dist/runner/types.d.ts +105 -7
  23. package/dist/sandbox/errors.d.ts +29 -0
  24. package/dist/sandbox/resolve.d.ts +9 -0
  25. package/dist/shared/failure-class.d.ts +91 -0
  26. package/dist/types.d.ts +1 -0
  27. package/dist/util.d.ts +3 -2
  28. package/dist/util.js +31 -5
  29. package/docs-site/zh/explanation/runner.mdx +35 -0
  30. package/docs-site/zh/reference/cli.mdx +3 -3
  31. package/docs-site/zh/reference/events.mdx +4 -4
  32. package/docs-site/zh/troubleshooting/debugging.mdx +2 -2
  33. package/docs-site/zh/tutorials/viewing-results.mdx +4 -5
  34. package/package.json +4 -12
  35. package/src/agents/ai-sdk.test.ts +26 -0
  36. package/src/agents/ai-sdk.ts +7 -4
  37. package/src/agents/index.ts +5 -3
  38. package/src/agents/langgraph.test.ts +30 -0
  39. package/src/agents/langgraph.ts +5 -2
  40. package/src/agents/openai-compat.test.ts +35 -0
  41. package/src/agents/openai-compat.ts +16 -4
  42. package/src/agents/sdk-streams.test.ts +66 -0
  43. package/src/agents/sdk-streams.ts +3 -1
  44. package/src/agents/types.ts +5 -4
  45. package/src/cli.ts +55 -14
  46. package/src/context/context.test.ts +34 -0
  47. package/src/context/context.ts +14 -5
  48. package/src/context/send-retry.test.ts +86 -0
  49. package/src/context/send-retry.ts +37 -12
  50. package/src/context/session.ts +24 -0
  51. package/src/context/turn-errors.test.ts +124 -17
  52. package/src/context/turn-errors.ts +60 -50
  53. package/src/define.ts +5 -0
  54. package/src/i18n/en.ts +20 -1
  55. package/src/i18n/zh-CN.ts +20 -1
  56. package/src/index.ts +8 -0
  57. package/src/o11y/cost.ts +4 -2
  58. package/src/o11y/derive.test.ts +40 -0
  59. package/src/o11y/derive.ts +28 -22
  60. package/src/o11y/otlp/sandbox-receiver.test.ts +201 -0
  61. package/src/o11y/otlp/sandbox-receiver.ts +73 -27
  62. package/src/o11y/parsers/bub.test.ts +30 -0
  63. package/src/o11y/parsers/bub.ts +5 -2
  64. package/src/o11y/parsers/codex.test.ts +19 -0
  65. package/src/o11y/parsers/codex.ts +5 -2
  66. package/src/o11y/types.ts +8 -5
  67. package/src/report/components/attempt-detail/UsageTable.tsx +4 -6
  68. package/src/report/components/attempt-detail/attempt-components.test.tsx +5 -8
  69. package/src/report/components/attempt-detail/compute.ts +2 -7
  70. package/src/report/components/attempt-detail/faces.ts +7 -7
  71. package/src/report/components/attempt-detail/index.tsx +0 -3
  72. package/src/report/components/entity-lists/EvalList.tsx +0 -0
  73. package/src/report/components/metric-views/compute.ts +1 -1
  74. package/src/report/model/types.ts +3 -4
  75. package/src/results/format.ts +9 -2
  76. package/src/results/index.ts +2 -0
  77. package/src/results/locator.ts +0 -0
  78. package/src/results/open.ts +132 -21
  79. package/src/results/select.ts +12 -0
  80. package/src/results/skipped-notice.ts +0 -0
  81. package/src/runner/attempt.test.ts +116 -0
  82. package/src/runner/attempt.ts +89 -8
  83. package/src/runner/discover.ts +21 -3
  84. package/src/runner/feedback/coordinator.ts +24 -2
  85. package/src/runner/feedback/eval-conclusions.ts +6 -3
  86. package/src/runner/feedback/human.test.ts +300 -6
  87. package/src/runner/feedback/human.ts +132 -30
  88. package/src/runner/feedback/json.test.ts +127 -2
  89. package/src/runner/feedback/json.ts +51 -3
  90. package/src/runner/feedback/reducer.test.ts +328 -29
  91. package/src/runner/feedback/reducer.ts +84 -9
  92. package/src/runner/feedback/sink.ts +39 -1
  93. package/src/runner/fingerprint.ts +49 -19
  94. package/src/runner/gate-lease.test.ts +510 -0
  95. package/src/runner/gate-lease.ts +350 -0
  96. package/src/runner/lock.test.ts +454 -0
  97. package/src/runner/lock.ts +288 -0
  98. package/src/runner/report.test.ts +1 -0
  99. package/src/runner/run.test.ts +2044 -9
  100. package/src/runner/run.ts +825 -61
  101. package/src/runner/teardown-registry.ts +20 -78
  102. package/src/runner/types.ts +103 -7
  103. package/src/sandbox/errors.test.ts +72 -0
  104. package/src/sandbox/errors.ts +83 -0
  105. package/src/sandbox/keep-registry.ts +22 -46
  106. package/src/sandbox/resolve.test.ts +100 -0
  107. package/src/sandbox/resolve.ts +84 -27
  108. package/src/shared/entry-file-store.test.ts +149 -0
  109. package/src/shared/entry-file-store.ts +117 -0
  110. package/src/shared/failure-class.test.ts +137 -0
  111. package/src/shared/failure-class.ts +175 -0
  112. package/src/show/index.ts +5 -6
  113. package/src/show/render.test.ts +116 -15
  114. package/src/show/render.ts +124 -35
  115. package/src/types.ts +9 -0
  116. package/src/util.ts +31 -4
  117. package/src/view/app/App.tsx +5 -1
  118. package/src/view/client-dist/app.js +1 -1
  119. package/src/view/data.ts +5 -9
  120. package/src/view/shared/types.ts +7 -1
  121. package/src/view/view-report.test.ts +54 -0
@@ -93,12 +93,15 @@ export function parseBubTranscript(raw: string | undefined): ParsedTranscript {
93
93
  // 从 event/run 的 data.usage(或 legacy 顶层 usage)累加用量。
94
94
  const addUsage = (usage: unknown): void => {
95
95
  if (!usage || typeof usage !== "object") return;
96
- const input = num(usage, "input_tokens", "prompt_tokens", "inputTokens");
96
+ const rawInput = num(usage, "input_tokens", "prompt_tokens", "inputTokens");
97
97
  const output = num(usage, "output_tokens", "completion_tokens", "outputTokens");
98
98
  let cache = num(usage, "cached_input_tokens", "cache_read_input_tokens", "cacheReadTokens");
99
99
  if (cache === 0) cache = num(get(usage, "prompt_tokens_details"), "cached_tokens");
100
100
  const cost = num(usage, "cost");
101
- if (input === 0 && output === 0 && cache === 0 && cost === 0) return;
101
+ if (rawInput === 0 && output === 0 && cache === 0 && cost === 0) return;
102
+ // Chat Completions 口径的 prompt_tokens 含缓存命中,cached_tokens 是其子集;
103
+ // 落互斥桶前扣掉(docs/feature/adapters/sdk/bub/cost.md)
104
+ const input = Math.max(0, rawInput - cache);
102
105
  inputTokens += input;
103
106
  outputTokens += output;
104
107
  cacheReadTokens += cache;
@@ -0,0 +1,19 @@
1
+ // cases: docs/engineering/testing/unit/results.md
2
+ // 「Usage、facts 与失败命令证据落盘」桶恒互斥归一:codex transcript 的 cached_input_tokens
3
+ // 是 input_tokens 子集,聚合前逐请求扣减。
4
+ // bug: memory/estimatecost-openai-inclusive-cache-double-billed.md
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { parseCodexTranscript } from "./codex.ts";
9
+
10
+ describe("parseCodexTranscript usage 归一(OpenAI 口径)", () => {
11
+ it("cached_input_tokens 从 input_tokens 里扣出,cache 单独成桶", () => {
12
+ const raw = [
13
+ JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1000, cached_input_tokens: 900, output_tokens: 50 } }),
14
+ JSON.stringify({ type: "turn.completed", usage: { input_tokens: 2000, cached_input_tokens: 1700, output_tokens: 30 } }),
15
+ ].join("\n");
16
+ const parsed = parseCodexTranscript(raw);
17
+ expect(parsed.usage).toMatchObject({ inputTokens: 400, cacheReadTokens: 2600, outputTokens: 80, requests: 2 });
18
+ });
19
+ });
@@ -80,14 +80,17 @@ function readUsage(u: unknown): { input: number; output: number; cacheRead: numb
80
80
  }
81
81
  return 0;
82
82
  };
83
- const input = num("input_tokens", "prompt_tokens", "inputTokens", "promptTokens");
84
- const output = num("output_tokens", "completion_tokens", "outputTokens", "completionTokens");
83
+ const rawInput = num("input_tokens", "prompt_tokens", "inputTokens", "promptTokens");
85
84
  const cacheRead = num(
86
85
  "cached_input_tokens",
87
86
  "cache_read_input_tokens",
88
87
  "cache_read_tokens",
89
88
  "cacheReadTokens",
90
89
  );
90
+ // codex-rs TokenUsage 的 cached_input_tokens 是 input_tokens 的子集,落互斥桶前扣掉
91
+ // (docs/feature/adapters/sdk/codex-cli/cost.md)
92
+ const input = Math.max(0, rawInput - cacheRead);
93
+ const output = num("output_tokens", "completion_tokens", "outputTokens", "completionTokens");
91
94
  // codex-rs TokenUsage 结构体与 Responses API usage.output_tokens_details 都拆出这项:
92
95
  // 推理模型(o-series/gpt-5 系列)的思考 token,已计入 output 但值得单列展示。
93
96
  const reasoning = num("reasoning_output_tokens", "reasoning_tokens", "reasoningOutputTokens", "reasoningTokens");
package/src/o11y/types.ts CHANGED
@@ -6,18 +6,21 @@ import type { JsonValue, SourceLoc } from "../shared/types.ts";
6
6
  /**
7
7
  * 一次运行的 token 用量(沙箱型从 transcript/OTel span 的 `gen_ai.usage.*` 属性抠,remote 由
8
8
  * send 的 `Turn.usage` 直接返回)。每个字段只在协议真实提供该值时存在——原始协议没有 usage 时
9
- * 省略,不编造数值;不存在「默认 0」或「默认 1」的字段(docs/feature/results/architecture.md#usage)。
9
+ * 省略,不编造数值;不存在「默认 0」或「默认 1」的字段。三个输入侧桶恒互斥:相加才是送进模型的
10
+ * 完整上下文量;把协议原生口径归一到互斥是 adapter 的落值义务——OpenAI 系报「含缓存的输入总量 +
11
+ * 缓存命中子集」,落 inputTokens 前先扣掉子集(docs/feature/results/architecture.md#usage,
12
+ * 各协议明细见 docs/feature/adapters/sdk/<name>/cost.md)。
10
13
  */
11
14
  export interface Usage {
12
- /** 计费口径的输入 token 总量(协议报什么记什么,含 cache read 时如实包含,不换算)。 */
15
+ /** 未命中缓存、按全价计费的输入 token;与两个 cache 桶互斥。 */
13
16
  inputTokens?: number;
14
17
  /** 输出(completion)token 数。 */
15
18
  outputTokens?: number;
16
- /** 从缓存命中的输入部分;与 inputTokens 同一计量口径(省略表示该 agent 不上报此项)。 */
19
+ /** 从提示缓存命中的输入 token;独立计价桶,不包含在 inputTokens 里(省略表示该 agent 不上报此项)。 */
17
20
  cacheReadTokens?: number;
18
- /** 写入 prompt 缓存创建的 token 数(省略表示该 agent 不上报此项)。 */
21
+ /** 写入提示缓存的输入 token;独立计价桶,不包含在 inputTokens 里(省略表示该 agent 不上报此项)。 */
19
22
  cacheCreationTokens?: number;
20
- /** 推理(thinking)token 数,只在协议真实提供时存在(省略表示该 agent 不上报此项)。 */
23
+ /** 推理(thinking)token 数,outputTokens 的已含明细,单列展示用;只在协议真实提供时存在。 */
21
24
  reasoningTokens?: number;
22
25
  /** 真实发生的模型请求数。协议不提供请求计数就省略,绝不写 1 凑数。 */
23
26
  requests?: number;
@@ -12,12 +12,10 @@ export function UsageTable({ data, className }: { data: UsageTableData | null; c
12
12
  const rows: [string, string][] = [];
13
13
  if (data.turns !== undefined) rows.push(["turns", String(data.turns)]);
14
14
  if (data.toolCalls !== undefined) rows.push(["tool calls", String(data.toolCalls)]);
15
- // 未缓存输入是消费端派生量,只在两个输入都在场时显示;缺任一个回退显示原始
16
- // inputTokens(不猜 0),两种来源用不同行标签区分。
17
- if (data.uncachedInputTokens !== undefined) {
18
- rows.push(["uncached in", data.uncachedInputTokens.toLocaleString()]);
19
- } else if (usage?.inputTokens !== undefined) {
20
- rows.push(["in", usage.inputTokens.toLocaleString()]);
15
+ // 桶恒互斥,inputTokens 就是未缓存输入;"uncached in" 标签只在 cache 拆分在场时用,
16
+ // cache 桶缺席的数字不贴标注。
17
+ if (usage?.inputTokens !== undefined) {
18
+ rows.push([usage.cacheReadTokens !== undefined ? "uncached in" : "in", usage.inputTokens.toLocaleString()]);
21
19
  }
22
20
  if (usage?.cacheReadTokens !== undefined) rows.push(["cache read", usage.cacheReadTokens.toLocaleString()]);
23
21
  if (usage?.outputTokens !== undefined) rows.push(["out", usage.outputTokens.toLocaleString()]);
@@ -422,15 +422,12 @@ describe("Attempt 详情组件族:非空/空证据矩阵", () => {
422
422
  expect("turns" in data).toBe(true);
423
423
  });
424
424
 
425
- it("UsageTable:uncachedInputTokens 只在 inputTokens 与 cacheReadTokens 都存在时派生,缺任一不猜 0", () => {
425
+ it("UsageTable:桶恒互斥,inputTokens 落盘原样透传,不派生第二个输入字段", () => {
426
+ // inputTokens 本身就是未缓存输入(docs/feature/results/architecture.md#usage),
427
+ // "uncached in" 只是 face 层在 cacheReadTokens 在场时给的标注,data 不多一个字段。
426
428
  const both = usageTableData(evidenceOf({ result: resultOf({ usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 40 } }) }))!;
427
- expect(both.uncachedInputTokens).toBe(60);
428
-
429
- // 只缺 cacheReadTokens(该 agent 不上报缓存命中):不派生,整字段省略(不回退猜 0)——
430
- // text 面此时回退显示原始 inputTokens,见 faces.ts::usageTableText。
431
- const noCacheRead = usageTableData(evidenceOf({ result: resultOf({ usage: { inputTokens: 100, outputTokens: 1 } }) }))!;
432
- expect(noCacheRead.uncachedInputTokens).toBeUndefined();
433
- expect("uncachedInputTokens" in noCacheRead).toBe(false);
429
+ expect(both.usage?.inputTokens).toBe(100);
430
+ expect("uncachedInputTokens" in both).toBe(false);
434
431
  });
435
432
 
436
433
  it("UsageTable:requests 缺失时不出现在 usage 对象里(落盘原样透传,不由 usageTableData 凑值)", () => {
@@ -486,8 +486,8 @@ export function attemptDiagnosticsData(evidence: AttemptEvidence): AttemptDiagno
486
486
  * identity 字段(locator/experimentId/evalId/attempt/verdict)恒有;turns/toolCalls 是 events
487
487
  * 派生(与 o11y.json 行为摘要同源,buildO11ySummary 与 o11y.json 落盘走同一份纯函数),没有
488
488
  * events 就整对省略——不因为其中一个恰好是 0 就当作"缺失"处理,0 是观测到的事实。
489
- * uncachedInputTokens 只在 inputTokens 与 cacheReadTokens 都存在时派生,缺任一个不猜 0
490
- * (text 面回退显示原始 inputTokens)。turns/toolCalls/usage 三者全部缺失时返回 null——
489
+ * token 桶恒互斥,inputTokens 本身就是未缓存输入,不派生第二个字段。
490
+ * turns/toolCalls/usage 三者全部缺失时返回 null——
491
491
  * 没有任何用量事实可摆,与其余叶子同一条"没有 usage 时零输出"规则。
492
492
  */
493
493
  export function usageTableData(evidence: AttemptEvidence): UsageTableData | null {
@@ -498,10 +498,6 @@ export function usageTableData(evidence: AttemptEvidence): UsageTableData | null
498
498
  const usage = result.usage;
499
499
  if (turns === undefined && toolCalls === undefined && usage === undefined) return null;
500
500
 
501
- const uncachedInputTokens =
502
- usage && typeof usage.inputTokens === "number" && typeof usage.cacheReadTokens === "number"
503
- ? usage.inputTokens - usage.cacheReadTokens
504
- : undefined;
505
501
  const estimatedCostUSD = attemptCostUSD(result);
506
502
 
507
503
  return {
@@ -513,7 +509,6 @@ export function usageTableData(evidence: AttemptEvidence): UsageTableData | null
513
509
  ...(turns !== undefined ? { turns } : {}),
514
510
  ...(toolCalls !== undefined ? { toolCalls } : {}),
515
511
  ...(usage !== undefined ? { usage } : {}),
516
- ...(uncachedInputTokens !== undefined ? { uncachedInputTokens } : {}),
517
512
  ...(estimatedCostUSD !== null ? { estimatedCostUSD } : {}),
518
513
  };
519
514
  }
@@ -314,14 +314,14 @@ export function usageTableText(data: UsageTableData | null, _ctx: TextContext):
314
314
  if (data === null) return "";
315
315
  const usage = data.usage;
316
316
 
317
- // 未缓存输入是消费端派生量,只在两个输入都在场时显示;缺任一个回退显示原始 inputTokens
318
- // (不猜 0),这里用 label 区分"派生值"与"原始值",不假装两者是同一件事。
317
+ // 桶恒互斥,inputTokens 就是未缓存输入;"uncached" 标注只在 cache 拆分真实在场时给,
318
+ // cache 桶缺席的数字不贴标注——不给没有拆分事实的数字暗示拆分。
319
319
  const inFragment =
320
- data.uncachedInputTokens !== undefined
321
- ? `${formatMetricValue(data.uncachedInputTokens)} uncached in`
322
- : usage?.inputTokens !== undefined
323
- ? `${formatMetricValue(usage.inputTokens)} in`
324
- : undefined;
320
+ usage?.inputTokens !== undefined
321
+ ? usage.cacheReadTokens !== undefined
322
+ ? `${formatMetricValue(usage.inputTokens)} uncached in`
323
+ : `${formatMetricValue(usage.inputTokens)} in`
324
+ : undefined;
325
325
  const cacheFragment = usage?.cacheReadTokens !== undefined ? `${formatMetricValue(usage.cacheReadTokens)} cache read` : undefined;
326
326
  const outFragment = usage?.outputTokens !== undefined ? `${formatMetricValue(usage.outputTokens)} out` : undefined;
327
327
  const inCache = [inFragment, cacheFragment].filter((s): s is string => s !== undefined).join(" + ");
@@ -594,9 +594,6 @@ export function validateUsageData(data: unknown): string | null {
594
594
  const usageProb = usageProblem(data.usage, "usage");
595
595
  if (usageProb !== null) return usageProb;
596
596
  }
597
- if (data.uncachedInputTokens !== undefined && typeof data.uncachedInputTokens !== "number") {
598
- return '"uncachedInputTokens" must be a number';
599
- }
600
597
  if (data.estimatedCostUSD !== undefined && typeof data.estimatedCostUSD !== "number") {
601
598
  return '"estimatedCostUSD" must be a number';
602
599
  }
@@ -421,7 +421,7 @@ export async function metricLineData(input: ReportInput, options: MetricLineOpti
421
421
  );
422
422
  }
423
423
  const series = options.series ? seriesKey(options.series, item) : undefined;
424
- const bucketKey = `${series ?? ""}${x === null ? "null" : String(x)}`;
424
+ const bucketKey = `${series ?? ""}\u0000${x === null ? "null" : String(x)}`;
425
425
  const bucket = buckets.get(bucketKey);
426
426
  if (bucket) bucket.items.push(item);
427
427
  else buckets.set(bucketKey, { series, x, items: [item] });
@@ -766,9 +766,9 @@ export interface AttemptDiagnosticsData {
766
766
  *
767
767
  * - `turns`/`toolCalls`:events 派生(与 `o11y.json` 行为摘要同源),只在有非空 events 时出现,
768
768
  * 哪怕派生值恰好是 0(有 events 但零轮/零工具调用是观测到的事实,不是缺失)。
769
- * - `usage`:落盘 `Usage` 原样,只在 `result.usage` 存在时出现。
770
- * - `uncachedInputTokens`:`usage.inputTokens − usage.cacheReadTokens`,只在两个输入都存在时派生;
771
- * 缺任一个不回退猜 0,整字段省略(text 面回退显示原始 `inputTokens`)。
769
+ * - `usage`:落盘 `Usage` 原样,只在 `result.usage` 存在时出现。桶恒互斥,`inputTokens` 本身
770
+ * 就是未缓存输入(契约见 docs/feature/results/architecture.md#usage);"uncached in" 标注只在
771
+ * `cacheReadTokens` 在场时由 face 层给出,不派生第二个字段。
772
772
  * - `estimatedCostUSD`:能算出成本(`usage.costUSD` 或 `result.estimatedCostUSD`)时才出现。
773
773
  *
774
774
  * `turns`/`toolCalls`/`usage` 三者全部缺失时(没有 events 也没有落盘 usage)整个 data 为
@@ -783,7 +783,6 @@ export interface UsageTableData {
783
783
  turns?: number;
784
784
  toolCalls?: number;
785
785
  usage?: Usage;
786
- uncachedInputTokens?: number;
787
786
  estimatedCostUSD?: number;
788
787
  }
789
788
 
@@ -13,10 +13,17 @@ export const RESULT_FILE = "result.json";
13
13
  /** 快照元数据文件名。 */
14
14
  export const SNAPSHOT_FILE = "snapshot.json";
15
15
 
16
+ /** eval 目录(相对快照根):evalId 里的 / 保留作目录层级,其余危险字符替换。 */
17
+ export function evalDirOf(evalId: string): string {
18
+ return evalId.replace(/[^\w./@-]/g, "_");
19
+ }
20
+
21
+ /** attempt 目录名前缀:`a<attempt>`,同一 eval 目录下按序号并列。 */
22
+ export const ATTEMPT_DIR_PREFIX = "a";
23
+
16
24
  /** attempt 目录(相对快照根):`<evalId>/a<attempt>`;evalId 里的 / 保留作目录层级,其余危险字符替换。 */
17
25
  export function attemptDirOf(r: Pick<EvalResult, "id" | "attempt">): string {
18
- const id = r.id.replace(/[^\w./@-]/g, "_");
19
- return `${id}/a${r.attempt}`;
26
+ return `${evalDirOf(r.id)}/${ATTEMPT_DIR_PREFIX}${r.attempt}`;
20
27
  }
21
28
 
22
29
  /** 实验目录名:experimentId 里的 / 与其它非 [\w.@-] 字符替换成 _。 */
@@ -9,6 +9,8 @@
9
9
 
10
10
  export {
11
11
  openResults,
12
+ loadLatestResultsForCase,
13
+ withArtifactBase,
12
14
  experimentOfSnapshot,
13
15
  resolveLocator,
14
16
  LocatorNotFoundError,
Binary file
@@ -12,8 +12,16 @@ import type { EvalResult } from "../types.ts";
12
12
  import type { FailedCommandEvidence, O11ySummary, StreamEvent, TraceSpan } from "../types.ts";
13
13
  import type { AgentSetupManifest, DiffData, SourceArtifact } from "../types.ts";
14
14
  import { deriveDiffData } from "../scoring/diff.ts";
15
- import { RESULT_FILE, SNAPSHOT_FILE, artifactFileOf, classifySnapshot } from "./format.ts";
16
- import { isNewerSnapshot, selectCurrentResults, selectLatest } from "./select.ts";
15
+ import {
16
+ ATTEMPT_DIR_PREFIX,
17
+ RESULT_FILE,
18
+ SNAPSHOT_FILE,
19
+ artifactFileOf,
20
+ classifySnapshot,
21
+ evalDirOf,
22
+ experimentDirOf,
23
+ } from "./format.ts";
24
+ import { isNewerSnapshot, isNewerSnapshotPlacement, selectCurrentResults, selectLatest } from "./select.ts";
17
25
  import {
18
26
  encodeAttemptLocator,
19
27
  resolveAttemptLocator,
@@ -267,9 +275,9 @@ async function handleMetaFile(path: string, state: ScanState): Promise<void> {
267
275
 
268
276
  // ───────────────────────── 快照读取 ─────────────────────────
269
277
 
270
- /** 快照目录:递归收集全部 result.json,组装成 evals / attempts;单个 result.json 坏 JSON 不拖垮快照。 */
271
- async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState): Promise<Snapshot> {
272
- const snapshot: Snapshot = {
278
+ /** snapshot.json 的元数据 → 空壳 Snapshot(evals / attempts 待填);全量扫描与收窄读共用。 */
279
+ function makeSnapshotShell(dir: string, meta: SnapshotMeta): Snapshot {
280
+ return {
273
281
  experimentId: meta.experimentId,
274
282
  startedAt: meta.startedAt,
275
283
  ...(meta.completedAt !== undefined ? { completedAt: meta.completedAt } : {}),
@@ -286,6 +294,124 @@ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState
286
294
  dir,
287
295
  ...(meta.knownEvalIds?.length ? { knownEvalIds: [...meta.knownEvalIds] } : {}),
288
296
  };
297
+ }
298
+
299
+ /**
300
+ * 快照级字段拼合:「缺才补」,条目自带的值(携带条目的 startedAt)优先。全量扫描与收窄读
301
+ * 共用这一份——收窄读若自己拼一遍,携入的条目会缺 experimentId / locator 一类字段。
302
+ */
303
+ function applySnapshotDefaults(record: EvalResult, meta: SnapshotMeta): void {
304
+ record.experimentId ??= meta.experimentId;
305
+ record.agent ??= meta.agent;
306
+ if (record.model === undefined && meta.model !== undefined) record.model = meta.model;
307
+ record.startedAt ??= meta.startedAt;
308
+ if (record.experiment === undefined && meta.experiment !== undefined) record.experiment = meta.experiment;
309
+ // locator 同理「缺才补」:niceeval 自己的 writer(schemaVersion 5 起)恒会写这个字段,
310
+ // 携带条目原样携带上一轮的值——只有真缺失(第三方 harness 没实现 locator,或手工构造的
311
+ // 落盘)才按当前身份兜底算一份;这份兜底不保证跨未来的 --resume 稳定,但至少确定性、
312
+ // 可解析,不比完全没有 locator 差。
313
+ record.locator ??= encodeAttemptLocator({
314
+ experimentId: record.experimentId,
315
+ snapshotStartedAt: meta.startedAt,
316
+ evalId: record.id,
317
+ attempt: record.attempt,
318
+ });
319
+ }
320
+
321
+ /**
322
+ * 携带条目 / 新算的 artifactBase(相对结果根):条目自带值优先(`--resume` 携入的条目指向
323
+ * 原快照),缺则按所在 attempt 的 ref 现拼。携带条目要能被 view 找回 artifact,少了这一步
324
+ * 报告里的携带行会静默丢 artifact——所以全量扫描(view/data.ts)与收窄读共用这一份公式。
325
+ */
326
+ export function withArtifactBase(attempt: AttemptHandle): EvalResult {
327
+ const r = attempt.result;
328
+ if (r.artifactBase !== undefined) return r;
329
+ return { ...r, artifactBase: `${attempt.ref.snapshot}/${attempt.ref.attempt}` };
330
+ }
331
+
332
+ /**
333
+ * 收窄读:只取某条 `(experimentId, evalId)` 的「当前可用」attempt,不扫全根。
334
+ *
335
+ * 口径与 `loadLatestResultsPerEval` 完全一致——整批取自**含它的最新快照**,不跨快照混装;
336
+ * 「哪份最新」走 `isNewerSnapshotPlacement`(snapshot.json 的 `startedAt` 优先、同刻按目录名),
337
+ * 目录名倒序只作为候选遍历顺序,不作判据。**不检查快照有没有 `completedAt`**:被中断或强杀的
338
+ * 未收尾快照里已落盘的终态照常可读(docs/runner.md「缓存:指纹去重」的「携带来源不要求快照收尾」)。
339
+ *
340
+ * 代价与该实验的历史快照数近似无关:没跑过这条 eval 的快照只付一次 readdir,不读任何文件;
341
+ * 只有命中的候选才读 `snapshot.json`。派发路径上的携带重查用它替代全树扫描
342
+ * (`openResults` 会读并 parse 全根每一个 `result.json`)。
343
+ */
344
+ export async function loadLatestResultsForCase(
345
+ root: string,
346
+ experimentId: string,
347
+ evalId: string,
348
+ ): Promise<EvalResult[]> {
349
+ const experimentDir = join(resolve(root), experimentDirOf(experimentId));
350
+ let snapshotDirNames: string[];
351
+ try {
352
+ snapshotDirNames = (await readdir(experimentDir, { withFileTypes: true }))
353
+ .filter((e) => e.isDirectory())
354
+ .map((e) => e.name);
355
+ } catch {
356
+ return []; // 这个实验还没跑过:不是错误
357
+ }
358
+ // 目录名带时间戳前缀,倒序 = 最可能命中的先试;权威判定仍在下面的 isNewerSnapshotPlacement。
359
+ snapshotDirNames.sort((a, b) => b.localeCompare(a));
360
+ const evalSegment = evalDirOf(evalId);
361
+
362
+ let best: { dir: string; meta: SnapshotMeta; attemptDirNames: string[] } | undefined;
363
+ for (const name of snapshotDirNames) {
364
+ const snapshotDir = join(experimentDir, name);
365
+ let attemptDirNames: string[];
366
+ try {
367
+ attemptDirNames = (await readdir(join(snapshotDir, evalSegment), { withFileTypes: true }))
368
+ .filter((e) => e.isDirectory() && ATTEMPT_DIR_RE.test(e.name))
369
+ .map((e) => e.name);
370
+ } catch {
371
+ continue; // 这份快照没跑过这条 eval:只付一次 readdir
372
+ }
373
+ if (attemptDirNames.length === 0) continue;
374
+ let meta: SnapshotMeta;
375
+ try {
376
+ const classified = classifySnapshot(JSON.parse(await readFile(join(snapshotDir, SNAPSHOT_FILE), "utf-8")));
377
+ if (classified.kind !== "ok") continue; // 坏 / 不兼容版本的快照:跳过,不拖垮这次读
378
+ meta = classified.meta;
379
+ } catch {
380
+ continue;
381
+ }
382
+ if (best === undefined || isNewerSnapshotPlacement({ startedAt: meta.startedAt, dir: snapshotDir }, { startedAt: best.meta.startedAt, dir: best.dir })) {
383
+ best = { dir: snapshotDir, meta, attemptDirNames };
384
+ }
385
+ }
386
+ if (best === undefined) return [];
387
+
388
+ const snapshot = makeSnapshotShell(best.dir, best.meta);
389
+ const out: EvalResult[] = [];
390
+ for (const attemptDirName of best.attemptDirNames.sort(byAttemptIndex)) {
391
+ const attemptDir = join(best.dir, evalSegment, attemptDirName);
392
+ let record: EvalResult;
393
+ try {
394
+ record = JSON.parse(await readFile(join(attemptDir, RESULT_FILE), "utf-8")) as EvalResult;
395
+ } catch {
396
+ continue; // 缺 result.json / 坏 JSON:如实跳过(与 openResults 的 skipped 同一「不拖垮整次读」精神)
397
+ }
398
+ // 目录名是被清洗过的 evalId,两条不同的 id 理论上可以洗成同一段;记录自报的 id 才是权威。
399
+ if (record.id !== evalId) continue;
400
+ applySnapshotDefaults(record, best.meta);
401
+ out.push(withArtifactBase(makeAttempt(snapshot, best.dir, attemptDir, record)));
402
+ }
403
+ return out;
404
+ }
405
+
406
+ const ATTEMPT_DIR_RE = new RegExp(`^${ATTEMPT_DIR_PREFIX}\\d+$`);
407
+
408
+ function byAttemptIndex(a: string, b: string): number {
409
+ return Number(a.slice(ATTEMPT_DIR_PREFIX.length)) - Number(b.slice(ATTEMPT_DIR_PREFIX.length));
410
+ }
411
+
412
+ /** 快照目录:递归收集全部 result.json,组装成 evals / attempts;单个 result.json 坏 JSON 不拖垮快照。 */
413
+ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState): Promise<Snapshot> {
414
+ const snapshot = makeSnapshotShell(dir, meta);
289
415
 
290
416
  const resultPaths = (await findResultFiles(dir)).sort();
291
417
  const evalsById = new Map<string, Eval>();
@@ -301,22 +427,7 @@ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState
301
427
  continue;
302
428
  }
303
429
 
304
- // 快照级字段拼合:「缺才补」,条目自带的值(携带条目的 startedAt)优先。
305
- record.experimentId ??= meta.experimentId;
306
- record.agent ??= meta.agent;
307
- if (record.model === undefined && meta.model !== undefined) record.model = meta.model;
308
- record.startedAt ??= meta.startedAt;
309
- if (record.experiment === undefined && meta.experiment !== undefined) record.experiment = meta.experiment;
310
- // locator 同理「缺才补」:niceeval 自己的 writer(schemaVersion 5 起)恒会写这个字段,
311
- // 携带条目原样携带上一轮的值——只有真缺失(第三方 harness 没实现 locator,或手工构造的
312
- // 落盘)才按当前身份兜底算一份;这份兜底不保证跨未来的 --resume 稳定,但至少确定性、
313
- // 可解析,不比完全没有 locator 差。
314
- record.locator ??= encodeAttemptLocator({
315
- experimentId: record.experimentId,
316
- snapshotStartedAt: meta.startedAt,
317
- evalId: record.id,
318
- attempt: record.attempt,
319
- });
430
+ applySnapshotDefaults(record, meta);
320
431
 
321
432
  const attempt = makeAttempt(snapshot, dir, attemptDir, record);
322
433
  let ev = evalsById.get(record.id);
@@ -385,6 +385,18 @@ export function dedupeAttempts(attempts: AttemptHandle[]): { attempts: AttemptHa
385
385
 
386
386
  /** 快照新旧比较:startedAt 优先,同刻按快照目录名(时间戳 + 随机后缀,字典序即时序)。 */
387
387
  export function isNewerSnapshot(a: Snapshot, b: Snapshot): boolean {
388
+ return isNewerSnapshotPlacement(a, b);
389
+ }
390
+
391
+ /**
392
+ * isNewerSnapshot 的原始口径,供还没组装成 Snapshot 的读取面共用同一判定(收窄读
393
+ * `loadLatestResultsForCase` 只拿到目录名 + snapshot.json 的 startedAt)。同一份磁盘在两条
394
+ * 读取面上必须给出同一个「哪份最新」的答案,否则携带与报告会分叉。
395
+ */
396
+ export function isNewerSnapshotPlacement(
397
+ a: Pick<Snapshot, "startedAt" | "dir">,
398
+ b: Pick<Snapshot, "startedAt" | "dir">,
399
+ ): boolean {
388
400
  const byStart = a.startedAt.localeCompare(b.startedAt);
389
401
  if (byStart !== 0) return byStart > 0;
390
402
  return a.dir.localeCompare(b.dir) > 0;
Binary file
@@ -20,6 +20,7 @@
20
20
  import { describe, expect, it, vi } from "vitest";
21
21
  import { Effect } from "effect";
22
22
  import { runAttemptEffect } from "./attempt.ts";
23
+ import { activateFeedbackSink, type DiagnosticInput, type FeedbackSink } from "./feedback/sink.ts";
23
24
  import { defineSandboxAgent, defineSandbox } from "../define.ts";
24
25
  import { writeAgentSetupManifest, AGENT_SETUP_MANIFEST_PATH } from "../agents/manifest.ts";
25
26
  import { equals } from "../expect/index.ts";
@@ -921,3 +922,118 @@ describe("runAttemptEffect · 失败命令证据包装(公开 runCommand/runShel
921
922
  expect(result.commands).toBeUndefined();
922
923
  });
923
924
  });
925
+
926
+ // cases: docs/engineering/testing/unit/experiments-runner.md「attempt 级诊断的对外词法与阶段标注」
927
+ // bug: memory/diagnostic-key-doubles-as-json-warning-code.md
928
+ describe("runAttemptEffect · attempt 级诊断进反馈流的 code 与 phase", () => {
929
+ /** 捕获 reportDiagnostic 的入参:其余 sink 方法一律 no-op,这里只关心诊断这一条通路。 */
930
+ function captureDiagnostics(): { seen: DiagnosticInput[]; deactivate: () => void } {
931
+ const seen: DiagnosticInput[] = [];
932
+ const noop = () => {};
933
+ const sink: FeedbackSink = {
934
+ activity: noop,
935
+ diagnostic: (input) => {
936
+ seen.push(input);
937
+ },
938
+ interrupted: noop,
939
+ reporterError: noop,
940
+ failure: noop,
941
+ budgetExhausted: noop,
942
+ kept: noop,
943
+ experimentHook: noop,
944
+ precheck: noop,
945
+ lockWait: noop,
946
+ experimentProgress: noop,
947
+ lifecycle: noop,
948
+ };
949
+ return { seen, deactivate: activateFeedbackSink(sink) };
950
+ }
951
+
952
+ it("作者不传 dedupeKey:折叠 key 编进 attempt 身份,code 仍是作者给的干净字面量,phase 是报上来那一刻的阶段", async () => {
953
+ const agent = defineSandboxAgent({
954
+ name: "fake-agent-diagnostic-code",
955
+ send: async () => ({ events: [], status: "completed" }),
956
+ });
957
+ const { seen, deactivate } = captureDiagnostics();
958
+ try {
959
+ await runOnce(agent, new FakeSandbox(), {
960
+ experimentId: "compare/codex",
961
+ evalDefOverrides: {
962
+ setup: async (_sandbox, ctx) => {
963
+ ctx.diagnostic({
964
+ code: "memory-warmup-degraded",
965
+ level: "warning",
966
+ message: "Memory warmup failed; continuing with a cold index",
967
+ });
968
+ },
969
+ },
970
+ });
971
+ } finally {
972
+ deactivate();
973
+ }
974
+
975
+ expect(seen).toHaveLength(1);
976
+ const [forwarded] = seen;
977
+ expect(forwarded!.code).toBe("memory-warmup-degraded");
978
+ expect(forwarded!.key).toBe("memory-warmup-degraded:compare/codex|fake/eval|0");
979
+ expect(forwarded!.data?.phase).toBe("eval.setup");
980
+ expect(forwarded!.identity).toEqual({ experimentId: "compare/codex", evalId: "fake/eval", attempt: 0 });
981
+ });
982
+
983
+ it("作者传了 dedupeKey:折叠按作者的 key,code 与 phase 不受影响", async () => {
984
+ const agent = defineSandboxAgent({
985
+ name: "fake-agent-diagnostic-dedupe-key",
986
+ send: async () => ({ events: [], status: "completed" }),
987
+ });
988
+ const { seen, deactivate } = captureDiagnostics();
989
+ try {
990
+ await runOnce(agent, new FakeSandbox(), {
991
+ experimentId: "compare/codex",
992
+ evalDefOverrides: {
993
+ test: async (t) => {
994
+ t.diagnostic({
995
+ code: "index-rebuilt",
996
+ level: "warning",
997
+ message: "rebuilt the index",
998
+ dedupeKey: "index-rebuilt:compare/codex",
999
+ });
1000
+ },
1001
+ },
1002
+ });
1003
+ } finally {
1004
+ deactivate();
1005
+ }
1006
+
1007
+ expect(seen).toHaveLength(1);
1008
+ expect(seen[0]!.key).toBe("index-rebuilt:compare/codex");
1009
+ expect(seen[0]!.code).toBe("index-rebuilt");
1010
+ expect(seen[0]!.data?.phase).toBe("eval.run");
1011
+ });
1012
+
1013
+ it("作者 data 里冒充的 phase 被运行器的实际阶段压过,其余 data 字段原样保留", async () => {
1014
+ const agent = defineSandboxAgent({
1015
+ name: "fake-agent-diagnostic-forged-phase",
1016
+ send: async () => ({ events: [], status: "completed" }),
1017
+ });
1018
+ const { seen, deactivate } = captureDiagnostics();
1019
+ try {
1020
+ await runOnce(agent, new FakeSandbox(), {
1021
+ evalDefOverrides: {
1022
+ setup: async (_sandbox, ctx) => {
1023
+ ctx.diagnostic({
1024
+ code: "warmup-degraded",
1025
+ level: "warning",
1026
+ message: "cold index",
1027
+ data: { phase: "scoring.evaluate", indexAgeDays: 12 },
1028
+ });
1029
+ },
1030
+ },
1031
+ });
1032
+ } finally {
1033
+ deactivate();
1034
+ }
1035
+
1036
+ expect(seen).toHaveLength(1);
1037
+ expect(seen[0]!.data).toEqual({ phase: "eval.setup", indexAgeDays: 12 });
1038
+ });
1039
+ });