niceeval 0.1.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 (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
@@ -0,0 +1,77 @@
1
+ // 发现:扫 evals/ 找 *.eval.ts(默认导出 EvalDef 或数组),扫 experiments/ 找实验。
2
+ // 路径即身份:id 从相对路径推导,排序保证稳定。
3
+
4
+ import { readdir } from "node:fs/promises";
5
+ import { dirname, join, relative, sep } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { pad4 } from "../util.ts";
8
+ import type { DiscoveredEval, DiscoveredExperiment, EvalDef, ExperimentDef } from "../types.ts";
9
+
10
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".niceeval", "dist", ".next"]);
11
+
12
+ async function walkFiles(dir: string, match: (name: string) => boolean): Promise<string[]> {
13
+ const out: string[] = [];
14
+ async function walk(current: string): Promise<void> {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(current, { withFileTypes: true });
18
+ } catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ const full = join(current, e.name);
23
+ if (e.isDirectory()) {
24
+ if (SKIP_DIRS.has(e.name)) continue;
25
+ await walk(full);
26
+ } else if (e.isFile() && match(e.name)) {
27
+ out.push(full);
28
+ }
29
+ }
30
+ }
31
+ await walk(dir);
32
+ return out;
33
+ }
34
+
35
+ export async function discoverEvals(root: string): Promise<DiscoveredEval[]> {
36
+ const dir = join(root, "evals");
37
+ const files = (await walkFiles(dir, (n) => n.endsWith(".eval.ts") || n.endsWith(".eval.tsx"))).sort();
38
+ const out: DiscoveredEval[] = [];
39
+ for (const file of files) {
40
+ const mod = (await import(pathToFileURL(file).href)) as { default?: EvalDef | EvalDef[] };
41
+ const def = mod.default;
42
+ if (!def) continue;
43
+ const baseId = relative(dir, file).replace(/\.eval\.tsx?$/, "").split(sep).join("/");
44
+ const baseDir = dirname(file);
45
+ if (Array.isArray(def)) {
46
+ def.forEach((d, i) => out.push({ ...d, id: `${baseId}/${pad4(i)}`, baseDir, sourcePath: file }));
47
+ } else {
48
+ out.push({ ...def, id: baseId, baseDir, sourcePath: file });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export async function discoverExperiments(root: string): Promise<DiscoveredExperiment[]> {
55
+ const dir = join(root, "experiments");
56
+ const files = (await walkFiles(dir, (n) => n.endsWith(".ts") && !n.endsWith(".d.ts"))).sort();
57
+ const out: DiscoveredExperiment[] = [];
58
+ for (const file of files) {
59
+ const mod = (await import(pathToFileURL(file).href)) as { default?: ExperimentDef };
60
+ const def = mod.default;
61
+ if (!def || !def.agent) continue;
62
+ const id = relative(dir, file)
63
+ .replace(/\.ts$/, "")
64
+ .replace(/\.experiment$/, "")
65
+ .split(sep)
66
+ .join("/");
67
+ const group = id.includes("/") ? id.split("/")[0]! : "";
68
+ out.push({ ...def, id, group });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** id 前缀过滤:精确匹配或目录前缀(weather 命中 weather 与 weather/*)。 */
74
+ export function makeFilter(patterns: string[]): (id: string) => boolean {
75
+ if (patterns.length === 0) return () => true;
76
+ return (id) => patterns.some((p) => id === p || id.startsWith(p + "/"));
77
+ }
@@ -0,0 +1,81 @@
1
+ // 默认本地工件报告器:给 `niceeval view` 提供稳定的离线输入。
2
+ //
3
+ // 布局(每 eval-attempt 一个文件夹,重数据分文件;summary.json 只留榜单元数据):
4
+ // .niceeval/<run>/
5
+ // summary.json # run 元数据 + 各 result 的判决/usage/断言/引用(瘦身)
6
+ // <evalId>/<agent>/<model>/a<attempt>/
7
+ // events.json sources.json trace.json o11y.json diff.json
8
+ // view 读 summary.json 渲染榜单,展开某条 trace 时再按需 fetch 它的 trace.json。
9
+
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import type { EvalResult, Reporter, RunSummary } from "../../types.ts";
13
+
14
+ /** 一个 attempt 的工件子目录(相对 run 根):<evalId>/<agent>/<model>/a<attempt>。 */
15
+ function attemptDir(r: EvalResult): string {
16
+ const safe = (s: string) => s.replace(/[^\w.@-]/g, "_");
17
+ // evalId 里的 / 保留作目录层级,其余危险字符替换。
18
+ const id = r.id.replace(/[^\w./@-]/g, "_");
19
+ return `${id}/${safe(r.agent)}/${safe(r.model ?? "default")}/a${r.attempt}`;
20
+ }
21
+
22
+ /** summary.json 用的瘦身结果:去掉大数组(events/trace/o11y/diff/sources),换成目录引用 + 存在标记。 */
23
+ function slimResult(r: EvalResult): EvalResult {
24
+ const { events, sources, o11y, trace, diff, rawTranscript, ...rest } = r;
25
+ void events;
26
+ void sources;
27
+ void o11y;
28
+ void trace;
29
+ void diff;
30
+ void rawTranscript;
31
+ return {
32
+ ...rest,
33
+ artifactsDir: attemptDir(r),
34
+ hasTrace: !!(trace && trace.length),
35
+ hasEvents: !!(events && events.length),
36
+ hasSources: !!(sources && sources.length),
37
+ };
38
+ }
39
+
40
+ export function Artifacts(root = ".niceeval"): Reporter {
41
+ let outputDir = "";
42
+ const ensureDir = async (): Promise<void> => {
43
+ if (!outputDir) outputDir = join(root, safeTimestamp(new Date()));
44
+ await mkdir(outputDir, { recursive: true });
45
+ };
46
+
47
+ return {
48
+ async onRunStart() {
49
+ outputDir = join(root, safeTimestamp(new Date()));
50
+ await mkdir(outputDir, { recursive: true });
51
+ },
52
+
53
+ // 每条结果一出来就把它的重数据落到自己的文件夹(增量、互不影响)。
54
+ async onEvalComplete(result) {
55
+ await ensureDir();
56
+ const dir = join(outputDir, attemptDir(result));
57
+ await mkdir(dir, { recursive: true });
58
+ const writes: Promise<unknown>[] = [];
59
+ if (result.events?.length)
60
+ writes.push(writeFile(join(dir, "events.json"), JSON.stringify(result.events), "utf-8"));
61
+ if (result.sources?.length)
62
+ writes.push(writeFile(join(dir, "sources.json"), JSON.stringify(result.sources), "utf-8"));
63
+ if (result.trace?.length)
64
+ writes.push(writeFile(join(dir, "trace.json"), JSON.stringify(result.trace), "utf-8"));
65
+ if (result.o11y) writes.push(writeFile(join(dir, "o11y.json"), JSON.stringify(result.o11y), "utf-8"));
66
+ if (result.diff) writes.push(writeFile(join(dir, "diff.json"), JSON.stringify(result.diff), "utf-8"));
67
+ await Promise.all(writes);
68
+ },
69
+
70
+ // run 结束写瘦身 summary.json(只含榜单要的字段 + 工件引用)。
71
+ async onRunComplete(summary) {
72
+ await ensureDir();
73
+ const slim: RunSummary = { ...summary, outputDir, results: summary.results.map(slimResult) };
74
+ await writeFile(join(outputDir, "summary.json"), JSON.stringify(slim, null, 2), "utf-8");
75
+ },
76
+ };
77
+ }
78
+
79
+ function safeTimestamp(d: Date): string {
80
+ return d.toISOString().replace(/[:.]/g, "-");
81
+ }
@@ -0,0 +1,76 @@
1
+ // 控制台报告器:流式逐行输出,失败断言内联展开,末尾出效率三件套(时间 / token / $)。
2
+
3
+ import type { EvalResult, Reporter, RunSummary } from "../../types.ts";
4
+ import { t } from "../../i18n/index.ts";
5
+ import { formatDuration, formatTokens, renderRunReport } from "./table.ts";
6
+
7
+ const SYMBOL: Record<string, string> = {
8
+ passed: "✓",
9
+ failed: "✗",
10
+ errored: "!",
11
+ skipped: "○",
12
+ };
13
+
14
+ export function Console(): Reporter {
15
+ return {
16
+ onRunStart(evals, _agent, shape) {
17
+ // compare(多 agent / 多 model)或 runs>1 时,实际 attempt 数 > eval 数。头部如实报清,
18
+ // 否则「本次运行 5 个 eval」会和末尾「5 passed, 5 failed」(按 attempt 计 10)对不上。
19
+ const n = shape?.evals ?? evals.length;
20
+ const extra =
21
+ shape && shape.totalRuns > n
22
+ ? t("report.runStartExtra", { configs: shape.configs, totalRuns: shape.totalRuns })
23
+ : "";
24
+ process.stdout.write(t("report.runStart", { count: n, extra }));
25
+ },
26
+ onEvalComplete(result: EvalResult) {
27
+ const sym = SYMBOL[result.outcome] ?? "?";
28
+ const tok = (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0);
29
+ // requests > 0 但 tokens = 0 → agent 跑了但不上报用量(如 bub);显示 — 而非误导性的 0
30
+ const tokStr = tok > 0 ? `${formatTokens(tok)} tok` : (result.usage?.requests ?? 0) > 0 ? `— tok` : `0 tok`;
31
+ const cost = result.estimatedCostUSD !== undefined ? ` $${result.estimatedCostUSD.toFixed(3)}` : "";
32
+ const who = result.model ? `${result.agent}/${result.model}` : result.agent;
33
+ const meta = `(${formatDuration(result.durationMs)} ${tokStr}${cost})`;
34
+ const label = result.outcome === "passed" ? "" : ` ${formatOutcome(result.outcome)}`;
35
+ process.stdout.write(` ${sym} ${result.id}${label} [${who}] ${meta}\n`);
36
+
37
+ if (result.skipReason) {
38
+ process.stdout.write(` ○ ${t("report.skipped")}: ${result.skipReason}\n`);
39
+ }
40
+ if (result.error) {
41
+ process.stdout.write(` ! ${t("report.error")}: ${truncate(result.error, 400)}\n`);
42
+ }
43
+ let lastGroup: string | undefined;
44
+ for (const a of result.assertions) {
45
+ if (a.passed) continue;
46
+ if (a.group !== undefined && a.group !== lastGroup) {
47
+ process.stdout.write(` ▸ ${a.group}\n`);
48
+ }
49
+ lastGroup = a.group;
50
+ const sev = a.severity === "gate" ? t("report.gate") : t("report.soft");
51
+ const thr = a.threshold !== undefined
52
+ ? t("report.assertionThreshold", { score: a.score.toFixed(2), threshold: a.threshold })
53
+ : "";
54
+ const indent = a.group !== undefined ? " " : " ";
55
+ process.stdout.write(`${indent}- ${sev}: ${a.name}${thr}${a.detail ? ` — ${truncate(a.detail, 300)}` : ""}\n`);
56
+ }
57
+ },
58
+ onRunComplete(summary: RunSummary) {
59
+ process.stdout.write(renderRunReport(summary));
60
+ },
61
+ };
62
+ }
63
+
64
+ function formatOutcome(outcome: string): string {
65
+ switch (outcome) {
66
+ case "passed": return t("report.passed");
67
+ case "failed": return t("report.failed");
68
+ case "errored": return t("report.errored");
69
+ case "skipped": return t("report.skipped");
70
+ default: return outcome;
71
+ }
72
+ }
73
+
74
+ function truncate(s: string, n: number): string {
75
+ return s.length > n ? s.slice(0, n) + "…" : s;
76
+ }
@@ -0,0 +1,5 @@
1
+ // 内置报告器:Console(默认)/ Artifacts / Json / JUnit。第三方实验跟踪平台也走这条 Reporter 通道。
2
+
3
+ export { Console } from "./console.ts";
4
+ export { Artifacts } from "./artifacts.ts";
5
+ export { Json, JUnit } from "./json.ts";
@@ -0,0 +1,52 @@
1
+ // JSON / JUnit 报告器:把运行结果落成机器可读工件,接 CI 或下游 dashboard。
2
+
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import type { Reporter, RunSummary } from "../../types.ts";
6
+
7
+ export function Json(path: string): Reporter {
8
+ return {
9
+ async onRunComplete(summary: RunSummary) {
10
+ await mkdir(dirname(path), { recursive: true });
11
+ await writeFile(path, JSON.stringify(summary, null, 2), "utf-8");
12
+ },
13
+ };
14
+ }
15
+
16
+ export function JUnit(path: string): Reporter {
17
+ return {
18
+ async onRunComplete(summary: RunSummary) {
19
+ await mkdir(dirname(path), { recursive: true });
20
+ const cases = summary.results
21
+ .map((r) => {
22
+ const name = xmlAttr(`${r.id} [${r.agent}${r.model ? "/" + r.model : ""}]`);
23
+ const time = (r.durationMs / 1000).toFixed(3);
24
+ if (r.outcome === "errored") {
25
+ return ` <testcase name="${name}" time="${time}"><error message="${xmlAttr(r.error ?? "execution error")}"/></testcase>`;
26
+ }
27
+ if (r.outcome === "failed") {
28
+ const msg = xmlAttr(r.assertions.filter((a) => !a.passed).map((a) => a.name).join("; "));
29
+ return ` <testcase name="${name}" time="${time}"><failure message="${msg}"/></testcase>`;
30
+ }
31
+ if (r.outcome === "skipped") {
32
+ return ` <testcase name="${name}" time="${time}"><skipped message="${xmlAttr(r.skipReason ?? "")}"/></testcase>`;
33
+ }
34
+ return ` <testcase name="${name}" time="${time}"/>`;
35
+ })
36
+ .join("\n");
37
+ const xml =
38
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
39
+ `<testsuite name="niceeval" tests="${summary.results.length}" failures="${summary.failed}" errors="${summary.errored}" skipped="${summary.skipped}" time="${(summary.durationMs / 1000).toFixed(3)}">\n` +
40
+ `${cases}\n</testsuite>\n`;
41
+ await writeFile(path, xml, "utf-8");
42
+ },
43
+ };
44
+ }
45
+
46
+ function xmlAttr(s: string): string {
47
+ return s
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;");
52
+ }
@@ -0,0 +1,178 @@
1
+ // Live terminal reporter:在 TTY 终端里渲染实时状态表,每个 (eval, who) 对占一行。
2
+ // spinner 每 80ms 刷新;attempt 完成后行内显示 ✓/✗/~ 符号。
3
+ // onRunComplete 时清除状态表,打印和网页榜单同口径的表格报告。
4
+
5
+ import type { Reporter, RunShape, RunSummary } from "../../types.ts";
6
+ import { t } from "../../i18n/index.ts";
7
+ import { renderRunReport } from "./table.ts";
8
+
9
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+
11
+ const OUTCOME_SYM: Record<string, string> = {
12
+ passed: "✓",
13
+ failed: "✗",
14
+ errored: "!",
15
+ skipped: "○",
16
+ };
17
+
18
+ // 进度日志里的基础设施噪声:OTLP 端口、remote-agent 启动提示、trace span 计数。
19
+ // 这些在行尾显示毫无意义,直接丢弃。
20
+ const NOISE_PATTERNS = [
21
+ /^OTLP /,
22
+ /^使用 remote agent/,
23
+ /^using remote agent/,
24
+ /^驱动 agent/,
25
+ /^driving agent/,
26
+ /^trace:\d/,
27
+ /^agent tracing/,
28
+ /^agent setup/,
29
+ ];
30
+
31
+ function isNoise(msg: string): boolean {
32
+ return NOISE_PATTERNS.some((p) => p.test(msg));
33
+ }
34
+
35
+ export interface LiveRow {
36
+ evalId: string;
37
+ who: string;
38
+ /** 该 (evalId, who) 对的总 attempt 数(含 earlyExit 估算值)。 */
39
+ total: number;
40
+ }
41
+
42
+ export interface LiveReporter extends Reporter {
43
+ /** 被 RunOptions.onProgress 调用,更新行尾的 lastMsg。 */
44
+ progress(evalId: string, who: string, msg: string): void;
45
+ }
46
+
47
+ interface RowState extends LiveRow {
48
+ completed: number;
49
+ lastMsg: string;
50
+ dominantOutcome: string | undefined;
51
+ }
52
+
53
+ export function Live(rows: LiveRow[], totalAttempts: number): LiveReporter {
54
+ const stateMap = new Map<string, RowState>();
55
+ const keyOrder: string[] = [];
56
+
57
+ for (const r of rows) {
58
+ const k = `${r.evalId}|${r.who}`;
59
+ if (!stateMap.has(k)) {
60
+ stateMap.set(k, { ...r, completed: 0, lastMsg: "", dominantOutcome: undefined });
61
+ keyOrder.push(k);
62
+ } else {
63
+ // 同一 (evalId, who) 可能在多个 agentRun 里出现(不应发生,但做防御)
64
+ stateMap.get(k)!.total += r.total;
65
+ }
66
+ }
67
+
68
+ let spinFrame = 0;
69
+ let totalCompleted = 0;
70
+ let drawnLines = 0; // 上次 draw() 写了多少行,用于 \x1B[nA 回跳
71
+ let intervalId: ReturnType<typeof setInterval> | undefined;
72
+ let shape: RunShape | undefined;
73
+
74
+ const cols = () => process.stderr.columns || 100;
75
+
76
+ function renderRow(state: RowState, frame: number): string {
77
+ const done = state.completed >= state.total;
78
+ const sym = done
79
+ ? (OUTCOME_SYM[state.dominantOutcome ?? ""] ?? "?")
80
+ : SPINNER[frame % SPINNER.length];
81
+
82
+ const evalCol = state.evalId.slice(0, 24).padEnd(24);
83
+ const whoCol = `[${state.who}]`.slice(0, 26).padEnd(26);
84
+ const cntCol = `${state.completed}/${state.total}`.padEnd(5);
85
+
86
+ const prefix = ` ${sym} ${evalCol} ${whoCol} ${cntCol} `;
87
+ const budget = Math.max(0, cols() - prefix.length - 1);
88
+ const msg = done ? "" : state.lastMsg.slice(0, budget);
89
+
90
+ return `\x1B[2K${prefix}${msg}`;
91
+ }
92
+
93
+ function renderHeader(): string {
94
+ const hdr = shape
95
+ ? t("live.running", {
96
+ totalRuns: shape.totalRuns,
97
+ evals: shape.evals,
98
+ configs: shape.configs,
99
+ completed: totalCompleted,
100
+ total: totalAttempts,
101
+ })
102
+ : t("live.runningUnknown", { completed: totalCompleted, total: totalAttempts });
103
+ return `\x1B[2K${hdr}`;
104
+ }
105
+
106
+ function draw(frame: number) {
107
+ if (!process.stderr.isTTY) return;
108
+
109
+ if (drawnLines > 0) {
110
+ process.stderr.write(`\x1B[${drawnLines}A`);
111
+ }
112
+
113
+ let out = renderHeader() + "\n";
114
+ for (const k of keyOrder) {
115
+ out += renderRow(stateMap.get(k)!, frame) + "\n";
116
+ }
117
+
118
+ process.stderr.write(out);
119
+ drawnLines = 1 + keyOrder.length;
120
+ }
121
+
122
+ function clearDisplay() {
123
+ if (!process.stderr.isTTY || drawnLines === 0) return;
124
+ // 回到起点,逐行清空
125
+ process.stderr.write(`\x1B[${drawnLines}A`);
126
+ for (let i = 0; i < drawnLines; i++) {
127
+ process.stderr.write("\x1B[2K\n");
128
+ }
129
+ process.stderr.write(`\x1B[${drawnLines}A`);
130
+ drawnLines = 0;
131
+ }
132
+
133
+ return {
134
+ progress(evalId, who, msg) {
135
+ if (isNoise(msg)) return;
136
+ const state = stateMap.get(`${evalId}|${who}`);
137
+ if (state) state.lastMsg = msg;
138
+ // 不在这里 draw();由 interval 驱动,避免每条日志都刷屏
139
+ },
140
+
141
+ onRunStart(_evals, _agent, s) {
142
+ shape = s;
143
+ // 初始渲染:让用户看到行表
144
+ draw(0);
145
+ intervalId = setInterval(() => {
146
+ spinFrame = (spinFrame + 1) % SPINNER.length;
147
+ draw(spinFrame);
148
+ }, 80);
149
+ },
150
+
151
+ onEvalComplete(result) {
152
+ const who = result.model ? `${result.agent}/${result.model}` : result.agent;
153
+ const state = stateMap.get(`${result.id}|${who}`);
154
+ if (state) {
155
+ state.completed += 1;
156
+ totalCompleted += 1;
157
+ const prev = state.dominantOutcome;
158
+ if (!prev || (prev === "passed" && result.outcome !== "passed")) {
159
+ state.dominantOutcome = result.outcome;
160
+ }
161
+ } else {
162
+ totalCompleted += 1;
163
+ }
164
+ },
165
+
166
+ onRunComplete(summary) {
167
+ if (intervalId) {
168
+ clearInterval(intervalId);
169
+ intervalId = undefined;
170
+ }
171
+ // 最后刷一帧(所有行都 done)
172
+ draw(spinFrame);
173
+ clearDisplay();
174
+
175
+ process.stdout.write(renderRunReport(summary));
176
+ },
177
+ };
178
+ }