@post-print/agent-test 0.3.4 → 0.3.6

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 +17 -61
  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 +41 -0
  11. package/dist/compare-scenario.d.ts.map +1 -0
  12. package/dist/compare-scenario.js +194 -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 +504 -323
  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 +588 -82
  76. package/dist/run-suite.js.map +1 -1
  77. package/dist/scenario-story.d.ts +43 -0
  78. package/dist/scenario-story.d.ts.map +1 -0
  79. package/dist/scenario-story.js +215 -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 +30 -5
  86. package/dist/theme.d.ts.map +1 -1
  87. package/dist/theme.js +207 -45
  88. package/dist/theme.js.map +1 -1
  89. package/dist/types.d.ts +105 -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 +268 -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, compareArmDescription, compareArmLabel, compareStoryFields, plainDescription, 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,23 @@ async function restoreActiveCallerHead() {
38
42
  function isChildProcess() {
39
43
  return process.env.AGENT_TEST_CHILD === "1";
40
44
  }
45
+ function logScenarioDescription(description) {
46
+ const text = plainDescription(description);
47
+ if (!text) {
48
+ return;
49
+ }
50
+ for (const line of theme.scenarioDescription(text)) {
51
+ logProgress(line);
52
+ }
53
+ }
54
+ function resolveMaxConversationTurns() {
55
+ const raw = process.env.AGENT_TEST_MAX_TURNS?.trim();
56
+ if (!raw) {
57
+ return undefined;
58
+ }
59
+ const parsed = Number(raw);
60
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
61
+ }
41
62
  /** Parent process prints suite headers and final verdicts; children only print phases. */
42
63
  export function shouldPrintSuiteChrome() {
43
64
  return !isChildProcess();
@@ -52,6 +73,7 @@ export function registerLiveRunHandlers() {
52
73
  killActiveLiveChildren();
53
74
  cancelActiveCursorRun();
54
75
  cancelActiveClaudeRun();
76
+ cancelActiveOpenaiRun();
55
77
  const cleanup = activeWorktreeCleanup;
56
78
  const headRestore = activeCallerHeadRestore;
57
79
  if (!cleanup && !headRestore) {
@@ -99,6 +121,82 @@ function normalizeJudgeCriteria(judge) {
99
121
  return { id: item.id ?? `judge-${index}`, question: item.question };
100
122
  });
101
123
  }
124
+ /** Explicit judge questions plus skill-invoke questions for mustInvokeSkill. */
125
+ export function collectJudgeCriteria(rubric) {
126
+ return [
127
+ ...normalizeJudgeCriteria(rubric.judge),
128
+ ...skillInvokeJudgeCriteria(rubric.mustInvokeSkill ?? []),
129
+ ];
130
+ }
131
+ /** Pairwise judge questions only. Skill-follow checks stay per-arm and deterministic. */
132
+ export function collectCompareJudgeCriteria(rubric) {
133
+ return normalizeJudgeCriteria(rubric.judge);
134
+ }
135
+ /** Judge auth and the judge call apply only when a rubric has judge work. */
136
+ export function judgeAuthRequired(judge, rubrics) {
137
+ return judge !== false && rubrics.some((rubric) => collectJudgeCriteria(rubric).length > 0);
138
+ }
139
+ /** Pairwise judge on compare. Skill-follow judge questions stay on non-compare scenarios. */
140
+ export function scenarioNeedsJudge(judge, scenario) {
141
+ if (judge === false) {
142
+ return false;
143
+ }
144
+ if (scenario.compare) {
145
+ return collectCompareJudgeCriteria(scenario.rubric).length > 0;
146
+ }
147
+ return collectJudgeCriteria(scenario.rubric).length > 0;
148
+ }
149
+ /** True when the selected live run will call the judge. */
150
+ export async function selectedRunNeedsJudge(options) {
151
+ if (options.judge === false) {
152
+ return false;
153
+ }
154
+ const suitePaths = await discoverSuites(resolve(options.cwd, options.suitesDir));
155
+ const filtered = options.filter
156
+ ? suitePaths.filter((suitePath) => {
157
+ const suiteName = suiteNameFromPath(suitePath);
158
+ return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
159
+ })
160
+ : suitePaths;
161
+ for (const suitePath of filtered) {
162
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
163
+ for (const scenario of suite.scenarios) {
164
+ if (scenario.skip) {
165
+ continue;
166
+ }
167
+ if (options.scenarioFilter && scenario.name !== options.scenarioFilter) {
168
+ continue;
169
+ }
170
+ if (scenarioNeedsJudge(true, scenario)) {
171
+ return true;
172
+ }
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ export async function loadSelectedRubrics(options) {
178
+ const suitePaths = await discoverSuites(resolve(options.cwd, options.suitesDir));
179
+ const filtered = options.filter
180
+ ? suitePaths.filter((suitePath) => {
181
+ const suiteName = suiteNameFromPath(suitePath);
182
+ return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
183
+ })
184
+ : suitePaths;
185
+ const rubrics = [];
186
+ for (const suitePath of filtered) {
187
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
188
+ for (const scenario of suite.scenarios) {
189
+ if (scenario.skip) {
190
+ continue;
191
+ }
192
+ if (options.scenarioFilter && scenario.name !== options.scenarioFilter) {
193
+ continue;
194
+ }
195
+ rubrics.push(scenario.rubric);
196
+ }
197
+ }
198
+ return rubrics;
199
+ }
102
200
  function questionForCriterion(criteria, id) {
103
201
  return criteria.find((c) => c.id === id)?.question ?? id;
104
202
  }
@@ -145,6 +243,7 @@ function emitScenarioVerdict(options) {
145
243
  judgeVerdicts: options.judgeVerdicts,
146
244
  rubricFailures: rubricFailuresOnly(options.failures),
147
245
  failureCategory: options.failures[0]?.category,
246
+ story: options.story,
148
247
  debug: options.debug,
149
248
  debugBundleDir: options.debugBundleDir,
150
249
  }));
@@ -156,7 +255,7 @@ async function maybeWriteDebugBundle(options) {
156
255
  if (!options.stagingSessionId) {
157
256
  return undefined;
158
257
  }
159
- const dir = getDebugBundleDir(options.stagingSessionId, options.suiteName, options.scenario.name, getLiveStagingSessionRoot);
258
+ const dir = getDebugBundleDir(options.stagingSessionId, options.suiteName, options.scenario.name, getLiveStagingSessionRoot, options.host);
160
259
  const cliPath = process.argv[1] ?? resolve(options.cwd, "node_modules/@post-print/agent-test/dist/cli.js");
161
260
  try {
162
261
  await writeDebugBundle({
@@ -235,12 +334,20 @@ async function runSuiteBody(options) {
235
334
  }
236
335
  const defaultHost = options.host ?? suite.defaults?.host ?? "cursor";
237
336
  const results = [];
238
- const scenarios = options.scenarioFilter
337
+ const selected = options.scenarioFilter
239
338
  ? suite.scenarios.filter((scenario) => scenario.name === options.scenarioFilter)
240
339
  : suite.scenarios;
241
- if (options.scenarioFilter && scenarios.length === 0) {
340
+ if (options.scenarioFilter && selected.length === 0) {
242
341
  throw new Error(`Scenario not found: ${options.scenarioFilter}`);
243
342
  }
343
+ const hostLocked = options.hostLocked === true;
344
+ const scenarios = hostLocked
345
+ ? selected.filter((scenario) => scenarioRunsOnHost(scenario, defaultHost, true))
346
+ : selected;
347
+ if (hostLocked && options.scenarioFilter && selected.length > 0 && scenarios.length === 0) {
348
+ const pinned = selected[0]?.host;
349
+ throw new Error(`Scenario ${options.scenarioFilter} is pinned to ${pinned} (this run is ${defaultHost})`);
350
+ }
244
351
  const filteredTotal = scenarios.length;
245
352
  const parentCounters = parentScenarioCounters();
246
353
  const displayTotal = parentCounters?.total ?? filteredTotal;
@@ -249,7 +356,7 @@ async function runSuiteBody(options) {
249
356
  if (shouldPrintSuiteChrome()) {
250
357
  logProgress(`\n${theme.suiteHeader(suite.name, defaultHost, displayTotal)}`);
251
358
  if (isolateLive) {
252
- logProgress(` ${theme.isolationNote()}`);
359
+ logProgress(` ${theme.isolationNote()}`);
253
360
  }
254
361
  }
255
362
  for (let index = 0; index < scenarios.length; index++) {
@@ -263,13 +370,22 @@ async function runSuiteBody(options) {
263
370
  if (scenario.skip) {
264
371
  const skipLabel = `[${scenarioIndex}/${scenarioTotal}] ${scenario.name}`;
265
372
  logProgress(theme.skipped(skipLabel));
373
+ logScenarioDescription(scenario.description);
266
374
  results.push({
267
375
  suite: suite.name,
268
376
  scenario: scenario.name,
377
+ description: plainDescription(scenario.description),
378
+ prompt: scenario.prompt,
269
379
  passed: true,
270
380
  failures: [],
271
381
  skipped: true,
272
382
  durationMs: 0,
383
+ story: buildScenarioStory({
384
+ rubric: scenario.rubric,
385
+ passed: true,
386
+ skipped: true,
387
+ failures: [],
388
+ }),
273
389
  });
274
390
  continue;
275
391
  }
@@ -282,9 +398,10 @@ async function runSuiteBody(options) {
282
398
  let failures = [];
283
399
  let scenarioTrace;
284
400
  let previousAttemptExitCode = previousIsolatedExitCode;
401
+ let childSidecar;
285
402
  while (true) {
286
403
  attempts++;
287
- const exitCode = await spawnLiveScenario({
404
+ const spawned = await spawnLiveScenario({
288
405
  cwd: options.cwd,
289
406
  suiteName: suite.name,
290
407
  scenarioName: scenario.name,
@@ -296,6 +413,7 @@ async function runSuiteBody(options) {
296
413
  worktree: options.worktree,
297
414
  judge: options.judge,
298
415
  host: defaultHost,
416
+ adapterModules: options.adapterModules,
299
417
  scenarioIndex: index + 1,
300
418
  scenarioTotal: filteredTotal,
301
419
  timeoutMs: resolveLiveTimeoutMs(options.timeoutMs),
@@ -305,23 +423,34 @@ async function runSuiteBody(options) {
305
423
  debugDir: options.debugDir,
306
424
  previousExitCode: previousAttemptExitCode,
307
425
  });
308
- previousAttemptExitCode = exitCode;
309
- previousIsolatedExitCode = exitCode;
426
+ previousAttemptExitCode = spawned.exitCode;
427
+ previousIsolatedExitCode = spawned.exitCode;
310
428
  failures = [];
311
429
  scenarioTrace = undefined;
312
- if (exitCode !== 0) {
313
- const childResult = options.stagingSessionId !== undefined
430
+ childSidecar =
431
+ options.stagingSessionId !== undefined
314
432
  ? await loadStagingResult(getStagingResultPath(options.stagingSessionId, suite.name, scenario.name))
315
433
  : undefined;
316
- failures.push(...failuresForLiveSubprocessExit(exitCode, childResult));
434
+ if (spawned.exitCode !== 0) {
435
+ failures.push(...failuresForLiveSubprocessExit(spawned.exitCode, childSidecar, spawned.stderr));
317
436
  }
318
437
  if (options.stagingSessionId) {
319
- const tracePath = getStagingTracePath(options.stagingSessionId, suite.name, scenario.name);
320
- try {
321
- scenarioTrace = await loadStagingTrace(tracePath);
438
+ if (scenario.compare) {
439
+ try {
440
+ scenarioTrace = await loadStagingTrace(getStagingTracePath(options.stagingSessionId, suite.name, scenario.name, "a"));
441
+ }
442
+ catch {
443
+ // Trace may be missing when the child crashed before recording.
444
+ }
322
445
  }
323
- catch {
324
- // Trace may be missing when the child crashed before recording.
446
+ else {
447
+ const tracePath = getStagingTracePath(options.stagingSessionId, suite.name, scenario.name);
448
+ try {
449
+ scenarioTrace = await loadStagingTrace(tracePath);
450
+ }
451
+ catch {
452
+ // Trace may be missing when the child crashed before recording.
453
+ }
325
454
  }
326
455
  }
327
456
  const canRetry = failures.length > 0 &&
@@ -334,38 +463,82 @@ async function runSuiteBody(options) {
334
463
  break;
335
464
  }
336
465
  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);
466
+ let compareResult;
467
+ const judgeHost = options.host ?? scenario.host ?? suite.defaults?.host ?? "cursor";
468
+ if (scenario.compare && options.stagingSessionId) {
469
+ compareResult = applySidecarCompareDurations(await loadCompareResultFromStaging(options.stagingSessionId, suite.name, scenario), childSidecar);
470
+ scenarioTrace = compareResult.a.trace ?? scenarioTrace;
471
+ }
472
+ if (failures.length === 0 && options.judge !== false) {
473
+ if (scenario.compare && compareResult?.a.trace && compareResult.b.trace) {
474
+ const criteria = collectCompareJudgeCriteria(scenario.rubric);
475
+ if (criteria.length > 0) {
476
+ releaseLiveMemory();
477
+ logPhase(theme.judgePhase(criteria.length), { last: true });
478
+ try {
479
+ const judged = await runCompareJudgeRubric(compareResult, scenario.rubric, options.cwd, judgeHost);
480
+ failures.push(...judged.failures);
481
+ judgeVerdicts = toJudgeVerdictResults({ ...compareResult.a.trace, judgeVerdicts: judged.verdicts }, criteria, judged.verdicts);
482
+ }
483
+ catch (error) {
484
+ failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to judge compare traces", "judge_infra"));
485
+ }
347
486
  }
348
- catch (error) {
349
- failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to load staging trace for judge", "judge_infra"));
487
+ }
488
+ else if (scenarioTrace && !scenario.compare) {
489
+ const criteria = collectJudgeCriteria(scenario.rubric);
490
+ if (criteria.length > 0) {
491
+ releaseLiveMemory();
492
+ logPhase(theme.judgePhase(criteria.length), { last: true });
493
+ try {
494
+ const judged = await runJudgeRubric(scenarioTrace, scenario.rubric, options.cwd, judgeHost);
495
+ failures.push(...judged.failures);
496
+ scenarioTrace = judged.trace;
497
+ judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
498
+ }
499
+ catch (error) {
500
+ failures.push(assertionFailure("judge", error instanceof Error ? error.message : "failed to load staging trace for judge", "judge_infra"));
501
+ }
350
502
  }
351
503
  }
352
504
  }
353
505
  const durationMs = Math.round(performance.now() - started);
354
506
  const passed = failures.length === 0;
355
507
  const usageFields = buildScenarioResultUsage({
356
- agentUsage: scenarioTrace?.usage,
508
+ agentUsage: compareResult
509
+ ? sumUsageParts([compareResult.a.trace?.usage, compareResult.b.trace?.usage])
510
+ : scenarioTrace?.usage,
511
+ judgeVerdicts,
512
+ });
513
+ const story = buildScenarioStory({
514
+ rubric: scenario.rubric,
515
+ trace: scenarioTrace,
516
+ passed,
517
+ failures,
357
518
  judgeVerdicts,
519
+ compare: compareResult
520
+ ? {
521
+ ...compareStoryFields(scenario),
522
+ aTrace: compareResult.a.trace,
523
+ bTrace: compareResult.b.trace,
524
+ aDurationMs: compareResult.a.durationMs,
525
+ bDurationMs: compareResult.b.durationMs,
526
+ }
527
+ : undefined,
358
528
  });
359
529
  const scenarioResult = {
360
530
  suite: suite.name,
361
531
  scenario: scenario.name,
362
- compareId: scenario.compareId,
532
+ description: plainDescription(scenario.description),
533
+ prompt: scenario.prompt,
363
534
  passed,
364
535
  failures,
365
536
  durationMs,
366
537
  attempts,
367
538
  judgeVerdicts,
368
539
  trace: scenarioTrace,
540
+ compare: compareResult,
541
+ story,
369
542
  ...usageFields,
370
543
  };
371
544
  const debugBundleDir = await maybeWriteDebugBundle({
@@ -396,6 +569,7 @@ async function runSuiteBody(options) {
396
569
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, scenarioTrace?.usage),
397
570
  judgeVerdicts,
398
571
  failures,
572
+ story,
399
573
  debug,
400
574
  debugBundleDir,
401
575
  });
@@ -435,10 +609,58 @@ async function runSuiteBody(options) {
435
609
  summary: summarizeReportResults(results),
436
610
  };
437
611
  }
612
+ /** Suite trees and MCP scripts from the caller, so uncommitted fixtures reach the host. */
613
+ function liveOverlayExtras(cwd, suitesDir, contextSources, mcpServers) {
614
+ const extras = [...(contextSources ?? [])];
615
+ const suitesRel = overlayRelPath(cwd, suitesDir);
616
+ if (suitesRel && !extras.includes(suitesRel)) {
617
+ extras.push(suitesRel);
618
+ }
619
+ for (const script of mcpStdioScriptPaths(mcpServers)) {
620
+ if (!extras.includes(script)) {
621
+ extras.push(script);
622
+ }
623
+ }
624
+ return extras;
625
+ }
626
+ function overlayRelPath(cwd, path) {
627
+ const trimmed = path.replace(/^\.\//, "").trim();
628
+ if (trimmed.length === 0) {
629
+ return undefined;
630
+ }
631
+ if (!trimmed.startsWith("/")) {
632
+ return trimmed;
633
+ }
634
+ const root = resolve(cwd);
635
+ if (trimmed === root) {
636
+ return undefined;
637
+ }
638
+ if (trimmed.startsWith(`${root}/`)) {
639
+ return trimmed.slice(root.length + 1);
640
+ }
641
+ return undefined;
642
+ }
643
+ function resolveRunWorkspace(defaultWorkspace, scenarioWorkspace) {
644
+ const raw = scenarioWorkspace !== undefined ? scenarioWorkspace : defaultWorkspace;
645
+ const parsed = parseScenarioWorkspace(raw);
646
+ if (!parsed.ok) {
647
+ throw new Error(parsed.message);
648
+ }
649
+ return parsed.rel;
650
+ }
438
651
  function mergeContextSources(defaults, scenarioSources) {
439
652
  const merged = [...(defaults ?? []), ...(scenarioSources ?? [])].filter((value) => typeof value === "string" && value.trim().length > 0);
440
653
  return merged.length > 0 ? merged : undefined;
441
654
  }
655
+ function defaultProfileForHost(host) {
656
+ if (host === "cursor") {
657
+ return "cursor";
658
+ }
659
+ if (host === "claude") {
660
+ return "claude";
661
+ }
662
+ return "shared";
663
+ }
442
664
  /** Run one real agent scenario. JSON suites delegate to this same execution boundary. */
443
665
  export async function runAgentTest(options) {
444
666
  const previousStagingRoot = getLiveStagingRootOverride();
@@ -462,7 +684,7 @@ async function runAgentTestBody(options) {
462
684
  if (legacyScenario.host === "replay" ||
463
685
  legacyDefaults?.host === "replay" ||
464
686
  "replayTrace" in legacyScenario) {
465
- throw new Error("Replay-based testing is deprecated and no longer supported; use Cursor or Claude.");
687
+ throw new Error("Replay-based testing is deprecated and no longer supported; use Cursor, Claude, or OpenAI.");
466
688
  }
467
689
  const suiteName = options.suiteName ?? "direct";
468
690
  const defaultHost = options.host ?? options.defaults?.host ?? "cursor";
@@ -471,7 +693,7 @@ async function runAgentTestBody(options) {
471
693
  let result;
472
694
  while (true) {
473
695
  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 });
696
+ 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
697
  const canRetry = !result.skipped &&
476
698
  !result.passed &&
477
699
  attempts < maxAttempts &&
@@ -512,13 +734,14 @@ async function runAgentTestBody(options) {
512
734
  totalTokens: totalTokensFromScenarioUsage(result.usage, result.trace?.usage),
513
735
  judgeVerdicts: result.judgeVerdicts,
514
736
  failures: result.failures,
737
+ story: result.story,
515
738
  debug,
516
739
  debugBundleDir,
517
740
  });
518
741
  }
519
742
  return result;
520
743
  }
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) {
744
+ 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
745
  const started = performance.now();
523
746
  const debug = isDebugEnabled({ debug: debugFlag });
524
747
  const suppressEmit = runOptions?.suppressEmit === true;
@@ -527,27 +750,49 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
527
750
  ? `[${scenarioIndex}/${scenarioTotal}] ${scenario.name}`
528
751
  : scenario.name;
529
752
  logProgress(theme.skipped(skipLabel));
753
+ logScenarioDescription(scenario.description);
530
754
  return {
531
755
  suite: suiteName,
532
756
  scenario: scenario.name,
757
+ description: plainDescription(scenario.description),
758
+ prompt: scenario.prompt,
533
759
  passed: true,
534
760
  failures: [],
535
761
  skipped: true,
536
762
  durationMs: 0,
763
+ story: buildScenarioStory({
764
+ rubric: scenario.rubric,
765
+ passed: true,
766
+ skipped: true,
767
+ failures: [],
768
+ }),
537
769
  };
538
770
  }
771
+ if (scenario.compare && runOptions?.compareArm === undefined) {
772
+ 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 });
773
+ }
539
774
  const host = scenario.host ?? defaultHost;
540
- const profile = scenario.profile ?? defaultProfile ?? (host === "cursor" ? "cursor" : "shared");
775
+ const profile = scenario.profile ?? defaultProfile ?? defaultProfileForHost(host);
541
776
  const skills = scenario.skills ?? defaultSkills;
542
777
  const contextSources = mergeContextSources(defaultContextSources, scenario.contextSources);
543
- const mcpServers = mergeMcpServers(defaultMcpServers, scenario.mcpServers);
778
+ const workspaceRel = resolveRunWorkspace(defaultWorkspace, scenario.workspace);
779
+ const fixtureWorkspace = workspaceRel !== undefined;
780
+ const allowUserSkills = resolveAllowUserSkills(scenario.allowUserSkills, defaultAllowUserSkills);
781
+ const mcpServers = fixtureWorkspace
782
+ ? bindMcpServersToCaller(mergeMcpServers(defaultMcpServers, scenario.mcpServers), cwd)
783
+ : mergeMcpServers(defaultMcpServers, scenario.mcpServers);
544
784
  const liveTimeoutMs = resolveLiveTimeoutMs(timeoutMs);
545
785
  const failOnUserInput = !allowUserInput;
546
- if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
786
+ if (runOptions?.compareArm) {
787
+ logPhase(theme.phase("arm", `${runOptions.compareArm.toUpperCase()} ${host}`));
788
+ }
789
+ else if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
547
790
  logProgress(theme.scenarioTitle(scenarioIndex, scenarioTotal, scenario.name, host));
791
+ logScenarioDescription(scenario.description);
548
792
  }
549
793
  else {
550
794
  logProgress(theme.scenarioLabel(scenario.name, host));
795
+ logScenarioDescription(scenario.description);
551
796
  }
552
797
  const useWorktree = worktree !== false && !process.env.AGENT_TEST_NO_WORKTREE;
553
798
  let worktreeHandle;
@@ -558,9 +803,14 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
558
803
  callerHeadBefore = await captureCallerHead(cwd);
559
804
  setCallerHeadRestore(cwd, callerHeadBefore);
560
805
  }
561
- worktreeHandle = await createScenarioWorktree(cwd, `${suiteName}-${scenario.name}`);
806
+ worktreeHandle = await createSealedWorkspace({
807
+ callerCwd: cwd,
808
+ workspace: workspaceRel,
809
+ overlayPaths: fixtureWorkspace
810
+ ? undefined
811
+ : defaultSealedOverlayPaths(liveOverlayExtras(cwd, suitesDir, contextSources, mcpServers), skillPathsFromSetting(skills)),
812
+ });
562
813
  activeWorktreeCleanup = worktreeHandle.cleanup;
563
- logPhase(theme.phase("worktree", theme.path(worktreeHandle.path)));
564
814
  if (scenario.seedPatch) {
565
815
  logPhase(theme.phase("seed", theme.basename(scenario.seedPatch)));
566
816
  await seedScenarioWorktree(cwd, worktreeHandle.path, scenario.seedPatch, {
@@ -571,24 +821,21 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
571
821
  else {
572
822
  logPhase(theme.phase("worktree", theme.phaseDim("disabled (AGENT_TEST_ALLOW_IN_PLACE=1)")));
573
823
  }
574
- const runCwd = worktreeHandle?.path ?? cwd;
824
+ const runCwd = worktreeHandle?.path ?? (workspaceRel ? resolve(cwd, workspaceRel) : cwd);
575
825
  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
826
  const context = await loadContext({
581
- cwd: contextRoot,
827
+ cwd: runCwd,
582
828
  profile,
583
829
  skills,
584
830
  contextSources,
585
831
  });
586
- logPhase(theme.phase("agent"));
587
832
  const outputContract = outputContractForRubric(scenario.rubric);
588
833
  const agentStartMarkerPath = isChildProcess() && stagingSessionId
589
834
  ? getStagingAgentStartPath(stagingSessionId, suiteName, scenario.name)
590
835
  : undefined;
836
+ logPhase(theme.phase("agent", theme.phaseDim("started")));
591
837
  const agentStarted = performance.now();
838
+ let livePreview;
592
839
  const session = await withHeartbeat(runAgent({
593
840
  host,
594
841
  cwd: runCwd,
@@ -597,12 +844,25 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
597
844
  prompt: scenario.prompt,
598
845
  outputContract,
599
846
  mcpServers,
847
+ allowUserSkills,
600
848
  timeoutMs: liveTimeoutMs,
601
849
  failOnUserInput,
850
+ maxConversationTurns: resolveMaxConversationTurns(),
602
851
  onDeadlineStart: agentStartMarkerPath
603
852
  ? () => writeAgentStartMarker(agentStartMarkerPath)
604
853
  : undefined,
605
- }), { started: agentStarted });
854
+ onAgentEvent: (event) => {
855
+ if (event.type === "tool") {
856
+ logLive(theme.liveTool(event.name, pathFromArgs(event.args)));
857
+ livePreview = undefined;
858
+ refreshHeartbeat();
859
+ return;
860
+ }
861
+ // Clock tick paints the preview. Do not rewrite here — a long
862
+ // line wraps and `\r` cannot clear the leftover row.
863
+ livePreview = quoteExcerpt(event.text);
864
+ },
865
+ }), { started: agentStarted, preview: () => livePreview });
606
866
  logPhase(theme.phase("agent", `${theme.statusCompleted(session.status)} ${theme.duration(formatDuration(performance.now() - agentStarted))}`));
607
867
  let trace = enrichTrace(session.trace);
608
868
  const failures = [];
@@ -629,10 +889,15 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
629
889
  else if (failOnUserInput && traceHasUserInputTool(trace.toolCalls)) {
630
890
  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
891
  }
632
- logPhase(theme.phase("rubric"));
633
892
  failures.push(...assertRubric(trace, scenario.rubric, {
634
893
  skillsMode: context.skillsMode,
635
894
  }));
895
+ if (worktreeHandle) {
896
+ const escaped = toolPathsOutsideWorkspace(trace, worktreeHandle.path);
897
+ if (escaped.length > 0) {
898
+ failures.push(assertionFailure("workingTreeLeak", `agent used paths outside the sealed workspace: ${escaped.join(", ")}`, "worktree_leak", `escaped=${escaped.join(", ")}`));
899
+ }
900
+ }
636
901
  if (useWorktree && callerTreeBefore !== undefined) {
637
902
  const callerTreeAfter = await captureWorkingTreeStatus(cwd);
638
903
  const ignoreRoots = resolveHarnessArtifactIgnoreRoots(cwd, getLiveStagingRootOverride());
@@ -663,11 +928,13 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
663
928
  ].join("\n")));
664
929
  }
665
930
  }
666
- const stagingTracePath = resolveRecordingPath(suiteName, scenario.name, stagingSessionId);
931
+ const stagingTracePath = resolveRecordingPath(suiteName, scenario.name, stagingSessionId, runOptions?.compareArm);
667
932
  if (stagingTracePath) {
668
933
  try {
669
934
  const path = await recordTrace(stagingTracePath, trace);
670
- logPhase(theme.phase("trace", theme.path(path)));
935
+ if (keepRecordings || debug) {
936
+ logPhase(theme.phase("trace", theme.path(path)));
937
+ }
671
938
  }
672
939
  catch (error) {
673
940
  failures.push(assertionFailure("recordTrace", error instanceof Error ? error.message : "failed to record trace", "recording_error"));
@@ -676,17 +943,17 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
676
943
  const deferJudgeToParent = isChildProcess();
677
944
  let judgeVerdicts;
678
945
  if (judge && !deferJudgeToParent) {
679
- const criteria = normalizeJudgeCriteria(scenario.rubric.judge);
946
+ const criteria = collectJudgeCriteria(scenario.rubric);
680
947
  if (criteria.length > 0) {
681
948
  logPhase(theme.judgePhase(criteria.length), { last: true });
682
949
  }
683
- const judged = await runJudgeRubric(trace, scenario.rubric, runCwd);
950
+ const judged = await runJudgeRubric(trace, scenario.rubric, runCwd, host);
684
951
  failures.push(...judged.failures);
685
952
  trace = judged.trace;
686
953
  judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
687
954
  }
688
955
  const durationMs = Math.round(performance.now() - started);
689
- if (isChildProcess() && stagingSessionId) {
956
+ if (isChildProcess() && stagingSessionId && runOptions?.writeSidecar !== false) {
690
957
  await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
691
958
  passed: failures.length === 0,
692
959
  failures,
@@ -694,10 +961,6 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
694
961
  });
695
962
  }
696
963
  if (worktreeHandle) {
697
- const willJudge = Boolean(judge) &&
698
- !isChildProcess() &&
699
- normalizeJudgeCriteria(scenario.rubric.judge).length > 0;
700
- logPhase(theme.phase("cleanup"), { last: !willJudge });
701
964
  await worktreeHandle.cleanup();
702
965
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
703
966
  activeWorktreeCleanup = undefined;
@@ -707,15 +970,25 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
707
970
  }
708
971
  worktreeHandle = undefined;
709
972
  }
973
+ const passed = failures.length === 0;
974
+ const story = buildScenarioStory({
975
+ rubric: scenario.rubric,
976
+ trace,
977
+ passed,
978
+ failures,
979
+ judgeVerdicts,
980
+ });
710
981
  const scenarioResult = {
711
982
  suite: suiteName,
712
983
  scenario: scenario.name,
713
- compareId: scenario.compareId,
714
- passed: failures.length === 0,
984
+ description: plainDescription(scenario.description),
985
+ prompt: scenario.prompt,
986
+ passed,
715
987
  failures,
716
988
  durationMs,
717
989
  judgeVerdicts,
718
990
  trace,
991
+ story,
719
992
  ...buildScenarioResultUsage({
720
993
  agentUsage: session.usage ?? trace?.usage,
721
994
  judgeVerdicts,
@@ -742,7 +1015,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
742
1015
  });
743
1016
  scenarioResult.debugBundleDir = debugBundleDir;
744
1017
  emitScenarioVerdict({
745
- passed: failures.length === 0,
1018
+ passed,
746
1019
  index: scenarioIndex,
747
1020
  total: scenarioTotal,
748
1021
  name: scenario.name,
@@ -750,6 +1023,7 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
750
1023
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, trace?.usage),
751
1024
  judgeVerdicts,
752
1025
  failures,
1026
+ story,
753
1027
  debug,
754
1028
  debugBundleDir,
755
1029
  });
@@ -766,7 +1040,6 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
766
1040
  }
767
1041
  }
768
1042
  if (worktreeHandle) {
769
- logPhase(theme.phase("cleanup"), { last: true });
770
1043
  await worktreeHandle.cleanup();
771
1044
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
772
1045
  activeWorktreeCleanup = undefined;
@@ -777,12 +1050,233 @@ async function runAgentTestOnce(cwd, suiteName, scenario, defaultHost, defaultPr
777
1050
  }
778
1051
  }
779
1052
  }
780
- async function runJudgeRubric(trace, rubric, runCwd) {
781
- const criteria = normalizeJudgeCriteria(rubric.judge);
1053
+ 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) {
1054
+ const started = performance.now();
1055
+ const debug = isDebugEnabled({ debug: debugFlag });
1056
+ const suppressEmit = runOptions?.suppressEmit === true;
1057
+ const host = scenario.host ?? defaultHost;
1058
+ if (scenarioIndex !== undefined && scenarioTotal !== undefined) {
1059
+ logProgress(theme.scenarioTitle(scenarioIndex, scenarioTotal, scenario.name, host));
1060
+ }
1061
+ else {
1062
+ logProgress(theme.scenarioLabel(scenario.name, host));
1063
+ }
1064
+ logScenarioDescription(scenario.description);
1065
+ const aScenario = applyCompareArm(scenario, "a");
1066
+ const bScenario = applyCompareArm(scenario, "b");
1067
+ const aLabel = compareArmLabel(scenario.compare?.a, "a");
1068
+ const bLabel = compareArmLabel(scenario.compare?.b, "b");
1069
+ const shared = {
1070
+ cwd,
1071
+ suiteName,
1072
+ defaultHost,
1073
+ defaultProfile,
1074
+ defaultSkills,
1075
+ defaultContextSources,
1076
+ defaultMcpServers,
1077
+ defaultWorkspace,
1078
+ defaultAllowUserSkills,
1079
+ worktree,
1080
+ stagingSessionId,
1081
+ timeoutMs,
1082
+ allowUserInput,
1083
+ debugFlag,
1084
+ debugDir,
1085
+ suitesDir,
1086
+ keepRecordings,
1087
+ rubricsDir,
1088
+ };
1089
+ 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 });
1090
+ 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 });
1091
+ const failures = [
1092
+ ...prefixCompareFailures(aLabel, aResult.failures),
1093
+ ...prefixCompareFailures(bLabel, bResult.failures),
1094
+ ];
1095
+ const compareResult = {
1096
+ a: {
1097
+ id: "a",
1098
+ label: aLabel,
1099
+ description: compareArmDescription(scenario.compare?.a),
1100
+ prompt: aScenario.prompt,
1101
+ trace: aResult.trace,
1102
+ durationMs: aResult.durationMs,
1103
+ },
1104
+ b: {
1105
+ id: "b",
1106
+ label: bLabel,
1107
+ description: compareArmDescription(scenario.compare?.b),
1108
+ prompt: bScenario.prompt,
1109
+ trace: bResult.trace,
1110
+ durationMs: bResult.durationMs,
1111
+ },
1112
+ };
1113
+ if (scenario.compare) {
1114
+ failures.push(...assertCompareMetrics(scenario.compare, compareResult));
1115
+ }
1116
+ const deferJudgeToParent = isChildProcess();
1117
+ let judgeVerdicts;
1118
+ if (judge &&
1119
+ !deferJudgeToParent &&
1120
+ failures.length === 0 &&
1121
+ compareResult.a.trace &&
1122
+ compareResult.b.trace) {
1123
+ const criteria = collectCompareJudgeCriteria(scenario.rubric);
1124
+ if (criteria.length > 0) {
1125
+ logPhase(theme.judgePhase(criteria.length), { last: true });
1126
+ }
1127
+ const judged = await runCompareJudgeRubric(compareResult, scenario.rubric, cwd, host);
1128
+ failures.push(...judged.failures);
1129
+ judgeVerdicts = toJudgeVerdictResults({ ...compareResult.a.trace, judgeVerdicts: judged.verdicts }, criteria, judged.verdicts);
1130
+ }
1131
+ const durationMs = Math.round(performance.now() - started);
1132
+ if (isChildProcess() && stagingSessionId) {
1133
+ await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
1134
+ passed: failures.length === 0,
1135
+ failures,
1136
+ durationMs,
1137
+ compare: {
1138
+ a: { durationMs: aResult.durationMs },
1139
+ b: { durationMs: bResult.durationMs },
1140
+ },
1141
+ });
1142
+ }
1143
+ const passed = failures.length === 0;
1144
+ const story = buildScenarioStory({
1145
+ rubric: scenario.rubric,
1146
+ trace: compareResult.a.trace,
1147
+ passed,
1148
+ failures,
1149
+ judgeVerdicts,
1150
+ compare: {
1151
+ ...compareStoryFields(scenario),
1152
+ aTrace: compareResult.a.trace,
1153
+ bTrace: compareResult.b.trace,
1154
+ aDurationMs: compareResult.a.durationMs,
1155
+ bDurationMs: compareResult.b.durationMs,
1156
+ },
1157
+ });
1158
+ const scenarioResult = {
1159
+ suite: suiteName,
1160
+ scenario: scenario.name,
1161
+ description: plainDescription(scenario.description),
1162
+ prompt: scenario.prompt,
1163
+ passed,
1164
+ failures,
1165
+ durationMs,
1166
+ judgeVerdicts,
1167
+ trace: compareResult.a.trace,
1168
+ compare: compareResult,
1169
+ story,
1170
+ ...buildScenarioResultUsage({
1171
+ agentUsage: sumUsageParts([aResult.agentUsage, bResult.agentUsage]),
1172
+ judgeVerdicts,
1173
+ }),
1174
+ };
1175
+ if (!suppressEmit) {
1176
+ const debugBundleDir = await maybeWriteDebugBundle({
1177
+ debug,
1178
+ cwd,
1179
+ suitesDir,
1180
+ rubricsDir,
1181
+ stagingSessionId,
1182
+ debugDir,
1183
+ suiteName,
1184
+ scenario,
1185
+ host,
1186
+ result: scenarioResult,
1187
+ trace: compareResult.a.trace,
1188
+ timeoutMs,
1189
+ worktree,
1190
+ judge,
1191
+ allowUserInput,
1192
+ keepRecordings,
1193
+ });
1194
+ scenarioResult.debugBundleDir = debugBundleDir;
1195
+ emitScenarioVerdict({
1196
+ passed,
1197
+ index: scenarioIndex,
1198
+ total: scenarioTotal,
1199
+ name: scenario.name,
1200
+ durationMs,
1201
+ totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, compareResult.a.trace?.usage),
1202
+ judgeVerdicts,
1203
+ failures,
1204
+ story,
1205
+ debug,
1206
+ debugBundleDir,
1207
+ });
1208
+ }
1209
+ return scenarioResult;
1210
+ }
1211
+ async function loadCompareResultFromStaging(stagingSessionId, suiteName, scenario) {
1212
+ const aLabel = compareArmLabel(scenario.compare?.a, "a");
1213
+ const bLabel = compareArmLabel(scenario.compare?.b, "b");
1214
+ const aScenario = applyCompareArm(scenario, "a");
1215
+ const bScenario = applyCompareArm(scenario, "b");
1216
+ const loadArm = async (side) => {
1217
+ try {
1218
+ return await loadStagingTrace(getStagingTracePath(stagingSessionId, suiteName, scenario.name, side));
1219
+ }
1220
+ catch {
1221
+ return undefined;
1222
+ }
1223
+ };
1224
+ return {
1225
+ a: {
1226
+ id: "a",
1227
+ label: aLabel,
1228
+ description: compareArmDescription(scenario.compare?.a),
1229
+ prompt: aScenario.prompt,
1230
+ trace: await loadArm("a"),
1231
+ },
1232
+ b: {
1233
+ id: "b",
1234
+ label: bLabel,
1235
+ description: compareArmDescription(scenario.compare?.b),
1236
+ prompt: bScenario.prompt,
1237
+ trace: await loadArm("b"),
1238
+ },
1239
+ };
1240
+ }
1241
+ async function runCompareJudgeRubric(compare, rubric, runCwd, host) {
1242
+ const criteria = collectCompareJudgeCriteria(rubric);
1243
+ if (criteria.length === 0 || !compare.a.trace || !compare.b.trace) {
1244
+ return { failures: [], verdicts: [] };
1245
+ }
1246
+ const result = await judgeCompareTraces({
1247
+ aLabel: compare.a.label,
1248
+ a: compare.a.trace,
1249
+ bLabel: compare.b.label,
1250
+ b: compare.b.trace,
1251
+ }, criteria, { cwd: runCwd, host });
1252
+ if (result.skipped) {
1253
+ return {
1254
+ failures: [assertionFailure("judge", result.error ?? "judge skipped", "judge_infra")],
1255
+ verdicts: [],
1256
+ };
1257
+ }
1258
+ const failures = [];
1259
+ for (const verdict of result.verdicts) {
1260
+ if (!verdict.pass) {
1261
+ const category = verdict.infraError
1262
+ ? "judge_infra"
1263
+ : verdict.parseError
1264
+ ? "judge_parse"
1265
+ : "rubric_miss";
1266
+ failures.push(assertionFailure(`judge:${verdict.id}`, verdict.rationale, category));
1267
+ }
1268
+ }
1269
+ if (result.error && !result.verdicts.some((verdict) => !verdict.pass)) {
1270
+ failures.push(assertionFailure("judge", result.error, "judge_infra"));
1271
+ }
1272
+ return { failures, verdicts: result.verdicts };
1273
+ }
1274
+ async function runJudgeRubric(trace, rubric, runCwd, host) {
1275
+ const criteria = collectJudgeCriteria(rubric);
782
1276
  if (criteria.length === 0) {
783
1277
  return { trace, failures: [], verdicts: [] };
784
1278
  }
785
- const result = await judgeTrace(trace, criteria, { cwd: runCwd });
1279
+ const result = await judgeTrace(trace, criteria, { cwd: runCwd, host });
786
1280
  if (result.skipped) {
787
1281
  return {
788
1282
  trace,
@@ -820,26 +1314,38 @@ export async function runAllSuites(options) {
820
1314
  return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
821
1315
  })
822
1316
  : suitePaths;
1317
+ const cliHosts = uniqueHosts(options.hosts ?? (options.host ? [options.host] : undefined));
823
1318
  const reports = [];
824
1319
  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
- }));
1320
+ const suite = await loadSuiteFile(suitePath, { rubricsDir: options.rubricsDir });
1321
+ const hosts = resolveSuiteHosts({
1322
+ cliHosts,
1323
+ suiteHosts: suite.hosts,
1324
+ defaultHost: suite.defaults?.host,
1325
+ });
1326
+ const hostLocked = hosts.length > 1;
1327
+ for (const host of hosts) {
1328
+ reports.push(await runSuite({
1329
+ cwd: options.cwd,
1330
+ suitePath,
1331
+ host,
1332
+ hostLocked,
1333
+ scenarioFilter: options.scenarioFilter,
1334
+ judge: options.judge,
1335
+ worktree: options.worktree,
1336
+ stagingSessionId: options.stagingSessionId,
1337
+ keepRecordings: options.keepRecordings,
1338
+ suitesDir: options.suitesDir,
1339
+ suiteFilter: options.filter,
1340
+ timeoutMs: options.timeoutMs,
1341
+ allowUserInput: options.allowUserInput,
1342
+ debug: options.debug,
1343
+ debugDir: options.debugDir,
1344
+ scenarioRetries: options.scenarioRetries,
1345
+ rubricsDir: options.rubricsDir,
1346
+ adapterModules: options.adapterModules,
1347
+ }));
1348
+ }
843
1349
  }
844
1350
  return reports;
845
1351
  }