@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
@@ -1,8 +1,8 @@
1
1
  import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { compareSuiteReports, } from "./compare.js";
5
- import { formatUsageStats, summarizeReportResults, summarizeReports } from "./suite-summary.js";
4
+ import { describeCompareOutcome } from "./compare-scenario.js";
5
+ import { summarizeReports } from "./suite-summary.js";
6
6
  function escapeHtml(value) {
7
7
  return value
8
8
  .replaceAll("&", "&")
@@ -11,11 +11,19 @@ function escapeHtml(value) {
11
11
  .replaceAll('"', """)
12
12
  .replaceAll("'", "'");
13
13
  }
14
+ const INTEGER_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
15
+ const DECIMAL_FORMAT = new Intl.NumberFormat("en-US", {
16
+ minimumFractionDigits: 1,
17
+ maximumFractionDigits: 1,
18
+ });
19
+ function formatInteger(value) {
20
+ return INTEGER_FORMAT.format(Math.round(value));
21
+ }
14
22
  function formatDuration(ms) {
15
23
  if (ms < 1000) {
16
- return `${Math.round(ms)}ms`;
24
+ return `${formatInteger(ms)}ms`;
17
25
  }
18
- return `${(ms / 1000).toFixed(1)}s`;
26
+ return `${DECIMAL_FORMAT.format(ms / 1000)}s`;
19
27
  }
20
28
  function statusLabel(result) {
21
29
  if (result.skipped) {
@@ -29,62 +37,95 @@ function statusClass(result) {
29
37
  }
30
38
  return result.passed ? "status-passed" : "status-failed";
31
39
  }
40
+ const MISSING_REQUIREMENT = {
41
+ label: "Missing requirement",
42
+ hint: "A required string did not appear in assistant text, commands, or tool results.",
43
+ };
44
+ const FORBIDDEN_BEHAVIOR = {
45
+ label: "Forbidden behavior",
46
+ hint: "The agent did something it was told not to do.",
47
+ };
48
+ const MISSING_COMMAND = {
49
+ label: "Missing command",
50
+ hint: "An expected command never ran.",
51
+ };
52
+ const MISSING_TOOL = {
53
+ label: "Missing tool call",
54
+ hint: "An expected tool was never invoked.",
55
+ };
56
+ const FORBIDDEN_TOOL = {
57
+ label: "Forbidden tool call",
58
+ hint: "A tool the scenario forbids was invoked.",
59
+ };
60
+ const MISSING_SKILL = {
61
+ label: "Missing skill",
62
+ hint: "The agent did not read a skill it was expected to use.",
63
+ };
64
+ const UNEXPECTED_SKILL = {
65
+ label: "Unexpected skill",
66
+ hint: "The agent read a skill it should have avoided.",
67
+ };
68
+ const MISSING_READ_PATH = {
69
+ label: "Missing read path",
70
+ hint: "Expected a Read tool call whose args mention this path fragment (registry-first / grounding).",
71
+ };
72
+ const FORBIDDEN_READ_PATH = {
73
+ label: "Forbidden read path",
74
+ hint: "A successful Read returned content from a path the scenario forbids (miss attempts do not fail).",
75
+ };
76
+ const ROUTING = {
77
+ label: "Routing",
78
+ hint: "The routing announcement did not match expectations.",
79
+ };
32
80
  /** Human-readable label + one-line explanation for the matcher codes assertRubric/judge emit. */
33
81
  const MATCHER_LABELS = {
34
82
  runAgent: { label: "Agent run", hint: "The agent session itself failed or was cut short." },
35
- must: {
36
- label: "Missing requirement",
37
- hint: "A required behavior did not show up in the transcript.",
38
- },
39
- mustNot: { label: "Forbidden behavior", hint: "The agent did something it was told not to do." },
40
- mustRun: { label: "Missing command", hint: "An expected command never ran." },
41
- mustCallTool: {
42
- label: "Missing tool call",
43
- hint: "An expected tool was never invoked.",
44
- },
45
- mustNotCallTool: {
46
- label: "Forbidden tool call",
47
- hint: "A tool the scenario forbids was invoked.",
48
- },
49
- toHaveCalledTool: {
50
- label: "Missing tool call",
51
- hint: "An expected tool was never invoked.",
52
- },
53
- toHaveNotCalledTool: {
54
- label: "Forbidden tool call",
55
- hint: "A tool the scenario forbids was invoked.",
56
- },
57
- mustInvokeSkill: {
58
- label: "Missing skill",
59
- hint: "The agent didn't read a skill it was expected to use.",
60
- },
61
- mustNotInvokeSkill: {
62
- label: "Unexpected skill",
63
- hint: "The agent read a skill it should have avoided.",
64
- },
65
- mustReadPath: {
66
- label: "Missing read path",
67
- hint: "Expected a Read tool call whose args mention this path fragment (registry-first / grounding).",
83
+ liveScenario: {
84
+ label: "Isolated scenario",
85
+ hint: "The scenario subprocess exited before it wrote a result.",
68
86
  },
69
- mustNotReadPath: {
70
- label: "Forbidden read path",
71
- hint: "A successful Read returned content from a path the scenario forbids (miss attempts do not fail).",
87
+ must: MISSING_REQUIREMENT,
88
+ mustInclude: MISSING_REQUIREMENT,
89
+ mustNot: FORBIDDEN_BEHAVIOR,
90
+ mustNotInclude: FORBIDDEN_BEHAVIOR,
91
+ mustRun: MISSING_COMMAND,
92
+ toHaveRunCommand: MISSING_COMMAND,
93
+ mustCallTool: MISSING_TOOL,
94
+ toHaveCalledTool: MISSING_TOOL,
95
+ mustNotCallTool: FORBIDDEN_TOOL,
96
+ toHaveNotCalledTool: FORBIDDEN_TOOL,
97
+ mustInvokeSkill: MISSING_SKILL,
98
+ toHaveInvokedSkill: MISSING_SKILL,
99
+ mustNotInvokeSkill: UNEXPECTED_SKILL,
100
+ toHaveNotInvokedSkill: UNEXPECTED_SKILL,
101
+ mustReadPath: MISSING_READ_PATH,
102
+ toHaveReadPath: MISSING_READ_PATH,
103
+ mustNotReadPath: FORBIDDEN_READ_PATH,
104
+ toHaveNotReadPath: FORBIDDEN_READ_PATH,
105
+ routingBlock: ROUTING,
106
+ toIncludeRoutingBlock: ROUTING,
107
+ toHaveRoutingBlockBeforeTools: ROUTING,
108
+ toHaveTier: ROUTING,
109
+ toHaveHandsOnTier: ROUTING,
110
+ toHaveHandsOnTierBeforeTools: ROUTING,
111
+ toHaveReviewDepth: {
112
+ label: "Review depth",
113
+ hint: "The review depth announcement did not match the rubric.",
72
114
  },
73
- toHaveReadPath: {
74
- label: "Missing read path",
75
- hint: "Expected a Read tool call whose args mention this path fragment (registry-first / grounding).",
76
- },
77
- toHaveNotReadPath: {
78
- label: "Forbidden read path",
79
- hint: "A successful Read returned content from a path the scenario forbids (miss attempts do not fail).",
80
- },
81
- routingBlock: { label: "Routing", hint: "The routing announcement didn't match expectations." },
82
115
  workingTreeLeak: {
83
116
  label: "Working tree leak",
84
- hint: "The agent's edits leaked outside its isolated worktree.",
117
+ hint: "The agent used a path outside the sealed workspace, or edited the caller checkout.",
85
118
  },
86
119
  recordTrace: { label: "Recording failed", hint: "Saving the trace to disk failed." },
87
120
  judge: { label: "Judge", hint: "The LLM judge flagged this scenario." },
121
+ faster: {
122
+ label: "Faster arm",
123
+ hint: "The named arm must finish in less time.",
124
+ },
125
+ cheaper: {
126
+ label: "Cheaper arm",
127
+ hint: "The named arm must use fewer tokens.",
128
+ },
88
129
  };
89
130
  function humanizeMatcher(matcher) {
90
131
  const [base] = matcher.split(":");
@@ -153,23 +194,45 @@ function buildOrderedTimeline(trace) {
153
194
  }
154
195
  return items.sort((a, b) => a.seq - b.seq);
155
196
  }
156
- function renderChat(trace) {
197
+ function promptAlreadyInTrace(trace, prompt) {
198
+ return (trace?.messages.some((message) => message.role === "user" && message.content.trim() === prompt) ?? false);
199
+ }
200
+ function renderPromptBubble(prompt, trace) {
201
+ const text = prompt?.trim();
202
+ if (!text || promptAlreadyInTrace(trace, text)) {
203
+ return "";
204
+ }
205
+ return renderMessageBubble({ role: "user", content: text });
206
+ }
207
+ function renderChat(trace, prompt) {
208
+ const promptBubble = renderPromptBubble(prompt, trace);
157
209
  if (!trace) {
210
+ if (promptBubble) {
211
+ return `<div class="chat">${promptBubble}</div>`;
212
+ }
158
213
  return `<p class="empty">No transcript recorded for this scenario.</p>`;
159
214
  }
160
215
  const parts = [];
161
216
  const timeline = buildOrderedTimeline(trace);
162
217
  if (timeline) {
163
218
  parts.push(`<div class="chat">`);
219
+ if (promptBubble) {
220
+ parts.push(promptBubble);
221
+ }
164
222
  for (const item of timeline) {
165
223
  parts.push(item.kind === "message" ? renderMessageBubble(item.message) : renderToolCallCard(item.call));
166
224
  }
167
225
  parts.push(`</div>`);
168
226
  }
169
- else if (trace.messages.length > 0 || trace.toolCalls.length > 0) {
170
- parts.push(`<p class="empty note">Emission order wasn't recorded for this tracemessages and tool calls are shown in separate groups below.</p>`);
171
- if (trace.messages.length > 0) {
227
+ else if (trace.messages.length > 0 || trace.toolCalls.length > 0 || promptBubble) {
228
+ if (!timeline && (trace.messages.length > 0 || trace.toolCalls.length > 0)) {
229
+ parts.push(`<p class="empty note">Emission order wasn't recorded for this tracemessages and tool calls are shown in separate groups below.</p>`);
230
+ }
231
+ if (promptBubble || trace.messages.length > 0) {
172
232
  parts.push(`<div class="chat">`);
233
+ if (promptBubble) {
234
+ parts.push(promptBubble);
235
+ }
173
236
  for (const message of trace.messages) {
174
237
  parts.push(renderMessageBubble(message));
175
238
  }
@@ -243,14 +306,14 @@ function formatTokensBadge(result) {
243
306
  return undefined;
244
307
  }
245
308
  if (typeof usage.totalTokens === "number") {
246
- return `${usage.totalTokens} tok`;
309
+ return `${formatInteger(usage.totalTokens)} tokens`;
247
310
  }
248
311
  const parts = [];
249
312
  if (typeof usage.inputTokens === "number") {
250
- parts.push(`in ${usage.inputTokens}`);
313
+ parts.push(`in ${formatInteger(usage.inputTokens)}`);
251
314
  }
252
315
  if (typeof usage.outputTokens === "number") {
253
- parts.push(`out ${usage.outputTokens}`);
316
+ parts.push(`out ${formatInteger(usage.outputTokens)}`);
254
317
  }
255
318
  return parts.length > 0 ? parts.join(" · ") : undefined;
256
319
  }
@@ -271,7 +334,7 @@ function renderUsageDetail(usage) {
271
334
  return "";
272
335
  }
273
336
  const items = present
274
- .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${value}</span></div>`)
337
+ .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${formatInteger(value ?? 0)}</span></div>`)
275
338
  .join("");
276
339
  return `<section class="scenario-meta"><h3>Token usage</h3><div class="meta-grid">${items}</div></section>`;
277
340
  }
@@ -282,62 +345,66 @@ function renderTraceMeta(result) {
282
345
  }
283
346
  const skills = trace.skillsInvoked?.length ? trace.skillsInvoked.join(", ") : "(none)";
284
347
  const items = [
285
- ["Messages", String(trace.messages.length)],
286
- ["Tool calls", String(trace.toolCalls.length)],
348
+ ["Messages", formatInteger(trace.messages.length)],
349
+ ["Tool calls", formatInteger(trace.toolCalls.length)],
287
350
  ["Skills invoked", skills],
288
351
  ["Routing tier", trace.routing?.tier ?? "(none)"],
289
352
  ];
290
353
  if (result.attempts !== undefined && result.attempts > 1) {
291
- items.push(["Attempts", String(result.attempts)]);
354
+ items.push(["Attempts", formatInteger(result.attempts)]);
292
355
  }
293
356
  const html = items
294
357
  .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${escapeHtml(value)}</span></div>`)
295
358
  .join("");
296
359
  return `<section class="scenario-meta"><h3>Trace stats</h3><div class="meta-grid">${html}</div></section>`;
297
360
  }
298
- function renderUsageStatsBlock(usage, title = "Token usage") {
299
- if (!usage) {
361
+ function costItem(label, detail, value) {
362
+ return `<div class="cost-item"><span class="cost-value">${escapeHtml(value)}</span><span class="cost-label">${escapeHtml(label)}</span><span class="cost-detail">${escapeHtml(detail)}</span></div>`;
363
+ }
364
+ function renderCostSection(usage) {
365
+ if (!usage || usage.sumTotalTokens === undefined) {
300
366
  return "";
301
367
  }
302
- const rows = [
303
- ["Scenarios with usage", String(usage.scenariosWithUsage)],
304
- ];
305
- if (usage.sumTotalTokens !== undefined) {
306
- rows.push(["Sum totalTokens", String(usage.sumTotalTokens)]);
307
- }
308
- if (usage.p50TotalTokens !== undefined) {
309
- rows.push(["p50 totalTokens", String(usage.p50TotalTokens)]);
310
- }
311
- if (usage.p95TotalTokens !== undefined) {
312
- rows.push(["p95 totalTokens", String(usage.p95TotalTokens)]);
313
- }
368
+ const items = [costItem("Total", "Cost of this run", formatInteger(usage.sumTotalTokens))];
314
369
  if (usage.sumInputTokens !== undefined) {
315
- rows.push(["Sum input", String(usage.sumInputTokens)]);
370
+ items.push(costItem("In", "Prompt and context the host sent", formatInteger(usage.sumInputTokens)));
316
371
  }
317
372
  if (usage.sumOutputTokens !== undefined) {
318
- rows.push(["Sum output", String(usage.sumOutputTokens)]);
373
+ items.push(costItem("Out", "Text the agent wrote", formatInteger(usage.sumOutputTokens)));
319
374
  }
320
- const items = rows
321
- .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${escapeHtml(value)}</span></div>`)
322
- .join("");
323
- return `<section class="usage-summary"><h3>${escapeHtml(title)}</h3><div class="meta-grid">${items}</div><p class="usage-compact muted">${escapeHtml(formatUsageStats(usage))}</p></section>`;
375
+ if (usage.scenariosWithUsage > 1 && usage.p50TotalTokens !== undefined) {
376
+ items.push(costItem("Typical", "Middle scenario. Half cost less than this.", formatInteger(usage.p50TotalTokens)));
377
+ }
378
+ if (usage.scenariosWithUsage > 1 && usage.maxTotalTokens !== undefined) {
379
+ items.push(costItem("Largest", "Heaviest scenario", formatInteger(usage.maxTotalTokens)));
380
+ }
381
+ return `<section class="cost" aria-labelledby="cost-heading">
382
+ <h2 id="cost-heading">Token cost</h2>
383
+ <p class="cost-lede">Tokens are cost, not the verdict. Pass and fail sit on each scenario.</p>
384
+ <div class="cost-grid">${items.join("")}</div>
385
+ </section>`;
324
386
  }
325
387
  function formatSigned(value) {
326
388
  if (value === undefined) {
327
389
  return "n/a";
328
390
  }
329
391
  if (value > 0) {
330
- return `+${value}`;
392
+ return `+${formatInteger(value)}`;
393
+ }
394
+ if (value < 0) {
395
+ return `-${formatInteger(Math.abs(value))}`;
331
396
  }
332
- return String(value);
397
+ return formatInteger(value);
333
398
  }
334
- function passCell(passed, skipped) {
335
- if (skipped) {
336
- return `<span class="badge status-skipped">skip</span>`;
399
+ function formatSignedDuration(deltaMs) {
400
+ if (deltaMs === undefined) {
401
+ return "n/a";
402
+ }
403
+ if (deltaMs === 0) {
404
+ return formatDuration(0);
337
405
  }
338
- return passed
339
- ? `<span class="badge status-passed">pass</span>`
340
- : `<span class="badge status-failed">fail</span>`;
406
+ const sign = deltaMs > 0 ? "+" : "-";
407
+ return `${sign}${formatDuration(Math.abs(deltaMs))}`;
341
408
  }
342
409
  function deltaClass(value) {
343
410
  if (value === undefined || value === 0) {
@@ -345,122 +412,181 @@ function deltaClass(value) {
345
412
  }
346
413
  return value > 0 ? "delta-up" : "delta-down";
347
414
  }
348
- function compareScenarioLabel(row) {
349
- if (row.aScenario && row.bScenario && row.aScenario !== row.bScenario) {
350
- return `${row.scenario}<br><span class="muted compare-aliases">A: ${escapeHtml(row.aScenario)}<br>B: ${escapeHtml(row.bScenario)}</span>`;
351
- }
352
- return escapeHtml(row.scenario);
353
- }
354
- /** Render an A/B compare table (fragment — no document chrome). */
355
- export function renderCompareHtmlSection(report) {
356
- const rows = report.paired
357
- .map((row) => {
358
- const regression = row.a.passed && !row.b.passed;
359
- const improvement = !row.a.passed && row.b.passed;
360
- const rowClass = regression
361
- ? "compare-regress"
362
- : improvement
363
- ? "compare-improve"
364
- : row.deltas.passedChanged
365
- ? "compare-changed"
366
- : "";
367
- return `
368
- <tr class="${rowClass}">
369
- <td class="compare-name">${compareScenarioLabel(row)}</td>
370
- <td>${passCell(row.a.passed, row.a.skipped)}</td>
371
- <td>${passCell(row.b.passed, row.b.skipped)}</td>
372
- <td class="${deltaClass(row.deltas.durationMs)}">${escapeHtml(formatSigned(row.deltas.durationMs))}</td>
373
- <td class="${deltaClass(row.deltas.toolCallCount)}">${escapeHtml(formatSigned(row.deltas.toolCallCount))}</td>
374
- <td class="${deltaClass(row.deltas.skillCount)}">${escapeHtml(formatSigned(row.deltas.skillCount))}</td>
375
- <td class="${deltaClass(row.deltas.registryHopCount)}">${escapeHtml(formatSigned(row.deltas.registryHopCount))}</td>
376
- <td class="${deltaClass(row.deltas.totalTokens)}">${escapeHtml(formatSigned(row.deltas.totalTokens))}</td>
415
+ function armDurationMs(arm) {
416
+ return typeof arm.durationMs === "number" ? arm.durationMs : undefined;
417
+ }
418
+ function armToolCount(arm) {
419
+ return arm.trace ? arm.trace.toolCalls.length : undefined;
420
+ }
421
+ function formatArmDuration(arm) {
422
+ const ms = armDurationMs(arm);
423
+ return ms === undefined ? "n/a" : formatDuration(ms);
424
+ }
425
+ function formatArmTokens(arm, key) {
426
+ const usage = arm.trace?.usage;
427
+ const value = key === "total"
428
+ ? usage?.totalTokens
429
+ : key === "input"
430
+ ? usage?.inputTokens
431
+ : usage?.outputTokens;
432
+ return typeof value === "number" ? formatInteger(value) : "n/a";
433
+ }
434
+ function tokenDelta(a, b, key) {
435
+ const pick = (arm) => {
436
+ const usage = arm.trace?.usage;
437
+ return key === "total"
438
+ ? usage?.totalTokens
439
+ : key === "input"
440
+ ? usage?.inputTokens
441
+ : usage?.outputTokens;
442
+ };
443
+ const aValue = pick(a);
444
+ const bValue = pick(b);
445
+ if (typeof aValue !== "number" || typeof bValue !== "number") {
446
+ return undefined;
447
+ }
448
+ return bValue - aValue;
449
+ }
450
+ function winnerCellClass(delta, side) {
451
+ if (delta === undefined || delta === 0) {
452
+ return "";
453
+ }
454
+ if (side === "a") {
455
+ return delta > 0 ? "is-better" : "is-worse";
456
+ }
457
+ return delta < 0 ? "is-better" : "is-worse";
458
+ }
459
+ function renderCompareMetricRow(label, aText, bText, delta, deltaText) {
460
+ return `<tr>
461
+ <th scope="row">${escapeHtml(label)}</th>
462
+ <td class="${winnerCellClass(delta, "a")}">${escapeHtml(aText)}</td>
463
+ <td class="${winnerCellClass(delta, "b")}">${escapeHtml(bText)}</td>
464
+ <td class="${deltaClass(delta)}">${escapeHtml(deltaText)}</td>
377
465
  </tr>`;
378
- })
379
- .join("\n");
380
- const onlyA = report.onlyInA.length > 0
381
- ? `<p class="muted">Only in A (${escapeHtml(report.aLabel)}): ${report.onlyInA.map((n) => escapeHtml(n)).join(", ")}</p>`
382
- : "";
383
- const onlyB = report.onlyInB.length > 0
384
- ? `<p class="muted">Only in B (${escapeHtml(report.bLabel)}): ${report.onlyInB.map((n) => escapeHtml(n)).join(", ")}</p>`
466
+ }
467
+ function renderCompareMetrics(result) {
468
+ const compare = result.compare;
469
+ if (!compare) {
470
+ return "";
471
+ }
472
+ const aMs = armDurationMs(compare.a);
473
+ const bMs = armDurationMs(compare.b);
474
+ const durationDelta = aMs !== undefined && bMs !== undefined ? bMs - aMs : undefined;
475
+ const aTools = armToolCount(compare.a);
476
+ const bTools = armToolCount(compare.b);
477
+ const toolDelta = aTools !== undefined && bTools !== undefined ? bTools - aTools : undefined;
478
+ const totalDelta = tokenDelta(compare.a, compare.b, "total");
479
+ const callouts = describeCompareOutcome(compare);
480
+ const calloutList = callouts.length > 0
481
+ ? `<ul class="compare-callouts">${callouts.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`
385
482
  : "";
386
483
  return `
387
484
  <section class="compare">
388
485
  <header class="compare-header">
389
- <h2>A/B compare</h2>
390
- <p class="muted">${escapeHtml(report.aLabel)} ${escapeHtml(report.bLabel)} · ${report.summary.pairedCount} paired · regressions ${report.summary.passRegressions} · improvements ${report.summary.passImprovements}</p>
486
+ <h3>Comparison</h3>
487
+ <p class="muted">${escapeHtml(compare.a.label)} vs ${escapeHtml(compare.b.label)}. Δ is B minus A. A lower time and a lower token count is better.</p>
391
488
  </header>
392
- <div class="compare-summary meta-grid">
393
- <div class="meta-item"><span class="meta-key">Mean Δ durationMs</span><span class="meta-val">${escapeHtml(formatSigned(report.summary.meanDurationDeltaMs !== undefined ? Math.round(report.summary.meanDurationDeltaMs) : undefined))}</span></div>
394
- <div class="meta-item"><span class="meta-key">Mean Δ tools</span><span class="meta-val">${escapeHtml(formatSigned(report.summary.meanToolCallDelta !== undefined ? Math.round(report.summary.meanToolCallDelta) : undefined))}</span></div>
395
- <div class="meta-item"><span class="meta-key">Mean Δ totalTokens</span><span class="meta-val">${escapeHtml(formatSigned(report.summary.meanTotalTokensDelta !== undefined ? Math.round(report.summary.meanTotalTokensDelta) : undefined))}</span></div>
396
- </div>
397
- ${onlyA}
398
- ${onlyB}
489
+ ${calloutList}
399
490
  <div class="compare-table-wrap">
400
491
  <table class="compare-table">
401
492
  <thead>
402
493
  <tr>
403
- <th>Scenario</th>
404
- <th>A</th>
405
- <th>B</th>
406
- <th>Δ duration</th>
407
- <th>Δ tools</th>
408
- <th>Δ skills</th>
409
- <th>Δ registry</th>
410
- <th>Δ tokens</th>
494
+ <th>Metric</th>
495
+ <th>${escapeHtml(compare.a.label)}</th>
496
+ <th>${escapeHtml(compare.b.label)}</th>
497
+ <th>Δ</th>
411
498
  </tr>
412
499
  </thead>
413
500
  <tbody>
414
- ${rows}
501
+ ${renderCompareMetricRow("Duration", formatArmDuration(compare.a), formatArmDuration(compare.b), durationDelta, formatSignedDuration(durationDelta))}
502
+ ${renderCompareMetricRow("Tokens", formatArmTokens(compare.a, "total"), formatArmTokens(compare.b, "total"), totalDelta, formatSigned(totalDelta))}
503
+ ${renderCompareMetricRow("In", formatArmTokens(compare.a, "input"), formatArmTokens(compare.b, "input"), tokenDelta(compare.a, compare.b, "input"), formatSigned(tokenDelta(compare.a, compare.b, "input")))}
504
+ ${renderCompareMetricRow("Out", formatArmTokens(compare.a, "output"), formatArmTokens(compare.b, "output"), tokenDelta(compare.a, compare.b, "output"), formatSigned(tokenDelta(compare.a, compare.b, "output")))}
505
+ ${renderCompareMetricRow("Tools", aTools === undefined ? "n/a" : formatInteger(aTools), bTools === undefined ? "n/a" : formatInteger(bTools), toolDelta, formatSigned(toolDelta))}
415
506
  </tbody>
416
507
  </table>
417
508
  </div>
418
509
  </section>`;
419
510
  }
420
- /** Standalone compare HTML document (written next to compare-report.json / .md). */
421
- export function renderCompareHtmlReport(report, meta = {}) {
422
- const generatedAt = meta.generatedAt ?? new Date();
423
- return `<!DOCTYPE html>
424
- <html lang="en">
425
- <head>
426
- <meta charset="utf-8" />
427
- <meta name="viewport" content="width=device-width, initial-scale=1" />
428
- <title>agent-test compare — ${escapeHtml(report.aLabel)} vs ${escapeHtml(report.bLabel)}</title>
429
- <style>${sharedReportCss()}</style>
430
- </head>
431
- <body>
432
- <main>
433
- <header class="report-header">
434
- <h1>agent-test compare</h1>
435
- <span class="report-kicker">${escapeHtml(report.aLabel)} vs ${escapeHtml(report.bLabel)}</span>
511
+ function renderArmMetrics(arm) {
512
+ const parts = [];
513
+ const ms = armDurationMs(arm);
514
+ if (ms !== undefined) {
515
+ parts.push(formatDuration(ms));
516
+ }
517
+ const tokens = formatArmTokens(arm, "total");
518
+ if (tokens !== "n/a") {
519
+ parts.push(`${tokens} tokens`);
520
+ }
521
+ const tools = armToolCount(arm);
522
+ if (tools !== undefined) {
523
+ parts.push(tools === 1 ? "1 tool" : `${formatInteger(tools)} tools`);
524
+ }
525
+ if (parts.length === 0) {
526
+ return "";
527
+ }
528
+ return `<p class="compare-arm-metrics">${escapeHtml(parts.join(" · "))}</p>`;
529
+ }
530
+ function renderArmColumn(arm, side) {
531
+ const description = arm.description
532
+ ? `<p class="compare-arm-description">${escapeHtml(arm.description)}</p>`
533
+ : "";
534
+ return `
535
+ <article class="compare-arm compare-arm-${side}">
536
+ <header class="compare-arm-header">
537
+ <p class="compare-arm-kicker">${side === "a" ? "Arm A" : "Arm B"}</p>
538
+ <h3>${escapeHtml(arm.label)}</h3>
539
+ ${description}
540
+ ${renderArmMetrics(arm)}
436
541
  </header>
437
- <div class="summary">
438
- <div class="stats">
439
- <span class="stat"><strong>${report.summary.pairedCount}</strong> paired</span>
440
- <span class="stat stat-fail"><strong>${report.summary.passRegressions}</strong> regressions</span>
441
- <span class="stat stat-pass"><strong>${report.summary.passImprovements}</strong> improvements</span>
442
- </div>
443
- <dl>
444
- <dt>Generated</dt><dd>${escapeHtml(generatedAt.toISOString())}</dd>
445
- <dt>A</dt><dd>${escapeHtml(report.aSuite)}</dd>
446
- <dt>B</dt><dd>${escapeHtml(report.bSuite)}</dd>
447
- </dl>
542
+ ${renderChat(arm.trace, arm.prompt)}
543
+ </article>`;
544
+ }
545
+ function renderCompareConversations(result) {
546
+ const compare = result.compare;
547
+ if (!compare) {
548
+ return `<section class="conversation">
549
+ <h3>Conversation</h3>
550
+ ${renderChat(result.trace, result.prompt)}
551
+ </section>`;
552
+ }
553
+ return `
554
+ <div class="compare-layout">
555
+ <div class="compare-arms">
556
+ ${renderArmColumn(compare.a, "a")}
557
+ ${renderArmColumn(compare.b, "b")}
448
558
  </div>
449
- ${renderCompareHtmlSection(report)}
450
- </main>
451
- </body>
452
- </html>
453
- `;
559
+ ${renderCompareMetrics(result)}
560
+ </div>`;
561
+ }
562
+ function renderStoryList(title, lines) {
563
+ if (!lines || lines.length === 0) {
564
+ return "";
565
+ }
566
+ const items = lines.map((line) => `<li>${escapeHtml(line)}</li>`).join("");
567
+ return `<section class="story-block"><h3>${escapeHtml(title)}</h3><ul class="story-list">${items}</ul></section>`;
568
+ }
569
+ function renderStory(result) {
570
+ const story = result.story;
571
+ if (!story) {
572
+ return "";
573
+ }
574
+ return `<div class="story">
575
+ ${renderStoryList("Criteria", story.criteria)}
576
+ ${renderStoryList("Result", [...story.result, ...story.verdict])}
577
+ </div>`;
454
578
  }
455
579
  function renderScenario(result) {
456
- const open = result.passed || result.skipped ? "" : " open";
457
- const failures = renderFailures(result);
580
+ const open = result.skipped ? "" : !result.passed || result.compare ? " open" : "";
581
+ const failures = result.story ? "" : renderFailures(result);
458
582
  const judgeVerdicts = renderJudgeVerdicts(result);
459
583
  const tokens = formatTokensBadge(result);
460
- const usageDetail = renderUsageDetail(usageOf(result));
461
- const traceMeta = renderTraceMeta(result);
462
- const diagnostics = failures || judgeVerdicts
584
+ const usageDetail = result.compare ? "" : renderUsageDetail(usageOf(result));
585
+ const traceMeta = result.compare ? "" : renderTraceMeta(result);
586
+ const story = renderStory(result);
587
+ const diagnostics = story || failures || judgeVerdicts
463
588
  ? `<div class="diagnostics">
589
+ ${story}
464
590
  ${failures ? `<section><h3>What went wrong</h3>${failures}</section>` : ""}
465
591
  ${judgeVerdicts ? `<section><h3>Judge verdict</h3>${judgeVerdicts}</section>` : ""}
466
592
  </div>`
@@ -468,133 +594,150 @@ function renderScenario(result) {
468
594
  const metaRow = usageDetail || traceMeta
469
595
  ? `<div class="diagnostics meta-row">${usageDetail}${traceMeta}</div>`
470
596
  : "";
597
+ const compareClass = result.compare ? " compare-scenario" : "";
598
+ const description = result.description
599
+ ? `<span class="scenario-lede">${escapeHtml(result.description)}</span>`
600
+ : "";
471
601
  return `
472
- <details class="scenario ${statusClass(result)}"${open}>
602
+ <details class="scenario ${statusClass(result)}${compareClass}"${open}>
473
603
  <summary>
474
604
  <span class="badge ${statusClass(result)}">${statusLabel(result)}</span>
475
- <span class="scenario-name">${escapeHtml(result.scenario)}</span>
605
+ <span class="scenario-heading">
606
+ <span class="scenario-name">${escapeHtml(result.scenario)}</span>
607
+ ${description}
608
+ </span>
476
609
  ${tokens ? `<span class="tokens">${escapeHtml(tokens)}</span>` : ""}
477
610
  <span class="duration">${escapeHtml(formatDuration(result.durationMs))}</span>
478
611
  </summary>
479
612
  <div class="scenario-body">
480
613
  ${diagnostics}
481
614
  ${metaRow}
482
- <section class="conversation">
483
- <h3>Conversation</h3>
484
- ${renderChat(result.trace)}
485
- </section>
615
+ ${renderCompareConversations(result)}
486
616
  </div>
487
617
  </details>`;
488
618
  }
489
619
  function renderSuite(report) {
490
620
  const scenarios = report.results.map(renderScenario).join("\n");
491
- const usage = (report.summary ?? summarizeReportResults(report.results)).usage;
492
- const usageLine = usage
493
- ? `<p class="suite-usage">${escapeHtml(formatUsageStats(usage))}</p>`
494
- : "";
495
621
  return `
496
622
  <section class="suite">
497
623
  <header class="suite-header">
498
- <div><h2>${escapeHtml(report.suite)}</h2><span class="host">${escapeHtml(report.host)}</span></div>
499
- <div class="suite-meta">
500
- <p class="suite-counts">${report.passed} passed · ${report.failed} failed · ${report.skipped} skipped</p>
501
- ${usageLine}
624
+ <div class="suite-title">
625
+ <h2>${escapeHtml(report.suite)}</h2>
626
+ <span class="host">${escapeHtml(report.host)}</span>
502
627
  </div>
628
+ <p class="suite-counts"><span>${formatInteger(report.passed)} passed</span><span>${formatInteger(report.failed)} failed</span><span>${formatInteger(report.skipped)} skipped</span></p>
503
629
  </header>
504
- ${usage ? renderUsageStatsBlock(usage, "Suite token usage") : ""}
505
630
  ${scenarios}
506
631
  </section>`;
507
632
  }
508
633
  function sharedReportCss() {
509
634
  return `
635
+ @layer reset, base, layout, components;
510
636
  :root {
511
- --bg: #0f1419;
512
- --panel: #1a2332;
513
- --panel-2: #141c28;
514
- --text: #e7ecf3;
515
- --muted: #8b9bb4;
516
- --border: #2a3548;
517
- --pass: #3dd68c;
518
- --fail: #f07178;
519
- --skip: #ffcc66;
520
- --user-bubble: #24406b;
521
- --assistant-bubble: #1f2f26;
522
- --system-bubble: #241f38;
523
- --tool: #e0af68;
524
- --tool-bubble: #2a2214;
525
- --improve: #3dd68c;
526
- --regress: #f07178;
637
+ color-scheme: dark;
638
+ --bg: oklch(0.18 0.02 250);
639
+ --panel: oklch(0.23 0.025 250);
640
+ --panel-2: oklch(0.2 0.022 250);
641
+ --text: oklch(0.93 0.015 250);
642
+ --muted: oklch(0.72 0.03 250);
643
+ --border: oklch(0.32 0.03 250);
644
+ --pass: oklch(0.8 0.16 155);
645
+ --fail: oklch(0.72 0.16 20);
646
+ --skip: oklch(0.84 0.14 85);
647
+ --user-bubble: oklch(0.3 0.06 250);
648
+ --assistant-bubble: oklch(0.26 0.04 155);
649
+ --system-bubble: oklch(0.26 0.04 300);
650
+ --tool: oklch(0.82 0.12 80);
651
+ --tool-bubble: oklch(0.26 0.04 80);
652
+ --arm-a: oklch(0.72 0.12 250);
653
+ --arm-b: oklch(0.78 0.14 75);
527
654
  }
528
- * { box-sizing: border-box; }
529
- body {
530
- margin: 0;
531
- font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
532
- background: var(--bg);
533
- color: var(--text);
534
- line-height: 1.45;
655
+ @layer reset {
656
+ * { box-sizing: border-box; }
657
+ body { margin: 0; }
658
+ h1, h2, h3, h4, p, ol { margin: 0; }
659
+ dl, dd { margin: 0; }
535
660
  }
536
- main { max-width: 1180px; margin: 0 auto; padding: 1.25rem 1.25rem 3rem; }
537
- h1 { font-size: 1.45rem; margin: 0; letter-spacing: -0.02em; }
538
- h2 { font-size: 1.1rem; margin: 0; }
539
- h3 { font-size: 0.78rem; margin: 0 0 0.5rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.07em; }
540
- h4 { font-size: 0.72rem; margin: 0.9rem 0 0.35rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
541
- .muted { color: var(--muted); }
542
- .empty { color: var(--muted); font-size: 0.85rem; font-style: italic; margin: 0; }
543
- .empty.note { margin-bottom: 0.5rem; }
544
- .report-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.65rem; }
545
- .report-kicker { color: var(--muted); font-size: 0.78rem; }
546
- .summary {
547
- background: var(--panel);
548
- border: 1px solid var(--border);
549
- border-radius: 10px;
550
- padding: 0.7rem 0.9rem;
551
- display: flex;
552
- align-items: center;
553
- justify-content: space-between;
554
- gap: 1rem;
555
- }
556
- .stats { display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap; }
557
- .stat { padding: 0.3rem 0.55rem; border-radius: 6px; background: #111923; font-size: 0.8rem; }
558
- .stat strong { font-size: 1rem; margin-right: 0.25rem; }
559
- .stat-pass strong { color: var(--pass); }
560
- .stat-fail strong { color: var(--fail); }
561
- .stat-skip strong { color: var(--skip); }
562
- .summary dl {
563
- display: flex;
564
- flex-wrap: wrap;
565
- justify-content: flex-end;
566
- gap: 0.15rem 1rem;
567
- margin: 0;
568
- font-size: 0.78rem;
569
- }
570
- .summary dt { color: var(--muted); margin-right: -0.7rem; }
571
- .summary dd { margin: 0; }
572
- .report-usage { margin-top: 0.75rem; }
573
- .suite { margin-top: 1.25rem; }
574
- .suite-header { display: flex; align-items: end; justify-content: space-between; margin: 0 0 0.5rem; padding: 0 0.2rem; }
575
- .suite-header > div { display: flex; align-items: center; gap: 0.5rem; }
576
- .host { color: var(--muted); font-size: 0.72rem; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: 0.1rem 0.4rem; }
577
- .suite-meta { text-align: right; }
578
- .suite-counts { color: var(--muted); margin: 0; font-size: 0.78rem; }
579
- .suite-usage { color: var(--muted); margin: 0.15rem 0 0; font-size: 0.72rem; font-variant-numeric: tabular-nums; }
580
- .tokens { color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
581
- .usage-summary, .scenario-meta {
582
- background: var(--panel-2);
583
- border: 1px solid var(--border);
584
- border-radius: 8px;
585
- padding: 0.65rem 0.7rem;
586
- margin: 0 0 0.7rem;
661
+ @layer base {
662
+ body {
663
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
664
+ background: var(--bg);
665
+ color: var(--text);
666
+ line-height: 1.5;
667
+ word-spacing: 0.16em;
668
+ }
669
+ main { max-width: 68rem; margin-inline: auto; padding: 1.5rem 1.25rem 3rem; }
670
+ main:has(.compare-layout) { max-width: 96rem; }
671
+ h1 { font-size: 1.7rem; font-weight: 650; letter-spacing: -0.02em; }
672
+ h2 { font-size: 1.05rem; font-weight: 650; }
673
+ h3 { font-size: 0.8rem; color: var(--muted); font-weight: 650; }
674
+ h4 { font-size: 0.75rem; margin-block: 0.9rem 0.35rem; color: var(--muted); font-weight: 650; }
675
+ .muted { color: var(--muted); }
676
+ .empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
677
+ .empty.note { margin-bottom: 0.5rem; }
587
678
  }
588
- .usage-compact { margin: 0.45rem 0 0; font-size: 0.72rem; font-variant-numeric: tabular-nums; }
589
- .meta-grid {
590
- display: grid;
591
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
592
- gap: 0.35rem 0.75rem;
679
+ @layer layout {
680
+ .report-header { display: grid; gap: 0.25rem; margin-bottom: 1rem; }
681
+ .brand { color: var(--muted); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
682
+ .lede, .when { color: var(--muted); font-size: 0.85rem; }
683
+ .summary, .cost, .guide, .compare {
684
+ background: var(--panel);
685
+ border: 1px solid var(--border);
686
+ border-radius: 12px;
687
+ padding: 0.9rem 1rem;
688
+ }
689
+ .summary { display: grid; gap: 0.65rem; }
690
+ .stats { display: flex; flex-wrap: wrap; gap: 0.5rem; }
691
+ .suite { margin-top: 1.75rem; display: grid; gap: 0.55rem; }
692
+ .suite-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; }
693
+ .suite-title { display: flex; align-items: center; gap: 0.5rem; }
694
+ .cost { margin-top: 0.85rem; display: grid; gap: 0.65rem; }
695
+ .cost-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.75rem; }
696
+ .guide { margin-top: 0.85rem; }
697
+ .diagnostics { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0.7rem; margin-bottom: 0.9rem; }
698
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.45rem 0.75rem; }
593
699
  }
594
- .meta-item { display: flex; flex-direction: column; gap: 0.1rem; }
595
- .meta-key { color: var(--muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; }
596
- .meta-val { font-size: 0.88rem; font-variant-numeric: tabular-nums; word-break: break-word; }
597
- .meta-row { margin-bottom: 0.7rem; }
700
+ @layer components {
701
+ .stat {
702
+ display: inline-flex;
703
+ align-items: baseline;
704
+ gap: 0.35rem;
705
+ padding: 0.4rem 0.65rem;
706
+ border-radius: 8px;
707
+ background: var(--panel-2);
708
+ font-size: 0.85rem;
709
+ }
710
+ .stat strong { font-size: 1.15rem; font-variant-numeric: tabular-nums; }
711
+ .stat-pass strong { color: var(--pass); }
712
+ .stat-fail strong { color: var(--fail); }
713
+ .stat-skip strong { color: var(--skip); }
714
+ .guide h2 { font-size: 0.95rem; }
715
+ .guide ol { margin: 0.65rem 0 0; padding-inline-start: 1.2rem; display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.88rem; }
716
+ .cost-lede { color: var(--muted); font-size: 0.85rem; }
717
+ .cost-item { display: grid; gap: 0.15rem; }
718
+ .cost-value { font-size: 1.2rem; font-weight: 650; font-variant-numeric: tabular-nums; }
719
+ .cost-label { font-size: 0.8rem; font-weight: 650; }
720
+ .cost-detail { color: var(--muted); font-size: 0.75rem; }
721
+ .host {
722
+ color: var(--muted);
723
+ font-size: 0.72rem;
724
+ background: var(--panel-2);
725
+ border: 1px solid var(--border);
726
+ border-radius: 999px;
727
+ padding: 0.1rem 0.5rem;
728
+ }
729
+ .suite-counts { display: flex; flex-wrap: wrap; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
730
+ .tokens { color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
731
+ .usage-summary, .scenario-meta {
732
+ background: var(--panel-2);
733
+ border: 1px solid var(--border);
734
+ border-radius: 8px;
735
+ padding: 0.65rem 0.7rem;
736
+ }
737
+ .meta-item { display: grid; gap: 0.1rem; }
738
+ .meta-key { color: var(--muted); font-size: 0.75rem; }
739
+ .meta-val { font-size: 0.88rem; font-variant-numeric: tabular-nums; word-break: break-word; }
740
+ .meta-row { margin-bottom: 0.7rem; }
598
741
  details.scenario {
599
742
  background: var(--panel);
600
743
  border: 1px solid var(--border);
@@ -613,7 +756,9 @@ function sharedReportCss() {
613
756
  }
614
757
  details.scenario[open] summary { border-bottom: 1px solid var(--border); background: #17202d; }
615
758
  details.scenario summary::-webkit-details-marker { display: none; }
759
+ .scenario-heading { display: grid; gap: 0.1rem; min-width: 12rem; flex: 1 1 16rem; }
616
760
  .scenario-name { font-weight: 600; font-size: 0.9rem; }
761
+ .scenario-lede { color: var(--muted); font-size: 0.78rem; font-weight: 400; }
617
762
  .duration { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
618
763
  .badge {
619
764
  display: inline-block;
@@ -633,6 +778,9 @@ function sharedReportCss() {
633
778
  .diagnostics section { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.7rem; min-width: 0; }
634
779
  .conversation { min-width: 0; }
635
780
 
781
+ .story { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.85rem; margin-bottom: 0.85rem; }
782
+ .story-block h3 { margin: 0 0 0.35rem; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
783
+ .story-list { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.25rem; color: var(--text); }
636
784
  .failures { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.55rem; }
637
785
  .failures li { border-left: 3px solid var(--fail); padding-left: 0.6rem; }
638
786
  .failure-label { margin: 0; font-weight: 600; font-size: 0.85rem; color: var(--fail); }
@@ -701,16 +849,61 @@ function sharedReportCss() {
701
849
  .shell-commands li { font-size: 0.8rem; background: #0b1017; border: 1px solid var(--border); border-radius: 6px; padding: 0.3rem 0.5rem; }
702
850
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; }
703
851
 
852
+ .compare-layout {
853
+ container-type: inline-size;
854
+ display: grid;
855
+ gap: 0.9rem;
856
+ }
857
+ .compare-arms {
858
+ display: grid;
859
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
860
+ gap: 0.85rem;
861
+ align-items: stretch;
862
+ }
863
+ .compare-arm {
864
+ min-width: 0;
865
+ display: grid;
866
+ align-content: start;
867
+ gap: 0.65rem;
868
+ background: var(--panel-2);
869
+ border: 1px solid var(--border);
870
+ border-radius: 10px;
871
+ padding: 0.75rem 0.8rem 0.85rem;
872
+ border-block-start-width: 3px;
873
+ }
874
+ .compare-arm-a { border-block-start-color: var(--arm-a); }
875
+ .compare-arm-b { border-block-start-color: var(--arm-b); }
876
+ .compare-arm-header { display: grid; gap: 0.15rem; padding-bottom: 0.55rem; border-bottom: 1px solid var(--border); }
877
+ .compare-arm-kicker {
878
+ color: var(--muted);
879
+ font-size: 0.66rem;
880
+ font-weight: 700;
881
+ letter-spacing: 0.06em;
882
+ text-transform: uppercase;
883
+ }
884
+ .compare-arm-a .compare-arm-kicker { color: var(--arm-a); }
885
+ .compare-arm-b .compare-arm-kicker { color: var(--arm-b); }
886
+ .compare-arm-header h3 { color: var(--text); font-size: 0.95rem; }
887
+ .compare-arm-description { color: var(--muted); font-size: 0.8rem; }
888
+ .compare-arm-metrics { color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
889
+ .compare-arm .bubble, .compare-arm .tool-card { max-width: 100%; }
890
+
704
891
  .compare {
705
- margin-top: 1.1rem;
892
+ margin-top: 0;
706
893
  background: var(--panel);
707
894
  border: 1px solid var(--border);
708
895
  border-radius: 10px;
709
896
  padding: 0.85rem 0.9rem 1rem;
710
897
  }
711
898
  .compare-header { margin-bottom: 0.65rem; }
712
- .compare-header h2 { margin-bottom: 0.2rem; }
713
- .compare-summary { margin-bottom: 0.75rem; }
899
+ .compare-header h3 { margin-bottom: 0.2rem; }
900
+ .compare-callouts {
901
+ margin: 0 0 0.75rem;
902
+ padding-left: 1.1rem;
903
+ display: grid;
904
+ gap: 0.25rem;
905
+ font-size: 0.85rem;
906
+ }
714
907
  .compare-table-wrap { overflow-x: auto; }
715
908
  .compare-table {
716
909
  width: 100%;
@@ -725,20 +918,23 @@ function sharedReportCss() {
725
918
  vertical-align: middle;
726
919
  }
727
920
  .compare-table th { color: var(--muted); font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; }
728
- .compare-name { font-weight: 600; }
729
- .compare-regress { background: color-mix(in srgb, var(--regress) 10%, transparent); }
730
- .compare-improve { background: color-mix(in srgb, var(--improve) 10%, transparent); }
731
921
  .delta-up { color: var(--fail); }
732
922
  .delta-down { color: var(--pass); }
733
923
  .delta-flat { color: var(--muted); }
924
+ .is-better { color: var(--pass); font-weight: 650; }
925
+ .is-worse { color: var(--muted); }
926
+
927
+ @container (max-width: 44rem) {
928
+ .compare-arms { grid-template-columns: minmax(0, 1fr); }
929
+ }
734
930
 
735
931
  @media (max-width: 720px) {
736
932
  main { padding: 0.75rem; }
737
- .summary { align-items: flex-start; flex-direction: column; }
738
- .summary dl { justify-content: flex-start; }
739
933
  .suite-header { align-items: flex-start; flex-direction: column; gap: 0.2rem; }
740
934
  .diagnostics { grid-template-columns: 1fr; }
741
935
  .bubble, .tool-card { max-width: 92%; }
936
+ .compare-arms { grid-template-columns: minmax(0, 1fr); }
937
+ }
742
938
  }
743
939
  `;
744
940
  }
@@ -748,22 +944,11 @@ export function renderHtmlReport(reports, meta = {}) {
748
944
  const totalPassed = reports.reduce((sum, report) => sum + report.passed, 0);
749
945
  const totalFailed = reports.reduce((sum, report) => sum + report.failed, 0);
750
946
  const totalSkipped = reports.reduce((sum, report) => sum + report.skipped, 0);
751
- const host = meta.host ?? reports[0]?.host ?? "unknown";
947
+ const hostNames = [...new Set(reports.map((report) => report.host).filter(Boolean))];
948
+ const host = meta.host ?? (hostNames.length > 0 ? hostNames.join(", ") : "unknown");
752
949
  const runUsage = summarizeReports(reports).usage;
753
- // Embed A/B only when explicitly requested (compare-pairs / compare labels), not for every 2-suite run.
754
- const includeCompare = reports.length === 2 &&
755
- (meta.includeCompare === true ||
756
- meta.compareALabel !== undefined ||
757
- meta.compareBLabel !== undefined);
758
- const compareSection = includeCompare && reports[0] && reports[1]
759
- ? renderCompareHtmlSection(compareSuiteReports({
760
- aLabel: meta.compareALabel ?? reports[0].suite,
761
- bLabel: meta.compareBLabel ?? reports[1].suite,
762
- a: reports[0],
763
- b: reports[1],
764
- }))
765
- : "";
766
950
  const suitesHtml = reports.map(renderSuite).join("\n");
951
+ const suiteWord = reports.length === 1 ? "suite" : "suites";
767
952
  return `<!DOCTYPE html>
768
953
  <html lang="en">
769
954
  <head>
@@ -775,32 +960,28 @@ export function renderHtmlReport(reports, meta = {}) {
775
960
  <body>
776
961
  <main>
777
962
  <header class="report-header">
778
- <h1>agent-test report</h1>
779
- <span class="report-kicker">${reports.length} suite${reports.length === 1 ? "" : "s"}</span>
963
+ <p class="brand">agent-test</p>
964
+ <h1>Run report</h1>
965
+ <p class="lede">${escapeHtml(String(host))} · ${formatInteger(reports.length)} ${suiteWord}${meta.suitesDir ? ` · ${escapeHtml(meta.suitesDir)}` : ""}</p>
780
966
  </header>
781
- <div class="summary">
967
+ <section class="summary" aria-label="Run verdict">
782
968
  <div class="stats">
783
- <span class="stat stat-pass"><strong>${totalPassed}</strong> passed</span>
784
- <span class="stat stat-fail"><strong>${totalFailed}</strong> failed</span>
785
- <span class="stat stat-skip"><strong>${totalSkipped}</strong> skipped</span>
786
- ${runUsage?.sumTotalTokens !== undefined
787
- ? `<span class="stat"><strong>${runUsage.sumTotalTokens}</strong> tokens</span>`
788
- : ""}
969
+ <span class="stat stat-pass"><strong>${formatInteger(totalPassed)}</strong><span>passed</span></span>
970
+ <span class="stat stat-fail"><strong>${formatInteger(totalFailed)}</strong><span>failed</span></span>
971
+ <span class="stat stat-skip"><strong>${formatInteger(totalSkipped)}</strong><span>skipped</span></span>
789
972
  </div>
790
- <dl>
791
- <dt>Host</dt><dd>${escapeHtml(String(host))}</dd>
792
- <dt>Generated</dt><dd>${escapeHtml(generatedAt.toISOString())}</dd>
793
- ${meta.suitesDir ? `<dt>Suites</dt><dd>${escapeHtml(meta.suitesDir)}</dd>` : ""}
794
- ${runUsage?.p50TotalTokens !== undefined
795
- ? `<dt>p50 tokens</dt><dd>${runUsage.p50TotalTokens}</dd>`
796
- : ""}
797
- ${runUsage?.p95TotalTokens !== undefined
798
- ? `<dt>p95 tokens</dt><dd>${runUsage.p95TotalTokens}</dd>`
799
- : ""}
800
- </dl>
801
- </div>
802
- ${runUsage ? `<div class="report-usage">${renderUsageStatsBlock(runUsage, "Run token usage")}</div>` : ""}
803
- ${compareSection}
973
+ <p class="when">Generated ${escapeHtml(generatedAt.toISOString())}</p>
974
+ </section>
975
+ <section class="guide" aria-labelledby="guide-heading">
976
+ <h2 id="guide-heading">How to read this report</h2>
977
+ <ol>
978
+ <li>The verdict is pass or fail. Tokens are cost, not the score.</li>
979
+ <li>Open a scenario for the criteria and the result. A compare scenario shows the two arms side by side, then a comparison of time, tokens, and tools.</li>
980
+ <li>Typical is the middle scenario cost. Largest is the heaviest scenario.</li>
981
+ <li>In is prompt and context. Out is generated text.</li>
982
+ </ol>
983
+ </section>
984
+ ${renderCostSection(runUsage)}
804
985
  ${suitesHtml}
805
986
  </main>
806
987
  </body>