niceeval 0.10.3-canary.6 → 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 (122) hide show
  1. package/bin/niceeval.js +9 -4
  2. package/dist/agents/types.d.ts +5 -4
  3. package/dist/context/turn-errors.d.ts +27 -23
  4. package/dist/i18n/en.d.ts +15 -0
  5. package/dist/i18n/en.js +16 -1
  6. package/dist/i18n/zh-CN.d.ts +16 -1
  7. package/dist/i18n/zh-CN.js +16 -1
  8. package/dist/o11y/derive.js +28 -24
  9. package/dist/o11y/types.d.ts +8 -5
  10. package/dist/report/components/attempt-detail/UsageTable.js +4 -7
  11. package/dist/report/components/attempt-detail/compute.d.ts +2 -2
  12. package/dist/report/components/attempt-detail/compute.js +2 -6
  13. package/dist/report/components/attempt-detail/faces.js +7 -7
  14. package/dist/report/components/attempt-detail/index.js +0 -3
  15. package/dist/report/components/entity-lists/EvalList.js +0 -0
  16. package/dist/report/components/metric-views/compute.js +1 -1
  17. package/dist/report/model/types.d.ts +3 -4
  18. package/dist/results/locator.js +0 -0
  19. package/dist/results/select.d.ts +6 -0
  20. package/dist/results/select.js +8 -0
  21. package/dist/runner/feedback/sink.d.ts +26 -1
  22. package/dist/runner/fingerprint.d.ts +23 -0
  23. package/dist/runner/types.d.ts +105 -7
  24. package/dist/sandbox/errors.d.ts +29 -0
  25. package/dist/sandbox/resolve.d.ts +9 -0
  26. package/dist/shared/failure-class.d.ts +91 -0
  27. package/dist/types.d.ts +1 -0
  28. package/dist/util.d.ts +3 -2
  29. package/dist/util.js +31 -5
  30. package/docs-site/zh/explanation/runner.mdx +35 -0
  31. package/docs-site/zh/reference/cli.mdx +10 -1
  32. package/docs-site/zh/reference/events.mdx +4 -4
  33. package/docs-site/zh/troubleshooting/debugging.mdx +11 -0
  34. package/docs-site/zh/tutorials/viewing-results.mdx +35 -3
  35. package/package.json +28 -24
  36. package/src/agents/ai-sdk.test.ts +26 -0
  37. package/src/agents/ai-sdk.ts +7 -4
  38. package/src/agents/index.ts +5 -3
  39. package/src/agents/langgraph.test.ts +30 -0
  40. package/src/agents/langgraph.ts +5 -2
  41. package/src/agents/openai-compat.test.ts +35 -0
  42. package/src/agents/openai-compat.ts +16 -4
  43. package/src/agents/sdk-streams.test.ts +66 -0
  44. package/src/agents/sdk-streams.ts +3 -1
  45. package/src/agents/types.ts +5 -4
  46. package/src/cli.ts +81 -16
  47. package/src/context/context.test.ts +34 -0
  48. package/src/context/context.ts +14 -5
  49. package/src/context/send-retry.test.ts +86 -0
  50. package/src/context/send-retry.ts +37 -12
  51. package/src/context/session.ts +24 -0
  52. package/src/context/turn-errors.test.ts +124 -17
  53. package/src/context/turn-errors.ts +60 -50
  54. package/src/define.ts +5 -0
  55. package/src/i18n/en.ts +22 -1
  56. package/src/i18n/zh-CN.ts +22 -1
  57. package/src/index.ts +8 -0
  58. package/src/o11y/cost.ts +4 -2
  59. package/src/o11y/derive.test.ts +40 -0
  60. package/src/o11y/derive.ts +28 -22
  61. package/src/o11y/otlp/sandbox-receiver.test.ts +201 -0
  62. package/src/o11y/otlp/sandbox-receiver.ts +73 -27
  63. package/src/o11y/parsers/bub.test.ts +30 -0
  64. package/src/o11y/parsers/bub.ts +5 -2
  65. package/src/o11y/parsers/codex.test.ts +19 -0
  66. package/src/o11y/parsers/codex.ts +5 -2
  67. package/src/o11y/types.ts +8 -5
  68. package/src/report/components/attempt-detail/UsageTable.tsx +4 -6
  69. package/src/report/components/attempt-detail/attempt-components.test.tsx +5 -8
  70. package/src/report/components/attempt-detail/compute.ts +2 -7
  71. package/src/report/components/attempt-detail/faces.ts +7 -7
  72. package/src/report/components/attempt-detail/index.tsx +0 -3
  73. package/src/report/components/entity-lists/EvalList.tsx +0 -0
  74. package/src/report/components/metric-views/compute.ts +1 -1
  75. package/src/report/model/types.ts +3 -4
  76. package/src/results/format.ts +9 -2
  77. package/src/results/index.ts +2 -0
  78. package/src/results/locator.ts +0 -0
  79. package/src/results/open.ts +132 -21
  80. package/src/results/select.ts +12 -0
  81. package/src/results/skipped-notice.ts +0 -0
  82. package/src/runner/attempt.test.ts +116 -0
  83. package/src/runner/attempt.ts +89 -8
  84. package/src/runner/discover.ts +21 -3
  85. package/src/runner/feedback/coordinator.ts +24 -2
  86. package/src/runner/feedback/eval-conclusions.ts +6 -3
  87. package/src/runner/feedback/human.test.ts +300 -6
  88. package/src/runner/feedback/human.ts +132 -30
  89. package/src/runner/feedback/json.test.ts +127 -2
  90. package/src/runner/feedback/json.ts +51 -3
  91. package/src/runner/feedback/reducer.test.ts +328 -29
  92. package/src/runner/feedback/reducer.ts +84 -9
  93. package/src/runner/feedback/sink.ts +39 -1
  94. package/src/runner/fingerprint.ts +49 -19
  95. package/src/runner/gate-lease.test.ts +510 -0
  96. package/src/runner/gate-lease.ts +350 -0
  97. package/src/runner/lock.test.ts +454 -0
  98. package/src/runner/lock.ts +288 -0
  99. package/src/runner/report.test.ts +1 -0
  100. package/src/runner/run.test.ts +2044 -9
  101. package/src/runner/run.ts +825 -61
  102. package/src/runner/teardown-registry.ts +20 -78
  103. package/src/runner/types.ts +103 -7
  104. package/src/sandbox/errors.test.ts +72 -0
  105. package/src/sandbox/errors.ts +83 -0
  106. package/src/sandbox/keep-registry.ts +22 -46
  107. package/src/sandbox/resolve.test.ts +100 -0
  108. package/src/sandbox/resolve.ts +84 -27
  109. package/src/shared/entry-file-store.test.ts +149 -0
  110. package/src/shared/entry-file-store.ts +117 -0
  111. package/src/shared/failure-class.test.ts +137 -0
  112. package/src/shared/failure-class.ts +175 -0
  113. package/src/show/index.ts +5 -6
  114. package/src/show/render.test.ts +116 -15
  115. package/src/show/render.ts +124 -35
  116. package/src/types.ts +9 -0
  117. package/src/util.ts +31 -4
  118. package/src/view/app/App.tsx +5 -1
  119. package/src/view/client-dist/app.js +1 -1
  120. package/src/view/data.ts +5 -9
  121. package/src/view/shared/types.ts +7 -1
  122. package/src/view/view-report.test.ts +54 -0
@@ -28,6 +28,7 @@ import {
28
28
  type ExperimentHookInput,
29
29
  type ExperimentProgressInput,
30
30
  type PrecheckInput,
31
+ type LockWaitInput,
31
32
  type FailureInput,
32
33
  type FeedbackSink,
33
34
  type KeptInput,
@@ -198,6 +199,7 @@ export function createFeedbackCoordinator(options: FeedbackCoordinatorOptions):
198
199
  type: "diagnostic",
199
200
  at: io.clock.now(),
200
201
  key: input.key,
202
+ ...(input.code !== undefined ? { code: input.code } : {}),
201
203
  severity: input.severity,
202
204
  message: input.message,
203
205
  identity: input.identity,
@@ -272,6 +274,22 @@ export function createFeedbackCoordinator(options: FeedbackCoordinatorOptions):
272
274
  });
273
275
  }
274
276
 
277
+ function lockWait(input: LockWaitInput): void {
278
+ emit({
279
+ type: "lock-wait",
280
+ at: io.clock.now(),
281
+ experimentId: input.experimentId,
282
+ evalId: input.evalId,
283
+ status: input.status,
284
+ ...(input.holderPid !== undefined ? { holderPid: input.holderPid } : {}),
285
+ ...(input.holderHost !== undefined ? { holderHost: input.holderHost } : {}),
286
+ ...(input.attempts !== undefined ? { attempts: input.attempts } : {}),
287
+ ...(input.carried !== undefined ? { carried: input.carried } : {}),
288
+ ...(input.dispatched !== undefined ? { dispatched: input.dispatched } : {}),
289
+ ...(input.waitedMs !== undefined ? { waitedMs: input.waitedMs } : {}),
290
+ });
291
+ }
292
+
275
293
  function reporterError(input: { reporter: string; required: boolean; message: string }): void {
276
294
  emit({
277
295
  type: "reporter-error",
@@ -313,6 +331,7 @@ export function createFeedbackCoordinator(options: FeedbackCoordinatorOptions):
313
331
  experimentHook,
314
332
  experimentProgress,
315
333
  precheck,
334
+ lockWait,
316
335
  lifecycle,
317
336
  });
318
337
  emit({ type: "plan", at: startedAtMs, plan });
@@ -378,6 +397,7 @@ export function createFeedbackCoordinator(options: FeedbackCoordinatorOptions):
378
397
  experimentHook,
379
398
  experimentProgress,
380
399
  precheck,
400
+ lockWait,
381
401
  lifecycle,
382
402
  stopDynamic,
383
403
  finish,
@@ -404,8 +424,10 @@ function fallbackTextFor(event: DurableFeedbackEvent): string | undefined {
404
424
  case "saved":
405
425
  case "experiment-hook":
406
426
  case "precheck":
407
- // 钩子/预检起止不是失败证据:setup 失败的每条 attempt 另有 "failure" 事件兜底,
408
- // 预检失败以既有错误路径中止运行,renderer 崩溃丢一行起止不丢数据。
427
+ case "lock-wait":
428
+ // 钩子/预检/锁等待起止都不是失败证据:setup 失败的每条 attempt 另有 "failure" 事件
429
+ // 兜底,预检失败以既有错误路径中止运行,锁等待解决与否不改变任何 verdict——
430
+ // renderer 崩溃丢一行起止不丢数据。
409
431
  return undefined;
410
432
  }
411
433
  }
@@ -40,12 +40,15 @@ export interface EvalConclusionRow {
40
40
  rate?: number;
41
41
  }
42
42
 
43
- /** `diagnostics` 里 `fail-fast:` 记录按 (experiment, eval) 求和的未派发次数;没有 `identity`
44
- * 的记录(理论上不应出现——fail-fast 诊断恒带 identity,见 run.ts)不计入任何 key。 */
43
+ /** `diagnostics` 里 fail-fast 记录按 (experiment, eval) 求和的未派发次数;没有 `identity`
44
+ * 的记录(理论上不应出现——fail-fast 诊断恒带 identity,见 run.ts)不计入任何 key。
45
+ * 归类按稳定词法 `code`(省略时回落到 key 首段,缺省 key 恒是 `${code}:${identity}`),不按
46
+ * `key` 前缀:key 里编着折叠身份,拿它匹配会在身份编码变动时静默失配,减不掉的那部分
47
+ * fail-fast 份额会当成真实的 earlyExit 省略数报出去。 */
45
48
  function failFastByEval(diagnostics: readonly DiagnosticNotice[]): ReadonlyMap<string, number> {
46
49
  const result = new Map<string, number>();
47
50
  for (const d of diagnostics) {
48
- if (!d.key.startsWith("fail-fast:") || !d.identity) continue;
51
+ if ((d.code ?? d.key.split(":", 1)[0]) !== "fail-fast" || !d.identity) continue;
49
52
  const key = evalConclusionKey(d.identity);
50
53
  result.set(key, (result.get(key) ?? 0) + d.count);
51
54
  }
@@ -5,10 +5,10 @@
5
5
  // boxed 能力下产生可识别的框线字符与正确的面板顺序/分隔,plain/非 TTY 下不产生任何框字符。
6
6
 
7
7
  import { afterEach, describe, expect, it } from "vitest";
8
- import { createHumanRenderer, renderDurableLines } from "./human.ts";
8
+ import { createHumanRenderer, renderDurableLines, renderHumanDryPlan } from "./human.ts";
9
9
  import { createFakeFeedbackIO } from "./testing.ts";
10
- import { createInitialRunFeedbackState } from "./reducer.ts";
11
- import { encodeAttemptKey } from "../types.ts";
10
+ import { createInitialRunFeedbackState, reduceRunFeedback } from "./reducer.ts";
11
+ import { encodeAttemptKey, HALT_DIAGNOSTIC_CODE } from "../types.ts";
12
12
  import { stringWidth } from "../../report/model/text-layout.ts";
13
13
  import type { DurableFeedbackEvent, InvocationCompletion, InvocationSummary, RunFeedbackPlan, RunFeedbackState } from "../types.ts";
14
14
  import type { AttemptLocator } from "../../results/locator.ts";
@@ -182,7 +182,8 @@ describe("live dashboard — 接线到 panel.ts", () => {
182
182
  reused: 6,
183
183
  running: 19,
184
184
  queued: 12,
185
- completed: 8,
185
+ passed: 6,
186
+ failed: 2,
186
187
  elapsedMs: 134_000,
187
188
  estimatedCostUSD: 0.84,
188
189
  active: new Map([[key, { identity, who: "compare/bub-e2b", phase: "eval.run", phaseStartedAt: 0 }]]),
@@ -260,7 +261,8 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
260
261
  reused: 6,
261
262
  running: 19,
262
263
  queued: 12,
263
- completed: 8,
264
+ passed: 6,
265
+ failed: 2,
264
266
  elapsedMs: 134_000,
265
267
  active: new Map([
266
268
  [key, { identity, who: "compare/bub-e2b", phase: "eval.run", phaseStartedAt: 0, detail: longDetail }],
@@ -358,7 +360,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
358
360
  ...createInitialRunFeedbackState(),
359
361
  total: 2,
360
362
  running: 1,
361
- completed: 1,
363
+ passed: 1,
362
364
  active: new Map([[shortKey, { identity: shortIdentity, who, phase: "eval.run", phaseStartedAt: 1 }]]),
363
365
  };
364
366
  renderer.onLifecycle?.(
@@ -418,3 +420,295 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
418
420
  for (const l of framedLines) expect(stringWidth(l)).toBe(100);
419
421
  });
420
422
  });
423
+
424
+ // cases: docs/engineering/testing/unit/experiments-runner.md「用例锁与并发 Invocation」——
425
+ // 字节级精确渲染归 E2E · CLI「反馈输出格式」;这里只做与 precheck/experiment-hook 同等级别
426
+ // 的最小 smoke 断言(行是否出现、关键子串是否存在),不断言列宽算术。
427
+ describe("用例锁等待(elsewhere)的显示", () => {
428
+ it("TTY:等待期间显示运行级行,面板首行的 elsewhere 计数非零", () => {
429
+ const { io, stderr } = createFakeFeedbackIO({ stderr: { isTTY: true, columns: 100, rows: 30 } });
430
+ const renderer = createHumanRenderer({ io, command: "niceeval exp compare/codex" });
431
+ const state: RunFeedbackState = {
432
+ ...createInitialRunFeedbackState(),
433
+ total: 3,
434
+ elsewhere: 2,
435
+ queued: 1,
436
+ lockWaits: new Map([
437
+ [
438
+ "compare/codex",
439
+ {
440
+ experimentId: "compare/codex",
441
+ waiting: new Map([
442
+ ["memory/a", { startedAt: 0, holderPid: 41267, holderHost: "mba.local" }],
443
+ ["memory/b", { startedAt: 5, holderPid: 41267, holderHost: "mba.local" }],
444
+ ]),
445
+ resolvedCarried: 0,
446
+ resolvedDispatched: 0,
447
+ },
448
+ ],
449
+ ]),
450
+ };
451
+ renderer.redrawDynamic?.(state);
452
+
453
+ const plain = stripAnsi(stderr.writes.join(""));
454
+ expect(plain).toMatch(/├─ ACTIVE ─+┤/);
455
+ expect(plain).toContain("waiting on another run");
456
+ expect(plain).toContain("compare/codex");
457
+ expect(plain).toContain("2 evals");
458
+ expect(plain).toContain("pid 41267");
459
+ expect(plain).toContain("2 elsewhere");
460
+ });
461
+
462
+ it("TTY appendDurable 对 lock-wait 直接返回,不写 scrollback 永久行(运行级行由 state.lockWaits 驱动)", () => {
463
+ const { io, stdout, stderr } = createFakeFeedbackIO({ stderr: { isTTY: true, columns: 100, rows: 30 } });
464
+ const renderer = createHumanRenderer({ io, command: "niceeval exp compare/codex" });
465
+ const state: RunFeedbackState = { ...createInitialRunFeedbackState(), total: 1, elsewhere: 1 };
466
+ renderer.appendDurable(
467
+ { type: "lock-wait", at: 0, experimentId: "compare/codex", evalId: "memory/a", status: "started", attempts: 1, holderPid: 1, holderHost: "h" },
468
+ state,
469
+ );
470
+ expect(stdout.writes.join("") + stderr.writes.join("")).toBe("");
471
+ });
472
+
473
+ it("非 TTY:started 只在窗口第一次打开(唯一等待用例)时追加一行,中途加入的用例不逐条刷屏", () => {
474
+ const { io, stdout } = createFakeFeedbackIO({ stderr: { isTTY: false } });
475
+ const renderer = createHumanRenderer({ io, command: "niceeval exp compare/codex" });
476
+ const firstState: RunFeedbackState = {
477
+ ...createInitialRunFeedbackState(),
478
+ total: 2,
479
+ elsewhere: 1,
480
+ lockWaits: new Map([
481
+ [
482
+ "compare/codex",
483
+ {
484
+ experimentId: "compare/codex",
485
+ waiting: new Map([["memory/a", { startedAt: 0, holderPid: 41267 }]]),
486
+ resolvedCarried: 0,
487
+ resolvedDispatched: 0,
488
+ },
489
+ ],
490
+ ]),
491
+ };
492
+ renderer.appendDurable(
493
+ { type: "lock-wait", at: 0, experimentId: "compare/codex", evalId: "memory/a", status: "started", attempts: 1, holderPid: 41267, holderHost: "h" },
494
+ firstState,
495
+ );
496
+ const secondState: RunFeedbackState = {
497
+ ...firstState,
498
+ elsewhere: 2,
499
+ lockWaits: new Map([
500
+ [
501
+ "compare/codex",
502
+ {
503
+ ...firstState.lockWaits.get("compare/codex")!,
504
+ waiting: new Map([
505
+ ["memory/a", { startedAt: 0, holderPid: 41267 }],
506
+ ["memory/b", { startedAt: 1, holderPid: 41267 }],
507
+ ]),
508
+ },
509
+ ],
510
+ ]),
511
+ };
512
+ renderer.appendDurable(
513
+ { type: "lock-wait", at: 1, experimentId: "compare/codex", evalId: "memory/b", status: "started", attempts: 1, holderPid: 41267, holderHost: "h" },
514
+ secondState,
515
+ );
516
+
517
+ const out = stdout.writes.join("");
518
+ expect(out).toContain("waiting on another run · compare/codex");
519
+ // 只出现一次:第二条(memory/b 加入)是同一窗口内的非首条,静默不刷屏。
520
+ expect(out.split("waiting on another run").length - 1).toBe(1);
521
+ });
522
+
523
+ it("非 TTY:resolved 只在窗口最后一次关闭(全部等待用例都已解决)时追加聚合收尾行", () => {
524
+ const { io, stdout } = createFakeFeedbackIO({ stderr: { isTTY: false } });
525
+ const renderer = createHumanRenderer({ io, command: "niceeval exp compare/codex" });
526
+ // 还剩一个用例没解决:窗口未关闭,静默。
527
+ const stillWaitingState: RunFeedbackState = {
528
+ ...createInitialRunFeedbackState(),
529
+ lockWaits: new Map([
530
+ [
531
+ "compare/codex",
532
+ {
533
+ experimentId: "compare/codex",
534
+ waiting: new Map([["memory/b", { startedAt: 1, holderPid: 1 }]]),
535
+ resolvedCarried: 2,
536
+ resolvedDispatched: 0,
537
+ },
538
+ ],
539
+ ]),
540
+ };
541
+ renderer.appendDurable(
542
+ { type: "lock-wait", at: 5, experimentId: "compare/codex", evalId: "memory/a", status: "resolved", carried: 2, dispatched: 0, waitedMs: 5_000 },
543
+ stillWaitingState,
544
+ );
545
+ expect(stdout.writes.join("")).toBe("");
546
+
547
+ // 最后一个也解决了:窗口关闭,打印聚合收尾行(carried + dispatched 混合的措辞两面都要覆盖)。
548
+ const closedState: RunFeedbackState = {
549
+ ...createInitialRunFeedbackState(),
550
+ lockWaits: new Map([
551
+ [
552
+ "compare/codex",
553
+ {
554
+ experimentId: "compare/codex",
555
+ waiting: new Map(),
556
+ resolvedCarried: 2,
557
+ resolvedDispatched: 1,
558
+ },
559
+ ],
560
+ ]),
561
+ };
562
+ renderer.appendDurable(
563
+ { type: "lock-wait", at: 94_000, experimentId: "compare/codex", evalId: "memory/b", status: "resolved", carried: 0, dispatched: 1, waitedMs: 94_000 },
564
+ closedState,
565
+ );
566
+ const out = stdout.writes.join("");
567
+ expect(out).toContain("lock wait resolved · compare/codex");
568
+ expect(out).toContain("2 carried");
569
+ expect(out).toContain("1 to run");
570
+ });
571
+ });
572
+
573
+ describe("诊断行:标题是「阶段标签 · code」,止损闸落闸是一行 error 级通知", () => {
574
+ /** 把同一条诊断喂 N 次(emitter 刷新 data.unstarted 时就是这个形状),返回每一次的渲染行。 */
575
+ function replayDiagnostic(event: DurableFeedbackEvent & { type: "diagnostic" }, times: number): string[][] {
576
+ let state = createInitialRunFeedbackState();
577
+ const out: string[][] = [];
578
+ for (let i = 0; i < times; i++) {
579
+ state = reduceRunFeedback(state, event);
580
+ out.push(renderDurableLines(event, state, { mode: "plain", width: 100 }));
581
+ }
582
+ return out;
583
+ }
584
+
585
+ it("普通诊断的标题用 code,不把编了身份的去重 key 甩进人读的一行", () => {
586
+ const [lines] = replayDiagnostic(
587
+ {
588
+ type: "diagnostic",
589
+ at: 0,
590
+ key: "lock-taken-over:compare/codex|memory/retention",
591
+ code: "lock-taken-over",
592
+ severity: "warning",
593
+ message: "took over a stale lock from pid 41267",
594
+ },
595
+ 1,
596
+ );
597
+ expect(lines![0]).toBe("! lock-taken-over");
598
+ expect(lines![1]).toContain("took over a stale lock");
599
+ });
600
+
601
+ it("同一 key 再次出现时标题带 ×N 折叠计数(非止损闸诊断保持既有形态)", () => {
602
+ const rounds = replayDiagnostic(
603
+ { type: "diagnostic", at: 0, key: "memory-warmup-degraded", severity: "warning", message: "cold index" },
604
+ 3,
605
+ );
606
+ expect(rounds[0]![0]).toBe("! memory-warmup-degraded");
607
+ expect(rounds[2]![0]).toBe("! memory-warmup-degraded (3 attempts)");
608
+ });
609
+
610
+ it("attempt 级诊断的标题是「阶段标签 · code」,阶段标签与失败行同一投影", () => {
611
+ // 失败行已有的 ` · <阶段标签>` 段是同一个投影的既有出口:拿它当参照,断言诊断行没有
612
+ // 另写一份阶段词表(不硬编码语言,en/zh-CN 两种 locale 下都成立)。
613
+ const failureLines = renderDurableLines(
614
+ {
615
+ type: "failure",
616
+ at: 0,
617
+ locator: locator("@1bwcxxiy"),
618
+ identity: { experimentId: "compare/codex", evalId: "memory/x", attempt: 1 },
619
+ who: "codex",
620
+ verdict: "errored",
621
+ reason: "boom",
622
+ phase: "sandbox.setup",
623
+ },
624
+ createInitialRunFeedbackState(),
625
+ { mode: "plain", width: 100 },
626
+ );
627
+ const label = failureLines[0]!.split("\n")[0]!.split(" · ")[1]!;
628
+
629
+ const rounds = replayDiagnostic(
630
+ {
631
+ type: "diagnostic",
632
+ at: 0,
633
+ // 作者没传 dedupeKey 时 attempt.ts 折出来的 key —— 编了身份,不能出现在人读标题里。
634
+ key: "memory-warmup-degraded:compare/codex|memory/x|1",
635
+ code: "memory-warmup-degraded",
636
+ severity: "warning",
637
+ message: "Memory warmup failed; continuing with a cold index",
638
+ identity: { experimentId: "compare/codex", evalId: "memory/x", attempt: 1 },
639
+ data: { phase: "sandbox.setup" },
640
+ },
641
+ 12,
642
+ );
643
+ expect(rounds[0]![0]).toBe(`! ${label} · memory-warmup-degraded`);
644
+ expect(rounds[11]![0]).toBe(`! ${label} · memory-warmup-degraded (12 attempts)`);
645
+ expect(rounds[11]![1]).toBe(" Memory warmup failed; continuing with a cold index");
646
+ });
647
+
648
+ it("运行级诊断没有 phase:标题只有 code,不留空的 · 分隔符", () => {
649
+ const [lines] = replayDiagnostic(
650
+ {
651
+ type: "diagnostic",
652
+ at: 0,
653
+ key: "budget-unenforceable:compare/codex",
654
+ code: "budget-unenforceable",
655
+ severity: "warning",
656
+ message: "no cost data; budget cannot be enforced",
657
+ data: { experimentId: "compare/codex" },
658
+ },
659
+ 1,
660
+ );
661
+ expect(lines![0]).toBe("! budget-unenforceable");
662
+ });
663
+
664
+ it("实验闸落闸:一行 error 级通知,文案就是契约字面,不再多一行标题", () => {
665
+ const [lines] = replayDiagnostic(
666
+ {
667
+ type: "diagnostic",
668
+ at: 0,
669
+ key: "dispatch-halted:experiment:compare/codex",
670
+ code: HALT_DIAGNOSTIC_CODE,
671
+ severity: "error",
672
+ message: "experiment halted (dispatch-halted): shared service is down; restart the tunnel",
673
+ data: { experimentId: "compare/codex", scope: "experiment", phase: "eval.run", unstarted: 0 },
674
+ },
675
+ 1,
676
+ );
677
+ expect(lines).toEqual(["✗ experiment halted (dispatch-halted): shared service is down; restart the tunnel"]);
678
+ });
679
+
680
+ it("每条未派发 attempt 刷一次的后续声明零输出:被中止的等待集不逐条刷屏,数量归完成状态的 unstarted", () => {
681
+ const rounds = replayDiagnostic(
682
+ {
683
+ type: "diagnostic",
684
+ at: 0,
685
+ key: "dispatch-halted:eval:compare/codex|memory/retention",
686
+ code: HALT_DIAGNOSTIC_CODE,
687
+ severity: "error",
688
+ message: "eval halted: fixture db is empty; run scripts/seed.ts",
689
+ data: { experimentId: "compare/codex", scope: "eval", evalId: "memory/retention", phase: "eval.run", unstarted: 4 },
690
+ },
691
+ 5,
692
+ );
693
+ expect(rounds[0]).toEqual(["✗ eval halted: fixture db is empty; run scripts/seed.ts"]);
694
+ expect(rounds.slice(1).flat()).toEqual([]);
695
+ });
696
+ });
697
+
698
+ describe("renderHumanDryPlan: locked 标注", () => {
699
+ it("locked 为 true 的行尾标注 locked;false/省略的行不受影响", () => {
700
+ const text = renderHumanDryPlan({
701
+ totalAttempts: 2,
702
+ evals: 2,
703
+ configs: 1,
704
+ runs: 1,
705
+ rows: [
706
+ { experimentId: "compare/codex", evalId: "memory/a", locked: true },
707
+ { experimentId: "compare/codex", evalId: "memory/b" },
708
+ ],
709
+ });
710
+ const lines = text.trim().split("\n");
711
+ expect(lines.find((l) => l.includes("memory/a"))).toContain("locked");
712
+ expect(lines.find((l) => l.includes("memory/b"))).not.toContain("locked");
713
+ });
714
+ });