niceeval 0.4.6 → 0.5.1

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 (174) hide show
  1. package/docs-site/zh/concepts/adapter.mdx +6 -6
  2. package/docs-site/zh/concepts/experiment.mdx +7 -7
  3. package/docs-site/zh/concepts/overview.mdx +1 -1
  4. package/docs-site/zh/concepts/tier.mdx +6 -6
  5. package/docs-site/zh/guides/agent-feedback-loop.mdx +64 -21
  6. package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
  7. package/docs-site/zh/guides/custom-reports.mdx +243 -0
  8. package/docs-site/zh/guides/experiments.mdx +3 -3
  9. package/docs-site/zh/guides/report-components.mdx +252 -0
  10. package/docs-site/zh/guides/reporters.mdx +1 -1
  11. package/docs-site/zh/guides/results-data.mdx +224 -0
  12. package/docs-site/zh/guides/runner.mdx +1 -1
  13. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  14. package/docs-site/zh/guides/viewing-results.mdx +147 -77
  15. package/docs-site/zh/guides/write-experiment.mdx +9 -9
  16. package/docs-site/zh/guides/write-send.mdx +6 -6
  17. package/docs-site/zh/index.mdx +1 -1
  18. package/docs-site/zh/introduction.mdx +1 -1
  19. package/docs-site/zh/reference/cli.mdx +11 -3
  20. package/docs-site/zh/reference/define-agent.mdx +5 -5
  21. package/docs-site/zh/reference/define-config.mdx +1 -1
  22. package/docs-site/zh/reference/define-eval.mdx +3 -3
  23. package/package.json +3 -2
  24. package/src/agents/ai-sdk.test.ts +1 -1
  25. package/src/agents/ai-sdk.ts +2 -2
  26. package/src/agents/claude-code.ts +1 -1
  27. package/src/agents/codex.ts +2 -2
  28. package/src/agents/streaming.test.ts +1 -1
  29. package/src/agents/types.ts +4 -4
  30. package/src/agents/ui-message-stream.test.ts +1 -1
  31. package/src/cli.ts +120 -18
  32. package/src/context/context.test.ts +1 -1
  33. package/src/context/context.ts +3 -3
  34. package/src/context/session.ts +2 -2
  35. package/src/context/types.ts +2 -2
  36. package/src/i18n/en.ts +29 -7
  37. package/src/i18n/zh-CN.ts +24 -2
  38. package/src/report/aggregate.ts +79 -50
  39. package/src/report/components.tsx +190 -0
  40. package/src/report/compute.ts +149 -84
  41. package/src/report/default-report.tsx +222 -0
  42. package/src/report/dual-face.test.tsx +527 -0
  43. package/src/report/format.ts +29 -0
  44. package/src/report/index.ts +79 -24
  45. package/src/report/load.ts +67 -0
  46. package/src/report/metrics.ts +2 -1
  47. package/src/report/param.ts +18 -0
  48. package/src/report/primitives.tsx +117 -0
  49. package/src/report/react/CaseList.tsx +2 -2
  50. package/src/report/react/DeltaTable.tsx +2 -2
  51. package/src/report/react/MetricBars.tsx +109 -0
  52. package/src/report/react/MetricLine.tsx +200 -0
  53. package/src/report/react/MetricMatrix.tsx +1 -1
  54. package/src/report/react/MetricScatter.tsx +1 -1
  55. package/src/report/react/MetricTable.tsx +1 -1
  56. package/src/report/react/RunOverview.tsx +4 -4
  57. package/src/report/react/Scoreboard.tsx +3 -3
  58. package/src/report/react/cell.tsx +1 -1
  59. package/src/report/react/fixtures.ts +98 -36
  60. package/src/report/react/format.ts +3 -28
  61. package/src/report/react/index.tsx +41 -24
  62. package/src/report/react/render.test.tsx +8 -8
  63. package/src/report/react/styles.css +77 -0
  64. package/src/report/report.test.ts +248 -128
  65. package/src/report/report.ts +69 -0
  66. package/src/report/text/faces.ts +356 -0
  67. package/src/report/text/layout.ts +125 -0
  68. package/src/report/text/plot.ts +127 -0
  69. package/src/report/tree.ts +220 -0
  70. package/src/report/types.ts +75 -27
  71. package/src/report/web.ts +40 -0
  72. package/src/results/copy.ts +95 -41
  73. package/src/results/format.ts +12 -11
  74. package/src/results/index.ts +34 -17
  75. package/src/results/open.ts +201 -81
  76. package/src/results/results.test.ts +595 -191
  77. package/src/results/select.ts +107 -55
  78. package/src/results/types.ts +137 -45
  79. package/src/results/writer.ts +258 -0
  80. package/src/runner/attempt.ts +3 -3
  81. package/src/runner/fingerprint.ts +1 -1
  82. package/src/runner/reporters/artifacts.ts +38 -78
  83. package/src/runner/reporters/braintrust.test.ts +2 -2
  84. package/src/runner/reporters/braintrust.ts +3 -3
  85. package/src/runner/run.ts +1 -1
  86. package/src/runner/types.ts +18 -12
  87. package/src/show/compose.ts +215 -0
  88. package/src/show/index.ts +263 -0
  89. package/src/show/render.ts +456 -0
  90. package/src/show/show.test.ts +505 -0
  91. package/src/view/app/App.tsx +85 -24
  92. package/src/view/app/components/CostScoreChart.tsx +29 -10
  93. package/src/view/app/components/ExperimentTable.tsx +27 -9
  94. package/src/view/app/components/GroupSelector.tsx +1 -1
  95. package/src/view/app/i18n.ts +18 -3
  96. package/src/view/app/lib/attempt-route.test.ts +9 -9
  97. package/src/view/app/lib/attempt-route.ts +5 -5
  98. package/src/view/app/lib/outcome.ts +1 -3
  99. package/src/view/app/lib/rows.ts +95 -15
  100. package/src/view/app/main.tsx +18 -6
  101. package/src/view/app/pages/RunsPage.tsx +4 -7
  102. package/src/view/app/pages/TracesPage.tsx +3 -8
  103. package/src/view/app/types.ts +50 -6
  104. package/src/view/client-dist/app.css +1 -1
  105. package/src/view/client-dist/app.js +20 -20
  106. package/src/view/data.test.ts +254 -0
  107. package/src/view/data.ts +336 -0
  108. package/src/view/index.ts +64 -10
  109. package/src/view/server.ts +31 -9
  110. package/src/view/shared/types.ts +47 -41
  111. package/src/view/styles.css +5 -0
  112. package/src/view/template.html +1 -0
  113. package/src/view/view-report.test.ts +308 -0
  114. package/docs-site/.mintignore +0 -13
  115. package/docs-site/AGENTS.md +0 -46
  116. package/docs-site/concepts/adapter.mdx +0 -287
  117. package/docs-site/concepts/assert.mdx +0 -243
  118. package/docs-site/concepts/drive.mdx +0 -118
  119. package/docs-site/concepts/evals.mdx +0 -108
  120. package/docs-site/concepts/experiment.mdx +0 -37
  121. package/docs-site/concepts/hitl.mdx +0 -83
  122. package/docs-site/concepts/judge.mdx +0 -129
  123. package/docs-site/concepts/overview.mdx +0 -98
  124. package/docs-site/concepts/tier.mdx +0 -42
  125. package/docs-site/docs-ref/00-index.md +0 -23
  126. package/docs-site/docs-ref/01-page-types.md +0 -43
  127. package/docs-site/docs-ref/03-style-rules.md +0 -45
  128. package/docs-site/docs-ref/04-code-examples.md +0 -37
  129. package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
  130. package/docs-site/docs-ref/06-checklists.md +0 -53
  131. package/docs-site/docs.json +0 -256
  132. package/docs-site/example/ai-agent-application.mdx +0 -148
  133. package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
  134. package/docs-site/example/claude-code-codex-skill.mdx +0 -147
  135. package/docs-site/example/showcase.mdx +0 -39
  136. package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
  137. package/docs-site/example/tier1-claude-sdk.mdx +0 -119
  138. package/docs-site/example/tier1-codex-sdk.mdx +0 -111
  139. package/docs-site/example/tier1-langgraph.mdx +0 -119
  140. package/docs-site/example/tier1-pi-sdk.mdx +0 -119
  141. package/docs-site/favicon.svg +0 -5
  142. package/docs-site/github-diff.css +0 -135
  143. package/docs-site/github-diff.js +0 -11
  144. package/docs-site/guides/authoring.mdx +0 -129
  145. package/docs-site/guides/ci-integration.mdx +0 -97
  146. package/docs-site/guides/connect-otel.mdx +0 -209
  147. package/docs-site/guides/connect-your-agent.mdx +0 -221
  148. package/docs-site/guides/dataset-fanout.mdx +0 -98
  149. package/docs-site/guides/experiments.mdx +0 -71
  150. package/docs-site/guides/fixtures.mdx +0 -147
  151. package/docs-site/guides/reporters.mdx +0 -113
  152. package/docs-site/guides/runner.mdx +0 -79
  153. package/docs-site/guides/sandbox-agent.mdx +0 -138
  154. package/docs-site/guides/sandbox-backends.mdx +0 -64
  155. package/docs-site/guides/scoring-guide.mdx +0 -92
  156. package/docs-site/guides/viewing-results.mdx +0 -195
  157. package/docs-site/guides/write-experiment.mdx +0 -81
  158. package/docs-site/guides/write-send.mdx +0 -347
  159. package/docs-site/index.mdx +0 -181
  160. package/docs-site/introduction.mdx +0 -141
  161. package/docs-site/quickstart.mdx +0 -136
  162. package/docs-site/reference/builtin-agents.mdx +0 -161
  163. package/docs-site/reference/capabilities.mdx +0 -76
  164. package/docs-site/reference/cli.mdx +0 -120
  165. package/docs-site/reference/define-agent.mdx +0 -168
  166. package/docs-site/reference/define-config.mdx +0 -41
  167. package/docs-site/reference/define-eval.mdx +0 -160
  168. package/docs-site/reference/events.mdx +0 -131
  169. package/docs-site/reference/expect.mdx +0 -112
  170. package/docs-site/tracker.js +0 -8
  171. package/src/report/react/data.ts +0 -17
  172. package/src/view/aggregate.ts +0 -103
  173. package/src/view/loader.test.ts +0 -61
  174. package/src/view/loader.ts +0 -257
@@ -1,21 +1,24 @@
1
- // niceeval/results 读取面的单测:临时目录里手工构造最小 summary.json / 工件 fixture,
2
- // 覆盖快照切片、latest-per-experiment、残缺警告、resume 去重、版本 skipped、懒加载。
3
- // fixture 的目录名/工件路径全部手写(不 import 库的路径函数),让测试独立于实现充当格式基准。
1
+ // niceeval/results 的单测:临时目录里构造最小 summary.json / 工件 fixture,覆盖定稿契约
2
+ // (docs/results-lib.md):分层读取、懒加载回退、skipped 三种原因、latest() 三种警告、
3
+ // Selection.filter 修剪、dedupeAttempts 身份键、writer roundtrip、copySnapshots 补记,
4
+ // 以及 Artifacts 报告器(writer 薄壳)与直写时代逐字节等价的守护。
5
+ // 读取面 fixture 的目录名/工件路径手写(不 import 库的路径函数),让测试独立于实现充当格式基准。
4
6
 
5
7
  import { afterEach, describe, expect, it } from "vitest";
6
- import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
8
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
7
9
  import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
11
  import {
10
12
  RESULTS_FORMAT,
11
13
  RESULTS_SCHEMA_VERSION,
12
- copyRun,
14
+ copySnapshots,
15
+ createRunWriter,
13
16
  dedupeAttempts,
14
- latestPerExperiment,
15
17
  openResults,
16
18
  type EvalResult,
17
19
  type RunSummary,
18
20
  } from "./index.ts";
21
+ import { Artifacts } from "../runner/reporters/artifacts.ts";
19
22
 
20
23
  // ───────────────────────── fixture 工具 ─────────────────────────
21
24
 
@@ -76,81 +79,196 @@ async function exists(path: string): Promise<boolean> {
76
79
  }
77
80
  }
78
81
 
79
- // ───────────────────────── 测试 ─────────────────────────
82
+ // ───────────────────────── 分层读取 ─────────────────────────
80
83
 
81
- describe("openResults · 快照切片", () => {
82
- it("一个 run 装两个 experiment:按 experimentId 切成两个快照,agent/model/evalIds 取自切片自身", async () => {
84
+ describe("openResults · 实验 → 快照 → eval → attempt 分层", () => {
85
+ it("一个 run 装两个 experiment:切成两个快照;实验按 id 字典序,快照最新在前,latest = snapshots[0]", async () => {
83
86
  const root = await makeRoot();
84
- await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
87
+ const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
85
88
  res({ id: "algebra/q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", startedAt: "2026-07-01T08:01:00.000Z" }),
86
- res({ id: "algebra/q2", agent: "bub", model: "gpt-5", experimentId: "compare/bub", outcome: "failed" }),
89
+ res({ id: "algebra/q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", attempt: 2, outcome: "failed" }),
90
+ res({ id: "algebra/q2", agent: "bub", model: "gpt-5", experimentId: "compare/bub" }),
87
91
  res({ id: "algebra/q1", agent: "codex", model: "o3", experimentId: "compare/codex" }),
88
- ]));
92
+ ], { startedAt: "2026-07-01T08:00:00.000Z" }));
93
+ const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
94
+ res({ id: "algebra/q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub" }),
95
+ ], { startedAt: "2026-07-02T08:00:00.000Z" }));
89
96
 
90
97
  const results = await openResults(root);
91
- expect(results.runs).toHaveLength(1);
92
98
  expect(results.skipped).toHaveLength(0);
93
- expect(results.warnings).toHaveLength(0);
94
- expect(results.snapshots).toHaveLength(2);
95
-
96
- const bub = results.snapshots.find((s) => s.experimentId === "compare/bub")!;
97
- expect(bub.agent).toBe("bub");
98
- expect(bub.model).toBe("gpt-5");
99
- expect(bub.attempts).toHaveLength(2);
100
- expect(bub.evalIds).toEqual(["algebra/q1", "algebra/q2"]);
101
- expect(bub.startedAt).toBe(results.runs[0].summary.startedAt);
102
- expect(bub.run).toBe(results.runs[0]);
103
-
104
- const codex = results.snapshots.find((s) => s.experimentId === "compare/codex")!;
105
- expect(codex.agent).toBe("codex");
106
- expect(codex.model).toBe("o3");
107
- expect(codex.attempts).toHaveLength(1);
99
+ expect(results.runDirs.map((r) => r.dir)).toEqual([tuesday, monday]); // 新→旧
100
+ expect(results.experiments.map((e) => e.id)).toEqual(["compare/bub", "compare/codex"]); // 字典序
101
+
102
+ const bub = results.experiments[0];
103
+ expect(bub.snapshots.map((s) => s.startedAt)).toEqual(["2026-07-02T08:00:00.000Z", "2026-07-01T08:00:00.000Z"]);
104
+ expect(bub.latest).toBe(bub.snapshots[0]);
105
+ expect(bub.evalIds).toEqual(["algebra/q1", "algebra/q2"]); // 本地历史并集
106
+
107
+ // eval 分组:attempt 挂在题下面;attempts 平铺 = evals 逐题展开。
108
+ const mondayBub = bub.snapshots[1];
109
+ expect(mondayBub.agent).toBe("bub");
110
+ expect(mondayBub.model).toBe("gpt-5");
111
+ expect(mondayBub.producer).toEqual({ name: "niceeval", version: "0.3.0" });
112
+ expect(mondayBub.schemaVersion).toBe(RESULTS_SCHEMA_VERSION);
113
+ expect(mondayBub.runDir.dir).toBe(monday);
114
+ expect(mondayBub.evals.map((e) => e.id)).toEqual(["algebra/q1", "algebra/q2"]);
115
+ expect(mondayBub.evals[0].attempts).toHaveLength(2);
116
+ expect(mondayBub.attempts).toHaveLength(3);
117
+
118
+ // attempt 直达字段与证据引用。
119
+ const attempt = mondayBub.evals[0].attempts[0];
120
+ expect(attempt.evalId).toBe("algebra/q1");
121
+ expect(attempt.experimentId).toBe("compare/bub");
122
+ expect(attempt.ref).toEqual({ run: "2026-07-01T08-00-00-000Z", result: 0 });
123
+ expect(attempt.result.startedAt).toBe("2026-07-01T08:01:00.000Z");
108
124
  });
109
125
 
110
- it("experimentId 缺失时以 <agent>/<model> 合成快照键,并出英文 warning(无 model 用 default)", async () => {
126
+ it("summary.snapshots 元数据:快照级 startedAt 覆盖顶层,knownEvalIds 并进 exp.evalIds", async () => {
111
127
  const root = await makeRoot();
112
128
  await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
113
- res({ id: "q1", agent: "codex" }),
114
- res({ id: "q2", agent: "codex" }),
115
- ]));
129
+ res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
130
+ res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
131
+ ], {
132
+ startedAt: "2026-07-01T08:00:00.000Z",
133
+ snapshots: {
134
+ "mid/b": { startedAt: "2026-06-20T08:00:00.000Z", knownEvalIds: ["q1", "q2", "q3"] },
135
+ },
136
+ }));
116
137
 
117
138
  const results = await openResults(root);
118
- expect(results.snapshots).toHaveLength(1);
119
- expect(results.snapshots[0].experimentId).toBe("codex/default");
120
- expect(results.warnings).toHaveLength(1);
121
- expect(results.warnings[0]).toMatch(/without experimentId/);
122
- expect(results.warnings[0]).toMatch(/"codex\/default"/);
139
+ const a = results.experiments.find((e) => e.id === "mid/a")!;
140
+ const b = results.experiments.find((e) => e.id === "mid/b")!;
141
+ expect(a.latest.startedAt).toBe("2026-07-01T08:00:00.000Z");
142
+ expect(b.latest.startedAt).toBe("2026-06-20T08:00:00.000Z");
143
+ expect(b.latest.knownEvalIds).toEqual(["q1", "q2", "q3"]);
144
+ expect(b.evalIds).toEqual(["q1", "q2", "q3"]); // 本地覆盖 ∪ 携带的 knownEvalIds
145
+ expect(a.evalIds).toEqual(["q1"]);
123
146
  });
124
147
  });
125
148
 
126
- describe("latestPerExperiment", () => {
127
- it("跨两个 run 各取所属:每个 experiment 拿自己最新的快照", async () => {
149
+ // ───────────────────────── 懒加载与回退 ─────────────────────────
150
+
151
+ describe("AttemptHandle · 懒加载", () => {
152
+ it("缺文件返回 null 不抛错;读过一次即记忆化;artifactsDir 优先、artifactBase 回退;原 run 清理后如实 null", async () => {
128
153
  const root = await makeRoot();
129
- const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
154
+ // run:携带条目的工件真身。
155
+ const oldRun = await writeRun(root, "2026-06-30T08-00-00-000Z", summaryOf([
156
+ res({ id: "q3", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q3/bub/gpt-5/e/a1", hasEvents: true }),
157
+ ], { startedAt: "2026-06-30T08:00:00.000Z" }));
158
+ await writeArtifact(oldRun, "q3/bub/gpt-5/e/a1", "events.json", [{ type: "message", text: "old" }]);
159
+
160
+ const runDir = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
161
+ res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q1/bub/gpt-5/e/a1", hasEvents: true }),
162
+ res({ id: "q2", agent: "bub", model: "gpt-5", experimentId: "e" }),
163
+ // --resume 携带条目:本 run 没有工件,artifactBase(相对结果根)指向原 run。
164
+ res({ id: "q3", agent: "bub", model: "gpt-5", experimentId: "e", artifactBase: "2026-06-30T08-00-00-000Z/q3/bub/gpt-5/e/a1", hasEvents: true }),
165
+ ], { startedAt: "2026-07-01T08:00:00.000Z" }));
166
+ const eventsPath = await writeArtifact(runDir, "q1/bub/gpt-5/e/a1", "events.json", [{ type: "message", text: "hi" }]);
167
+
168
+ const results = await openResults(root);
169
+ const snap = results.experiments[0].latest;
170
+ const [q1, q2, q3] = snap.evals.map((e) => e.attempts[0]);
171
+
172
+ const events = await q1.events();
173
+ expect(events).toHaveLength(1);
174
+ // summary 只有 hasEvents/hasTrace/hasSources 三个标记,o11y/diff 没有标记 —— 全靠方法语义吸收。
175
+ expect(await q1.trace()).toBeNull();
176
+ expect(await q1.o11y()).toBeNull();
177
+ expect(await q1.diff()).toBeNull();
178
+ expect(await q1.sources()).toBeNull();
179
+
180
+ // 记忆化:同一 handle 读一次缓存,文件删掉后再读仍返回同一份数据。
181
+ await rm(eventsPath);
182
+ expect(await q1.events()).toBe(events);
183
+
184
+ // 条目没有 artifactsDir 也没有 artifactBase:不猜路径,全部 null。
185
+ expect(await q2.events()).toBeNull();
186
+
187
+ // 携带条目经 artifactBase 回退读到原 run 的工件;ref 指条目所在的落盘(新 run)。
188
+ expect(await q3.events()).toEqual([{ type: "message", text: "old" }]);
189
+ expect(q3.ref.run).toBe("2026-07-01T08-00-00-000Z");
190
+
191
+ // 原 run 被清理:回退落空,如实返回 null(新句柄,不吃上面的记忆化)。
192
+ await rm(oldRun, { recursive: true });
193
+ const reopened = await openResults(root);
194
+ const q3Again = reopened.experiments[0].latest.evals.find((e) => e.id === "q3")!.attempts[0];
195
+ expect(await q3Again.events()).toBeNull();
196
+ });
197
+ });
198
+
199
+ // ───────────────────────── skipped 三种原因 ─────────────────────────
200
+
201
+ describe("openResults · skipped", () => {
202
+ it("版本不匹配带 schemaVersion 与完整 producer;坏 JSON 记 malformed;无 summary 有工件记 incomplete;无关 JSON 静默;legacy 无信封按 1 读", async () => {
203
+ const root = await makeRoot();
204
+ const incompatible = await writeRun(root, "2026-07-03T08-00-00-000Z", {
205
+ format: RESULTS_FORMAT,
206
+ schemaVersion: RESULTS_SCHEMA_VERSION + 1,
207
+ producer: { name: "other-harness", version: "9.9.9" },
208
+ startedAt: "2026-07-03T08:00:00.000Z",
209
+ results: [],
210
+ });
211
+ await writeRun(root, "2026-07-04T08-00-00-000Z", "not json {");
212
+ await writeRun(root, "unrelated", { hello: 1 });
213
+ await writeRun(root, "alien-results", { results: [] }); // 只沾一个键:无关 JSON,不进 skipped
214
+ // crash 没收尾:有 attempt 工件、没有 summary.json。
215
+ const crashed = join(root, "2026-07-05T08-00-00-000Z");
216
+ await writeArtifact(crashed, "q1/bub/default/a1", "events.json", [{ type: "message" }]);
217
+ // 空目录:既无 summary 也无工件,静默忽略。
218
+ await mkdir(join(root, "2026-07-06T08-00-00-000Z"), { recursive: true });
219
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([res({ id: "q1", agent: "bub", experimentId: "e" })]));
220
+ // legacy:引入版本号之前的存量报告,没有 format 信封,按 schemaVersion 1 读。
221
+ const legacy = summaryOf([res({ id: "q1", agent: "bub", experimentId: "old" })], { startedAt: "2026-06-01T08:00:00.000Z" });
222
+ delete legacy.format;
223
+ delete legacy.schemaVersion;
224
+ delete legacy.producer;
225
+ await writeRun(root, "2026-06-01T08-00-00-000Z", legacy);
226
+
227
+ const results = await openResults(root);
228
+ expect(results.runDirs).toHaveLength(2);
229
+ expect(results.skipped).toHaveLength(3);
230
+
231
+ const versionSkip = results.skipped.find((s) => s.reason === "incompatible-version")!;
232
+ expect(versionSkip.dir).toBe(incompatible);
233
+ expect(versionSkip.schemaVersion).toBe(RESULTS_SCHEMA_VERSION + 1);
234
+ // 完整 producer:第三方 harness 的名字如实报出,消费方才能做对「要不要拼 npx 提示」的分支。
235
+ expect(versionSkip.producer).toEqual({ name: "other-harness", version: "9.9.9" });
236
+
237
+ expect(results.skipped.find((s) => s.reason === "malformed")!.detail).toBe("invalid JSON");
238
+ expect(results.skipped.find((s) => s.reason === "incomplete")!.dir).toBe(crashed);
239
+
240
+ const legacyExp = results.experiments.find((e) => e.id === "old")!;
241
+ expect(legacyExp.latest.schemaVersion).toBe(1);
242
+ expect(legacyExp.latest.producer).toBeUndefined();
243
+ });
244
+ });
245
+
246
+ // ───────────────────────── latest() 选集与警告 ─────────────────────────
247
+
248
+ describe("results.latest() · Selection", () => {
249
+ it("每个实验取最新快照;experiments 前缀过滤同 CLI 语义(尾斜杠等价)", async () => {
250
+ const root = await makeRoot();
251
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
130
252
  res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
131
- res({ id: "q2", agent: "bub", experimentId: "mid/a" }),
132
253
  res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
133
- res({ id: "q2", agent: "codex", experimentId: "mid/b" }),
134
254
  ], { startedAt: "2026-07-01T08:00:00.000Z" }));
135
255
  const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
136
256
  res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
137
- res({ id: "q2", agent: "codex", experimentId: "mid/b" }),
138
257
  ], { startedAt: "2026-07-02T08:00:00.000Z" }));
139
258
 
140
- const all = await openResults(root);
141
- const { snapshots, warnings } = latestPerExperiment(all.snapshots);
142
- expect(warnings).toHaveLength(0);
143
- expect(snapshots.map((s) => s.experimentId)).toEqual(["mid/a", "mid/b"]);
144
- expect(snapshots.find((s) => s.experimentId === "mid/a")!.run.dir).toBe(monday);
145
- expect(snapshots.find((s) => s.experimentId === "mid/b")!.run.dir).toBe(tuesday);
146
-
147
- // experiments 前缀过滤,同 CLI 语义;尾斜杠写法等价。
148
- expect(latestPerExperiment(all.snapshots, { experiments: "mid/a" }).snapshots).toHaveLength(1);
149
- expect(latestPerExperiment(all.snapshots, { experiments: "mid/" }).snapshots).toHaveLength(2);
150
- expect(latestPerExperiment(all.snapshots, { experiments: "other" }).snapshots).toHaveLength(0);
259
+ const results = await openResults(root);
260
+ const latest = results.latest();
261
+ expect(latest.snapshots.map((s) => s.experimentId)).toEqual(["mid/a", "mid/b"]);
262
+ expect(latest.snapshots[1].runDir.dir).toBe(tuesday);
263
+
264
+ expect(results.latest({ experiments: "mid/a" }).snapshots).toHaveLength(1);
265
+ expect(results.latest({ experiments: "mid/" }).snapshots).toHaveLength(2);
266
+ expect(results.latest({ experiments: ["mid/a", "mid/b"] }).snapshots).toHaveLength(2);
267
+ expect(results.latest({ experiments: "other" }).snapshots).toHaveLength(0);
268
+ expect(results.latest({ experiments: "mid/a" }).snapshots[0].experimentId).toBe("mid/a"); // 不误配 "mid/ab"
151
269
  });
152
270
 
153
- it("最新快照残缺(只重跑了一道题)时生成 covers x of y 警告,仍返回该快照", async () => {
271
+ it("partial-coverage:最新快照覆盖 < 已知并集;结构化字段 + 渲染好的英文 message", async () => {
154
272
  const root = await makeRoot();
155
273
  await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
156
274
  res({ id: "algebra/q1", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
@@ -161,199 +279,485 @@ describe("latestPerExperiment", () => {
161
279
  res({ id: "algebra/q1", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
162
280
  ], { startedAt: "2026-07-05T08:00:00.000Z" }));
163
281
 
164
- const all = await openResults(root);
165
- const { snapshots, warnings } = latestPerExperiment(all.snapshots);
166
- expect(snapshots).toHaveLength(1);
167
- expect(snapshots[0].startedAt).toBe("2026-07-05T08:00:00.000Z");
168
- expect(warnings).toHaveLength(1);
169
- expect(warnings[0]).toMatch(/^warning: snapshot "midterm\/bub-gpt-5\.4" @ 2026-07-05T08:00:00\.000Z covers 1 of 3 evals seen in history\./);
170
- expect(warnings[0]).toMatch(/Re-run `niceeval exp midterm\/bub-gpt-5\.4` for a full snapshot/);
282
+ const latest = (await openResults(root)).latest();
283
+ expect(latest.snapshots).toHaveLength(1);
284
+ const partial = latest.warnings.find((w) => w.kind === "partial-coverage")!;
285
+ expect(partial).toMatchObject({ experimentId: "midterm/bub-gpt-5.4", covered: 1, total: 3 });
286
+ expect(partial.message).toBe(
287
+ "snapshot covers 1 of 3 evals seen in history; re-run `niceeval exp midterm/bub-gpt-5.4` for a full snapshot",
288
+ );
289
+ });
290
+
291
+ it("stale-snapshot:早于选集中最新落盘即触发(无阈值),message 带人话时距", async () => {
292
+ const root = await makeRoot();
293
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
294
+ res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
295
+ ], { startedAt: "2026-07-01T08:00:00.000Z" }));
296
+ await writeRun(root, "2026-07-05T08-00-00-000Z", summaryOf([
297
+ res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
298
+ ], { startedAt: "2026-07-05T08:00:00.000Z" }));
299
+
300
+ const latest = (await openResults(root)).latest();
301
+ const stale = latest.warnings.filter((w) => w.kind === "stale-snapshot");
302
+ expect(stale).toHaveLength(1);
303
+ expect(stale[0]).toMatchObject({
304
+ experimentId: "mid/a",
305
+ startedAt: "2026-07-01T08:00:00.000Z",
306
+ latestStartedAt: "2026-07-05T08:00:00.000Z",
307
+ });
308
+ expect(stale[0].message).toContain("predates the latest run in this selection by 4 days");
309
+ });
310
+
311
+ it("synthetic-experiment-id:落盘缺 experimentId 以 <agent>/<model> 合成键(无 model 用 default)", async () => {
312
+ const root = await makeRoot();
313
+ const runDir = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
314
+ res({ id: "q1", agent: "codex" }),
315
+ ]));
316
+
317
+ const results = await openResults(root);
318
+ expect(results.experiments[0].id).toBe("codex/default");
319
+ expect(results.experiments[0].latest.synthetic).toBe(true);
320
+
321
+ const latest = results.latest();
322
+ const synthetic = latest.warnings.find((w) => w.kind === "synthetic-experiment-id")!;
323
+ expect(synthetic).toMatchObject({ experimentId: "codex/default", runDir });
324
+ expect(synthetic.message).toContain("without experimentId");
325
+ // 合成键拼不出可执行的 niceeval exp 命令:partial 提示退化成中性说法(此处无 partial,仅验证不炸)。
326
+ });
327
+
328
+ it("Selection.filter 只删不换:快照删减,幸存实验的警告保留、其余丢弃", async () => {
329
+ const root = await makeRoot();
330
+ // 两个实验都制造 partial-coverage。
331
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
332
+ res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
333
+ res({ id: "q2", agent: "bub", experimentId: "mid/a" }),
334
+ res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
335
+ res({ id: "q2", agent: "codex", experimentId: "mid/b" }),
336
+ ], { startedAt: "2026-07-01T08:00:00.000Z" }));
337
+ await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
338
+ res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
339
+ res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
340
+ ], { startedAt: "2026-07-02T08:00:00.000Z" }));
341
+
342
+ const latest = (await openResults(root)).latest();
343
+ expect(latest.warnings.filter((w) => w.kind === "partial-coverage")).toHaveLength(2);
344
+
345
+ const filtered = latest.filter((s) => s.experimentId !== "mid/b");
346
+ expect(filtered.snapshots.map((s) => s.experimentId)).toEqual(["mid/a"]);
347
+ expect(filtered.warnings.map((w) => w.experimentId)).toEqual(["mid/a"]);
348
+ // 原选集不被改动。
349
+ expect(latest.snapshots).toHaveLength(2);
350
+ expect(latest.warnings).toHaveLength(2);
171
351
  });
172
352
  });
173
353
 
354
+ // ───────────────────────── 身份键去重 ─────────────────────────
355
+
174
356
  describe("dedupeAttempts", () => {
175
- it("resume 原样合入的重复 attempt 按身份键去重,保留最新 run 的那份;缺 startedAt 不去重并出 warning", async () => {
357
+ it(" (experimentId, evalId, attempt, startedAt) 去重,保留最新 run 目录的那份;缺 startedAt 不去重并出 missing-startedAt", async () => {
176
358
  const root = await makeRoot();
177
- // 周一:q1 通过、q2 失败;q3 没有 startedAt(异常数据)。
178
- const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
359
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
179
360
  res({ id: "q1", agent: "bub", experimentId: "e", startedAt: "2026-07-01T08:01:00.000Z" }),
180
361
  res({ id: "q2", agent: "bub", experimentId: "e", outcome: "failed", startedAt: "2026-07-01T08:02:00.000Z" }),
181
362
  res({ id: "q3", agent: "bub", experimentId: "e" }),
182
363
  ], { startedAt: "2026-07-01T08:00:00.000Z" }));
183
- // 周二 --resume:q1 原样合入(身份键完全相同),q2 重跑出新 startedAt,q3 依旧没有 startedAt。
184
364
  const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
185
- res({ id: "q1", agent: "bub", experimentId: "e", startedAt: "2026-07-01T08:01:00.000Z" }),
186
- res({ id: "q2", agent: "bub", experimentId: "e", startedAt: "2026-07-02T08:02:00.000Z" }),
365
+ res({ id: "q1", agent: "bub", experimentId: "e", startedAt: "2026-07-01T08:01:00.000Z" }), // resume 原样合入
366
+ res({ id: "q2", agent: "bub", experimentId: "e", startedAt: "2026-07-02T08:02:00.000Z" }), // 重跑,新 startedAt
187
367
  res({ id: "q3", agent: "bub", experimentId: "e" }),
188
368
  ], { startedAt: "2026-07-02T08:00:00.000Z" }));
189
369
 
190
- const all = await openResults(root);
191
- const attempts = all.snapshots.flatMap((s) => s.attempts);
192
- expect(attempts).toHaveLength(6);
370
+ const results = await openResults(root);
371
+ const all = results.experiments[0].snapshots.flatMap((s) => s.attempts);
372
+ expect(all).toHaveLength(6);
193
373
 
194
- const { attempts: deduped, warnings } = dedupeAttempts(attempts);
195
- // q1 合并成一条;q2 两条(startedAt 不同,是两次真实运行);q3 两条(缺 startedAt,不敢去重)。
196
- expect(deduped).toHaveLength(5);
197
- const q1 = deduped.filter((a) => a.result.id === "q1");
374
+ const { attempts, warnings } = dedupeAttempts(all);
375
+ // q1 合并成一条(取新 run 让 ref 落在最新落盘);q2 两条(两次真实运行);q3 两条(缺 startedAt 不敢去重)。
376
+ expect(attempts).toHaveLength(5);
377
+ const q1 = attempts.filter((a) => a.evalId === "q1");
198
378
  expect(q1).toHaveLength(1);
199
- expect(q1[0].run.dir).toBe(tuesday);
200
- expect(deduped.filter((a) => a.result.id === "q2")).toHaveLength(2);
201
- expect(deduped.filter((a) => a.result.id === "q3")).toHaveLength(2);
379
+ expect(q1[0].runDir.dir).toBe(tuesday);
380
+ expect(attempts.filter((a) => a.evalId === "q2")).toHaveLength(2);
381
+ expect(attempts.filter((a) => a.evalId === "q3")).toHaveLength(2);
382
+
202
383
  expect(warnings).toHaveLength(2);
203
- expect(warnings[0]).toMatch(/"q3".*has no startedAt/);
204
- void monday;
384
+ expect(warnings[0]).toMatchObject({ kind: "missing-startedAt", experimentId: "e", evalId: "q3" });
385
+ expect(warnings[0].message).toContain("has no startedAt");
205
386
  });
206
387
  });
207
388
 
208
- describe("openResults · 版本与坏文件", () => {
209
- it("schemaVersion 不匹配进 skipped 带 producerVersion;坏 JSON 记 malformed;无关 JSON 静默忽略;legacy 无信封照读", async () => {
389
+ // ───────────────────────── writer roundtrip ─────────────────────────
390
+
391
+ describe("createRunWriter", () => {
392
+ it("writeAttempt + snapshot 声明写出 → openResults 读回逐字段相等;工件懒加载;knownEvalIds 进分母", async () => {
210
393
  const root = await makeRoot();
211
- const incompatible = await writeRun(root, "2026-07-03T08-00-00-000Z", {
212
- format: RESULTS_FORMAT,
213
- schemaVersion: RESULTS_SCHEMA_VERSION + 1,
214
- producer: { name: "niceeval", version: "9.9.9" },
215
- startedAt: "2026-07-03T08:00:00.000Z",
216
- results: [],
394
+ const writer = await createRunWriter(root, { producer: { name: "my-harness", version: "1.0.0" } });
395
+ expect(writer.dir.startsWith(root)).toBe(true);
396
+
397
+ const snapA = writer.snapshot({
398
+ experiment: "compare/a",
399
+ agent: "bub",
400
+ model: "gpt-5",
401
+ startedAt: "2026-07-01T08:00:00.000Z",
402
+ knownEvalIds: ["q1", "q2", "q3"],
217
403
  });
218
- await writeRun(root, "2026-07-04T08-00-00-000Z", "not json {");
219
- await writeRun(root, "unrelated", { hello: 1 });
220
- // 只有 results 键、没有 startedAt:不满足 legacy 启发式,按无关 JSON 静默忽略(不进 skipped)
221
- await writeRun(root, "alien-results", { results: [] });
222
- await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([res({ id: "q1", agent: "bub", experimentId: "e" })]));
223
- // legacy:引入版本号之前的存量报告,没有 format 信封,按 schemaVersion 1 读。
224
- const legacy = summaryOf([res({ id: "q1", agent: "bub", experimentId: "old" })], { startedAt: "2026-06-01T08:00:00.000Z" });
225
- delete legacy.format;
226
- delete legacy.schemaVersion;
227
- delete legacy.producer;
228
- await writeRun(root, "2026-06-01T08-00-00-000Z", legacy);
404
+ const events = [{ type: "message", role: "assistant", text: "hi" }] as never[];
405
+ const o11y = { toolCalls: 2 } as never;
406
+ await snapA.writeAttempt(
407
+ {
408
+ id: "q1",
409
+ outcome: "passed",
410
+ attempt: 1,
411
+ durationMs: 100,
412
+ assertions: [],
413
+ usage: { inputTokens: 10, outputTokens: 5 },
414
+ estimatedCostUSD: 0.25,
415
+ },
416
+ { events, o11y },
417
+ );
418
+ await snapA.writeAttempt({ id: "q2", outcome: "failed", attempt: 1, durationMs: 50, assertions: [] });
419
+
420
+ const snapB = writer.snapshot({ experiment: "compare/b", agent: "codex", startedAt: "2026-07-02T09:00:00.000Z" });
421
+ await snapB.writeAttempt(
422
+ { id: "q1", outcome: "passed", attempt: 1, durationMs: 80, assertions: [] },
423
+ { diff: { generatedFiles: { "a.txt": "1" }, deletedFiles: [] } },
424
+ );
425
+
426
+ const summary = await writer.finish();
427
+ // summary 从已写 attempt 推导:计数永远和条目一致;版本元数据注入。
428
+ expect(summary.format).toBe(RESULTS_FORMAT);
429
+ expect(summary.schemaVersion).toBe(RESULTS_SCHEMA_VERSION);
430
+ expect(summary.producer).toEqual({ name: "my-harness", version: "1.0.0" });
431
+ expect(summary.passed).toBe(2);
432
+ expect(summary.failed).toBe(1);
433
+ expect(summary.startedAt).toBe("2026-07-01T08:00:00.000Z"); // 最早的快照时刻
434
+ expect(summary.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
435
+ expect(summary.estimatedCostUSD).toBe(0.25);
229
436
 
230
437
  const results = await openResults(root);
231
- expect(results.runs).toHaveLength(2);
232
- expect(results.skipped).toHaveLength(2);
438
+ expect(results.skipped).toHaveLength(0);
439
+ expect(results.experiments.map((e) => e.id)).toEqual(["compare/a", "compare/b"]);
440
+
441
+ const a = results.experiments[0].latest;
442
+ expect(a.agent).toBe("bub");
443
+ expect(a.model).toBe("gpt-5");
444
+ expect(a.startedAt).toBe("2026-07-01T08:00:00.000Z");
445
+ expect(a.producer).toEqual({ name: "my-harness", version: "1.0.0" });
446
+ expect(a.schemaVersion).toBe(RESULTS_SCHEMA_VERSION);
447
+ expect(a.knownEvalIds).toEqual(["q1", "q2", "q3"]);
448
+ expect(results.experiments[0].evalIds).toEqual(["q1", "q2", "q3"]);
449
+
450
+ // 快照级字段注入进条目(agent/model/experimentId/startedAt),attempt 级字段原样读回。
451
+ const q1 = a.evals.find((e) => e.id === "q1")!.attempts[0];
452
+ expect(q1.experimentId).toBe("compare/a");
453
+ expect(q1.result).toMatchObject({
454
+ id: "q1",
455
+ agent: "bub",
456
+ model: "gpt-5",
457
+ experimentId: "compare/a",
458
+ startedAt: "2026-07-01T08:00:00.000Z",
459
+ outcome: "passed",
460
+ durationMs: 100,
461
+ usage: { inputTokens: 10, outputTokens: 5 },
462
+ estimatedCostUSD: 0.25,
463
+ hasEvents: true,
464
+ hasTrace: false,
465
+ hasSources: false,
466
+ });
467
+ expect(await q1.events()).toEqual(events);
468
+ expect(await q1.o11y()).toEqual(o11y);
469
+ expect(await q1.trace()).toBeNull();
470
+ expect(await q1.diff()).toBeNull();
233
471
 
234
- const versionSkip = results.skipped.find((s) => s.reason === "incompatible-version")!;
235
- expect(versionSkip.dir).toBe(incompatible);
236
- expect(versionSkip.schemaVersion).toBe(RESULTS_SCHEMA_VERSION + 1);
237
- expect(versionSkip.producerVersion).toBe("9.9.9");
472
+ // 第二个快照:自己的 startedAt(≠ 顶层)经快照元数据读回;diff 工件可达。
473
+ const b = results.experiments[1].latest;
474
+ expect(b.agent).toBe("codex");
475
+ expect(b.model).toBeUndefined();
476
+ expect(b.startedAt).toBe("2026-07-02T09:00:00.000Z");
477
+ expect(await b.attempts[0].diff()).toEqual({ generatedFiles: { "a.txt": "1" }, deletedFiles: [] });
238
478
 
239
- const malformed = results.skipped.find((s) => s.reason === "malformed")!;
240
- expect(malformed.detail).toBe("invalid JSON");
479
+ // knownEvalIds 是残缺检测的分母:compare/a 只写了 2/3 partial-coverage。
480
+ const partial = results.latest().warnings.find((w) => w.kind === "partial-coverage")!;
481
+ expect(partial).toMatchObject({ experimentId: "compare/a", covered: 2, total: 3 });
241
482
  });
242
- });
243
483
 
244
- describe("AttemptHandle · 懒加载", () => {
245
- it("缺文件返回 null 不抛错;读过一次即缓存(文件删了也还在);没有 artifactsDir 时全部为 null", async () => {
484
+ it("没走到 finish() 的目录 = skipped(\"incomplete\"):有工件、无 summary,reader 不读半份落盘", async () => {
246
485
  const root = await makeRoot();
247
- const runDir = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
248
- res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true }),
249
- res({ id: "q2", agent: "bub", model: "gpt-5", experimentId: "e" }),
250
- ]));
251
- const eventsPath = await writeArtifact(runDir, "q1/bub/gpt-5/a1", "events.json", [
252
- { type: "message", role: "assistant", text: "hi" },
253
- ]);
254
-
486
+ const writer = await createRunWriter(root, { producer: { name: "my-harness" } });
487
+ const snap = writer.snapshot({ experiment: "e", agent: "bub", startedAt: "2026-07-01T08:00:00.000Z" });
488
+ await snap.writeAttempt(
489
+ { id: "q1", outcome: "passed", attempt: 1, durationMs: 10, assertions: [] },
490
+ { events: [{ type: "message" }] as never[] },
491
+ );
492
+ // crash:没有 finish()
255
493
  const results = await openResults(root);
256
- const [q1, q2] = results.runs[0].attempts;
257
-
258
- // ref 契约:run 目录名 + summary.results 下标,Reports 的 refs 与 view 深链直接可用
259
- expect(q1.ref).toEqual({ run: "2026-07-01T08-00-00-000Z", result: 0 });
260
- expect(q2.ref).toEqual({ run: "2026-07-01T08-00-00-000Z", result: 1 });
261
-
262
- const events = await q1.events();
263
- expect(events).toHaveLength(1);
264
- // summary 只有 hasEvents/hasTrace/hasSources 三个标记,o11y/diff 没有标记 —— 全靠方法语义吸收。
265
- expect(await q1.trace()).toBeNull();
266
- expect(await q1.o11y()).toBeNull();
267
- expect(await q1.diff()).toBeNull();
268
- expect(await q1.sources()).toBeNull();
269
-
270
- // 记忆化:同一 handle 读一次缓存,文件删掉后再读仍返回同一份数据。
271
- await rm(eventsPath);
272
- expect(await q1.events()).toBe(events);
494
+ expect(results.experiments).toHaveLength(0);
495
+ expect(results.skipped).toEqual([{ dir: writer.dir, reason: "incomplete" }]);
496
+ });
273
497
 
274
- // 条目没有 artifactsDir(异常/极简数据):不猜路径,全部 null。
275
- expect(await q2.events()).toBeNull();
276
- expect(await q2.diff()).toBeNull();
498
+ it("snapshot() startedAt 必填(运行时也拦):身份键与去重以它为锚", async () => {
499
+ const root = await makeRoot();
500
+ const writer = await createRunWriter(root, { producer: { name: "x" } });
501
+ expect(() => writer.snapshot({ experiment: "e", agent: "a", startedAt: "" })).toThrow(/startedAt/);
277
502
  });
278
503
  });
279
504
 
280
- describe("copyRun", () => {
281
- it("按指定工件种类复制选中快照,重建的 summary 只含选中条目并保留版本元数据;产物可被 openResults 回读", async () => {
505
+ // ───────────────────────── copySnapshots ─────────────────────────
506
+
507
+ describe("copySnapshots", () => {
508
+ it("按指定工件复制;summary 重建保留版本元数据;补记 knownEvalIds 让发布目录重算出同样的残缺警告", async () => {
282
509
  const root = await makeRoot();
283
510
  const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
284
- res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true, hasTrace: true, startedAt: "2026-07-01T08:01:00.000Z", estimatedCostUSD: 0.5, usage: { inputTokens: 10, outputTokens: 5 } }),
285
- res({ id: "q1", agent: "codex", model: "o3", experimentId: "compare/codex", artifactsDir: "q1/codex/o3/a1", hasEvents: true, outcome: "failed", estimatedCostUSD: 0.25, usage: { inputTokens: 20, outputTokens: 2 } }),
511
+ res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/compare_bub/a1", hasEvents: true, hasTrace: true, startedAt: "2026-07-01T08:01:00.000Z" }),
512
+ res({ id: "q2", agent: "bub", model: "gpt-5", experimentId: "compare/bub", startedAt: "2026-07-01T08:02:00.000Z" }),
513
+ res({ id: "q1", agent: "codex", model: "o3", experimentId: "compare/codex", artifactsDir: "q1/codex/o3/compare_codex/a1", hasEvents: true, outcome: "failed", startedAt: "2026-07-01T08:03:00.000Z" }),
286
514
  ], { startedAt: "2026-07-01T08:00:00.000Z" }));
287
- await writeArtifact(monday, "q1/bub/gpt-5/a1", "events.json", [{ type: "message" }]);
288
- await writeArtifact(monday, "q1/bub/gpt-5/a1", "trace.json", [{ name: "turn" }]);
289
- await writeArtifact(monday, "q1/bub/gpt-5/a1", "diff.json", { generatedFiles: {}, deletedFiles: [] });
290
- await writeArtifact(monday, "q1/codex/o3/a1", "events.json", [{ type: "message" }]);
291
- const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
292
- res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true, startedAt: "2026-07-02T08:01:00.000Z" }),
293
- ], { startedAt: "2026-07-02T08:00:00.000Z" }));
294
- await writeArtifact(tuesday, "q1/bub/gpt-5/a1", "events.json", [{ type: "message" }, { type: "message" }]);
515
+ await writeArtifact(monday, "q1/bub/gpt-5/compare_bub/a1", "events.json", [{ n: 1 }]);
516
+ await writeArtifact(monday, "q1/bub/gpt-5/compare_bub/a1", "trace.json", [{ name: "turn" }]);
517
+ await writeArtifact(monday, "q1/bub/gpt-5/compare_bub/a1", "diff.json", { generatedFiles: {}, deletedFiles: [] });
518
+ await writeArtifact(monday, "q1/codex/o3/compare_codex/a1", "events.json", [{ n: 2 }]);
519
+ // 周五只重跑了 compare/bub q1:它的最新快照残缺。
520
+ const friday = await writeRun(root, "2026-07-05T08-00-00-000Z", summaryOf([
521
+ res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/compare_bub/a1", hasEvents: true, startedAt: "2026-07-05T08:01:00.000Z" }),
522
+ ], { startedAt: "2026-07-05T08:00:00.000Z" }));
523
+ await writeArtifact(friday, "q1/bub/gpt-5/compare_bub/a1", "events.json", [{ n: 1 }, { n: 2 }]);
295
524
 
296
- const all = await openResults(root);
297
- const picked = latestPerExperiment(all.snapshots, { experiments: "compare/" });
525
+ const results = await openResults(root);
298
526
  const dest = join(await makeRoot(), "site/data/run");
299
- const copied = await copyRun(picked.snapshots, dest, { artifacts: ["events", "sources"] });
527
+ const copied = await copySnapshots(results.latest(), dest, { artifacts: ["events", "sources"] });
300
528
 
301
529
  expect(copied.warnings).toHaveLength(0);
302
530
  expect(copied.summary.format).toBe(RESULTS_FORMAT);
303
531
  expect(copied.summary.schemaVersion).toBe(RESULTS_SCHEMA_VERSION);
304
532
  expect(copied.summary.producer?.version).toBe("0.3.0");
305
533
  expect(copied.summary.results).toHaveLength(2);
306
- expect(copied.summary.passed).toBe(1);
307
- expect(copied.summary.failed).toBe(1);
308
- // bub 取周二那份(最新),codex 取周一那份。
309
- const bubEntry = copied.summary.results.find((r) => r.experimentId === "compare/bub")!;
310
- expect(bubEntry.startedAt).toBe("2026-07-02T08:01:00.000Z");
311
- expect(bubEntry.hasEvents).toBe(true);
312
- expect(bubEntry.hasTrace).toBe(false); // trace 没被选中,存在标记按目标目录重算
534
+ // 补记的覆盖事实:复制时刻该实验已知的 eval 并集。
535
+ expect(copied.summary.snapshots?.["compare/bub"].knownEvalIds).toEqual(["q1", "q2"]);
536
+ expect(copied.summary.snapshots?.["compare/codex"].knownEvalIds).toEqual(["q1"]);
313
537
 
314
- // 磁盘:只有选中的工件种类被复制。
315
- // 目标目录带 experiment 段(与 writer 的 attemptDir 同规则),源 fixture 的旧式路径靠 artifactsDir 定位
538
+ // 磁盘:只有选中的工件种类被复制;存在标记按目标目录重算。
316
539
  expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/events.json"))).toBe(true);
317
540
  expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/trace.json"))).toBe(false);
318
541
  expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/diff.json"))).toBe(false);
319
- expect(await exists(join(dest, "q1/codex/o3/compare_codex/a1/events.json"))).toBe(true);
320
-
321
- // 回读:产物是合法 run 目录,懒加载语义不变。
322
- const reopened = await openResults(dest);
323
- expect(reopened.runs).toHaveLength(1);
324
- expect(reopened.snapshots.map((s) => s.experimentId).sort()).toEqual(["compare/bub", "compare/codex"]);
325
- const bubAttempt = reopened.snapshots.find((s) => s.experimentId === "compare/bub")!.attempts[0];
542
+ const bubEntry = copied.summary.results.find((r) => r.experimentId === "compare/bub")!;
543
+ expect(bubEntry.startedAt).toBe("2026-07-05T08:01:00.000Z"); // 最新快照的那份
544
+ expect(bubEntry.hasEvents).toBe(true);
545
+ expect(bubEntry.hasTrace).toBe(false);
546
+
547
+ // 发布目录上重新 openResults().latest():残缺警告被同一套机制重新算出来,不靠发布者转述。
548
+ const republished = await openResults(dest);
549
+ expect(republished.experiments.find((e) => e.id === "compare/bub")!.evalIds).toEqual(["q1", "q2"]);
550
+ const partial = republished.latest().warnings.find((w) => w.kind === "partial-coverage")!;
551
+ expect(partial).toMatchObject({ experimentId: "compare/bub", covered: 1, total: 2 });
552
+ // 快照各自的 startedAt 也随行(compare/bub 周五、compare/codex 周一)。
553
+ expect(republished.experiments.find((e) => e.id === "compare/bub")!.latest.startedAt).toBe("2026-07-05T08:00:00.000Z");
554
+ expect(republished.experiments.find((e) => e.id === "compare/codex")!.latest.startedAt).toBe("2026-07-01T08:00:00.000Z");
555
+ // 工件懒加载在发布目录同样成立。
556
+ const bubAttempt = republished.experiments.find((e) => e.id === "compare/bub")!.latest.attempts[0];
326
557
  expect(await bubAttempt.events()).toHaveLength(2);
327
558
  expect(await bubAttempt.trace()).toBeNull();
559
+ });
560
+
561
+ it("目标目录非空即报错(不静默覆盖、不合并);artifacts 非法值报错;同实验重复快照落同一目录出 warning", async () => {
562
+ const root = await makeRoot();
563
+ await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
564
+ res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q1/bub/gpt-5/e/a1", startedAt: "2026-07-01T08:01:00.000Z" }),
565
+ ], { startedAt: "2026-07-01T08:00:00.000Z" }));
566
+ await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
567
+ res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q1/bub/gpt-5/e/a1", startedAt: "2026-07-01T08:01:00.000Z" }),
568
+ ], { startedAt: "2026-07-02T08:00:00.000Z" }));
569
+ const results = await openResults(root);
570
+
571
+ const occupied = await makeRoot();
572
+ await writeFile(join(occupied, "existing.txt"), "x", "utf-8");
573
+ await expect(copySnapshots(results.latest(), occupied)).rejects.toThrow(/not empty/);
328
574
 
329
- // 同一 experiment 的两个快照(未 dedupe)落到同一工件目录:保留最新并出 warning。
330
- const bubSnapshots = all.snapshots.filter((s) => s.experimentId === "compare/bub");
331
- expect(bubSnapshots).toHaveLength(2);
575
+ await expect(
576
+ copySnapshots(results.latest(), join(await makeRoot(), "out"), { artifacts: ["evnets" as never] }),
577
+ ).rejects.toThrow(/Unknown artifact kind/);
578
+
579
+ await expect(copySnapshots([], join(await makeRoot(), "out"))).rejects.toThrow(/no snapshots/);
580
+
581
+ // 同一 experiment 的两个快照(未走 latest 的手工数组):同键 attempt 落同一目录 → 保留最新 + warning。
582
+ const both = results.experiments[0].snapshots;
583
+ expect(both).toHaveLength(2);
332
584
  const dest2 = join(await makeRoot(), "run2");
333
- const collided = await copyRun(bubSnapshots, dest2);
585
+ const collided = await copySnapshots(both, dest2);
334
586
  expect(collided.warnings).toHaveLength(1);
335
- expect(collided.warnings[0]).toMatch(/multiple attempts map to "q1\/bub\/gpt-5\/compare_bub\/a1"/);
587
+ expect(collided.warnings[0]).toMatch(/multiple attempts map to/);
336
588
  expect(collided.summary.results).toHaveLength(1);
337
- expect(collided.summary.results[0].startedAt).toBe("2026-07-02T08:01:00.000Z");
338
589
  });
590
+ });
339
591
 
340
- it("同 agent model 只差 experiment 的两个实验互不覆盖(目录带 experiment 段)", async () => {
341
- // memory-evals 的典型配对:bub-gpt-5.4 vs bub-gpt-5.4--agents-md,只差 flags。
342
- // attemptDirOf 少了 experiment 段时,这两份工件会在拷贝时碰撞、其中一份被静默丢弃。
592
+ // ───────────────────────── Artifacts 报告器 = writer 薄壳 ─────────────────────────
593
+
594
+ describe("Artifacts reporter(writer 薄壳)", () => {
595
+ it("落盘行为与 runner 直写时代逐字节等价:summary.json 键序/瘦身/携带条目原样,工件文件按需紧凑写", async () => {
343
596
  const root = await makeRoot();
344
- const run = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
345
- res({ id: "q1", agent: "bub", model: "gpt-5.4", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5.4/compare_bub/a1", hasEvents: true }),
346
- res({ id: "q1", agent: "bub", model: "gpt-5.4", experimentId: "compare/bub--agents-md", artifactsDir: "q1/bub/gpt-5.4/compare_bub--agents-md/a1", hasEvents: true }),
347
- ]));
348
- await writeArtifact(run, "q1/bub/gpt-5.4/compare_bub/a1", "events.json", [{ n: 1 }]);
349
- await writeArtifact(run, "q1/bub/gpt-5.4/compare_bub--agents-md/a1", "events.json", [{ n: 2 }]);
597
+ const rep = Artifacts(root);
598
+ await rep.onRunStart?.([], {} as never);
599
+
600
+ const fresh: EvalResult = {
601
+ id: "algebra/q1",
602
+ experimentId: "compare/bub",
603
+ experiment: { id: "compare/bub", params: { style: "concise" } },
604
+ agent: "bub",
605
+ model: "gpt-5.4",
606
+ outcome: "passed",
607
+ fingerprint: "abc",
608
+ attempt: 1,
609
+ startedAt: "2026-07-01T08:01:00.000Z",
610
+ durationMs: 1234,
611
+ assertions: [{ kind: "contains", ok: true } as never],
612
+ usage: { inputTokens: 10, outputTokens: 5 },
613
+ estimatedCostUSD: 0.5,
614
+ events: [{ type: "message", role: "assistant", text: "hi" } as never],
615
+ sources: [{ path: "evals/a.ts", content: "x" }],
616
+ trace: [{ name: "turn", kind: "turn" } as never],
617
+ o11y: { toolCalls: 2 } as never,
618
+ diff: { generatedFiles: { "a.txt": "1" }, deletedFiles: [] },
619
+ rawTranscript: "raw",
620
+ };
621
+ const noArtifacts: EvalResult = {
622
+ id: "algebra/q2",
623
+ agent: "bub",
624
+ outcome: "failed",
625
+ attempt: 1,
626
+ durationMs: 10,
627
+ assertions: [],
628
+ events: [],
629
+ };
630
+ // --resume 携带条目:artifactBase 指向原 run,has* 真值原样携带,不得重算或编造 artifactsDir。
631
+ const carried: EvalResult = {
632
+ id: "algebra/q3",
633
+ experimentId: "compare/bub",
634
+ agent: "bub",
635
+ model: "gpt-5.4",
636
+ outcome: "passed",
637
+ attempt: 1,
638
+ startedAt: "2026-06-30T08:01:00.000Z",
639
+ durationMs: 99,
640
+ assertions: [],
641
+ artifactBase: "2026-06-30T08-00-00-000Z/algebra/q3/bub/gpt-5.4/compare_bub/a1",
642
+ hasEvents: true,
643
+ hasTrace: false,
644
+ hasSources: true,
645
+ };
646
+
647
+ await rep.onEvalComplete?.(fresh);
648
+ await rep.onEvalComplete?.(noArtifacts);
649
+ await rep.onRunComplete?.({
650
+ name: "demo",
651
+ agent: "bub",
652
+ startedAt: "2026-07-01T08:00:00.000Z",
653
+ completedAt: "2026-07-01T08:10:00.000Z",
654
+ passed: 2,
655
+ failed: 1,
656
+ skipped: 0,
657
+ errored: 0,
658
+ durationMs: 600000,
659
+ usage: { inputTokens: 10, outputTokens: 5 },
660
+ estimatedCostUSD: 0.5,
661
+ results: [carried, fresh, noArtifacts],
662
+ });
350
663
 
351
- const all = await openResults(root);
352
- const dest = join(await makeRoot(), "pair");
353
- const copied = await copyRun(all.snapshots, dest);
354
- expect(copied.warnings).toHaveLength(0);
355
- expect(copied.summary.results).toHaveLength(2);
356
- expect(await exists(join(dest, "q1/bub/gpt-5.4/compare_bub/a1/events.json"))).toBe(true);
357
- expect(await exists(join(dest, "q1/bub/gpt-5.4/compare_bub--agents-md/a1/events.json"))).toBe(true);
664
+ const dir = rep.outputDir();
665
+ const version = (JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf-8")) as { version: string }).version;
666
+
667
+ // 基线由改造前的实现捕获(runner 直写时代的真实输出),薄壳必须逐字节还原它。
668
+ const expected = `{
669
+ "format": "niceeval.results",
670
+ "schemaVersion": ${RESULTS_SCHEMA_VERSION},
671
+ "producer": {
672
+ "name": "niceeval",
673
+ "version": "${version}"
674
+ },
675
+ "name": "demo",
676
+ "agent": "bub",
677
+ "startedAt": "2026-07-01T08:00:00.000Z",
678
+ "completedAt": "2026-07-01T08:10:00.000Z",
679
+ "passed": 2,
680
+ "failed": 1,
681
+ "skipped": 0,
682
+ "errored": 0,
683
+ "durationMs": 600000,
684
+ "usage": {
685
+ "inputTokens": 10,
686
+ "outputTokens": 5
687
+ },
688
+ "estimatedCostUSD": 0.5,
689
+ "results": [
690
+ {
691
+ "id": "algebra/q3",
692
+ "experimentId": "compare/bub",
693
+ "agent": "bub",
694
+ "model": "gpt-5.4",
695
+ "outcome": "passed",
696
+ "attempt": 1,
697
+ "startedAt": "2026-06-30T08:01:00.000Z",
698
+ "durationMs": 99,
699
+ "assertions": [],
700
+ "artifactBase": "2026-06-30T08-00-00-000Z/algebra/q3/bub/gpt-5.4/compare_bub/a1",
701
+ "hasEvents": true,
702
+ "hasTrace": false,
703
+ "hasSources": true
704
+ },
705
+ {
706
+ "id": "algebra/q1",
707
+ "experimentId": "compare/bub",
708
+ "experiment": {
709
+ "id": "compare/bub",
710
+ "params": {
711
+ "style": "concise"
712
+ }
713
+ },
714
+ "agent": "bub",
715
+ "model": "gpt-5.4",
716
+ "outcome": "passed",
717
+ "fingerprint": "abc",
718
+ "attempt": 1,
719
+ "startedAt": "2026-07-01T08:01:00.000Z",
720
+ "durationMs": 1234,
721
+ "assertions": [
722
+ {
723
+ "kind": "contains",
724
+ "ok": true
725
+ }
726
+ ],
727
+ "usage": {
728
+ "inputTokens": 10,
729
+ "outputTokens": 5
730
+ },
731
+ "estimatedCostUSD": 0.5,
732
+ "artifactsDir": "algebra/q1/bub/gpt-5.4/compare_bub/a1",
733
+ "hasTrace": true,
734
+ "hasEvents": true,
735
+ "hasSources": true
736
+ },
737
+ {
738
+ "id": "algebra/q2",
739
+ "agent": "bub",
740
+ "outcome": "failed",
741
+ "attempt": 1,
742
+ "durationMs": 10,
743
+ "assertions": [],
744
+ "artifactsDir": "algebra/q2/bub/default/a1",
745
+ "hasTrace": false,
746
+ "hasEvents": false,
747
+ "hasSources": false
748
+ }
749
+ ],
750
+ "outputDir": "${dir}"
751
+ }`;
752
+ expect(await readFile(join(dir, "summary.json"), "utf-8")).toBe(expected);
753
+
754
+ // 工件:紧凑 JSON,按需生成(q2 全空不落文件;q1 五类都在)。
755
+ const q1Dir = join(dir, "algebra/q1/bub/gpt-5.4/compare_bub/a1");
756
+ expect(await readFile(join(q1Dir, "events.json"), "utf-8")).toBe('[{"type":"message","role":"assistant","text":"hi"}]');
757
+ expect(await readFile(join(q1Dir, "sources.json"), "utf-8")).toBe('[{"path":"evals/a.ts","content":"x"}]');
758
+ expect(await readFile(join(q1Dir, "trace.json"), "utf-8")).toBe('[{"name":"turn","kind":"turn"}]');
759
+ expect(await readFile(join(q1Dir, "o11y.json"), "utf-8")).toBe('{"toolCalls":2}');
760
+ expect(await readFile(join(q1Dir, "diff.json"), "utf-8")).toBe('{"generatedFiles":{"a.txt":"1"},"deletedFiles":[]}');
761
+ expect(await exists(join(dir, "algebra/q2/bub/default/a1/events.json"))).toBe(false);
358
762
  });
359
763
  });