@post-print/agent-test 0.3.3 → 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 (106) hide show
  1. package/README.md +126 -157
  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 +12 -14
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +342 -366
  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 -2
  15. package/dist/debug-bundle.d.ts.map +1 -1
  16. package/dist/debug-bundle.js +12 -12
  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 +136 -14
  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 +15 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +13 -7
  41. package/dist/index.js.map +1 -1
  42. package/dist/live-isolation.d.ts +11 -5
  43. package/dist/live-isolation.d.ts.map +1 -1
  44. package/dist/live-isolation.js +32 -14
  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 +16 -0
  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/preflight.d.ts +2 -2
  58. package/dist/preflight.d.ts.map +1 -1
  59. package/dist/preflight.js +4 -4
  60. package/dist/preflight.js.map +1 -1
  61. package/dist/progress.d.ts +7 -0
  62. package/dist/progress.d.ts.map +1 -1
  63. package/dist/progress.js +39 -9
  64. package/dist/progress.js.map +1 -1
  65. package/dist/record-trace.d.ts +11 -20
  66. package/dist/record-trace.d.ts.map +1 -1
  67. package/dist/record-trace.js +10 -57
  68. package/dist/record-trace.js.map +1 -1
  69. package/dist/report-preview-server.d.ts +2 -0
  70. package/dist/report-preview-server.d.ts.map +1 -0
  71. package/dist/report-preview-server.js +24 -0
  72. package/dist/report-preview-server.js.map +1 -0
  73. package/dist/report-preview.d.ts +11 -0
  74. package/dist/report-preview.d.ts.map +1 -0
  75. package/dist/report-preview.js +161 -0
  76. package/dist/report-preview.js.map +1 -0
  77. package/dist/run-suite.d.ts +62 -11
  78. package/dist/run-suite.d.ts.map +1 -1
  79. package/dist/run-suite.js +697 -206
  80. package/dist/run-suite.js.map +1 -1
  81. package/dist/scenario-story.d.ts +41 -0
  82. package/dist/scenario-story.d.ts.map +1 -0
  83. package/dist/scenario-story.js +198 -0
  84. package/dist/scenario-story.js.map +1 -0
  85. package/dist/suite-summary.d.ts +2 -0
  86. package/dist/suite-summary.d.ts.map +1 -1
  87. package/dist/suite-summary.js +70 -22
  88. package/dist/suite-summary.js.map +1 -1
  89. package/dist/theme.d.ts +29 -5
  90. package/dist/theme.d.ts.map +1 -1
  91. package/dist/theme.js +204 -45
  92. package/dist/theme.js.map +1 -1
  93. package/dist/types.d.ts +107 -24
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/validate-seeds.d.ts.map +1 -1
  96. package/dist/validate-seeds.js +21 -6
  97. package/dist/validate-seeds.js.map +1 -1
  98. package/dist/validate-suite.d.ts +3 -1
  99. package/dist/validate-suite.d.ts.map +1 -1
  100. package/dist/validate-suite.js +256 -22
  101. package/dist/validate-suite.js.map +1 -1
  102. package/package.json +5 -5
  103. package/dist/compare.d.ts +0 -79
  104. package/dist/compare.d.ts.map +0 -1
  105. package/dist/compare.js +0 -232
  106. package/dist/compare.js.map +0 -1
package/dist/run-suite.js CHANGED
@@ -1,20 +1,25 @@
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";
12
- import { getLiveStagingRootOverride, getLiveStagingSessionRoot, getStagingAgentStartPath, getStagingResultPath, getStagingTracePath, loadStagingResult, loadStagingTrace, recordTrace, resolveRecordingPath, setLiveStagingRootOverride, writeAgentStartMarker, writeStagingResult, } from "./record-trace.js";
13
+ import { bindMcpServersToCaller, mcpStdioScriptPaths } from "./mcp-config.js";
14
+ import { formatDuration, logLive, logPhase, logProgress, logVerdict, refreshHeartbeat, withHeartbeat, } from "./progress.js";
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";
22
+ import { validateSuiteFile } from "./validate-suite.js";
18
23
  const require = createRequire(import.meta.url);
19
24
  const packageVersion = require("../package.json").version;
20
25
  let activeWorktreeCleanup;
@@ -37,6 +42,14 @@ async function restoreActiveCallerHead() {
37
42
  function isChildProcess() {
38
43
  return process.env.AGENT_TEST_CHILD === "1";
39
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
+ }
40
53
  /** Parent process prints suite headers and final verdicts; children only print phases. */
41
54
  export function shouldPrintSuiteChrome() {
42
55
  return !isChildProcess();
@@ -51,6 +64,7 @@ export function registerLiveRunHandlers() {
51
64
  killActiveLiveChildren();
52
65
  cancelActiveCursorRun();
53
66
  cancelActiveClaudeRun();
67
+ cancelActiveOpenaiRun();
54
68
  const cleanup = activeWorktreeCleanup;
55
69
  const headRestore = activeCallerHeadRestore;
56
70
  if (!cleanup && !headRestore) {
@@ -98,6 +112,82 @@ function normalizeJudgeCriteria(judge) {
98
112
  return { id: item.id ?? `judge-${index}`, question: item.question };
99
113
  });
100
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
+ }
101
191
  function questionForCriterion(criteria, id) {
102
192
  return criteria.find((c) => c.id === id)?.question ?? id;
103
193
  }
@@ -144,6 +234,7 @@ function emitScenarioVerdict(options) {
144
234
  judgeVerdicts: options.judgeVerdicts,
145
235
  rubricFailures: rubricFailuresOnly(options.failures),
146
236
  failureCategory: options.failures[0]?.category,
237
+ story: options.story,
147
238
  debug: options.debug,
148
239
  debugBundleDir: options.debugBundleDir,
149
240
  }));
@@ -155,7 +246,7 @@ async function maybeWriteDebugBundle(options) {
155
246
  if (!options.stagingSessionId) {
156
247
  return undefined;
157
248
  }
158
- 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);
159
250
  const cliPath = process.argv[1] ?? resolve(options.cwd, "node_modules/@post-print/agent-test/dist/cli.js");
160
251
  try {
161
252
  await writeDebugBundle({
@@ -170,7 +261,7 @@ async function maybeWriteDebugBundle(options) {
170
261
  host: options.host,
171
262
  timeoutMs: options.timeoutMs,
172
263
  worktree: options.worktree,
173
- isolateLive: options.live && liveScenarioIsolationEnabled(),
264
+ isolateLive: liveScenarioIsolationEnabled(),
174
265
  }),
175
266
  rerun: {
176
267
  cliPath,
@@ -179,7 +270,6 @@ async function maybeWriteDebugBundle(options) {
179
270
  rubricsDir: options.rubricsDir,
180
271
  suite: options.suiteName,
181
272
  scenario: options.scenario.name,
182
- live: options.live,
183
273
  host: options.host,
184
274
  judge: options.judge,
185
275
  worktree: options.worktree,
@@ -210,10 +300,15 @@ export async function runSuite(options) {
210
300
  if (options.debugDir !== undefined) {
211
301
  setLiveStagingRootOverride(options.debugDir);
212
302
  }
303
+ const ownsStagingSession = options.stagingSessionId === undefined;
304
+ const stagingSessionId = options.stagingSessionId ?? createLiveStagingSessionId();
213
305
  try {
214
- return await runSuiteBody(options);
306
+ return await runSuiteBody({ ...options, stagingSessionId });
215
307
  }
216
308
  finally {
309
+ if (ownsStagingSession && !options.keepRecordings && !options.debug) {
310
+ await cleanupStagingSession(getLiveStagingSessionRoot(stagingSessionId)).catch(() => undefined);
311
+ }
217
312
  if (options.debugDir !== undefined) {
218
313
  setLiveStagingRootOverride(previousStagingRoot);
219
314
  }
@@ -221,24 +316,38 @@ export async function runSuite(options) {
221
316
  }
222
317
  async function runSuiteBody(options) {
223
318
  const suite = await loadSuiteFile(options.suitePath, { rubricsDir: options.rubricsDir });
224
- const defaultHost = options.host ?? suite.defaults?.host ?? "replay";
319
+ const validationIssues = validateSuiteFile(options.suitePath, suite);
320
+ if (validationIssues.length > 0) {
321
+ const details = validationIssues
322
+ .map((issue) => `${issue.scenario ? `${issue.scenario} · ` : ""}${issue.field}: ${issue.message}`)
323
+ .join("\n");
324
+ throw new Error(`Invalid suite file ${options.suitePath}:\n${details}`);
325
+ }
326
+ const defaultHost = options.host ?? suite.defaults?.host ?? "cursor";
225
327
  const results = [];
226
- const scenarios = options.scenarioFilter
328
+ const selected = options.scenarioFilter
227
329
  ? suite.scenarios.filter((scenario) => scenario.name === options.scenarioFilter)
228
330
  : suite.scenarios;
229
- if (options.scenarioFilter && scenarios.length === 0) {
331
+ if (options.scenarioFilter && selected.length === 0) {
230
332
  throw new Error(`Scenario not found: ${options.scenarioFilter}`);
231
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
+ }
232
342
  const filteredTotal = scenarios.length;
233
343
  const parentCounters = parentScenarioCounters();
234
344
  const displayTotal = parentCounters?.total ?? filteredTotal;
235
- const isLiveSuite = defaultHost !== "replay";
236
- const isolateLive = isLiveSuite && liveScenarioIsolationEnabled() && !options.scenarioFilter && filteredTotal > 1;
345
+ const isolateLive = liveScenarioIsolationEnabled() && !options.scenarioFilter && filteredTotal > 1;
237
346
  let previousIsolatedExitCode;
238
347
  if (shouldPrintSuiteChrome()) {
239
348
  logProgress(`\n${theme.suiteHeader(suite.name, defaultHost, displayTotal)}`);
240
349
  if (isolateLive) {
241
- logProgress(` ${theme.isolationNote()}`);
350
+ logProgress(` ${theme.isolationNote()}`);
242
351
  }
243
352
  }
244
353
  for (let index = 0; index < scenarios.length; index++) {
@@ -255,25 +364,33 @@ async function runSuiteBody(options) {
255
364
  results.push({
256
365
  suite: suite.name,
257
366
  scenario: scenario.name,
367
+ prompt: scenario.prompt,
258
368
  passed: true,
259
369
  failures: [],
260
370
  skipped: true,
261
371
  durationMs: 0,
372
+ story: buildScenarioStory({
373
+ rubric: scenario.rubric,
374
+ passed: true,
375
+ skipped: true,
376
+ failures: [],
377
+ }),
262
378
  });
263
379
  continue;
264
380
  }
265
381
  const started = performance.now();
266
382
  const debug = isDebugEnabled(options);
267
- const maxAttempts = !isChildProcess() && isLiveSuite
383
+ const maxAttempts = !isChildProcess()
268
384
  ? resolveScenarioRetryMaxAttempts(options.scenarioRetries)
269
385
  : 1;
270
386
  let attempts = 0;
271
387
  let failures = [];
272
388
  let scenarioTrace;
273
389
  let previousAttemptExitCode = previousIsolatedExitCode;
390
+ let childSidecar;
274
391
  while (true) {
275
392
  attempts++;
276
- const exitCode = await spawnLiveScenario({
393
+ const spawned = await spawnLiveScenario({
277
394
  cwd: options.cwd,
278
395
  suiteName: suite.name,
279
396
  scenarioName: scenario.name,
@@ -282,10 +399,10 @@ async function runSuiteBody(options) {
282
399
  suiteFilter: options.suiteFilter ?? suite.name,
283
400
  stagingSessionId: options.stagingSessionId,
284
401
  keepRecordings: options.keepRecordings,
285
- recordFixtures: options.recordFixtures,
286
402
  worktree: options.worktree,
287
403
  judge: options.judge,
288
404
  host: defaultHost,
405
+ adapterModules: options.adapterModules,
289
406
  scenarioIndex: index + 1,
290
407
  scenarioTotal: filteredTotal,
291
408
  timeoutMs: resolveLiveTimeoutMs(options.timeoutMs),
@@ -295,23 +412,34 @@ async function runSuiteBody(options) {
295
412
  debugDir: options.debugDir,
296
413
  previousExitCode: previousAttemptExitCode,
297
414
  });
298
- previousAttemptExitCode = exitCode;
299
- previousIsolatedExitCode = exitCode;
415
+ previousAttemptExitCode = spawned.exitCode;
416
+ previousIsolatedExitCode = spawned.exitCode;
300
417
  failures = [];
301
418
  scenarioTrace = undefined;
302
- if (exitCode !== 0) {
303
- const childResult = options.stagingSessionId !== undefined
419
+ childSidecar =
420
+ options.stagingSessionId !== undefined
304
421
  ? await loadStagingResult(getStagingResultPath(options.stagingSessionId, suite.name, scenario.name))
305
422
  : undefined;
306
- failures.push(...failuresForLiveSubprocessExit(exitCode, childResult));
423
+ if (spawned.exitCode !== 0) {
424
+ failures.push(...failuresForLiveSubprocessExit(spawned.exitCode, childSidecar, spawned.stderr));
307
425
  }
308
426
  if (options.stagingSessionId) {
309
- const tracePath = getStagingTracePath(options.stagingSessionId, suite.name, scenario.name);
310
- try {
311
- 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
+ }
312
434
  }
313
- catch {
314
- // 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
+ }
315
443
  }
316
444
  }
317
445
  const canRetry = failures.length > 0 &&
@@ -324,38 +452,79 @@ async function runSuiteBody(options) {
324
452
  break;
325
453
  }
326
454
  let judgeVerdicts;
327
- if (failures.length === 0 && options.judge !== false && scenarioTrace) {
328
- const criteria = normalizeJudgeCriteria(scenario.rubric.judge);
329
- if (criteria.length > 0) {
330
- releaseLiveMemory();
331
- logPhase(theme.judgePhase(criteria.length), { last: true });
332
- try {
333
- const judged = await runJudgeRubric(scenarioTrace, scenario.rubric, options.cwd);
334
- failures.push(...judged.failures);
335
- scenarioTrace = judged.trace;
336
- 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
+ }
337
475
  }
338
- catch (error) {
339
- 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
+ }
340
491
  }
341
492
  }
342
493
  }
343
494
  const durationMs = Math.round(performance.now() - started);
344
495
  const passed = failures.length === 0;
345
496
  const usageFields = buildScenarioResultUsage({
346
- agentUsage: scenarioTrace?.usage,
497
+ agentUsage: compareResult
498
+ ? sumUsageParts([compareResult.a.trace?.usage, compareResult.b.trace?.usage])
499
+ : scenarioTrace?.usage,
347
500
  judgeVerdicts,
348
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
+ });
349
516
  const scenarioResult = {
350
517
  suite: suite.name,
351
518
  scenario: scenario.name,
352
- compareId: scenario.compareId,
519
+ prompt: scenario.prompt,
353
520
  passed,
354
521
  failures,
355
522
  durationMs,
356
523
  attempts,
357
524
  judgeVerdicts,
358
525
  trace: scenarioTrace,
526
+ compare: compareResult,
527
+ story,
359
528
  ...usageFields,
360
529
  };
361
530
  const debugBundleDir = await maybeWriteDebugBundle({
@@ -373,7 +542,6 @@ async function runSuiteBody(options) {
373
542
  timeoutMs: options.timeoutMs,
374
543
  worktree: options.worktree,
375
544
  judge: options.judge,
376
- live: true,
377
545
  allowUserInput: options.allowUserInput,
378
546
  keepRecordings: options.keepRecordings,
379
547
  });
@@ -387,6 +555,7 @@ async function runSuiteBody(options) {
387
555
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, scenarioTrace?.usage),
388
556
  judgeVerdicts,
389
557
  failures,
558
+ story,
390
559
  debug,
391
560
  debugBundleDir,
392
561
  });
@@ -394,67 +563,27 @@ async function runSuiteBody(options) {
394
563
  releaseLiveMemory();
395
564
  continue;
396
565
  }
397
- const inProcessMaxAttempts = !isChildProcess() && isLiveSuite
398
- ? resolveScenarioRetryMaxAttempts(options.scenarioRetries)
399
- : 1;
400
- if (inProcessMaxAttempts > 1) {
401
- let attempts = 0;
402
- let scenarioResult;
403
- while (true) {
404
- attempts++;
405
- scenarioResult = await runScenario(options.cwd, suite.name, scenario, defaultHost, suite.defaults?.profile, suite.defaults?.skills, suite.defaults?.contextSources, suite.defaults?.mcpServers, options.record, options.recordFixtures, options.judge, options.worktree, options.stagingSessionId, scenarioIndex, scenarioTotal, options.timeoutMs, options.allowUserInput, options.debug, options.debugDir, options.suitesDir ?? "agent-suites", options.keepRecordings, options.rubricsDir, { suppressEmit: true });
406
- const canRetry = !scenarioResult.skipped &&
407
- !scenarioResult.passed &&
408
- attempts < inProcessMaxAttempts &&
409
- shouldRetryAnnounceStopFlake(scenarioResult.failures, scenarioResult.trace);
410
- if (canRetry) {
411
- logPhase(theme.phase("retry", `${attempts}/${inProcessMaxAttempts - 1}`));
412
- continue;
413
- }
414
- break;
415
- }
416
- scenarioResult.attempts = attempts;
417
- const debug = isDebugEnabled(options);
418
- const debugBundleDir = await maybeWriteDebugBundle({
419
- debug,
420
- cwd: options.cwd,
421
- suitesDir: options.suitesDir ?? "agent-suites",
422
- rubricsDir: options.rubricsDir,
423
- stagingSessionId: options.stagingSessionId,
424
- debugDir: options.debugDir,
425
- suiteName: suite.name,
426
- scenario,
427
- host: scenario.host ?? defaultHost,
428
- result: scenarioResult,
429
- trace: scenarioResult.trace,
430
- timeoutMs: options.timeoutMs,
431
- worktree: options.worktree,
432
- judge: options.judge,
433
- live: true,
434
- allowUserInput: options.allowUserInput,
435
- keepRecordings: options.keepRecordings,
436
- });
437
- scenarioResult.debugBundleDir = debugBundleDir;
438
- emitScenarioVerdict({
439
- passed: scenarioResult.passed,
440
- index: scenarioIndex,
441
- total: scenarioTotal,
442
- name: scenario.name,
443
- durationMs: scenarioResult.durationMs,
444
- totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, scenarioResult.trace?.usage),
445
- judgeVerdicts: scenarioResult.judgeVerdicts,
446
- failures: scenarioResult.failures,
447
- debug,
448
- debugBundleDir,
449
- });
450
- results.push(scenarioResult);
451
- }
452
- else {
453
- results.push(await runScenario(options.cwd, suite.name, scenario, defaultHost, suite.defaults?.profile, suite.defaults?.skills, suite.defaults?.contextSources, suite.defaults?.mcpServers, options.record, options.recordFixtures, options.judge, options.worktree, options.stagingSessionId, scenarioIndex, scenarioTotal, options.timeoutMs, options.allowUserInput, options.debug, options.debugDir, options.suitesDir ?? "agent-suites", options.keepRecordings, options.rubricsDir));
454
- }
455
- if (isLiveSuite) {
456
- releaseLiveMemory();
457
- }
566
+ results.push(await runAgentTest({
567
+ cwd: options.cwd,
568
+ suiteName: suite.name,
569
+ scenario,
570
+ defaults: suite.defaults,
571
+ host: options.host,
572
+ judge: options.judge,
573
+ worktree: options.worktree,
574
+ stagingSessionId: options.stagingSessionId,
575
+ scenarioIndex,
576
+ scenarioTotal,
577
+ timeoutMs: options.timeoutMs,
578
+ allowUserInput: options.allowUserInput,
579
+ debug: options.debug,
580
+ debugDir: options.debugDir,
581
+ suitesDir: options.suitesDir,
582
+ keepRecordings: options.keepRecordings,
583
+ rubricsDir: options.rubricsDir,
584
+ scenarioRetries: isChildProcess() ? 0 : options.scenarioRetries,
585
+ }));
586
+ releaseLiveMemory();
458
587
  }
459
588
  return {
460
589
  suite: suite.name,
@@ -466,11 +595,139 @@ async function runSuiteBody(options) {
466
595
  summary: summarizeReportResults(results),
467
596
  };
468
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
+ }
469
637
  function mergeContextSources(defaults, scenarioSources) {
470
638
  const merged = [...(defaults ?? []), ...(scenarioSources ?? [])].filter((value) => typeof value === "string" && value.trim().length > 0);
471
639
  return merged.length > 0 ? merged : undefined;
472
640
  }
473
- async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile, defaultSkills, defaultContextSources, defaultMcpServers, record, recordFixtures, judge, worktree, stagingSessionId, scenarioIndex, scenarioTotal, timeoutMs, allowUserInput, debugFlag, debugDir, suitesDir = "agent-suites", keepRecordings, rubricsDir, runOptions) {
641
+ function defaultProfileForHost(host) {
642
+ if (host === "cursor") {
643
+ return "cursor";
644
+ }
645
+ if (host === "claude") {
646
+ return "claude";
647
+ }
648
+ return "shared";
649
+ }
650
+ /** Run one real agent scenario. JSON suites delegate to this same execution boundary. */
651
+ export async function runAgentTest(options) {
652
+ const previousStagingRoot = getLiveStagingRootOverride();
653
+ if (options.debugDir !== undefined) {
654
+ setLiveStagingRootOverride(options.debugDir);
655
+ }
656
+ const stagingSessionId = options.stagingSessionId ??
657
+ (options.debug || options.keepRecordings ? createLiveStagingSessionId() : undefined);
658
+ try {
659
+ return await runAgentTestBody({ ...options, stagingSessionId });
660
+ }
661
+ finally {
662
+ if (options.debugDir !== undefined) {
663
+ setLiveStagingRootOverride(previousStagingRoot);
664
+ }
665
+ }
666
+ }
667
+ async function runAgentTestBody(options) {
668
+ const legacyScenario = options.scenario;
669
+ const legacyDefaults = options.defaults;
670
+ if (legacyScenario.host === "replay" ||
671
+ legacyDefaults?.host === "replay" ||
672
+ "replayTrace" in legacyScenario) {
673
+ throw new Error("Replay-based testing is deprecated and no longer supported; use Cursor, Claude, or OpenAI.");
674
+ }
675
+ const suiteName = options.suiteName ?? "direct";
676
+ const defaultHost = options.host ?? options.defaults?.host ?? "cursor";
677
+ const maxAttempts = resolveScenarioRetryMaxAttempts(options.scenarioRetries);
678
+ let attempts = 0;
679
+ let result;
680
+ while (true) {
681
+ attempts++;
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 });
683
+ const canRetry = !result.skipped &&
684
+ !result.passed &&
685
+ attempts < maxAttempts &&
686
+ shouldRetryAnnounceStopFlake(result.failures, result.trace);
687
+ if (!canRetry) {
688
+ break;
689
+ }
690
+ logPhase(theme.phase("retry", `${attempts}/${maxAttempts - 1}`));
691
+ }
692
+ result.attempts = attempts;
693
+ if (maxAttempts > 1) {
694
+ const debug = isDebugEnabled(options);
695
+ const debugBundleDir = await maybeWriteDebugBundle({
696
+ debug,
697
+ cwd: options.cwd,
698
+ suitesDir: options.suitesDir ?? "agent-suites",
699
+ rubricsDir: options.rubricsDir,
700
+ stagingSessionId: options.stagingSessionId,
701
+ debugDir: options.debugDir,
702
+ suiteName,
703
+ scenario: options.scenario,
704
+ host: options.scenario.host ?? defaultHost,
705
+ result,
706
+ trace: result.trace,
707
+ timeoutMs: options.timeoutMs,
708
+ worktree: options.worktree,
709
+ judge: options.judge ?? true,
710
+ allowUserInput: options.allowUserInput,
711
+ keepRecordings: options.keepRecordings,
712
+ });
713
+ result.debugBundleDir = debugBundleDir;
714
+ emitScenarioVerdict({
715
+ passed: result.passed,
716
+ index: options.scenarioIndex,
717
+ total: options.scenarioTotal,
718
+ name: options.scenario.name,
719
+ durationMs: result.durationMs,
720
+ totalTokens: totalTokensFromScenarioUsage(result.usage, result.trace?.usage),
721
+ judgeVerdicts: result.judgeVerdicts,
722
+ failures: result.failures,
723
+ story: result.story,
724
+ debug,
725
+ debugBundleDir,
726
+ });
727
+ }
728
+ return result;
729
+ }
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) {
474
731
  const started = performance.now();
475
732
  const debug = isDebugEnabled({ debug: debugFlag });
476
733
  const suppressEmit = runOptions?.suppressEmit === true;
@@ -482,99 +739,113 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
482
739
  return {
483
740
  suite: suiteName,
484
741
  scenario: scenario.name,
742
+ prompt: scenario.prompt,
485
743
  passed: true,
486
744
  failures: [],
487
745
  skipped: true,
488
746
  durationMs: 0,
747
+ story: buildScenarioStory({
748
+ rubric: scenario.rubric,
749
+ passed: true,
750
+ skipped: true,
751
+ failures: [],
752
+ }),
489
753
  };
490
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
+ }
491
758
  const host = scenario.host ?? defaultHost;
492
- const profile = scenario.profile ?? defaultProfile ?? (host === "cursor" ? "cursor" : "shared");
759
+ const profile = scenario.profile ?? defaultProfile ?? defaultProfileForHost(host);
493
760
  const skills = scenario.skills ?? defaultSkills;
494
761
  const contextSources = mergeContextSources(defaultContextSources, scenario.contextSources);
495
- const mcpServers = mergeMcpServers(defaultMcpServers, scenario.mcpServers);
496
- const isLive = host !== "replay";
497
- const liveTimeoutMs = isLive ? resolveLiveTimeoutMs(timeoutMs) : undefined;
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);
768
+ const liveTimeoutMs = resolveLiveTimeoutMs(timeoutMs);
498
769
  const failOnUserInput = !allowUserInput;
499
- 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) {
500
774
  logProgress(theme.scenarioTitle(scenarioIndex, scenarioTotal, scenario.name, host));
501
775
  }
502
776
  else {
503
777
  logProgress(theme.scenarioLabel(scenario.name, host));
504
778
  }
505
- const useWorktree = isLive && worktree !== false && !process.env.AGENT_TEST_NO_WORKTREE;
779
+ const useWorktree = worktree !== false && !process.env.AGENT_TEST_NO_WORKTREE;
506
780
  let worktreeHandle;
507
781
  let callerHeadBefore;
508
782
  const callerTreeBefore = useWorktree ? await captureWorkingTreeStatus(cwd) : undefined;
509
783
  if (useWorktree) {
510
- if (isLive && scenario.seedPatch) {
784
+ if (scenario.seedPatch) {
511
785
  callerHeadBefore = await captureCallerHead(cwd);
512
786
  setCallerHeadRestore(cwd, callerHeadBefore);
513
787
  }
514
- 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
+ });
515
795
  activeWorktreeCleanup = worktreeHandle.cleanup;
516
- logPhase(theme.phase("worktree", theme.path(worktreeHandle.path)));
517
- if (isLive && scenario.seedPatch) {
796
+ if (scenario.seedPatch) {
518
797
  logPhase(theme.phase("seed", theme.basename(scenario.seedPatch)));
519
798
  await seedScenarioWorktree(cwd, worktreeHandle.path, scenario.seedPatch, {
520
799
  stageOnly: scenario.seedStageOnly === true,
521
800
  });
522
801
  }
523
802
  }
524
- else if (isLive) {
803
+ else {
525
804
  logPhase(theme.phase("worktree", theme.phaseDim("disabled (AGENT_TEST_ALLOW_IN_PLACE=1)")));
526
805
  }
527
- const runCwd = worktreeHandle?.path ?? cwd;
806
+ const runCwd = worktreeHandle?.path ?? (workspaceRel ? resolve(cwd, workspaceRel) : cwd);
528
807
  try {
529
- logPhase(theme.phase("context"));
530
- // Live worktree runs code in an isolated checkout; load rules/AGENTS from caller cwd
531
- // so uncommitted .cursor/rules and AGENTS.md edits apply during dogfood.
532
- const contextRoot = isLive && useWorktree ? cwd : runCwd;
533
808
  const context = await loadContext({
534
- cwd: contextRoot,
809
+ cwd: runCwd,
535
810
  profile,
536
811
  skills,
537
812
  contextSources,
538
813
  });
539
- const useReplay = host === "replay";
540
- if (useReplay) {
541
- logPhase(theme.phase("replay", theme.path(scenario.replayTrace ?? "trace")));
542
- }
543
- else {
544
- logPhase(theme.phase("agent"));
545
- }
546
- const outputContract = isLive ? outputContractForRubric(scenario.rubric) : undefined;
547
- const agentStartMarkerPath = isChildProcess() && isLive && stagingSessionId
814
+ const outputContract = outputContractForRubric(scenario.rubric);
815
+ const agentStartMarkerPath = isChildProcess() && stagingSessionId
548
816
  ? getStagingAgentStartPath(stagingSessionId, suiteName, scenario.name)
549
817
  : undefined;
818
+ logPhase(theme.phase("agent", theme.phaseDim("started")));
550
819
  const agentStarted = performance.now();
551
- const session = await (isLive
552
- ? withHeartbeat(runAgent({
553
- host,
554
- cwd: runCwd,
555
- context,
556
- profile,
557
- prompt: scenario.prompt,
558
- outputContract,
559
- mcpServers,
560
- timeoutMs: liveTimeoutMs,
561
- failOnUserInput,
562
- onDeadlineStart: agentStartMarkerPath
563
- ? () => writeAgentStartMarker(agentStartMarkerPath)
564
- : undefined,
565
- }), { started: agentStarted })
566
- : runAgent({
567
- host,
568
- cwd: runCwd,
569
- context,
570
- profile,
571
- prompt: scenario.prompt,
572
- replayTracePath: useReplay ? scenario.replayTrace : undefined,
573
- mcpServers,
574
- }));
575
- if (isLive) {
576
- logPhase(theme.phase("agent", `${theme.statusCompleted(session.status)} ${theme.duration(formatDuration(performance.now() - agentStarted))}`));
577
- }
820
+ let livePreview;
821
+ const session = await withHeartbeat(runAgent({
822
+ host,
823
+ cwd: runCwd,
824
+ context,
825
+ profile,
826
+ prompt: scenario.prompt,
827
+ outputContract,
828
+ mcpServers,
829
+ allowUserSkills,
830
+ timeoutMs: liveTimeoutMs,
831
+ failOnUserInput,
832
+ maxConversationTurns: resolveMaxConversationTurns(),
833
+ onDeadlineStart: agentStartMarkerPath
834
+ ? () => writeAgentStartMarker(agentStartMarkerPath)
835
+ : undefined,
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 });
848
+ logPhase(theme.phase("agent", `${theme.statusCompleted(session.status)} ${theme.duration(formatDuration(performance.now() - agentStarted))}`));
578
849
  let trace = enrichTrace(session.trace);
579
850
  const failures = [];
580
851
  if (session.status !== "completed") {
@@ -597,18 +868,23 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
597
868
  .join("\n");
598
869
  failures.push(assertionFailure("runAgent", session.error ?? `agent session ${session.status}`, "agent_runtime", runtimeEvidence));
599
870
  }
600
- else if (isLive && failOnUserInput && traceHasUserInputTool(trace.toolCalls)) {
871
+ else if (failOnUserInput && traceHasUserInputTool(trace.toolCalls)) {
601
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(", ")}`));
602
873
  }
603
- logPhase(theme.phase("rubric"));
604
874
  failures.push(...assertRubric(trace, scenario.rubric, {
605
875
  skillsMode: context.skillsMode,
606
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
+ }
607
883
  if (useWorktree && callerTreeBefore !== undefined) {
608
884
  const callerTreeAfter = await captureWorkingTreeStatus(cwd);
609
885
  const ignoreRoots = resolveHarnessArtifactIgnoreRoots(cwd, getLiveStagingRootOverride());
610
886
  const leaked = filterWorkingTreeLeaks(findWorkingTreeLeak(callerTreeBefore, callerTreeAfter), ignoreRoots, cwd);
611
- const seedPaths = isLive && scenario.seedPatch
887
+ const seedPaths = scenario.seedPatch
612
888
  ? await loadUnifiedDiffPaths(resolve(cwd, scenario.seedPatch)).catch(() => [])
613
889
  : [];
614
890
  const outsideEdits = worktreeHandle !== undefined
@@ -634,33 +910,32 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
634
910
  ].join("\n")));
635
911
  }
636
912
  }
637
- if (record && isLive) {
638
- const resolved = resolveRecordingPath(suiteName, scenario.name, scenario.replayTrace, recordFixtures === true, { repoRoot: cwd, stagingSessionId });
639
- if (resolved) {
640
- try {
641
- const path = await recordTrace(resolved.path, trace);
642
- const recordLabel = resolved.kind === "fixture" ? "fixture" : "trace";
643
- logPhase(theme.phase(recordLabel, theme.path(path)));
644
- }
645
- catch (error) {
646
- failures.push(assertionFailure("recordTrace", error instanceof Error ? error.message : "failed to record trace", "recording_error"));
913
+ const stagingTracePath = resolveRecordingPath(suiteName, scenario.name, stagingSessionId, runOptions?.compareArm);
914
+ if (stagingTracePath) {
915
+ try {
916
+ const path = await recordTrace(stagingTracePath, trace);
917
+ if (keepRecordings || debug) {
918
+ logPhase(theme.phase("trace", theme.path(path)));
647
919
  }
648
920
  }
921
+ catch (error) {
922
+ failures.push(assertionFailure("recordTrace", error instanceof Error ? error.message : "failed to record trace", "recording_error"));
923
+ }
649
924
  }
650
925
  const deferJudgeToParent = isChildProcess();
651
926
  let judgeVerdicts;
652
- if (judge && isLive && !deferJudgeToParent) {
653
- const criteria = normalizeJudgeCriteria(scenario.rubric.judge);
927
+ if (judge && !deferJudgeToParent) {
928
+ const criteria = collectJudgeCriteria(scenario.rubric);
654
929
  if (criteria.length > 0) {
655
930
  logPhase(theme.judgePhase(criteria.length), { last: true });
656
931
  }
657
- const judged = await runJudgeRubric(trace, scenario.rubric, runCwd);
932
+ const judged = await runJudgeRubric(trace, scenario.rubric, runCwd, host);
658
933
  failures.push(...judged.failures);
659
934
  trace = judged.trace;
660
935
  judgeVerdicts = toJudgeVerdictResults(judged.trace, criteria, judged.verdicts);
661
936
  }
662
937
  const durationMs = Math.round(performance.now() - started);
663
- if (isChildProcess() && isLive && stagingSessionId) {
938
+ if (isChildProcess() && stagingSessionId && runOptions?.writeSidecar !== false) {
664
939
  await writeStagingResult(getStagingResultPath(stagingSessionId, suiteName, scenario.name), {
665
940
  passed: failures.length === 0,
666
941
  failures,
@@ -668,11 +943,6 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
668
943
  });
669
944
  }
670
945
  if (worktreeHandle) {
671
- const willJudge = Boolean(judge) &&
672
- isLive &&
673
- !isChildProcess() &&
674
- normalizeJudgeCriteria(scenario.rubric.judge).length > 0;
675
- logPhase(theme.phase("cleanup"), { last: !willJudge });
676
946
  await worktreeHandle.cleanup();
677
947
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
678
948
  activeWorktreeCleanup = undefined;
@@ -682,15 +952,24 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
682
952
  }
683
953
  worktreeHandle = undefined;
684
954
  }
955
+ const passed = failures.length === 0;
956
+ const story = buildScenarioStory({
957
+ rubric: scenario.rubric,
958
+ trace,
959
+ passed,
960
+ failures,
961
+ judgeVerdicts,
962
+ });
685
963
  const scenarioResult = {
686
964
  suite: suiteName,
687
965
  scenario: scenario.name,
688
- compareId: scenario.compareId,
689
- passed: failures.length === 0,
966
+ prompt: scenario.prompt,
967
+ passed,
690
968
  failures,
691
969
  durationMs,
692
970
  judgeVerdicts,
693
971
  trace,
972
+ story,
694
973
  ...buildScenarioResultUsage({
695
974
  agentUsage: session.usage ?? trace?.usage,
696
975
  judgeVerdicts,
@@ -712,13 +991,12 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
712
991
  timeoutMs,
713
992
  worktree,
714
993
  judge,
715
- live: isLive,
716
994
  allowUserInput,
717
995
  keepRecordings,
718
996
  });
719
997
  scenarioResult.debugBundleDir = debugBundleDir;
720
998
  emitScenarioVerdict({
721
- passed: failures.length === 0,
999
+ passed,
722
1000
  index: scenarioIndex,
723
1001
  total: scenarioTotal,
724
1002
  name: scenario.name,
@@ -726,6 +1004,7 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
726
1004
  totalTokens: totalTokensFromScenarioUsage(scenarioResult.usage, trace?.usage),
727
1005
  judgeVerdicts,
728
1006
  failures,
1007
+ story,
729
1008
  debug,
730
1009
  debugBundleDir,
731
1010
  });
@@ -742,7 +1021,6 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
742
1021
  }
743
1022
  }
744
1023
  if (worktreeHandle) {
745
- logPhase(theme.phase("cleanup"), { last: true });
746
1024
  await worktreeHandle.cleanup();
747
1025
  if (activeWorktreeCleanup === worktreeHandle.cleanup) {
748
1026
  activeWorktreeCleanup = undefined;
@@ -753,12 +1031,215 @@ async function runScenario(cwd, suiteName, scenario, defaultHost, defaultProfile
753
1031
  }
754
1032
  }
755
1033
  }
756
- async function runJudgeRubric(trace, rubric, runCwd) {
757
- 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);
758
1239
  if (criteria.length === 0) {
759
1240
  return { trace, failures: [], verdicts: [] };
760
1241
  }
761
- const result = await judgeTrace(trace, criteria, { cwd: runCwd });
1242
+ const result = await judgeTrace(trace, criteria, { cwd: runCwd, host });
762
1243
  if (result.skipped) {
763
1244
  return {
764
1245
  trace,
@@ -796,28 +1277,38 @@ export async function runAllSuites(options) {
796
1277
  return suiteName === options.filter || suitePath.includes(`/${options.filter}/`);
797
1278
  })
798
1279
  : suitePaths;
1280
+ const cliHosts = uniqueHosts(options.hosts ?? (options.host ? [options.host] : undefined));
799
1281
  const reports = [];
800
1282
  for (const suitePath of filtered) {
801
- reports.push(await runSuite({
802
- cwd: options.cwd,
803
- suitePath,
804
- host: options.host,
805
- scenarioFilter: options.scenarioFilter,
806
- record: options.record,
807
- recordFixtures: options.recordFixtures,
808
- judge: options.judge,
809
- worktree: options.worktree,
810
- stagingSessionId: options.stagingSessionId,
811
- keepRecordings: options.keepRecordings,
812
- suitesDir: options.suitesDir,
813
- suiteFilter: options.filter,
814
- timeoutMs: options.timeoutMs,
815
- allowUserInput: options.allowUserInput,
816
- debug: options.debug,
817
- debugDir: options.debugDir,
818
- scenarioRetries: options.scenarioRetries,
819
- rubricsDir: options.rubricsDir,
820
- }));
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
+ }
821
1312
  }
822
1313
  return reports;
823
1314
  }