@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
@@ -1,8 +1,7 @@
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 { summarizeReports } from "./suite-summary.js";
6
5
  function escapeHtml(value) {
7
6
  return value
8
7
  .replaceAll("&", "&")
@@ -11,11 +10,19 @@ function escapeHtml(value) {
11
10
  .replaceAll('"', """)
12
11
  .replaceAll("'", "'");
13
12
  }
13
+ const INTEGER_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
14
+ const DECIMAL_FORMAT = new Intl.NumberFormat("en-US", {
15
+ minimumFractionDigits: 1,
16
+ maximumFractionDigits: 1,
17
+ });
18
+ function formatInteger(value) {
19
+ return INTEGER_FORMAT.format(Math.round(value));
20
+ }
14
21
  function formatDuration(ms) {
15
22
  if (ms < 1000) {
16
- return `${Math.round(ms)}ms`;
23
+ return `${formatInteger(ms)}ms`;
17
24
  }
18
- return `${(ms / 1000).toFixed(1)}s`;
25
+ return `${DECIMAL_FORMAT.format(ms / 1000)}s`;
19
26
  }
20
27
  function statusLabel(result) {
21
28
  if (result.skipped) {
@@ -29,62 +36,95 @@ function statusClass(result) {
29
36
  }
30
37
  return result.passed ? "status-passed" : "status-failed";
31
38
  }
39
+ const MISSING_REQUIREMENT = {
40
+ label: "Missing requirement",
41
+ hint: "A required string did not appear in assistant text, commands, or tool results.",
42
+ };
43
+ const FORBIDDEN_BEHAVIOR = {
44
+ label: "Forbidden behavior",
45
+ hint: "The agent did something it was told not to do.",
46
+ };
47
+ const MISSING_COMMAND = {
48
+ label: "Missing command",
49
+ hint: "An expected command never ran.",
50
+ };
51
+ const MISSING_TOOL = {
52
+ label: "Missing tool call",
53
+ hint: "An expected tool was never invoked.",
54
+ };
55
+ const FORBIDDEN_TOOL = {
56
+ label: "Forbidden tool call",
57
+ hint: "A tool the scenario forbids was invoked.",
58
+ };
59
+ const MISSING_SKILL = {
60
+ label: "Missing skill",
61
+ hint: "The agent did not read a skill it was expected to use.",
62
+ };
63
+ const UNEXPECTED_SKILL = {
64
+ label: "Unexpected skill",
65
+ hint: "The agent read a skill it should have avoided.",
66
+ };
67
+ const MISSING_READ_PATH = {
68
+ label: "Missing read path",
69
+ hint: "Expected a Read tool call whose args mention this path fragment (registry-first / grounding).",
70
+ };
71
+ const FORBIDDEN_READ_PATH = {
72
+ label: "Forbidden read path",
73
+ hint: "A successful Read returned content from a path the scenario forbids (miss attempts do not fail).",
74
+ };
75
+ const ROUTING = {
76
+ label: "Routing",
77
+ hint: "The routing announcement did not match expectations.",
78
+ };
32
79
  /** Human-readable label + one-line explanation for the matcher codes assertRubric/judge emit. */
33
80
  const MATCHER_LABELS = {
34
81
  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.",
82
+ liveScenario: {
83
+ label: "Isolated scenario",
84
+ hint: "The scenario subprocess exited before it wrote a result.",
48
85
  },
49
- toHaveCalledTool: {
50
- label: "Missing tool call",
51
- hint: "An expected tool was never invoked.",
86
+ must: MISSING_REQUIREMENT,
87
+ mustInclude: MISSING_REQUIREMENT,
88
+ mustNot: FORBIDDEN_BEHAVIOR,
89
+ mustNotInclude: FORBIDDEN_BEHAVIOR,
90
+ mustRun: MISSING_COMMAND,
91
+ toHaveRunCommand: MISSING_COMMAND,
92
+ mustCallTool: MISSING_TOOL,
93
+ toHaveCalledTool: MISSING_TOOL,
94
+ mustNotCallTool: FORBIDDEN_TOOL,
95
+ toHaveNotCalledTool: FORBIDDEN_TOOL,
96
+ mustInvokeSkill: MISSING_SKILL,
97
+ toHaveInvokedSkill: MISSING_SKILL,
98
+ mustNotInvokeSkill: UNEXPECTED_SKILL,
99
+ toHaveNotInvokedSkill: UNEXPECTED_SKILL,
100
+ mustReadPath: MISSING_READ_PATH,
101
+ toHaveReadPath: MISSING_READ_PATH,
102
+ mustNotReadPath: FORBIDDEN_READ_PATH,
103
+ toHaveNotReadPath: FORBIDDEN_READ_PATH,
104
+ routingBlock: ROUTING,
105
+ toIncludeRoutingBlock: ROUTING,
106
+ toHaveRoutingBlockBeforeTools: ROUTING,
107
+ toHaveTier: ROUTING,
108
+ toHaveHandsOnTier: ROUTING,
109
+ toHaveHandsOnTierBeforeTools: ROUTING,
110
+ toHaveReviewDepth: {
111
+ label: "Review depth",
112
+ hint: "The review depth announcement did not match the rubric.",
52
113
  },
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).",
68
- },
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).",
72
- },
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
114
  workingTreeLeak: {
83
115
  label: "Working tree leak",
84
- hint: "The agent's edits leaked outside its isolated worktree.",
116
+ hint: "The agent used a path outside the sealed workspace, or edited the caller checkout.",
85
117
  },
86
118
  recordTrace: { label: "Recording failed", hint: "Saving the trace to disk failed." },
87
119
  judge: { label: "Judge", hint: "The LLM judge flagged this scenario." },
120
+ faster: {
121
+ label: "Faster arm",
122
+ hint: "The named arm must finish in less time.",
123
+ },
124
+ cheaper: {
125
+ label: "Cheaper arm",
126
+ hint: "The named arm must use fewer tokens.",
127
+ },
88
128
  };
89
129
  function humanizeMatcher(matcher) {
90
130
  const [base] = matcher.split(":");
@@ -153,23 +193,45 @@ function buildOrderedTimeline(trace) {
153
193
  }
154
194
  return items.sort((a, b) => a.seq - b.seq);
155
195
  }
156
- function renderChat(trace) {
196
+ function promptAlreadyInTrace(trace, prompt) {
197
+ return (trace?.messages.some((message) => message.role === "user" && message.content.trim() === prompt) ?? false);
198
+ }
199
+ function renderPromptBubble(prompt, trace) {
200
+ const text = prompt?.trim();
201
+ if (!text || promptAlreadyInTrace(trace, text)) {
202
+ return "";
203
+ }
204
+ return renderMessageBubble({ role: "user", content: text });
205
+ }
206
+ function renderChat(trace, prompt) {
207
+ const promptBubble = renderPromptBubble(prompt, trace);
157
208
  if (!trace) {
209
+ if (promptBubble) {
210
+ return `<div class="chat">${promptBubble}</div>`;
211
+ }
158
212
  return `<p class="empty">No transcript recorded for this scenario.</p>`;
159
213
  }
160
214
  const parts = [];
161
215
  const timeline = buildOrderedTimeline(trace);
162
216
  if (timeline) {
163
217
  parts.push(`<div class="chat">`);
218
+ if (promptBubble) {
219
+ parts.push(promptBubble);
220
+ }
164
221
  for (const item of timeline) {
165
222
  parts.push(item.kind === "message" ? renderMessageBubble(item.message) : renderToolCallCard(item.call));
166
223
  }
167
224
  parts.push(`</div>`);
168
225
  }
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) {
226
+ else if (trace.messages.length > 0 || trace.toolCalls.length > 0 || promptBubble) {
227
+ if (!timeline && (trace.messages.length > 0 || trace.toolCalls.length > 0)) {
228
+ parts.push(`<p class="empty note">Emission order wasn't recorded for this tracemessages and tool calls are shown in separate groups below.</p>`);
229
+ }
230
+ if (promptBubble || trace.messages.length > 0) {
172
231
  parts.push(`<div class="chat">`);
232
+ if (promptBubble) {
233
+ parts.push(promptBubble);
234
+ }
173
235
  for (const message of trace.messages) {
174
236
  parts.push(renderMessageBubble(message));
175
237
  }
@@ -243,14 +305,14 @@ function formatTokensBadge(result) {
243
305
  return undefined;
244
306
  }
245
307
  if (typeof usage.totalTokens === "number") {
246
- return `${usage.totalTokens} tok`;
308
+ return `${formatInteger(usage.totalTokens)} tokens`;
247
309
  }
248
310
  const parts = [];
249
311
  if (typeof usage.inputTokens === "number") {
250
- parts.push(`in ${usage.inputTokens}`);
312
+ parts.push(`in ${formatInteger(usage.inputTokens)}`);
251
313
  }
252
314
  if (typeof usage.outputTokens === "number") {
253
- parts.push(`out ${usage.outputTokens}`);
315
+ parts.push(`out ${formatInteger(usage.outputTokens)}`);
254
316
  }
255
317
  return parts.length > 0 ? parts.join(" · ") : undefined;
256
318
  }
@@ -271,7 +333,7 @@ function renderUsageDetail(usage) {
271
333
  return "";
272
334
  }
273
335
  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>`)
336
+ .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${formatInteger(value ?? 0)}</span></div>`)
275
337
  .join("");
276
338
  return `<section class="scenario-meta"><h3>Token usage</h3><div class="meta-grid">${items}</div></section>`;
277
339
  }
@@ -282,62 +344,66 @@ function renderTraceMeta(result) {
282
344
  }
283
345
  const skills = trace.skillsInvoked?.length ? trace.skillsInvoked.join(", ") : "(none)";
284
346
  const items = [
285
- ["Messages", String(trace.messages.length)],
286
- ["Tool calls", String(trace.toolCalls.length)],
347
+ ["Messages", formatInteger(trace.messages.length)],
348
+ ["Tool calls", formatInteger(trace.toolCalls.length)],
287
349
  ["Skills invoked", skills],
288
350
  ["Routing tier", trace.routing?.tier ?? "(none)"],
289
351
  ];
290
352
  if (result.attempts !== undefined && result.attempts > 1) {
291
- items.push(["Attempts", String(result.attempts)]);
353
+ items.push(["Attempts", formatInteger(result.attempts)]);
292
354
  }
293
355
  const html = items
294
356
  .map(([label, value]) => `<div class="meta-item"><span class="meta-key">${escapeHtml(label)}</span><span class="meta-val">${escapeHtml(value)}</span></div>`)
295
357
  .join("");
296
358
  return `<section class="scenario-meta"><h3>Trace stats</h3><div class="meta-grid">${html}</div></section>`;
297
359
  }
298
- function renderUsageStatsBlock(usage, title = "Token usage") {
299
- if (!usage) {
360
+ function costItem(label, detail, value) {
361
+ 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>`;
362
+ }
363
+ function renderCostSection(usage) {
364
+ if (!usage || usage.sumTotalTokens === undefined) {
300
365
  return "";
301
366
  }
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
- }
367
+ const items = [costItem("Total", "Cost of this run", formatInteger(usage.sumTotalTokens))];
314
368
  if (usage.sumInputTokens !== undefined) {
315
- rows.push(["Sum input", String(usage.sumInputTokens)]);
369
+ items.push(costItem("In", "Prompt and context the host sent", formatInteger(usage.sumInputTokens)));
316
370
  }
317
371
  if (usage.sumOutputTokens !== undefined) {
318
- rows.push(["Sum output", String(usage.sumOutputTokens)]);
372
+ items.push(costItem("Out", "Text the agent wrote", formatInteger(usage.sumOutputTokens)));
319
373
  }
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>`;
374
+ if (usage.scenariosWithUsage > 1 && usage.p50TotalTokens !== undefined) {
375
+ items.push(costItem("Typical", "Middle scenario. Half cost less than this.", formatInteger(usage.p50TotalTokens)));
376
+ }
377
+ if (usage.scenariosWithUsage > 1 && usage.maxTotalTokens !== undefined) {
378
+ items.push(costItem("Largest", "Heaviest scenario", formatInteger(usage.maxTotalTokens)));
379
+ }
380
+ return `<section class="cost" aria-labelledby="cost-heading">
381
+ <h2 id="cost-heading">Token cost</h2>
382
+ <p class="cost-lede">Tokens are cost, not the verdict. Pass and fail sit on each scenario.</p>
383
+ <div class="cost-grid">${items.join("")}</div>
384
+ </section>`;
324
385
  }
325
386
  function formatSigned(value) {
326
387
  if (value === undefined) {
327
388
  return "n/a";
328
389
  }
329
390
  if (value > 0) {
330
- return `+${value}`;
391
+ return `+${formatInteger(value)}`;
392
+ }
393
+ if (value < 0) {
394
+ return `-${formatInteger(Math.abs(value))}`;
331
395
  }
332
- return String(value);
396
+ return formatInteger(value);
333
397
  }
334
- function passCell(passed, skipped) {
335
- if (skipped) {
336
- return `<span class="badge status-skipped">skip</span>`;
398
+ function formatSignedDuration(deltaMs) {
399
+ if (deltaMs === undefined) {
400
+ return "n/a";
401
+ }
402
+ if (deltaMs === 0) {
403
+ return formatDuration(0);
337
404
  }
338
- return passed
339
- ? `<span class="badge status-passed">pass</span>`
340
- : `<span class="badge status-failed">fail</span>`;
405
+ const sign = deltaMs > 0 ? "+" : "-";
406
+ return `${sign}${formatDuration(Math.abs(deltaMs))}`;
341
407
  }
342
408
  function deltaClass(value) {
343
409
  if (value === undefined || value === 0) {
@@ -345,122 +411,116 @@ function deltaClass(value) {
345
411
  }
346
412
  return value > 0 ? "delta-up" : "delta-down";
347
413
  }
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>
414
+ function armDurationMs(arm) {
415
+ return typeof arm.durationMs === "number" ? arm.durationMs : undefined;
416
+ }
417
+ function armToolCount(arm) {
418
+ return arm.trace ? arm.trace.toolCalls.length : undefined;
419
+ }
420
+ function formatArmDuration(arm) {
421
+ const ms = armDurationMs(arm);
422
+ return ms === undefined ? "n/a" : formatDuration(ms);
423
+ }
424
+ function formatArmTokens(arm, key) {
425
+ const usage = arm.trace?.usage;
426
+ const value = key === "total"
427
+ ? usage?.totalTokens
428
+ : key === "input"
429
+ ? usage?.inputTokens
430
+ : usage?.outputTokens;
431
+ return typeof value === "number" ? formatInteger(value) : "n/a";
432
+ }
433
+ function tokenDelta(a, b, key) {
434
+ const pick = (arm) => {
435
+ const usage = arm.trace?.usage;
436
+ return key === "total"
437
+ ? usage?.totalTokens
438
+ : key === "input"
439
+ ? usage?.inputTokens
440
+ : usage?.outputTokens;
441
+ };
442
+ const aValue = pick(a);
443
+ const bValue = pick(b);
444
+ if (typeof aValue !== "number" || typeof bValue !== "number") {
445
+ return undefined;
446
+ }
447
+ return bValue - aValue;
448
+ }
449
+ function renderCompareMetricRow(label, aText, bText, delta, deltaText) {
450
+ return `<tr>
451
+ <th scope="row">${escapeHtml(label)}</th>
452
+ <td>${escapeHtml(aText)}</td>
453
+ <td>${escapeHtml(bText)}</td>
454
+ <td class="${deltaClass(delta)}">${escapeHtml(deltaText)}</td>
377
455
  </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>`
385
- : "";
456
+ }
457
+ function renderCompareMetrics(result) {
458
+ const compare = result.compare;
459
+ if (!compare) {
460
+ return "";
461
+ }
462
+ const aMs = armDurationMs(compare.a);
463
+ const bMs = armDurationMs(compare.b);
464
+ const durationDelta = aMs !== undefined && bMs !== undefined ? bMs - aMs : undefined;
465
+ const aTools = armToolCount(compare.a);
466
+ const bTools = armToolCount(compare.b);
467
+ const toolDelta = aTools !== undefined && bTools !== undefined ? bTools - aTools : undefined;
468
+ const totalDelta = tokenDelta(compare.a, compare.b, "total");
386
469
  return `
387
470
  <section class="compare">
388
471
  <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>
472
+ <h3>Comparison</h3>
473
+ <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
474
  </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}
399
475
  <div class="compare-table-wrap">
400
476
  <table class="compare-table">
401
477
  <thead>
402
478
  <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>
479
+ <th>Metric</th>
480
+ <th>${escapeHtml(compare.a.label)}</th>
481
+ <th>${escapeHtml(compare.b.label)}</th>
482
+ <th>Δ</th>
411
483
  </tr>
412
484
  </thead>
413
485
  <tbody>
414
- ${rows}
486
+ ${renderCompareMetricRow("Duration", formatArmDuration(compare.a), formatArmDuration(compare.b), durationDelta, formatSignedDuration(durationDelta))}
487
+ ${renderCompareMetricRow("Tokens", formatArmTokens(compare.a, "total"), formatArmTokens(compare.b, "total"), totalDelta, formatSigned(totalDelta))}
488
+ ${renderCompareMetricRow("In", formatArmTokens(compare.a, "input"), formatArmTokens(compare.b, "input"), tokenDelta(compare.a, compare.b, "input"), formatSigned(tokenDelta(compare.a, compare.b, "input")))}
489
+ ${renderCompareMetricRow("Out", formatArmTokens(compare.a, "output"), formatArmTokens(compare.b, "output"), tokenDelta(compare.a, compare.b, "output"), formatSigned(tokenDelta(compare.a, compare.b, "output")))}
490
+ ${renderCompareMetricRow("Tools", aTools === undefined ? "n/a" : formatInteger(aTools), bTools === undefined ? "n/a" : formatInteger(bTools), toolDelta, formatSigned(toolDelta))}
415
491
  </tbody>
416
492
  </table>
417
493
  </div>
418
494
  </section>`;
419
495
  }
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>
436
- </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>
448
- </div>
449
- ${renderCompareHtmlSection(report)}
450
- </main>
451
- </body>
452
- </html>
453
- `;
496
+ function renderStoryList(title, lines) {
497
+ if (!lines || lines.length === 0) {
498
+ return "";
499
+ }
500
+ const items = lines.map((line) => `<li>${escapeHtml(line)}</li>`).join("");
501
+ return `<section class="story-block"><h3>${escapeHtml(title)}</h3><ul class="story-list">${items}</ul></section>`;
502
+ }
503
+ function renderStory(result) {
504
+ const story = result.story;
505
+ if (!story) {
506
+ return "";
507
+ }
508
+ return `<div class="story">
509
+ ${renderStoryList("Criteria", story.criteria)}
510
+ ${renderStoryList("Result", [...story.result, ...story.verdict])}
511
+ </div>`;
454
512
  }
455
513
  function renderScenario(result) {
456
514
  const open = result.passed || result.skipped ? "" : " open";
457
- const failures = renderFailures(result);
515
+ const failures = result.story ? "" : renderFailures(result);
458
516
  const judgeVerdicts = renderJudgeVerdicts(result);
459
517
  const tokens = formatTokensBadge(result);
460
518
  const usageDetail = renderUsageDetail(usageOf(result));
461
519
  const traceMeta = renderTraceMeta(result);
462
- const diagnostics = failures || judgeVerdicts
520
+ const story = renderStory(result);
521
+ const diagnostics = story || failures || judgeVerdicts
463
522
  ? `<div class="diagnostics">
523
+ ${story}
464
524
  ${failures ? `<section><h3>What went wrong</h3>${failures}</section>` : ""}
465
525
  ${judgeVerdicts ? `<section><h3>Judge verdict</h3>${judgeVerdicts}</section>` : ""}
466
526
  </div>`
@@ -479,122 +539,142 @@ function renderScenario(result) {
479
539
  <div class="scenario-body">
480
540
  ${diagnostics}
481
541
  ${metaRow}
542
+ ${renderCompareMetrics(result)}
543
+ ${result.compare
544
+ ? `<section class="conversation">
545
+ <h3>Conversation · ${escapeHtml(result.compare.a.label)}</h3>
546
+ ${renderChat(result.compare.a.trace, result.compare.a.prompt)}
547
+ </section>
482
548
  <section class="conversation">
549
+ <h3>Conversation · ${escapeHtml(result.compare.b.label)}</h3>
550
+ ${renderChat(result.compare.b.trace, result.compare.b.prompt)}
551
+ </section>`
552
+ : `<section class="conversation">
483
553
  <h3>Conversation</h3>
484
- ${renderChat(result.trace)}
485
- </section>
554
+ ${renderChat(result.trace, result.prompt)}
555
+ </section>`}
486
556
  </div>
487
557
  </details>`;
488
558
  }
489
559
  function renderSuite(report) {
490
560
  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
561
  return `
496
562
  <section class="suite">
497
563
  <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}
564
+ <div class="suite-title">
565
+ <h2>${escapeHtml(report.suite)}</h2>
566
+ <span class="host">${escapeHtml(report.host)}</span>
502
567
  </div>
568
+ <p class="suite-counts"><span>${formatInteger(report.passed)} passed</span><span>${formatInteger(report.failed)} failed</span><span>${formatInteger(report.skipped)} skipped</span></p>
503
569
  </header>
504
- ${usage ? renderUsageStatsBlock(usage, "Suite token usage") : ""}
505
570
  ${scenarios}
506
571
  </section>`;
507
572
  }
508
573
  function sharedReportCss() {
509
574
  return `
575
+ @layer reset, base, layout, components;
510
576
  :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;
527
- }
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;
535
- }
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;
577
+ color-scheme: dark;
578
+ --bg: oklch(0.18 0.02 250);
579
+ --panel: oklch(0.23 0.025 250);
580
+ --panel-2: oklch(0.2 0.022 250);
581
+ --text: oklch(0.93 0.015 250);
582
+ --muted: oklch(0.72 0.03 250);
583
+ --border: oklch(0.32 0.03 250);
584
+ --pass: oklch(0.8 0.16 155);
585
+ --fail: oklch(0.72 0.16 20);
586
+ --skip: oklch(0.84 0.14 85);
587
+ --user-bubble: oklch(0.3 0.06 250);
588
+ --assistant-bubble: oklch(0.26 0.04 155);
589
+ --system-bubble: oklch(0.26 0.04 300);
590
+ --tool: oklch(0.82 0.12 80);
591
+ --tool-bubble: oklch(0.26 0.04 80);
555
592
  }
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;
593
+ @layer reset {
594
+ * { box-sizing: border-box; }
595
+ body { margin: 0; }
596
+ h1, h2, h3, h4, p, ol { margin: 0; }
597
+ dl, dd { margin: 0; }
569
598
  }
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;
599
+ @layer base {
600
+ body {
601
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
602
+ background: var(--bg);
603
+ color: var(--text);
604
+ line-height: 1.5;
605
+ word-spacing: 0.16em;
606
+ }
607
+ main { max-width: 68rem; margin-inline: auto; padding: 1.5rem 1.25rem 3rem; }
608
+ h1 { font-size: 1.7rem; font-weight: 650; letter-spacing: -0.02em; }
609
+ h2 { font-size: 1.05rem; font-weight: 650; }
610
+ h3 { font-size: 0.8rem; color: var(--muted); font-weight: 650; }
611
+ h4 { font-size: 0.75rem; margin-block: 0.9rem 0.35rem; color: var(--muted); font-weight: 650; }
612
+ .muted { color: var(--muted); }
613
+ .empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
614
+ .empty.note { margin-bottom: 0.5rem; }
587
615
  }
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;
616
+ @layer layout {
617
+ .report-header { display: grid; gap: 0.25rem; margin-bottom: 1rem; }
618
+ .brand { color: var(--muted); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
619
+ .lede, .when { color: var(--muted); font-size: 0.85rem; }
620
+ .summary, .cost, .guide, .compare {
621
+ background: var(--panel);
622
+ border: 1px solid var(--border);
623
+ border-radius: 12px;
624
+ padding: 0.9rem 1rem;
625
+ }
626
+ .summary { display: grid; gap: 0.65rem; }
627
+ .stats { display: flex; flex-wrap: wrap; gap: 0.5rem; }
628
+ .suite { margin-top: 1.75rem; display: grid; gap: 0.55rem; }
629
+ .suite-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; }
630
+ .suite-title { display: flex; align-items: center; gap: 0.5rem; }
631
+ .cost { margin-top: 0.85rem; display: grid; gap: 0.65rem; }
632
+ .cost-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.75rem; }
633
+ .guide { margin-top: 0.85rem; }
634
+ .diagnostics { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0.7rem; margin-bottom: 0.9rem; }
635
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.45rem 0.75rem; }
593
636
  }
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; }
637
+ @layer components {
638
+ .stat {
639
+ display: inline-flex;
640
+ align-items: baseline;
641
+ gap: 0.35rem;
642
+ padding: 0.4rem 0.65rem;
643
+ border-radius: 8px;
644
+ background: var(--panel-2);
645
+ font-size: 0.85rem;
646
+ }
647
+ .stat strong { font-size: 1.15rem; font-variant-numeric: tabular-nums; }
648
+ .stat-pass strong { color: var(--pass); }
649
+ .stat-fail strong { color: var(--fail); }
650
+ .stat-skip strong { color: var(--skip); }
651
+ .guide h2 { font-size: 0.95rem; }
652
+ .guide ol { margin: 0.65rem 0 0; padding-inline-start: 1.2rem; display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.88rem; }
653
+ .cost-lede { color: var(--muted); font-size: 0.85rem; }
654
+ .cost-item { display: grid; gap: 0.15rem; }
655
+ .cost-value { font-size: 1.2rem; font-weight: 650; font-variant-numeric: tabular-nums; }
656
+ .cost-label { font-size: 0.8rem; font-weight: 650; }
657
+ .cost-detail { color: var(--muted); font-size: 0.75rem; }
658
+ .host {
659
+ color: var(--muted);
660
+ font-size: 0.72rem;
661
+ background: var(--panel-2);
662
+ border: 1px solid var(--border);
663
+ border-radius: 999px;
664
+ padding: 0.1rem 0.5rem;
665
+ }
666
+ .suite-counts { display: flex; flex-wrap: wrap; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
667
+ .tokens { color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
668
+ .usage-summary, .scenario-meta {
669
+ background: var(--panel-2);
670
+ border: 1px solid var(--border);
671
+ border-radius: 8px;
672
+ padding: 0.65rem 0.7rem;
673
+ }
674
+ .meta-item { display: grid; gap: 0.1rem; }
675
+ .meta-key { color: var(--muted); font-size: 0.75rem; }
676
+ .meta-val { font-size: 0.88rem; font-variant-numeric: tabular-nums; word-break: break-word; }
677
+ .meta-row { margin-bottom: 0.7rem; }
598
678
  details.scenario {
599
679
  background: var(--panel);
600
680
  border: 1px solid var(--border);
@@ -633,6 +713,9 @@ function sharedReportCss() {
633
713
  .diagnostics section { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.7rem; min-width: 0; }
634
714
  .conversation { min-width: 0; }
635
715
 
716
+ .story { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.85rem; margin-bottom: 0.85rem; }
717
+ .story-block h3 { margin: 0 0 0.35rem; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
718
+ .story-list { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.25rem; color: var(--text); }
636
719
  .failures { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.55rem; }
637
720
  .failures li { border-left: 3px solid var(--fail); padding-left: 0.6rem; }
638
721
  .failure-label { margin: 0; font-weight: 600; font-size: 0.85rem; color: var(--fail); }
@@ -709,8 +792,7 @@ function sharedReportCss() {
709
792
  padding: 0.85rem 0.9rem 1rem;
710
793
  }
711
794
  .compare-header { margin-bottom: 0.65rem; }
712
- .compare-header h2 { margin-bottom: 0.2rem; }
713
- .compare-summary { margin-bottom: 0.75rem; }
795
+ .compare-header h3 { margin-bottom: 0.2rem; }
714
796
  .compare-table-wrap { overflow-x: auto; }
715
797
  .compare-table {
716
798
  width: 100%;
@@ -725,21 +807,17 @@ function sharedReportCss() {
725
807
  vertical-align: middle;
726
808
  }
727
809
  .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
810
  .delta-up { color: var(--fail); }
732
811
  .delta-down { color: var(--pass); }
733
812
  .delta-flat { color: var(--muted); }
734
813
 
735
814
  @media (max-width: 720px) {
736
815
  main { padding: 0.75rem; }
737
- .summary { align-items: flex-start; flex-direction: column; }
738
- .summary dl { justify-content: flex-start; }
739
816
  .suite-header { align-items: flex-start; flex-direction: column; gap: 0.2rem; }
740
817
  .diagnostics { grid-template-columns: 1fr; }
741
818
  .bubble, .tool-card { max-width: 92%; }
742
819
  }
820
+ }
743
821
  `;
744
822
  }
745
823
  /** Build a self-contained HTML report for a completed suite run. */
@@ -748,22 +826,11 @@ export function renderHtmlReport(reports, meta = {}) {
748
826
  const totalPassed = reports.reduce((sum, report) => sum + report.passed, 0);
749
827
  const totalFailed = reports.reduce((sum, report) => sum + report.failed, 0);
750
828
  const totalSkipped = reports.reduce((sum, report) => sum + report.skipped, 0);
751
- const host = meta.host ?? reports[0]?.host ?? "unknown";
829
+ const hostNames = [...new Set(reports.map((report) => report.host).filter(Boolean))];
830
+ const host = meta.host ?? (hostNames.length > 0 ? hostNames.join(", ") : "unknown");
752
831
  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
832
  const suitesHtml = reports.map(renderSuite).join("\n");
833
+ const suiteWord = reports.length === 1 ? "suite" : "suites";
767
834
  return `<!DOCTYPE html>
768
835
  <html lang="en">
769
836
  <head>
@@ -775,32 +842,28 @@ export function renderHtmlReport(reports, meta = {}) {
775
842
  <body>
776
843
  <main>
777
844
  <header class="report-header">
778
- <h1>agent-test report</h1>
779
- <span class="report-kicker">${reports.length} suite${reports.length === 1 ? "" : "s"}</span>
845
+ <p class="brand">agent-test</p>
846
+ <h1>Run report</h1>
847
+ <p class="lede">${escapeHtml(String(host))} · ${formatInteger(reports.length)} ${suiteWord}${meta.suitesDir ? ` · ${escapeHtml(meta.suitesDir)}` : ""}</p>
780
848
  </header>
781
- <div class="summary">
849
+ <section class="summary" aria-label="Run verdict">
782
850
  <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
- : ""}
851
+ <span class="stat stat-pass"><strong>${formatInteger(totalPassed)}</strong><span>passed</span></span>
852
+ <span class="stat stat-fail"><strong>${formatInteger(totalFailed)}</strong><span>failed</span></span>
853
+ <span class="stat stat-skip"><strong>${formatInteger(totalSkipped)}</strong><span>skipped</span></span>
789
854
  </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}
855
+ <p class="when">Generated ${escapeHtml(generatedAt.toISOString())}</p>
856
+ </section>
857
+ <section class="guide" aria-labelledby="guide-heading">
858
+ <h2 id="guide-heading">How to read this report</h2>
859
+ <ol>
860
+ <li>The verdict is pass or fail. Tokens are cost, not the score.</li>
861
+ <li>Open a scenario for the criteria and the result. A compare scenario shows a time and token table, then two conversations.</li>
862
+ <li>Typical is the middle scenario cost. Largest is the heaviest scenario.</li>
863
+ <li>In is prompt and context. Out is generated text.</li>
864
+ </ol>
865
+ </section>
866
+ ${renderCostSection(runUsage)}
804
867
  ${suitesHtml}
805
868
  </main>
806
869
  </body>