@post-print/agent-test 0.3.12 → 1.0.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 (97) hide show
  1. package/README.md +18 -4
  2. package/dist/cli.d.ts +9 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +53 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/command-allowlist.d.ts +9 -0
  7. package/dist/command-allowlist.d.ts.map +1 -0
  8. package/dist/command-allowlist.js +29 -0
  9. package/dist/command-allowlist.js.map +1 -0
  10. package/dist/compare-scenario.d.ts +14 -18
  11. package/dist/compare-scenario.d.ts.map +1 -1
  12. package/dist/compare-scenario.js +146 -257
  13. package/dist/compare-scenario.js.map +1 -1
  14. package/dist/debug-bundle.d.ts.map +1 -1
  15. package/dist/debug-bundle.js +2 -0
  16. package/dist/debug-bundle.js.map +1 -1
  17. package/dist/expect.d.ts +3 -0
  18. package/dist/expect.d.ts.map +1 -1
  19. package/dist/expect.js +36 -0
  20. package/dist/expect.js.map +1 -1
  21. package/dist/html-report.d.ts +1 -0
  22. package/dist/html-report.d.ts.map +1 -1
  23. package/dist/html-report.js +194 -38
  24. package/dist/html-report.js.map +1 -1
  25. package/dist/index.d.ts +8 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +7 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/live-isolation.d.ts +8 -1
  30. package/dist/live-isolation.d.ts.map +1 -1
  31. package/dist/live-isolation.js +96 -8
  32. package/dist/live-isolation.js.map +1 -1
  33. package/dist/record-trace.d.ts +2 -2
  34. package/dist/record-trace.d.ts.map +1 -1
  35. package/dist/record-trace.js +6 -4
  36. package/dist/record-trace.js.map +1 -1
  37. package/dist/run-suite.d.ts +9 -1
  38. package/dist/run-suite.d.ts.map +1 -1
  39. package/dist/run-suite.js +190 -58
  40. package/dist/run-suite.js.map +1 -1
  41. package/dist/scenario-story.d.ts +4 -3
  42. package/dist/scenario-story.d.ts.map +1 -1
  43. package/dist/scenario-story.js +54 -71
  44. package/dist/scenario-story.js.map +1 -1
  45. package/dist/scenario-usage.d.ts.map +1 -1
  46. package/dist/scenario-usage.js +2 -3
  47. package/dist/scenario-usage.js.map +1 -1
  48. package/dist/types.d.ts +38 -22
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/validate-suite.d.ts.map +1 -1
  51. package/dist/validate-suite.js +65 -38
  52. package/dist/validate-suite.js.map +1 -1
  53. package/dist/viewer/catalog.d.ts +53 -0
  54. package/dist/viewer/catalog.d.ts.map +1 -0
  55. package/dist/viewer/catalog.js +116 -0
  56. package/dist/viewer/catalog.js.map +1 -0
  57. package/dist/viewer/cli.d.ts +17 -0
  58. package/dist/viewer/cli.d.ts.map +1 -0
  59. package/dist/viewer/cli.js +38 -0
  60. package/dist/viewer/cli.js.map +1 -0
  61. package/dist/viewer/compare-metrics.d.ts +30 -0
  62. package/dist/viewer/compare-metrics.d.ts.map +1 -0
  63. package/dist/viewer/compare-metrics.js +102 -0
  64. package/dist/viewer/compare-metrics.js.map +1 -0
  65. package/dist/viewer/context-files.d.ts +8 -0
  66. package/dist/viewer/context-files.d.ts.map +1 -0
  67. package/dist/viewer/context-files.js +56 -0
  68. package/dist/viewer/context-files.js.map +1 -0
  69. package/dist/viewer/emit.d.ts +7 -0
  70. package/dist/viewer/emit.d.ts.map +1 -0
  71. package/dist/viewer/emit.js +25 -0
  72. package/dist/viewer/emit.js.map +1 -0
  73. package/dist/viewer/events.d.ts +75 -0
  74. package/dist/viewer/events.d.ts.map +1 -0
  75. package/dist/viewer/events.js +47 -0
  76. package/dist/viewer/events.js.map +1 -0
  77. package/dist/viewer/live-runner.d.ts +20 -0
  78. package/dist/viewer/live-runner.d.ts.map +1 -0
  79. package/dist/viewer/live-runner.js +102 -0
  80. package/dist/viewer/live-runner.js.map +1 -0
  81. package/dist/viewer/page.d.ts +3 -0
  82. package/dist/viewer/page.d.ts.map +1 -0
  83. package/dist/viewer/page.js +1664 -0
  84. package/dist/viewer/page.js.map +1 -0
  85. package/dist/viewer/run-controller.d.ts +27 -0
  86. package/dist/viewer/run-controller.d.ts.map +1 -0
  87. package/dist/viewer/run-controller.js +154 -0
  88. package/dist/viewer/run-controller.js.map +1 -0
  89. package/dist/viewer/server.d.ts +16 -0
  90. package/dist/viewer/server.d.ts.map +1 -0
  91. package/dist/viewer/server.js +154 -0
  92. package/dist/viewer/server.js.map +1 -0
  93. package/dist/worker-pool.d.ts +8 -0
  94. package/dist/worker-pool.d.ts.map +1 -0
  95. package/dist/worker-pool.js +26 -0
  96. package/dist/worker-pool.js.map +1 -0
  97. package/package.json +7 -5
package/dist/run-suite.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { cancelActiveClaudeRun, cancelActiveCursorRun, cancelActiveOpenaiRun, captureWorkingTreeStatus, createSealedWorkspace, defaultSealedOverlayPaths, enrichTrace, filterWorkingTreeLeaks, findWorkingTreeLeak, formatWorkingTreeLeak, getProcessAuthMode, judgeCompareTraces, judgeTrace, loadContext, loadUnifiedDiffPaths, mergeMcpServers, parseScenarioWorkspace, partitionSeedCollateralLeaks, porcelainPathsFromLines, resolveAllowUserSkills, resolveHarnessArtifactIgnoreRoots, restoreWorkingTreePaths, runAgent, setProcessAuthMode, skillInvokeJudgeCriteria, skillPathsFromSetting, sumUsageParts, toolPathsOutsideWorkspace, traceEditsOutsideWorktree, traceHasUserInputTool, } from "@post-print/agent-harness";
4
- import { applyCompareArm, applySidecarCompareDurations, assertCompareMetrics, attachCompareStoryResults, buildCompareResult, compareArmDescription, compareArmLabel, compareResultArms, compareStoryFields, plainDescription, prefixCompareFailures, resolveCompareArms, resolveCompareMetricPairs, } from "./compare-scenario.js";
4
+ import { applyCompareArm, applySidecarCompareDurations, assertCompareGates, attachCompareStoryResults, buildCompareResult, compareArmDescription, compareArmLabel, compareArmTokens, compareArmTurns, compareResultArms, compareStoryFields, evaluateCompareGates, plainDescription, prefixCompareFailures, requireCompareArm, resolveCompareArms, } from "./compare-scenario.js";
5
5
  import { collectDebugEnvironment, getDebugBundleDir, writeDebugBundle } from "./debug-bundle.js";
6
6
  import { discoverSuites } from "./discover-suites.js";
7
7
  import { assertRubric } from "./expect.js";
@@ -20,6 +20,9 @@ import { buildScenarioResultUsage, totalTokensFromScenarioUsage } from "./scenar
20
20
  import { summarizeReportResults } from "./suite-summary.js";
21
21
  import { theme } from "./theme.js";
22
22
  import { validateSuiteFile } from "./validate-suite.js";
23
+ import { viewerContextFiles } from "./viewer/context-files.js";
24
+ import { emitViewerEvent } from "./viewer/emit.js";
25
+ import { DEFAULT_CLI_WORKERS, runWorkerPool } from "./worker-pool.js";
23
26
  const require = createRequire(import.meta.url);
24
27
  const packageVersion = require("../package.json").version;
25
28
  let activeWorktreeCleanup;
@@ -142,7 +145,8 @@ export function scenarioNeedsJudge(judge, scenario) {
142
145
  return false;
143
146
  }
144
147
  if (scenario.compare) {
145
- return collectCompareJudgeCriteria(scenario.rubric).length > 0;
148
+ return (collectCompareJudgeCriteria(scenario.rubric).length > 0 ||
149
+ (scenario.compare.judgeMetrics?.length ?? 0) > 0);
146
150
  }
147
151
  return collectJudgeCriteria(scenario.rubric).length > 0;
148
152
  }
@@ -330,26 +334,26 @@ async function runSuiteBody(options) {
330
334
  const parentCounters = parentScenarioCounters();
331
335
  const displayTotal = parentCounters?.total ?? filteredTotal;
332
336
  const isolateLive = liveScenarioIsolationEnabled() && !options.scenarioFilter && filteredTotal > 1;
333
- let previousIsolatedExitCode;
337
+ const workers = Math.max(1, options.workers ?? DEFAULT_CLI_WORKERS);
334
338
  if (shouldPrintSuiteChrome()) {
335
339
  logProgress(`\n${theme.suiteHeader(suite.name, defaultHost, displayTotal)}`);
336
340
  if (isolateLive) {
337
341
  logProgress(` ${theme.isolationNote()}`);
338
342
  }
339
343
  }
340
- for (let index = 0; index < scenarios.length; index++) {
341
- const scenario = scenarios[index];
342
- if (!scenario) {
343
- continue;
344
- }
345
- const scenarioIndex = parentCounters?.index ?? index + 1;
346
- const scenarioTotal = displayTotal;
347
- if (isolateLive) {
344
+ if (isolateLive) {
345
+ const isolateSlots = new Array(scenarios.length);
346
+ await runWorkerPool(scenarios.map((scenario, index) => ({ scenario, index })), workers, async ({ scenario, index }) => {
347
+ if (!scenario) {
348
+ return;
349
+ }
350
+ const scenarioIndex = parentCounters?.index ?? index + 1;
351
+ const scenarioTotal = displayTotal;
348
352
  if (scenario.skip) {
349
353
  const skipLabel = `[${scenarioIndex}/${scenarioTotal}] ${scenario.name}`;
350
354
  logProgress(theme.skipped(skipLabel));
351
355
  logScenarioDescription(scenario.description);
352
- results.push({
356
+ isolateSlots[index] = {
353
357
  suite: suite.name,
354
358
  scenario: scenario.name,
355
359
  description: plainDescription(scenario.description),
@@ -364,8 +368,8 @@ async function runSuiteBody(options) {
364
368
  skipped: true,
365
369
  failures: [],
366
370
  }),
367
- });
368
- continue;
371
+ };
372
+ return;
369
373
  }
370
374
  const started = performance.now();
371
375
  const debug = isDebugEnabled(options);
@@ -375,7 +379,7 @@ async function runSuiteBody(options) {
375
379
  let attempts = 0;
376
380
  let failures = [];
377
381
  let scenarioTrace;
378
- let previousAttemptExitCode = previousIsolatedExitCode;
382
+ let previousAttemptExitCode;
379
383
  let childSidecar;
380
384
  while (true) {
381
385
  attempts++;
@@ -403,7 +407,6 @@ async function runSuiteBody(options) {
403
407
  authMode: options.authMode ?? getProcessAuthMode(),
404
408
  });
405
409
  previousAttemptExitCode = spawned.exitCode;
406
- previousIsolatedExitCode = spawned.exitCode;
407
410
  failures = [];
408
411
  scenarioTrace = undefined;
409
412
  childSidecar =
@@ -451,7 +454,32 @@ async function runSuiteBody(options) {
451
454
  compareResult = applySidecarCompareDurations(await loadCompareResultFromStaging(options.stagingSessionId, suite.name, scenario), childSidecar);
452
455
  scenarioTrace = compareResultArms(compareResult)[0]?.trace ?? scenarioTrace;
453
456
  }
454
- if (failures.length === 0 && options.judge !== false) {
457
+ if (compareResult && options.judge !== false && scenario.compare?.judgeMetrics?.length) {
458
+ for (const arm of compareResult.arms) {
459
+ if (!arm.trace)
460
+ continue;
461
+ try {
462
+ const judged = await runJudgeRubric(arm.trace, { judge: scenario.compare.judgeMetrics }, options.cwd, judgeHost);
463
+ arm.trace = judged.trace;
464
+ arm.judgeVerdicts = toJudgeVerdictResults(judged.trace, scenario.compare.judgeMetrics, judged.verdicts);
465
+ arm.failures = [...(arm.failures ?? []), ...judged.failures];
466
+ failures.push(...prefixCompareFailures(arm.label, judged.failures.filter((failure) => failure.category !== "rubric_miss")));
467
+ }
468
+ catch (error) {
469
+ failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to judge compare arm", "judge_infra"));
470
+ }
471
+ }
472
+ }
473
+ if (compareResult) {
474
+ compareResult.gateResults = evaluateCompareGates(compareResult.gates, compareResult);
475
+ if (failures.every((failure) => failure.category === "rubric_miss")) {
476
+ failures.push(...assertCompareGates(compareResult.gates, compareResult));
477
+ }
478
+ }
479
+ if ((options.judge !== false && failures.length === 0) ||
480
+ (options.judge !== false &&
481
+ scenario.compare &&
482
+ failures.every((failure) => failure.category === "rubric_miss"))) {
455
483
  const compareTraces = compareResult ? compareResultArms(compareResult) : [];
456
484
  if (scenario.compare &&
457
485
  compareResult &&
@@ -492,7 +520,9 @@ async function runSuiteBody(options) {
492
520
  judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
493
521
  }
494
522
  catch (error) {
495
- failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to load staging trace for judge", "judge_infra"));
523
+ failures.push(assertionFailure("judge", error instanceof Error
524
+ ? error.message
525
+ : "failed to load staging trace for judge", "judge_infra"));
496
526
  }
497
527
  }
498
528
  }
@@ -563,31 +593,47 @@ async function runSuiteBody(options) {
563
593
  debug,
564
594
  debugBundleDir,
565
595
  });
566
- results.push(scenarioResult);
596
+ isolateSlots[index] = scenarioResult;
597
+ releaseLiveMemory();
598
+ return;
599
+ });
600
+ for (const result of isolateSlots) {
601
+ if (result) {
602
+ results.push(result);
603
+ }
604
+ }
605
+ }
606
+ else {
607
+ for (let index = 0; index < scenarios.length; index++) {
608
+ const scenario = scenarios[index];
609
+ if (!scenario) {
610
+ continue;
611
+ }
612
+ const scenarioIndex = parentCounters?.index ?? index + 1;
613
+ const scenarioTotal = displayTotal;
614
+ results.push(await runAgentTest({
615
+ cwd: options.cwd,
616
+ suiteName: suite.name,
617
+ scenario,
618
+ defaults: suite.defaults,
619
+ host: options.host,
620
+ judge: options.judge,
621
+ worktree: options.worktree,
622
+ stagingSessionId: options.stagingSessionId,
623
+ scenarioIndex,
624
+ scenarioTotal,
625
+ timeoutMs: options.timeoutMs,
626
+ allowUserInput: options.allowUserInput,
627
+ debug: options.debug,
628
+ debugDir: options.debugDir,
629
+ suitesDir: options.suitesDir,
630
+ keepRecordings: options.keepRecordings,
631
+ rubricsDir: options.rubricsDir,
632
+ scenarioRetries: isChildProcess() ? 0 : options.scenarioRetries,
633
+ compareArm: options.compareArm,
634
+ }));
567
635
  releaseLiveMemory();
568
- continue;
569
636
  }
570
- results.push(await runAgentTest({
571
- cwd: options.cwd,
572
- suiteName: suite.name,
573
- scenario,
574
- defaults: suite.defaults,
575
- host: options.host,
576
- judge: options.judge,
577
- worktree: options.worktree,
578
- stagingSessionId: options.stagingSessionId,
579
- scenarioIndex,
580
- scenarioTotal,
581
- timeoutMs: options.timeoutMs,
582
- allowUserInput: options.allowUserInput,
583
- debug: options.debug,
584
- debugDir: options.debugDir,
585
- suitesDir: options.suitesDir,
586
- keepRecordings: options.keepRecordings,
587
- rubricsDir: options.rubricsDir,
588
- scenarioRetries: isChildProcess() ? 0 : options.scenarioRetries,
589
- }));
590
- releaseLiveMemory();
591
637
  }
592
638
  return {
593
639
  suite: suite.name,
@@ -690,7 +736,7 @@ async function runAgentTestBody(options) {
690
736
  let result;
691
737
  while (true) {
692
738
  attempts++;
693
- result = await runAgentTestOnce(options.cwd, suiteName, options.scenario, defaultHost, options.defaults?.profile, options.defaults?.skills, options.defaults?.contextSources, options.defaults?.mcpServers, options.defaults?.workspace, options.defaults?.allowUserSkills, options.judge ?? true, options.worktree ?? true, options.stagingSessionId, options.scenarioIndex, options.scenarioTotal, options.timeoutMs, options.allowUserInput, options.debug, options.debugDir, options.suitesDir ?? "agent-suites", options.keepRecordings, options.rubricsDir, { suppressEmit: maxAttempts > 1 });
739
+ result = await runAgentTestOnce(options.cwd, suiteName, options.scenario, defaultHost, options.defaults?.profile, options.defaults?.skills, options.defaults?.contextSources, options.defaults?.mcpServers, options.defaults?.workspace, options.defaults?.allowUserSkills, options.judge ?? true, options.worktree ?? true, options.stagingSessionId, options.scenarioIndex, options.scenarioTotal, options.timeoutMs, options.allowUserInput, options.debug, options.debugDir, options.suitesDir ?? "agent-suites", options.keepRecordings, options.rubricsDir, { suppressEmit: maxAttempts > 1, compareArm: options.compareArm });
694
740
  const canRetry = !result.skipped &&
695
741
  !result.passed &&
696
742
  attempts < maxAttempts &&
@@ -769,6 +815,9 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
769
815
  if (scenario.compare && runOptions?.compareArm === undefined) {
770
816
  return runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultProfile, defaultSkills, defaultContextSources, defaultMcpServers, defaultWorkspace, defaultAllowUserSkills, judge, worktree, stagingSessionId, scenarioIndex, scenarioTotal, timeoutMs, allowUserInput, debugFlag, debugDir, suitesDir, keepRecordings, rubricsDir, { suppressEmit });
771
817
  }
818
+ if (runOptions?.compareArm && scenario.compare) {
819
+ scenario = requireCompareArm(scenario, runOptions.compareArm);
820
+ }
772
821
  const host = scenario.host ?? defaultHost;
773
822
  const profile = scenario.profile ?? defaultProfile ?? defaultProfileForHost(host);
774
823
  const skills = scenario.skills ?? defaultSkills;
@@ -781,6 +830,12 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
781
830
  : mergeMcpServers(defaultMcpServers, scenario.mcpServers);
782
831
  const liveTimeoutMs = resolveLiveTimeoutMs(timeoutMs);
783
832
  const failOnUserInput = !allowUserInput;
833
+ const viewerEnvelope = {
834
+ suite: suiteName,
835
+ scenario: scenario.name,
836
+ host,
837
+ ...(runOptions?.compareArm ? { arm: runOptions.compareArm } : {}),
838
+ };
784
839
  if (runOptions?.compareArm) {
785
840
  logPhase(theme.phase("arm", `${runOptions.compareArm.toUpperCase()} ${host}`));
786
841
  }
@@ -797,6 +852,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
797
852
  let callerHeadBefore;
798
853
  const callerTreeBefore = useWorktree ? await captureWorkingTreeStatus(cwd) : undefined;
799
854
  if (useWorktree) {
855
+ emitViewerEvent({ type: "status", text: "Creating sealed workspace.", ...viewerEnvelope });
800
856
  if (scenario.seedPatch) {
801
857
  callerHeadBefore = await captureCallerHead(cwd);
802
858
  setCallerHeadRestore(cwd, callerHeadBefore);
@@ -811,9 +867,15 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
811
867
  activeWorktreeCleanup = worktreeHandle.cleanup;
812
868
  if (scenario.seedPatch) {
813
869
  logPhase(theme.phase("seed", theme.basename(scenario.seedPatch)));
870
+ emitViewerEvent({
871
+ type: "status",
872
+ text: `Applying seed ${basename(scenario.seedPatch)}.`,
873
+ ...viewerEnvelope,
874
+ });
814
875
  await seedScenarioWorktree(cwd, worktreeHandle.path, scenario.seedPatch, {
815
876
  stageOnly: scenario.seedStageOnly === true,
816
877
  });
878
+ emitViewerEvent({ type: "status", text: "Seed applied.", ...viewerEnvelope });
817
879
  }
818
880
  }
819
881
  else {
@@ -829,8 +891,20 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
829
891
  });
830
892
  const outputContract = outputContractForRubric(scenario.rubric);
831
893
  const agentStartMarkerPath = isChildProcess() && stagingSessionId
832
- ? getStagingAgentStartPath(stagingSessionId, suiteName, scenario.name)
894
+ ? getStagingAgentStartPath(stagingSessionId, suiteName, scenario.name, runOptions?.compareArm)
833
895
  : undefined;
896
+ emitViewerEvent({ type: "cell_started", ...viewerEnvelope });
897
+ emitViewerEvent({ type: "status", text: "Starting host agent.", ...viewerEnvelope });
898
+ const contextFiles = viewerContextFiles(context.preamble, context.sources, contextSources ?? []);
899
+ if (contextFiles.length > 0) {
900
+ emitViewerEvent({
901
+ type: "status",
902
+ text: contextFiles.map((file) => file.why).join(" "),
903
+ ...viewerEnvelope,
904
+ });
905
+ emitViewerEvent({ type: "context", files: contextFiles, ...viewerEnvelope });
906
+ }
907
+ emitViewerEvent({ type: "prompt", text: scenario.prompt, ...viewerEnvelope });
834
908
  logPhase(theme.phase("agent", theme.phaseDim("started")));
835
909
  const agentStarted = performance.now();
836
910
  let livePreview;
@@ -852,11 +926,18 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
852
926
  : undefined,
853
927
  onAgentEvent: (event) => {
854
928
  if (event.type === "tool") {
929
+ emitViewerEvent({
930
+ type: "tool",
931
+ name: event.name,
932
+ args: event.args,
933
+ ...viewerEnvelope,
934
+ });
855
935
  logLive(theme.liveTool(event.name, pathFromArgs(event.args)));
856
936
  livePreview = undefined;
857
937
  refreshHeartbeat();
858
938
  return;
859
939
  }
940
+ emitViewerEvent({ type: "text", text: event.text, ...viewerEnvelope });
860
941
  // Clock tick paints the preview. Do not rewrite here — a long
861
942
  // line wraps and `\r` cannot clear the leftover row.
862
943
  livePreview = quoteExcerpt(event.text);
@@ -953,7 +1034,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
953
1034
  }
954
1035
  const durationMs = Math.round(performance.now() - started);
955
1036
  if (isChildProcess() && stagingSessionId && runOptions?.writeSidecar !== false) {
956
- await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
1037
+ await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name, runOptions?.compareArm), {
957
1038
  passed: failures.length === 0,
958
1039
  failures,
959
1040
  durationMs,
@@ -993,6 +1074,42 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
993
1074
  judgeVerdicts,
994
1075
  }),
995
1076
  };
1077
+ const armMetrics = {
1078
+ id: runOptions?.compareArm ?? "cell",
1079
+ label: runOptions?.compareArm ?? "cell",
1080
+ prompt: scenario.prompt,
1081
+ trace,
1082
+ };
1083
+ const turns = compareArmTurns(armMetrics);
1084
+ const tokens = compareArmTokens(armMetrics);
1085
+ const tools = trace.toolCalls.length;
1086
+ emitViewerEvent({
1087
+ type: "cell_finished",
1088
+ ...viewerEnvelope,
1089
+ passed,
1090
+ durationMs,
1091
+ failures: failures.map((failure) => ({
1092
+ matcher: failure.matcher,
1093
+ message: failure.message,
1094
+ })),
1095
+ metrics: {
1096
+ ...(turns !== undefined ? { turns } : {}),
1097
+ ...(tokens !== undefined ? { tokens } : {}),
1098
+ tools,
1099
+ },
1100
+ });
1101
+ if (judgeVerdicts && judgeVerdicts.length > 0) {
1102
+ emitViewerEvent({
1103
+ type: "judge",
1104
+ ...viewerEnvelope,
1105
+ verdicts: judgeVerdicts.map((verdict) => ({
1106
+ id: verdict.id,
1107
+ question: verdict.question,
1108
+ pass: verdict.pass,
1109
+ rationale: verdict.rationale,
1110
+ })),
1111
+ });
1112
+ }
996
1113
  if (!suppressEmit) {
997
1114
  const debugBundleDir = await maybeWriteDebugBundle({
998
1115
  debug,
@@ -1090,8 +1207,10 @@ async function runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, de
1090
1207
  const result = await runAgentTestOnce(shared.cwd, shared.suiteName, armScenario, shared.defaultHost, shared.defaultProfile, shared.defaultSkills, shared.defaultContextSources, shared.defaultMcpServers, shared.defaultWorkspace, shared.defaultAllowUserSkills, false, shared.worktree, shared.stagingSessionId, undefined, undefined, shared.timeoutMs, shared.allowUserInput, shared.debugFlag, shared.debugDir, shared.suitesDir, shared.keepRecordings, shared.rubricsDir, { suppressEmit: true, compareArm: entry.id, writeSidecar: false });
1091
1208
  armRuns.push({ id: entry.id, label, scenario: armScenario, result });
1092
1209
  }
1093
- const failures = armRuns.flatMap((arm) => prefixCompareFailures(arm.label, arm.result.failures));
1094
- const armIds = resolvedArms.map((entry) => entry.id);
1210
+ const experimentMode = (scenario.compare?.gates?.length ?? 0) > 0;
1211
+ const failures = armRuns.flatMap((arm) => prefixCompareFailures(arm.label, experimentMode
1212
+ ? arm.result.failures.filter((failure) => failure.category !== "rubric_miss")
1213
+ : arm.result.failures));
1095
1214
  const compareResult = buildCompareResult(armRuns.map((arm) => ({
1096
1215
  id: arm.id,
1097
1216
  label: arm.label,
@@ -1099,12 +1218,25 @@ async function runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, de
1099
1218
  prompt: arm.scenario.prompt,
1100
1219
  trace: arm.result.trace,
1101
1220
  durationMs: arm.result.durationMs,
1102
- })), {
1103
- faster: resolveCompareMetricPairs(scenario.compare?.faster, armIds),
1104
- cheaper: resolveCompareMetricPairs(scenario.compare?.cheaper, armIds),
1105
- });
1106
- if (scenario.compare) {
1107
- failures.push(...assertCompareMetrics(scenario.compare, compareResult));
1221
+ passed: !arm.result.failures.some((failure) => failure.category === "rubric_miss"),
1222
+ failures: arm.result.failures,
1223
+ })), scenario.compare?.gates);
1224
+ if (judge && !isChildProcess() && scenario.compare?.judgeMetrics?.length) {
1225
+ for (const arm of compareResult.arms) {
1226
+ if (!arm.trace)
1227
+ continue;
1228
+ const judged = await runJudgeRubric(arm.trace, { judge: scenario.compare.judgeMetrics }, cwd, host);
1229
+ arm.trace = judged.trace;
1230
+ arm.judgeVerdicts = toJudgeVerdictResults(judged.trace, scenario.compare.judgeMetrics, judged.verdicts);
1231
+ arm.failures = [...(arm.failures ?? []), ...judged.failures];
1232
+ failures.push(...prefixCompareFailures(arm.label, judged.failures.filter((failure) => failure.category !== "rubric_miss")));
1233
+ }
1234
+ }
1235
+ if (!isChildProcess()) {
1236
+ compareResult.gateResults = evaluateCompareGates(compareResult.gates, compareResult);
1237
+ if (failures.every((failure) => failure.category === "rubric_miss")) {
1238
+ failures.push(...assertCompareGates(compareResult.gates, compareResult));
1239
+ }
1108
1240
  }
1109
1241
  const deferJudgeToParent = isChildProcess();
1110
1242
  const compareArms = compareResultArms(compareResult);
@@ -1112,7 +1244,7 @@ async function runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, de
1112
1244
  let judgeVerdicts;
1113
1245
  if (judge &&
1114
1246
  !deferJudgeToParent &&
1115
- failures.length === 0 &&
1247
+ failures.every((failure) => failure.category === "rubric_miss") &&
1116
1248
  compareArms.length > 0 &&
1117
1249
  compareArms.every((arm) => arm.trace)) {
1118
1250
  const criteria = collectCompareJudgeCriteria(scenario.rubric);
@@ -1207,7 +1339,6 @@ async function runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, de
1207
1339
  }
1208
1340
  async function loadCompareResultFromStaging(stagingSessionId, suiteName, scenario) {
1209
1341
  const resolved = resolveCompareArms(scenario.compare);
1210
- const armIds = resolved.map((entry) => entry.id);
1211
1342
  const arms = [];
1212
1343
  for (const entry of resolved) {
1213
1344
  const armScenario = applyCompareArm(scenario, entry.id);
@@ -1224,12 +1355,11 @@ async function loadCompareResultFromStaging(stagingSessionId, suiteName, scenari
1224
1355
  description: compareArmDescription(entry.arm),
1225
1356
  prompt: armScenario.prompt,
1226
1357
  trace,
1358
+ passed: trace ? assertRubric(trace, armScenario.rubric).length === 0 : undefined,
1359
+ failures: trace ? assertRubric(trace, armScenario.rubric) : undefined,
1227
1360
  });
1228
1361
  }
1229
- return buildCompareResult(arms, {
1230
- faster: resolveCompareMetricPairs(scenario.compare?.faster, armIds),
1231
- cheaper: resolveCompareMetricPairs(scenario.compare?.cheaper, armIds),
1232
- });
1362
+ return buildCompareResult(arms, scenario.compare?.gates);
1233
1363
  }
1234
1364
  async function runCompareJudgeRubric(compare, rubric, runCwd, host) {
1235
1365
  const criteria = collectCompareJudgeCriteria(rubric);
@@ -1358,6 +1488,8 @@ async function runAllSuitesBody(options) {
1358
1488
  rubricsDir: options.rubricsDir,
1359
1489
  adapterModules: options.adapterModules,
1360
1490
  authMode: options.authMode ?? getProcessAuthMode(),
1491
+ compareArm: options.compareArm,
1492
+ workers: options.workers,
1361
1493
  }));
1362
1494
  }
1363
1495
  }