@post-print/agent-test 0.3.4 → 0.3.5

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 (102) hide show
  1. package/README.md +122 -43
  2. package/dist/check.d.ts +38 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +169 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/cli.d.ts +11 -7
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +327 -318
  9. package/dist/cli.js.map +1 -1
  10. package/dist/compare-scenario.d.ts +33 -0
  11. package/dist/compare-scenario.d.ts.map +1 -0
  12. package/dist/compare-scenario.js +128 -0
  13. package/dist/compare-scenario.js.map +1 -0
  14. package/dist/debug-bundle.d.ts +2 -1
  15. package/dist/debug-bundle.d.ts.map +1 -1
  16. package/dist/debug-bundle.js +12 -5
  17. package/dist/debug-bundle.js.map +1 -1
  18. package/dist/doctor.d.ts +6 -1
  19. package/dist/doctor.d.ts.map +1 -1
  20. package/dist/doctor.js +133 -11
  21. package/dist/doctor.js.map +1 -1
  22. package/dist/expect.d.ts +6 -5
  23. package/dist/expect.d.ts.map +1 -1
  24. package/dist/expect.js +67 -57
  25. package/dist/expect.js.map +1 -1
  26. package/dist/host-log.d.ts +17 -0
  27. package/dist/host-log.d.ts.map +1 -0
  28. package/dist/host-log.js +154 -0
  29. package/dist/host-log.js.map +1 -0
  30. package/dist/hosts.d.ts +22 -0
  31. package/dist/hosts.d.ts.map +1 -0
  32. package/dist/hosts.js +84 -0
  33. package/dist/hosts.js.map +1 -0
  34. package/dist/html-report.d.ts +0 -13
  35. package/dist/html-report.d.ts.map +1 -1
  36. package/dist/html-report.js +381 -318
  37. package/dist/html-report.js.map +1 -1
  38. package/dist/index.d.ts +14 -7
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +12 -6
  41. package/dist/index.js.map +1 -1
  42. package/dist/live-isolation.d.ts +11 -4
  43. package/dist/live-isolation.d.ts.map +1 -1
  44. package/dist/live-isolation.js +31 -10
  45. package/dist/live-isolation.js.map +1 -1
  46. package/dist/load-adapters.d.ts +13 -0
  47. package/dist/load-adapters.d.ts.map +1 -0
  48. package/dist/load-adapters.js +76 -0
  49. package/dist/load-adapters.js.map +1 -0
  50. package/dist/load-suite.d.ts.map +1 -1
  51. package/dist/load-suite.js +6 -2
  52. package/dist/load-suite.js.map +1 -1
  53. package/dist/mcp-config.d.ts +8 -0
  54. package/dist/mcp-config.d.ts.map +1 -1
  55. package/dist/mcp-config.js +46 -0
  56. package/dist/mcp-config.js.map +1 -1
  57. package/dist/progress.d.ts +7 -0
  58. package/dist/progress.d.ts.map +1 -1
  59. package/dist/progress.js +39 -9
  60. package/dist/progress.js.map +1 -1
  61. package/dist/record-trace.d.ts +9 -7
  62. package/dist/record-trace.d.ts.map +1 -1
  63. package/dist/record-trace.js +5 -16
  64. package/dist/record-trace.js.map +1 -1
  65. package/dist/report-preview-server.d.ts +2 -0
  66. package/dist/report-preview-server.d.ts.map +1 -0
  67. package/dist/report-preview-server.js +24 -0
  68. package/dist/report-preview-server.js.map +1 -0
  69. package/dist/report-preview.d.ts +11 -0
  70. package/dist/report-preview.d.ts.map +1 -0
  71. package/dist/report-preview.js +161 -0
  72. package/dist/report-preview.js.map +1 -0
  73. package/dist/run-suite.d.ts +34 -1
  74. package/dist/run-suite.d.ts.map +1 -1
  75. package/dist/run-suite.js +551 -82
  76. package/dist/run-suite.js.map +1 -1
  77. package/dist/scenario-story.d.ts +41 -0
  78. package/dist/scenario-story.d.ts.map +1 -0
  79. package/dist/scenario-story.js +198 -0
  80. package/dist/scenario-story.js.map +1 -0
  81. package/dist/suite-summary.d.ts +2 -0
  82. package/dist/suite-summary.d.ts.map +1 -1
  83. package/dist/suite-summary.js +70 -22
  84. package/dist/suite-summary.js.map +1 -1
  85. package/dist/theme.d.ts +29 -5
  86. package/dist/theme.d.ts.map +1 -1
  87. package/dist/theme.js +204 -45
  88. package/dist/theme.js.map +1 -1
  89. package/dist/types.d.ts +97 -11
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/validate-seeds.d.ts.map +1 -1
  92. package/dist/validate-seeds.js +21 -6
  93. package/dist/validate-seeds.js.map +1 -1
  94. package/dist/validate-suite.d.ts +3 -1
  95. package/dist/validate-suite.d.ts.map +1 -1
  96. package/dist/validate-suite.js +258 -23
  97. package/dist/validate-suite.js.map +1 -1
  98. package/package.json +4 -4
  99. package/dist/compare.d.ts +0 -79
  100. package/dist/compare.d.ts.map +0 -1
  101. package/dist/compare.js +0 -232
  102. package/dist/compare.js.map +0 -1
package/dist/run-suite.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
- import { cancelActiveClaudeRun, cancelActiveCursorRun, captureWorkingTreeStatus, createScenarioWorktree, enrichTrace, filterWorkingTreeLeaks, findWorkingTreeLeak, formatWorkingTreeLeak, judgeTrace, loadContext, loadUnifiedDiffPaths, mergeMcpServers, partitionSeedCollateralLeaks, porcelainPathsFromLines, resolveHarnessArtifactIgnoreRoots, restoreWorkingTreePaths, runAgent, traceEditsOutsideWorktree, traceHasUserInputTool, } from "@post-print/agent-harness";
3
+ import { cancelActiveClaudeRun, cancelActiveCursorRun, cancelActiveOpenaiRun, captureWorkingTreeStatus, createSealedWorkspace, defaultSealedOverlayPaths, enrichTrace, filterWorkingTreeLeaks, findWorkingTreeLeak, formatWorkingTreeLeak, judgeCompareTraces, judgeTrace, loadContext, loadUnifiedDiffPaths, mergeMcpServers, parseScenarioWorkspace, partitionSeedCollateralLeaks, porcelainPathsFromLines, resolveAllowUserSkills, resolveHarnessArtifactIgnoreRoots, restoreWorkingTreePaths, runAgent, skillInvokeJudgeCriteria, skillPathsFromSetting, sumUsageParts, toolPathsOutsideWorkspace, traceEditsOutsideWorktree, traceHasUserInputTool, } from "@post-print/agent-harness";
4
+ import { applyCompareArm, applySidecarCompareDurations, assertCompareMetrics, compareArmLabel, compareStoryFields, prefixCompareFailures, } from "./compare-scenario.js";
4
5
  import { collectDebugEnvironment, getDebugBundleDir, writeDebugBundle } from "./debug-bundle.js";
5
6
  import { discoverSuites } from "./discover-suites.js";
6
7
  import { assertRubric } from "./expect.js";
7
8
  import { assertionFailure } from "./failures.js";
9
+ import { resolveSuiteHosts, scenarioRunsOnHost, uniqueHosts } from "./hosts.js";
8
10
  import { failuresForLiveSubprocessExit, killActiveLiveChildren, liveScenarioIsolationEnabled, parentScenarioCounters, spawnLiveScenario, } from "./live-isolation.js";
9
11
  import { resolveLiveTimeoutMs } from "./live-timeout.js";
10
12
  import { loadSuiteFile } from "./load-suite.js";
11
- import { formatDuration, logPhase, logProgress, logVerdict, withHeartbeat } from "./progress.js";
13
+ import { bindMcpServersToCaller, mcpStdioScriptPaths } from "./mcp-config.js";
14
+ import { formatDuration, logLive, logPhase, logProgress, logVerdict, refreshHeartbeat, withHeartbeat, } from "./progress.js";
12
15
  import { cleanupStagingSession, createLiveStagingSessionId, getLiveStagingRootOverride, getLiveStagingSessionRoot, getStagingAgentStartPath, getStagingResultPath, getStagingTracePath, loadStagingResult, loadStagingTrace, recordTrace, resolveRecordingPath, setLiveStagingRootOverride, writeAgentStartMarker, writeStagingResult, } from "./record-trace.js";
13
16
  import { resolveScenarioRetryMaxAttempts, shouldRetryAnnounceStopFlake } from "./scenario-retry.js";
14
17
  import { captureCallerHead, restoreCallerHeadIfSeedCommit, seedScenarioWorktree, } from "./scenario-seed.js";
18
+ import { buildScenarioStory, pathFromArgs, quoteExcerpt } from "./scenario-story.js";
15
19
  import { buildScenarioResultUsage, totalTokensFromScenarioUsage } from "./scenario-usage.js";
16
20
  import { summarizeReportResults } from "./suite-summary.js";
17
21
  import { theme } from "./theme.js";
@@ -38,6 +42,14 @@ async function restoreActiveCallerHead() {
38
42
  function isChildProcess() {
39
43
  return process.env.AGENT_TEST_CHILD === "1";
40
44
  }
45
+ function resolveMaxConversationTurns() {
46
+ const raw = process.env.AGENT_TEST_MAX_TURNS?.trim();
47
+ if (!raw) {
48
+ return undefined;
49
+ }
50
+ const parsed = Number(raw);
51
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
52
+ }
41
53
  /** Parent process prints suite headers and final verdicts; children only print phases. */
42
54
  export function shouldPrintSuiteChrome() {
43
55
  return !isChildProcess();
@@ -52,6 +64,7 @@ export function registerLiveRunHandlers() {
52
64
  killActiveLiveChildren();
53
65
  cancelActiveCursorRun();
54
66
  cancelActiveClaudeRun();
67
+ cancelActiveOpenaiRun();
55
68
  const cleanup = activeWorktreeCleanup;
56
69
  const headRestore = activeCallerHeadRestore;
57
70
  if (!cleanup && !headRestore) {
@@ -99,6 +112,82 @@ function normalizeJudgeCriteria(judge) {
99
112
  return { id: item.id ?? `judge-${index}`, question: item.question };
100
113
  });
101
114
  }
115
+ /** Explicit judge questions plus skill-invoke questions for mustInvokeSkill. */
116
+ export function collectJudgeCriteria(rubric) {
117
+ return [
118
+ ...normalizeJudgeCriteria(rubric.judge),
119
+ ...skillInvokeJudgeCriteria(rubric.mustInvokeSkill ?? []),
120
+ ];
121
+ }
122
+ /** Pairwise judge questions only. Skill-follow checks stay per-arm and deterministic. */
123
+ export function collectCompareJudgeCriteria(rubric) {
124
+ return normalizeJudgeCriteria(rubric.judge);
125
+ }
126
+ /** Judge auth and the judge call apply only when a rubric has judge work. */
127
+ export function judgeAuthRequired(judge, rubrics) {
128
+ return judge !== false && rubrics.some((rubric) => collectJudgeCriteria(rubric).length > 0);
129
+ }
130
+ /** Pairwise judge on compare. Skill-follow judge questions stay on non-compare scenarios. */
131
+ export function scenarioNeedsJudge(judge, scenario) {
132
+ if (judge === false) {
133
+ return false;
134
+ }
135
+ if (scenario.compare) {
136
+ return collectCompareJudgeCriteria(scenario.rubric).length > 0;
137
+ }
138
+ return collectJudgeCriteria(scenario.rubric).length > 0;
139
+ }
140
+ /** True when the selected live run will call the judge. */
141
+ export async function selectedRunNeedsJudge(options) {
142
+ if (options.judge === false) {
143
+ return false;
144
+ }
145
+ const suitePaths = await discoverSuites(resolve(options.cwd, options.suitesDir));
146
+ const filtered = options.filter
147
+ ? suitePaths.filter((suitePath) => {
148
+ const suiteName = suiteNameFromPath(suitePath);
149
+ return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
150
+ })
151
+ : suitePaths;
152
+ for (const suitePath of filtered) {
153
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
154
+ for (const scenario of suite.scenarios) {
155
+ if (scenario.skip) {
156
+ continue;
157
+ }
158
+ if (options.scenarioFilter && scenario.name !== options.scenarioFilter) {
159
+ continue;
160
+ }
161
+ if (scenarioNeedsJudge(true, scenario)) {
162
+ return true;
163
+ }
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+ export async function loadSelectedRubrics(options) {
169
+ const suitePaths = await discoverSuites(resolve(options.cwd, options.suitesDir));
170
+ const filtered = options.filter
171
+ ? suitePaths.filter((suitePath) => {
172
+ const suiteName = suiteNameFromPath(suitePath);
173
+ return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
174
+ })
175
+ : suitePaths;
176
+ const rubrics = [];
177
+ for (const suitePath of filtered) {
178
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
179
+ for (const scenario of suite.scenarios) {
180
+ if (scenario.skip) {
181
+ continue;
182
+ }
183
+ if (options.scenarioFilter && scenario.name !== options.scenarioFilter) {
184
+ continue;
185
+ }
186
+ rubrics.push(scenario.rubric);
187
+ }
188
+ }
189
+ return rubrics;
190
+ }
102
191
  function questionForCriterion(criteria, id) {
103
192
  return criteria.find((c) => c.id === id)?.question ?? id;
104
193
  }
@@ -145,6 +234,7 @@ function emitScenarioVerdict(options) {
145
234
  judgeVerdicts: options.judgeVerdicts,
146
235
  rubricFailures: rubricFailuresOnly(options.failures),
147
236
  failureCategory: options.failures[0]?.category,
237
+ story: options.story,
148
238
  debug: options.debug,
149
239
  debugBundleDir: options.debugBundleDir,
150
240
  }));
@@ -156,7 +246,7 @@ async function maybeWriteDebugBundle(options) {
156
246
  if (!options.stagingSessionId) {
157
247
  return undefined;
158
248
  }
159
- const dir = getDebugBundleDir(options.stagingSessionId, options.suiteName, options.scenario.name, getLiveStagingSessionRoot);
249
+ const dir = getDebugBundleDir(options.stagingSessionId, options.suiteName, options.scenario.name, getLiveStagingSessionRoot, options.host);
160
250
  const cliPath = process.argv[1] ?? resolve(options.cwd, "node_modules/@post-print/agent-test/dist/cli.js");
161
251
  try {
162
252
  await writeDebugBundle({
@@ -235,12 +325,20 @@ async function runSuiteBody(options) {
235
325
  }
236
326
  const defaultHost = options.host ?? suite.defaults?.host ?? "cursor";
237
327
  const results = [];
238
- const scenarios = options.scenarioFilter
328
+ const selected = options.scenarioFilter
239
329
  ? suite.scenarios.filter((scenario) => scenario.name === options.scenarioFilter)
240
330
  : suite.scenarios;
241
- if (options.scenarioFilter && scenarios.length === 0) {
331
+ if (options.scenarioFilter && selected.length === 0) {
242
332
  throw new Error(`Scenario not found: ${options.scenarioFilter}`);
243
333
  }
334
+ const hostLocked = options.hostLocked === true;
335
+ const scenarios = hostLocked
336
+ ? selected.filter((scenario) => scenarioRunsOnHost(scenario, defaultHost, true))
337
+ : selected;
338
+ if (hostLocked && options.scenarioFilter && selected.length > 0 && scenarios.length === 0) {
339
+ const pinned = selected[0]?.host;
340
+ throw new Error(`Scenario ${options.scenarioFilter} is pinned to ${pinned} (this run is ${defaultHost})`);
341
+ }
244
342
  const filteredTotal = scenarios.length;
245
343
  const parentCounters = parentScenarioCounters();
246
344
  const displayTotal = parentCounters?.total ?? filteredTotal;
@@ -249,7 +347,7 @@ async function runSuiteBody(options) {
249
347
  if (shouldPrintSuiteChrome()) {
250
348
  logProgress(`\n${theme.suiteHeader(suite.name, defaultHost, displayTotal)}`);
251
349
  if (isolateLive) {
252
- logProgress(` ${theme.isolationNote()}`);
350
+ logProgress(` ${theme.isolationNote()}`);
253
351
  }
254
352
  }
255
353
  for (let index = 0; index < scenarios.length; index++) {
@@ -266,10 +364,17 @@ async function runSuiteBody(options) {
266
364
  results.push({
267
365
  suite: suite.name,
268
366
  scenario: scenario.name,
367
+ prompt: scenario.prompt,
269
368
  passed: true,
270
369
  failures: [],
271
370
  skipped: true,
272
371
  durationMs: 0,
372
+ story: buildScenarioStory({
373
+ rubric: scenario.rubric,
374
+ passed: true,
375
+ skipped: true,
376
+ failures: [],
377
+ }),
273
378
  });
274
379
  continue;
275
380
  }
@@ -282,9 +387,10 @@ async function runSuiteBody(options) {
282
387
  let failures = [];
283
388
  let scenarioTrace;
284
389
  let previousAttemptExitCode = previousIsolatedExitCode;
390
+ let childSidecar;
285
391
  while (true) {
286
392
  attempts++;
287
- const exitCode = await spawnLiveScenario({
393
+ const spawned = await spawnLiveScenario({
288
394
  cwd: options.cwd,
289
395
  suiteName: suite.name,
290
396
  scenarioName: scenario.name,
@@ -296,6 +402,7 @@ async function runSuiteBody(options) {
296
402
  worktree: options.worktree,
297
403
  judge: options.judge,
298
404
  host: defaultHost,
405
+ adapterModules: options.adapterModules,
299
406
  scenarioIndex: index + 1,
300
407
  scenarioTotal: filteredTotal,
301
408
  timeoutMs: resolveLiveTimeoutMs(options.timeoutMs),
@@ -305,23 +412,34 @@ async function runSuiteBody(options) {
305
412
  debugDir: options.debugDir,
306
413
  previousExitCode: previousAttemptExitCode,
307
414
  });
308
- previousAttemptExitCode = exitCode;
309
- previousIsolatedExitCode = exitCode;
415
+ previousAttemptExitCode = spawned.exitCode;
416
+ previousIsolatedExitCode = spawned.exitCode;
310
417
  failures = [];
311
418
  scenarioTrace = undefined;
312
- if (exitCode !== 0) {
313
- const childResult = options.stagingSessionId !== undefined
419
+ childSidecar =
420
+ options.stagingSessionId !== undefined
314
421
  ? await loadStagingResult(getStagingResultPath(options.stagingSessionId, suite.name, scenario.name))
315
422
  : undefined;
316
- failures.push(...failuresForLiveSubprocessExit(exitCode, childResult));
423
+ if (spawned.exitCode !== 0) {
424
+ failures.push(...failuresForLiveSubprocessExit(spawned.exitCode, childSidecar, spawned.stderr));
317
425
  }
318
426
  if (options.stagingSessionId) {
319
- const tracePath = getStagingTracePath(options.stagingSessionId, suite.name, scenario.name);
320
- try {
321
- scenarioTrace = await loadStagingTrace(tracePath);
427
+ if (scenario.compare) {
428
+ try {
429
+ scenarioTrace = await loadStagingTrace(getStagingTracePath(options.stagingSessionId, suite.name, scenario.name, "a"));
430
+ }
431
+ catch {
432
+ // Trace may be missing when the child crashed before recording.
433
+ }
322
434
  }
323
- catch {
324
- // Trace may be missing when the child crashed before recording.
435
+ else {
436
+ const tracePath = getStagingTracePath(options.stagingSessionId, suite.name, scenario.name);
437
+ try {
438
+ scenarioTrace = await loadStagingTrace(tracePath);
439
+ }
440
+ catch {
441
+ // Trace may be missing when the child crashed before recording.
442
+ }
325
443
  }
326
444
  }
327
445
  const canRetry = failures.length > 0 &&
@@ -334,38 +452,79 @@ async function runSuiteBody(options) {
334
452
  break;
335
453
  }
336
454
  let judgeVerdicts;
337
- if (failures.length === 0 && options.judge !== false && scenarioTrace) {
338
- const criteria = normalizeJudgeCriteria(scenario.rubric.judge);
339
- if (criteria.length > 0) {
340
- releaseLiveMemory();
341
- logPhase(theme.judgePhase(criteria.length), { last: true });
342
- try {
343
- const judged = await runJudgeRubric(scenarioTrace, scenario.rubric, options.cwd);
344
- failures.push(...judged.failures);
345
- scenarioTrace = judged.trace;
346
- judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
455
+ let compareResult;
456
+ const judgeHost = options.host ?? scenario.host ?? suite.defaults?.host ?? "cursor";
457
+ if (scenario.compare && options.stagingSessionId) {
458
+ compareResult = applySidecarCompareDurations(await loadCompareResultFromStaging(options.stagingSessionId, suite.name, scenario), childSidecar);
459
+ scenarioTrace = compareResult.a.trace ?? scenarioTrace;
460
+ }
461
+ if (failures.length === 0 && options.judge !== false) {
462
+ if (scenario.compare && compareResult?.a.trace && compareResult.b.trace) {
463
+ const criteria = collectCompareJudgeCriteria(scenario.rubric);
464
+ if (criteria.length > 0) {
465
+ releaseLiveMemory();
466
+ logPhase(theme.judgePhase(criteria.length), { last: true });
467
+ try {
468
+ const judged = await runCompareJudgeRubric(compareResult, scenario.rubric, options.cwd, judgeHost);
469
+ failures.push(...judged.failures);
470
+ judgeVerdicts = toJudgeVerdictResults({ ...compareResult.a.trace, judgeVerdicts: judged.verdicts }, criteria, judged.verdicts);
471
+ }
472
+ catch (error) {
473
+ failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to judge compare traces", "judge_infra"));
474
+ }
347
475
  }
348
- catch (error) {
349
- failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to load staging trace for judge", "judge_infra"));
476
+ }
477
+ else if (scenarioTrace && !scenario.compare) {
478
+ const criteria = collectJudgeCriteria(scenario.rubric);
479
+ if (criteria.length > 0) {
480
+ releaseLiveMemory();
481
+ logPhase(theme.judgePhase(criteria.length), { last: true });
482
+ try {
483
+ const judged = await runJudgeRubric(scenarioTrace, scenario.rubric, options.cwd, judgeHost);
484
+ failures.push(...judged.failures);
485
+ scenarioTrace = judged.trace;
486
+ judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
487
+ }
488
+ catch (error) {
489
+ failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to load staging trace for judge", "judge_infra"));
490
+ }
350
491
  }
351
492
  }
352
493
  }
353
494
  const durationMs = Math.round(performance.now() - started);
354
495
  const passed = failures.length === 0;
355
496
  const usageFields = buildScenarioResultUsage({
356
- agentUsage: scenarioTrace?.usage,
497
+ agentUsage: compareResult
498
+ ? sumUsageParts([compareResult.a.trace?.usage, compareResult.b.trace?.usage])
499
+ : scenarioTrace?.usage,
357
500
  judgeVerdicts,
358
501
  });
502
+ const story = buildScenarioStory({
503
+ rubric: scenario.rubric,
504
+ trace: scenarioTrace,
505
+ passed,
506
+ failures,
507
+ judgeVerdicts,
508
+ compare: compareResult
509
+ ? {
510
+ ...compareStoryFields(scenario),
511
+ aTrace: compareResult.a.trace,
512
+ bTrace: compareResult.b.trace,
513
+ }
514
+ : undefined,
515
+ });
359
516
  const scenarioResult = {
360
517
  suite: suite.name,
361
518
  scenario: scenario.name,
362
- compareId: scenario.compareId,
519
+ prompt: scenario.prompt,
363
520
  passed,
364
521
  failures,
365
522
  durationMs,
366
523
  attempts,
367
524
  judgeVerdicts,
368
525
  trace: scenarioTrace,
526
+ compare: compareResult,
527
+ story,
369
528
  ...usageFields,
370
529
  };
371
530
  const debugBundleDir = await maybeWriteDebugBundle({
@@ -396,6 +555,7 @@ async function runSuiteBody(options) {
396
555
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, scenarioTrace?.usage),
397
556
  judgeVerdicts,
398
557
  failures,
558
+ story,
399
559
  debug,
400
560
  debugBundleDir,
401
561
  });
@@ -435,10 +595,58 @@ async function runSuiteBody(options) {
435
595
  summary: summarizeReportResults(results),
436
596
  };
437
597
  }
598
+ /** Suite trees and MCP scripts from the caller, so uncommitted fixtures reach the host. */
599
+ function liveOverlayExtras(cwd, suitesDir, contextSources, mcpServers) {
600
+ const extras = [...(contextSources ?? [])];
601
+ const suitesRel = overlayRelPath(cwd, suitesDir);
602
+ if (suitesRel && !extras.includes(suitesRel)) {
603
+ extras.push(suitesRel);
604
+ }
605
+ for (const script of mcpStdioScriptPaths(mcpServers)) {
606
+ if (!extras.includes(script)) {
607
+ extras.push(script);
608
+ }
609
+ }
610
+ return extras;
611
+ }
612
+ function overlayRelPath(cwd, path) {
613
+ const trimmed = path.replace(/^\.\//, "").trim();
614
+ if (trimmed.length === 0) {
615
+ return undefined;
616
+ }
617
+ if (!trimmed.startsWith("/")) {
618
+ return trimmed;
619
+ }
620
+ const root = resolve(cwd);
621
+ if (trimmed === root) {
622
+ return undefined;
623
+ }
624
+ if (trimmed.startsWith(`${root}/`)) {
625
+ return trimmed.slice(root.length + 1);
626
+ }
627
+ return undefined;
628
+ }
629
+ function resolveRunWorkspace(defaultWorkspace, scenarioWorkspace) {
630
+ const raw = scenarioWorkspace !== undefined ? scenarioWorkspace : defaultWorkspace;
631
+ const parsed = parseScenarioWorkspace(raw);
632
+ if (!parsed.ok) {
633
+ throw new Error(parsed.message);
634
+ }
635
+ return parsed.rel;
636
+ }
438
637
  function mergeContextSources(defaults, scenarioSources) {
439
638
  const merged = [...(defaults ?? []), ...(scenarioSources ?? [])].filter((value) => typeof value === "string" && value.trim().length > 0);
440
639
  return merged.length > 0 ? merged : undefined;
441
640
  }
641
+ function defaultProfileForHost(host) {
642
+ if (host === "cursor") {
643
+ return "cursor";
644
+ }
645
+ if (host === "claude") {
646
+ return "claude";
647
+ }
648
+ return "shared";
649
+ }
442
650
  /** Run one real agent scenario. JSON suites delegate to this same execution boundary. */
443
651
  export async function runAgentTest(options) {
444
652
  const previousStagingRoot = getLiveStagingRootOverride();
@@ -462,7 +670,7 @@ async function runAgentTestBody(options) {
462
670
  if (legacyScenario.host === "replay" ||
463
671
  legacyDefaults?.host === "replay" ||
464
672
  "replayTrace" in legacyScenario) {
465
- throw new Error("Replay-based testing is deprecated and no longer supported; use Cursor or Claude.");
673
+ throw new Error("Replay-based testing is deprecated and no longer supported; use Cursor, Claude, or OpenAI.");
466
674
  }
467
675
  const suiteName = options.suiteName ?? "direct";
468
676
  const defaultHost = options.host ?? options.defaults?.host ?? "cursor";
@@ -471,7 +679,7 @@ async function runAgentTestBody(options) {
471
679
  let result;
472
680
  while (true) {
473
681
  attempts++;
474
- result = await runAgentTestOnce(options.cwd, suiteName, options.scenario, defaultHost, options.defaults?.profile, options.defaults?.skills, options.defaults?.contextSources, options.defaults?.mcpServers, 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 });
682
+ 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 });
475
683
  const canRetry = !result.skipped &&
476
684
  !result.passed &&
477
685
  attempts < maxAttempts &&
@@ -512,13 +720,14 @@ async function runAgentTestBody(options) {
512
720
  totalTokens: totalTokensFromScenarioUsage(result.usage, result.trace?.usage),
513
721
  judgeVerdicts: result.judgeVerdicts,
514
722
  failures: result.failures,
723
+ story: result.story,
515
724
  debug,
516
725
  debugBundleDir,
517
726
  });
518
727
  }
519
728
  return result;
520
729
  }
521
- async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultProfile, defaultSkills, defaultContextSources, defaultMcpServers, judge, worktree, stagingSessionId, scenarioIndex, scenarioTotal, timeoutMs, allowUserInput, debugFlag, debugDir, suitesDir = "agent-suites", keepRecordings, rubricsDir, runOptions) {
730
+ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultProfile, defaultSkills, defaultContextSources, defaultMcpServers, defaultWorkspace, defaultAllowUserSkills, judge, worktree, stagingSessionId, scenarioIndex, scenarioTotal, timeoutMs, allowUserInput, debugFlag, debugDir, suitesDir = "agent-suites", keepRecordings, rubricsDir, runOptions) {
522
731
  const started = performance.now();
523
732
  const debug = isDebugEnabled({ debug: debugFlag });
524
733
  const suppressEmit = runOptions?.suppressEmit === true;
@@ -530,20 +739,38 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
530
739
  return {
531
740
  suite: suiteName,
532
741
  scenario: scenario.name,
742
+ prompt: scenario.prompt,
533
743
  passed: true,
534
744
  failures: [],
535
745
  skipped: true,
536
746
  durationMs: 0,
747
+ story: buildScenarioStory({
748
+ rubric: scenario.rubric,
749
+ passed: true,
750
+ skipped: true,
751
+ failures: [],
752
+ }),
537
753
  };
538
754
  }
755
+ if (scenario.compare && runOptions?.compareArm === undefined) {
756
+ 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 });
757
+ }
539
758
  const host = scenario.host ?? defaultHost;
540
- const profile = scenario.profile ?? defaultProfile ?? (host === "cursor" ? "cursor" : "shared");
759
+ const profile = scenario.profile ?? defaultProfile ?? defaultProfileForHost(host);
541
760
  const skills = scenario.skills ?? defaultSkills;
542
761
  const contextSources = mergeContextSources(defaultContextSources, scenario.contextSources);
543
- const mcpServers = mergeMcpServers(defaultMcpServers, scenario.mcpServers);
762
+ const workspaceRel = resolveRunWorkspace(defaultWorkspace, scenario.workspace);
763
+ const fixtureWorkspace = workspaceRel !== undefined;
764
+ const allowUserSkills = resolveAllowUserSkills(scenario.allowUserSkills, defaultAllowUserSkills);
765
+ const mcpServers = fixtureWorkspace
766
+ ? bindMcpServersToCaller(mergeMcpServers(defaultMcpServers, scenario.mcpServers), cwd)
767
+ : mergeMcpServers(defaultMcpServers, scenario.mcpServers);
544
768
  const liveTimeoutMs = resolveLiveTimeoutMs(timeoutMs);
545
769
  const failOnUserInput = !allowUserInput;
546
- if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
770
+ if (runOptions?.compareArm) {
771
+ logPhase(theme.phase("arm", `${runOptions.compareArm.toUpperCase()} ${host}`));
772
+ }
773
+ else if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
547
774
  logProgress(theme.scenarioTitle(scenarioIndex, scenarioTotal, scenario.name, host));
548
775
  }
549
776
  else {
@@ -558,9 +785,14 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
558
785
  callerHeadBefore = await captureCallerHead(cwd);
559
786
  setCallerHeadRestore(cwd, callerHeadBefore);
560
787
  }
561
- worktreeHandle = await createScenarioWorktree(cwd, `${suiteName}-${scenario.name}`);
788
+ worktreeHandle = await createSealedWorkspace({
789
+ callerCwd: cwd,
790
+ workspace: workspaceRel,
791
+ overlayPaths: fixtureWorkspace
792
+ ? undefined
793
+ : defaultSealedOverlayPaths(liveOverlayExtras(cwd, suitesDir, contextSources, mcpServers), skillPathsFromSetting(skills)),
794
+ });
562
795
  activeWorktreeCleanup = worktreeHandle.cleanup;
563
- logPhase(theme.phase("worktree", theme.path(worktreeHandle.path)));
564
796
  if (scenario.seedPatch) {
565
797
  logPhase(theme.phase("seed", theme.basename(scenario.seedPatch)));
566
798
  await seedScenarioWorktree(cwd, worktreeHandle.path, scenario.seedPatch, {
@@ -571,24 +803,21 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
571
803
  else {
572
804
  logPhase(theme.phase("worktree", theme.phaseDim("disabled (AGENT_TEST_ALLOW_IN_PLACE=1)")));
573
805
  }
574
- const runCwd = worktreeHandle?.path ?? cwd;
806
+ const runCwd = worktreeHandle?.path ?? (workspaceRel ? resolve(cwd, workspaceRel) : cwd);
575
807
  try {
576
- logPhase(theme.phase("context"));
577
- // Worktree runs code in an isolated checkout; load rules/AGENTS from caller cwd
578
- // so uncommitted .cursor/rules and AGENTS.md edits apply during dogfood.
579
- const contextRoot = useWorktree ? cwd : runCwd;
580
808
  const context = await loadContext({
581
- cwd: contextRoot,
809
+ cwd: runCwd,
582
810
  profile,
583
811
  skills,
584
812
  contextSources,
585
813
  });
586
- logPhase(theme.phase("agent"));
587
814
  const outputContract = outputContractForRubric(scenario.rubric);
588
815
  const agentStartMarkerPath = isChildProcess() && stagingSessionId
589
816
  ? getStagingAgentStartPath(stagingSessionId, suiteName, scenario.name)
590
817
  : undefined;
818
+ logPhase(theme.phase("agent", theme.phaseDim("started")));
591
819
  const agentStarted = performance.now();
820
+ let livePreview;
592
821
  const session = await withHeartbeat(runAgent({
593
822
  host,
594
823
  cwd: runCwd,
@@ -597,12 +826,25 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
597
826
  prompt: scenario.prompt,
598
827
  outputContract,
599
828
  mcpServers,
829
+ allowUserSkills,
600
830
  timeoutMs: liveTimeoutMs,
601
831
  failOnUserInput,
832
+ maxConversationTurns: resolveMaxConversationTurns(),
602
833
  onDeadlineStart: agentStartMarkerPath
603
834
  ? () => writeAgentStartMarker(agentStartMarkerPath)
604
835
  : undefined,
605
- }), { started: agentStarted });
836
+ onAgentEvent: (event) => {
837
+ if (event.type === "tool") {
838
+ logLive(theme.liveTool(event.name, pathFromArgs(event.args)));
839
+ livePreview = undefined;
840
+ refreshHeartbeat();
841
+ return;
842
+ }
843
+ // Clock tick paints the preview. Do not rewrite here — a long
844
+ // line wraps and `\r` cannot clear the leftover row.
845
+ livePreview = quoteExcerpt(event.text);
846
+ },
847
+ }), { started: agentStarted, preview: () => livePreview });
606
848
  logPhase(theme.phase("agent", `${theme.statusCompleted(session.status)} ${theme.duration(formatDuration(performance.now() - agentStarted))}`));
607
849
  let trace = enrichTrace(session.trace);
608
850
  const failures = [];
@@ -629,10 +871,15 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
629
871
  else if (failOnUserInput && traceHasUserInputTool(trace.toolCalls)) {
630
872
  failures.push(assertionFailure("runAgent", "agent trace contains AskQuestion-style user-input tool in headless mode", "agent_runtime", `toolCalls=${trace.toolCalls.map((call) => call.name).join(", ")}`));
631
873
  }
632
- logPhase(theme.phase("rubric"));
633
874
  failures.push(...assertRubric(trace, scenario.rubric, {
634
875
  skillsMode: context.skillsMode,
635
876
  }));
877
+ if (worktreeHandle) {
878
+ const escaped = toolPathsOutsideWorkspace(trace, worktreeHandle.path);
879
+ if (escaped.length > 0) {
880
+ failures.push(assertionFailure("workingTreeLeak", `agent used paths outside the sealed workspace: ${escaped.join(", ")}`, "worktree_leak", `escaped=${escaped.join(", ")}`));
881
+ }
882
+ }
636
883
  if (useWorktree && callerTreeBefore !== undefined) {
637
884
  const callerTreeAfter = await captureWorkingTreeStatus(cwd);
638
885
  const ignoreRoots = resolveHarnessArtifactIgnoreRoots(cwd, getLiveStagingRootOverride());
@@ -663,11 +910,13 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
663
910
  ].join("\n")));
664
911
  }
665
912
  }
666
- const stagingTracePath = resolveRecordingPath(suiteName, scenario.name, stagingSessionId);
913
+ const stagingTracePath = resolveRecordingPath(suiteName, scenario.name, stagingSessionId, runOptions?.compareArm);
667
914
  if (stagingTracePath) {
668
915
  try {
669
916
  const path = await recordTrace(stagingTracePath, trace);
670
- logPhase(theme.phase("trace", theme.path(path)));
917
+ if (keepRecordings || debug) {
918
+ logPhase(theme.phase("trace", theme.path(path)));
919
+ }
671
920
  }
672
921
  catch (error) {
673
922
  failures.push(assertionFailure("recordTrace", error instanceof Error ? error.message : "failed to record trace", "recording_error"));
@@ -676,17 +925,17 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
676
925
  const deferJudgeToParent = isChildProcess();
677
926
  let judgeVerdicts;
678
927
  if (judge && !deferJudgeToParent) {
679
- const criteria = normalizeJudgeCriteria(scenario.rubric.judge);
928
+ const criteria = collectJudgeCriteria(scenario.rubric);
680
929
  if (criteria.length > 0) {
681
930
  logPhase(theme.judgePhase(criteria.length), { last: true });
682
931
  }
683
- const judged = await runJudgeRubric(trace, scenario.rubric, runCwd);
932
+ const judged = await runJudgeRubric(trace, scenario.rubric, runCwd, host);
684
933
  failures.push(...judged.failures);
685
934
  trace = judged.trace;
686
935
  judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
687
936
  }
688
937
  const durationMs = Math.round(performance.now() - started);
689
- if (isChildProcess() && stagingSessionId) {
938
+ if (isChildProcess() && stagingSessionId && runOptions?.writeSidecar !== false) {
690
939
  await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
691
940
  passed: failures.length === 0,
692
941
  failures,
@@ -694,10 +943,6 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
694
943
  });
695
944
  }
696
945
  if (worktreeHandle) {
697
- const willJudge = Boolean(judge) &&
698
- !isChildProcess() &&
699
- normalizeJudgeCriteria(scenario.rubric.judge).length > 0;
700
- logPhase(theme.phase("cleanup"), { last: !willJudge });
701
946
  await worktreeHandle.cleanup();
702
947
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
703
948
  activeWorktreeCleanup = undefined;
@@ -707,15 +952,24 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
707
952
  }
708
953
  worktreeHandle = undefined;
709
954
  }
955
+ const passed = failures.length === 0;
956
+ const story = buildScenarioStory({
957
+ rubric: scenario.rubric,
958
+ trace,
959
+ passed,
960
+ failures,
961
+ judgeVerdicts,
962
+ });
710
963
  const scenarioResult = {
711
964
  suite: suiteName,
712
965
  scenario: scenario.name,
713
- compareId: scenario.compareId,
714
- passed: failures.length === 0,
966
+ prompt: scenario.prompt,
967
+ passed,
715
968
  failures,
716
969
  durationMs,
717
970
  judgeVerdicts,
718
971
  trace,
972
+ story,
719
973
  ...buildScenarioResultUsage({
720
974
  agentUsage: session.usage ?? trace?.usage,
721
975
  judgeVerdicts,
@@ -742,7 +996,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
742
996
  });
743
997
  scenarioResult.debugBundleDir = debugBundleDir;
744
998
  emitScenarioVerdict({
745
- passed: failures.length === 0,
999
+ passed,
746
1000
  index: scenarioIndex,
747
1001
  total: scenarioTotal,
748
1002
  name: scenario.name,
@@ -750,6 +1004,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
750
1004
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, trace?.usage),
751
1005
  judgeVerdicts,
752
1006
  failures,
1007
+ story,
753
1008
  debug,
754
1009
  debugBundleDir,
755
1010
  });
@@ -766,7 +1021,6 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
766
1021
  }
767
1022
  }
768
1023
  if (worktreeHandle) {
769
- logPhase(theme.phase("cleanup"), { last: true });
770
1024
  await worktreeHandle.cleanup();
771
1025
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
772
1026
  activeWorktreeCleanup = undefined;
@@ -777,12 +1031,215 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
777
1031
  }
778
1032
  }
779
1033
  }
780
- async function runJudgeRubric(trace, rubric, runCwd) {
781
- const criteria = normalizeJudgeCriteria(rubric.judge);
1034
+ async function runCompareAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultProfile, defaultSkills, defaultContextSources, defaultMcpServers, defaultWorkspace, defaultAllowUserSkills, judge, worktree, stagingSessionId, scenarioIndex, scenarioTotal, timeoutMs, allowUserInput, debugFlag, debugDir, suitesDir = "agent-suites", keepRecordings, rubricsDir, runOptions) {
1035
+ const started = performance.now();
1036
+ const debug = isDebugEnabled({ debug: debugFlag });
1037
+ const suppressEmit = runOptions?.suppressEmit === true;
1038
+ const host = scenario.host ?? defaultHost;
1039
+ if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
1040
+ logProgress(theme.scenarioTitle(scenarioIndex, scenarioTotal, scenario.name, host));
1041
+ }
1042
+ else {
1043
+ logProgress(theme.scenarioLabel(scenario.name, host));
1044
+ }
1045
+ const aScenario = applyCompareArm(scenario, "a");
1046
+ const bScenario = applyCompareArm(scenario, "b");
1047
+ const aLabel = compareArmLabel(scenario.compare?.a, "a");
1048
+ const bLabel = compareArmLabel(scenario.compare?.b, "b");
1049
+ const shared = {
1050
+ cwd,
1051
+ suiteName,
1052
+ defaultHost,
1053
+ defaultProfile,
1054
+ defaultSkills,
1055
+ defaultContextSources,
1056
+ defaultMcpServers,
1057
+ defaultWorkspace,
1058
+ defaultAllowUserSkills,
1059
+ worktree,
1060
+ stagingSessionId,
1061
+ timeoutMs,
1062
+ allowUserInput,
1063
+ debugFlag,
1064
+ debugDir,
1065
+ suitesDir,
1066
+ keepRecordings,
1067
+ rubricsDir,
1068
+ };
1069
+ const aResult = await runAgentTestOnce(shared.cwd, shared.suiteName, aScenario, 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: "a", writeSidecar: false });
1070
+ const bResult = await runAgentTestOnce(shared.cwd, shared.suiteName, bScenario, 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: "b", writeSidecar: false });
1071
+ const failures = [
1072
+ ...prefixCompareFailures(aLabel, aResult.failures),
1073
+ ...prefixCompareFailures(bLabel, bResult.failures),
1074
+ ];
1075
+ const compareResult = {
1076
+ a: {
1077
+ id: "a",
1078
+ label: aLabel,
1079
+ prompt: aScenario.prompt,
1080
+ trace: aResult.trace,
1081
+ durationMs: aResult.durationMs,
1082
+ },
1083
+ b: {
1084
+ id: "b",
1085
+ label: bLabel,
1086
+ prompt: bScenario.prompt,
1087
+ trace: bResult.trace,
1088
+ durationMs: bResult.durationMs,
1089
+ },
1090
+ };
1091
+ if (scenario.compare) {
1092
+ failures.push(...assertCompareMetrics(scenario.compare, compareResult));
1093
+ }
1094
+ const deferJudgeToParent = isChildProcess();
1095
+ let judgeVerdicts;
1096
+ if (judge &&
1097
+ !deferJudgeToParent &&
1098
+ failures.length === 0 &&
1099
+ compareResult.a.trace &&
1100
+ compareResult.b.trace) {
1101
+ const criteria = collectCompareJudgeCriteria(scenario.rubric);
1102
+ if (criteria.length > 0) {
1103
+ logPhase(theme.judgePhase(criteria.length), { last: true });
1104
+ }
1105
+ const judged = await runCompareJudgeRubric(compareResult, scenario.rubric, cwd, host);
1106
+ failures.push(...judged.failures);
1107
+ judgeVerdicts = toJudgeVerdictResults({ ...compareResult.a.trace, judgeVerdicts: judged.verdicts }, criteria, judged.verdicts);
1108
+ }
1109
+ const durationMs = Math.round(performance.now() - started);
1110
+ if (isChildProcess() && stagingSessionId) {
1111
+ await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
1112
+ passed: failures.length === 0,
1113
+ failures,
1114
+ durationMs,
1115
+ compare: {
1116
+ a: { durationMs: aResult.durationMs },
1117
+ b: { durationMs: bResult.durationMs },
1118
+ },
1119
+ });
1120
+ }
1121
+ const passed = failures.length === 0;
1122
+ const story = buildScenarioStory({
1123
+ rubric: scenario.rubric,
1124
+ trace: compareResult.a.trace,
1125
+ passed,
1126
+ failures,
1127
+ judgeVerdicts,
1128
+ compare: {
1129
+ ...compareStoryFields(scenario),
1130
+ aTrace: compareResult.a.trace,
1131
+ bTrace: compareResult.b.trace,
1132
+ },
1133
+ });
1134
+ const scenarioResult = {
1135
+ suite: suiteName,
1136
+ scenario: scenario.name,
1137
+ prompt: scenario.prompt,
1138
+ passed,
1139
+ failures,
1140
+ durationMs,
1141
+ judgeVerdicts,
1142
+ trace: compareResult.a.trace,
1143
+ compare: compareResult,
1144
+ story,
1145
+ ...buildScenarioResultUsage({
1146
+ agentUsage: sumUsageParts([aResult.agentUsage, bResult.agentUsage]),
1147
+ judgeVerdicts,
1148
+ }),
1149
+ };
1150
+ if (!suppressEmit) {
1151
+ const debugBundleDir = await maybeWriteDebugBundle({
1152
+ debug,
1153
+ cwd,
1154
+ suitesDir,
1155
+ rubricsDir,
1156
+ stagingSessionId,
1157
+ debugDir,
1158
+ suiteName,
1159
+ scenario,
1160
+ host,
1161
+ result: scenarioResult,
1162
+ trace: compareResult.a.trace,
1163
+ timeoutMs,
1164
+ worktree,
1165
+ judge,
1166
+ allowUserInput,
1167
+ keepRecordings,
1168
+ });
1169
+ scenarioResult.debugBundleDir = debugBundleDir;
1170
+ emitScenarioVerdict({
1171
+ passed,
1172
+ index: scenarioIndex,
1173
+ total: scenarioTotal,
1174
+ name: scenario.name,
1175
+ durationMs,
1176
+ totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, compareResult.a.trace?.usage),
1177
+ judgeVerdicts,
1178
+ failures,
1179
+ story,
1180
+ debug,
1181
+ debugBundleDir,
1182
+ });
1183
+ }
1184
+ return scenarioResult;
1185
+ }
1186
+ async function loadCompareResultFromStaging(stagingSessionId, suiteName, scenario) {
1187
+ const aLabel = compareArmLabel(scenario.compare?.a, "a");
1188
+ const bLabel = compareArmLabel(scenario.compare?.b, "b");
1189
+ const aScenario = applyCompareArm(scenario, "a");
1190
+ const bScenario = applyCompareArm(scenario, "b");
1191
+ const loadArm = async (side) => {
1192
+ try {
1193
+ return await loadStagingTrace(getStagingTracePath(stagingSessionId, suiteName, scenario.name, side));
1194
+ }
1195
+ catch {
1196
+ return undefined;
1197
+ }
1198
+ };
1199
+ return {
1200
+ a: { id: "a", label: aLabel, prompt: aScenario.prompt, trace: await loadArm("a") },
1201
+ b: { id: "b", label: bLabel, prompt: bScenario.prompt, trace: await loadArm("b") },
1202
+ };
1203
+ }
1204
+ async function runCompareJudgeRubric(compare, rubric, runCwd, host) {
1205
+ const criteria = collectCompareJudgeCriteria(rubric);
1206
+ if (criteria.length === 0 || !compare.a.trace || !compare.b.trace) {
1207
+ return { failures: [], verdicts: [] };
1208
+ }
1209
+ const result = await judgeCompareTraces({
1210
+ aLabel: compare.a.label,
1211
+ a: compare.a.trace,
1212
+ bLabel: compare.b.label,
1213
+ b: compare.b.trace,
1214
+ }, criteria, { cwd: runCwd, host });
1215
+ if (result.skipped) {
1216
+ return {
1217
+ failures: [assertionFailure("judge", result.error ?? "judge skipped", "judge_infra")],
1218
+ verdicts: [],
1219
+ };
1220
+ }
1221
+ const failures = [];
1222
+ for (const verdict of result.verdicts) {
1223
+ if (!verdict.pass) {
1224
+ const category = verdict.infraError
1225
+ ? "judge_infra"
1226
+ : verdict.parseError
1227
+ ? "judge_parse"
1228
+ : "rubric_miss";
1229
+ failures.push(assertionFailure(`judge:${verdict.id}`, verdict.rationale, category));
1230
+ }
1231
+ }
1232
+ if (result.error && !result.verdicts.some((verdict) => !verdict.pass)) {
1233
+ failures.push(assertionFailure("judge", result.error, "judge_infra"));
1234
+ }
1235
+ return { failures, verdicts: result.verdicts };
1236
+ }
1237
+ async function runJudgeRubric(trace, rubric, runCwd, host) {
1238
+ const criteria = collectJudgeCriteria(rubric);
782
1239
  if (criteria.length === 0) {
783
1240
  return { trace, failures: [], verdicts: [] };
784
1241
  }
785
- const result = await judgeTrace(trace, criteria, { cwd: runCwd });
1242
+ const result = await judgeTrace(trace, criteria, { cwd: runCwd, host });
786
1243
  if (result.skipped) {
787
1244
  return {
788
1245
  trace,
@@ -820,26 +1277,38 @@ export async function runAllSuites(options) {
820
1277
  return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
821
1278
  })
822
1279
  : suitePaths;
1280
+ const cliHosts = uniqueHosts(options.hosts ?? (options.host ? [options.host] : undefined));
823
1281
  const reports = [];
824
1282
  for (const suitePath of filtered) {
825
- reports.push(await runSuite({
826
- cwd: options.cwd,
827
- suitePath,
828
- host: options.host,
829
- scenarioFilter: options.scenarioFilter,
830
- judge: options.judge,
831
- worktree: options.worktree,
832
- stagingSessionId: options.stagingSessionId,
833
- keepRecordings: options.keepRecordings,
834
- suitesDir: options.suitesDir,
835
- suiteFilter: options.filter,
836
- timeoutMs: options.timeoutMs,
837
- allowUserInput: options.allowUserInput,
838
- debug: options.debug,
839
- debugDir: options.debugDir,
840
- scenarioRetries: options.scenarioRetries,
841
- rubricsDir: options.rubricsDir,
842
- }));
1283
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
1284
+ const hosts = resolveSuiteHosts({
1285
+ cliHosts,
1286
+ suiteHosts: suite.hosts,
1287
+ defaultHost: suite.defaults?.host,
1288
+ });
1289
+ const hostLocked = hosts.length > 1;
1290
+ for (const host of hosts) {
1291
+ reports.push(await runSuite({
1292
+ cwd: options.cwd,
1293
+ suitePath,
1294
+ host,
1295
+ hostLocked,
1296
+ scenarioFilter: options.scenarioFilter,
1297
+ judge: options.judge,
1298
+ worktree: options.worktree,
1299
+ stagingSessionId: options.stagingSessionId,
1300
+ keepRecordings: options.keepRecordings,
1301
+ suitesDir: options.suitesDir,
1302
+ suiteFilter: options.filter,
1303
+ timeoutMs: options.timeoutMs,
1304
+ allowUserInput: options.allowUserInput,
1305
+ debug: options.debug,
1306
+ debugDir: options.debugDir,
1307
+ scenarioRetries: options.scenarioRetries,
1308
+ rubricsDir: options.rubricsDir,
1309
+ adapterModules: options.adapterModules,
1310
+ }));
1311
+ }
843
1312
  }
844
1313
  return reports;
845
1314
  }