diff --git a/core/src/execute/aggregate.ts b/core/src/execute/aggregate.ts
index 2aee517..f3c23b8 100644
--- a/core/src/execute/aggregate.ts
+++ b/core/src/execute/aggregate.ts
@@ -141,12 +141,18 @@ export function buildUnifiedReport(
};
}
-/** Format a "provider/model" label, falling back to the attacker model for the judge. */
+/**
+ * Format a "provider/model" label, falling back to the attacker model for the judge.
+ * `openai-compatible` is a generic custom-baseURL wrapper, not a real vendor identity,
+ * so it's dropped and only the model name is shown for that provider.
+ */
export function modelLabel(
attackerLlm: { provider: string; model: string },
judgeLlm?: { provider: string; model: string }
): { attackModel: string; judgeModel: string } {
- const attackModel = `${attackerLlm.provider}/${attackerLlm.model}`;
- const judgeModel = judgeLlm ? `${judgeLlm.provider}/${judgeLlm.model}` : attackModel;
+ const format = (llm: { provider: string; model: string }): string =>
+ llm.provider === "openai-compatible" ? llm.model : `${llm.provider}/${llm.model}`;
+ const attackModel = format(attackerLlm);
+ const judgeModel = judgeLlm ? format(judgeLlm) : attackModel;
return { attackModel, judgeModel };
}
diff --git a/core/src/report/render.ts b/core/src/report/render.ts
index 6c5a5be..005e9f8 100644
--- a/core/src/report/render.ts
+++ b/core/src/report/render.ts
@@ -3,6 +3,7 @@
* Shared by the CLI, MCP server, SDK, and browser extension runners.
*/
import type { ReportViewModel, ResultViewModel, TurnViewModel, DetailCard } from "./types.js";
+import { formatStandardsLabel } from "../evaluators/standards.js";
/** Format a token count for display (e.g. 51300 → "51.3K"). */
function formatTokenCount(n: number): string {
@@ -197,7 +198,7 @@ export function renderReport(model: ReportViewModel): string {
: null;
const showTestHeading = e.results.length > 1;
const cards = e.results
- .map((r, i) => resultDetailCard(r, i, model.mode, showTestHeading, idx))
+ .map((r, i) => resultDetailCard(r, i, model.mode, showTestHeading, idx, e.standards))
.join("");
return `
@@ -342,7 +343,7 @@ export function renderReport(model: ReportViewModel): string {
.exec-verdict-text.pass{color:var(--pass)}
.exec-verdict-text.fail{color:var(--fail)}
.exec-verdict-text.error{color:#D97706}
- .exec-risk{font-size:11px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid;white-space:nowrap}
+ .exec-risk{font-size:11px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid;white-space:nowrap;cursor:default}
.gauge-value{font-size:22px;font-weight:800;color:var(--text);text-align:center;margin-top:-30px}
.gauge-sub{font-size:12px;color:var(--muted);text-align:center;margin-top:2px}
.sc-value{font-size:26px;font-weight:800;line-height:1;color:var(--text)}
@@ -393,6 +394,8 @@ export function renderReport(model: ReportViewModel): string {
.eval-meta-row{display:flex;flex-wrap:wrap;gap:36px;margin-bottom:14px}
.eval-meta-col{display:flex;flex-direction:column;gap:6px}
.eval-meta-col .meta-v-lg{font-size:20px;font-weight:700;color:var(--text);line-height:1}
+ .eval-meta-col.standards-col{margin-left:20px}
+ .eval-meta-col .meta-v-standards{font-size:13px;color:var(--text-2);line-height:1.5}
/* ── Transcript ── */
.transcript-toggle{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted);background:none;border:none;cursor:pointer;padding:12px 0 0;margin-top:2px;border-top:1px solid var(--line);width:100%}
@@ -403,17 +406,30 @@ export function renderReport(model: ReportViewModel): string {
.transcript{border:1px solid var(--line);border-radius:8px;overflow:hidden}
.transcript-header{padding:8px 12px;background:var(--surface-2);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
.tc-count{font-weight:400;color:var(--muted-2)}
- .turn{padding:12px 0}
+ .transcript-body{display:flex;align-items:flex-start;gap:16px;padding:12px 12px 12px 0;max-height:560px;overflow-y:auto;overscroll-behavior:contain}
+ .turn-rail{display:flex;flex-direction:column;align-items:center;gap:16px;flex-shrink:0;position:sticky;top:4px;padding:4px 0 4px 12px}
+ .turn-step{position:relative;width:26px;height:26px;border-radius:7px;border:1px solid var(--line-2);background:var(--surface);color:var(--muted);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
+ .turn-step:hover{border-color:var(--muted-2);color:var(--text)}
+ .turn-step.active{border-color:var(--accent);color:var(--accent);background:rgba(255,77,79,0.08);box-shadow:0 0 0 2px rgba(255,77,79,0.18)}
+ .turn-step:not(:first-child)::before{content:"";position:absolute;bottom:100%;left:50%;transform:translateX(-50%);width:1px;height:16px;background:var(--line-2)}
+ .turn-content{flex:1;min-width:0}
+ .turn{padding:0 0 20px}
+ .turn:last-child{padding-bottom:0}
+ .turn-heading{display:flex;align-items:center;gap:10px;margin-bottom:10px}
+ .turn-heading-label{font-size:11px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);white-space:nowrap}
+ .turn-heading-line{flex:1;height:1px;background:var(--line)}
.turn-row{padding:2px 16px}
.turn-row.attacker-row{padding-left:64px}
.turn-row.agent-row{margin-top:12px}
.turn-role{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px}
.turn-role .turn-icon{display:inline-flex;align-items:center}
- .turn-role .turn-sep{color:var(--muted-2);font-weight:400}
- .turn-role .turn-num{color:var(--muted);font-weight:500}
.turn-row pre{margin:0;white-space:pre-wrap;word-break:break-word;font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text-2)}
.agent-bubble{display:inline-block;max-width:78%;background:var(--surface-2);border-radius:10px;padding:10px 14px}
.agent-bubble.turn-highlight{background:var(--fail-bg);border:1px dashed var(--fail-border)}
+ @media(max-width:640px){
+ .turn-rail{display:none}
+ .transcript-body{padding-left:12px}
+ }
/* ── Footer ── */
.report-footer{max-width:960px;margin:40px auto 0;padding:16px 24px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}
@@ -425,6 +441,8 @@ export function renderReport(model: ReportViewModel): string {
.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}
.eval-detail{border:1px solid var(--line)}
.scope-card,.eval-detail,.exec-strip{break-inside:avoid;box-shadow:none}
+ .transcript-body{max-height:none;overflow:visible}
+ .turn-rail{position:static}
}
@media(max-width:640px){
.cover-meta{grid-template-columns:1fr}
@@ -470,7 +488,7 @@ export function renderReport(model: ReportViewModel): string {
Overall Verdict
${overallVerdict === "PASS" ? "Pass" : overallVerdict === "ERROR" ? "Error" : "Fail"}
-
${overallVerdict === "ERROR" ? "Inconclusive" : riskLevel.label.toUpperCase()}
+
${overallVerdict === "ERROR" ? "Inconclusive" : riskLevel.label.toUpperCase()}
@@ -554,7 +572,39 @@ export function renderReport(model: ReportViewModel): string {
var open = wrap.classList.toggle('open');
btn.querySelector('.tt-label').textContent = open ? 'View less details' : 'View more details';
btn.querySelector('svg').style.transform = open ? 'rotate(180deg)' : 'rotate(0deg)';
+ if (open) {
+ requestAnimationFrame(function(){
+ var flagged = wrap.querySelector('.turn-highlight');
+ if (flagged) flagged.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
+ }
+ });
+ });
+ // Turn rail: click a step to jump to that turn; highlight whichever turn is
+ // topmost in view as the user scrolls (scroll-spy).
+ document.querySelectorAll('.transcript-body').forEach(function(body){
+ var steps = body.querySelectorAll('.turn-step');
+ if (!steps.length) return;
+ var turns = body.querySelectorAll('.turn[id]');
+ var inView = {};
+ var setActive = function(id){
+ steps.forEach(function(s){ s.classList.toggle('active', s.dataset.turn === id); });
+ };
+ steps.forEach(function(step){
+ step.addEventListener('click', function(){
+ var target = document.getElementById(step.dataset.turn);
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
});
+ var observer = new IntersectionObserver(function(entries){
+ entries.forEach(function(entry){
+ inView[entry.target.id] = entry.isIntersecting;
+ });
+ var topmost = null;
+ turns.forEach(function(t){ if (!topmost && inView[t.id]) topmost = t.id; });
+ if (topmost) setActive(topmost);
+ }, { root: body, threshold: 0.4 });
+ turns.forEach(function(t){ observer.observe(t); });
});
})();
@@ -564,35 +614,41 @@ export function renderReport(model: ReportViewModel): string {
// ── Result detail card ────────────────────────────────────────────
-/** Role label row: icon + name, optionally " | Turn N". */
-function roleLabel(icon: string, name: string, turnLabel?: string): string {
- const suffix = turnLabel
- ? `
|${turnLabel}`
- : "";
- return `
${icon}${name}${suffix}
`;
+/** Role label row: icon + name. */
+function roleLabel(icon: string, name: string): string {
+ return `
${icon}${name}
`;
+}
+
+/** "TURN N ────" heading rule that opens each turn block. */
+function turnHeading(turnIndex: number): string {
+ return `
Turn ${turnIndex}
`;
}
/** Render a single conversation turn: the attacker prompt as plain text, the agent
* response as a left-anchored bubble (highlighted when the judge cited this turn
* in its `failingTurns`). There's no per-turn judge — the transcript is judged once,
* at the end, over the whole exchange — so the flagged-turn signal comes from the
- * result-level `judge.failingTurns` list, not from `turn.judge`. */
-function renderTurn(turn: TurnViewModel, failingTurns: Set
): string {
+ * result-level `judge.failingTurns` list, not from `turn.judge`.
+ *
+ * `id` anchors this block for the turn-rail's scroll-spy + click-to-scroll (see the
+ * inline