feat: streamline qa lab live runs

This commit is contained in:
Peter Steinberger
2026-04-07 10:04:27 +01:00
parent 5b5018bac5
commit f2494aa33f
14 changed files with 367 additions and 143 deletions

View File

@@ -39,7 +39,6 @@ pnpm openclaw qa suite \
--provider-mode live-openai \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
```

View File

@@ -47,14 +47,12 @@ export async function runQaSuiteCommand(opts: {
providerMode?: "mock-openai" | "live-openai";
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
}) {
const result = await runQaSuite({
outputDir: opts.outputDir ? path.resolve(opts.outputDir) : undefined,
providerMode: opts.providerMode,
primaryModel: opts.primaryModel,
alternateModel: opts.alternateModel,
fastMode: opts.fastMode,
});
process.stdout.write(`QA suite watch: ${result.watchUrl}\n`);
process.stdout.write(`QA suite report: ${result.reportPath}\n`);

View File

@@ -19,7 +19,6 @@ async function runQaSuite(opts: {
providerMode?: "mock-openai" | "live-openai";
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
}) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaSuiteCommand(opts);
@@ -96,21 +95,18 @@ export function registerQaLabCli(program: Command) {
.option("--provider-mode <mode>", "Provider mode: mock-openai or live-openai", "mock-openai")
.option("--model <ref>", "Primary provider/model ref")
.option("--alt-model <ref>", "Alternate provider/model ref")
.option("--fast", "Enable provider fast mode where supported", false)
.action(
async (opts: {
outputDir?: string;
providerMode?: "mock-openai" | "live-openai";
model?: string;
altModel?: string;
fast?: boolean;
}) => {
await runQaSuite({
outputDir: opts.outputDir,
providerMode: opts.providerMode,
primaryModel: opts.model,
alternateModel: opts.altModel,
fastMode: opts.fast,
});
},
);

View File

@@ -122,7 +122,6 @@ export async function startQaGatewayChild(params: {
providerMode?: "mock-openai" | "live-openai";
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
controlUiEnabled?: boolean;
}) {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qa-suite-"));
@@ -156,7 +155,6 @@ export async function startQaGatewayChild(params: {
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
controlUiEnabled: params.controlUiEnabled,
});
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");

View File

@@ -696,7 +696,6 @@ export async function startQaLabServer(params?: {
providerMode: selection.providerMode,
primaryModel: selection.primaryModel,
alternateModel: selection.alternateModel,
fastMode: selection.fastMode,
scenarioIds: selection.scenarioIds,
});
runnerSnapshot = {

View File

@@ -0,0 +1,40 @@
export type QaProviderMode = "mock-openai" | "live-openai";
export type QaModelSelection = {
primaryModel: string;
alternateModel: string;
};
export function defaultQaModelForMode(
mode: QaProviderMode,
options?: {
alternate?: boolean;
preferredLiveModel?: string;
},
) {
if (mode === "live-openai") {
return options?.preferredLiveModel ?? "openai/gpt-5.4";
}
return options?.alternate ? "mock-openai/gpt-5.4-alt" : "mock-openai/gpt-5.4";
}
export function splitQaModelRef(ref: string) {
const slash = ref.indexOf("/");
if (slash <= 0 || slash === ref.length - 1) {
return null;
}
return {
provider: ref.slice(0, slash),
model: ref.slice(slash + 1),
};
}
export function isQaFastModeModelRef(ref: string) {
return splitQaModelRef(ref)?.provider === "openai";
}
export function isQaFastModeEnabled(selection: QaModelSelection) {
return (
isQaFastModeModelRef(selection.primaryModel) || isQaFastModeModelRef(selection.alternateModel)
);
}

View File

@@ -38,7 +38,6 @@ describe("buildQaGatewayConfig", () => {
qaBusBaseUrl: "http://127.0.0.1:43124",
workspaceDir: "/tmp/qa-workspace",
providerMode: "live-openai",
fastMode: true,
primaryModel: "openai/gpt-5.4",
alternateModel: "openai/gpt-5.4",
});

View File

@@ -1,5 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
defaultQaModelForMode,
isQaFastModeModelRef,
splitQaModelRef,
type QaProviderMode,
} from "./model-selection.js";
const DISABLED_BUNDLED_CHANNELS = Object.freeze({
bluebubbles: { enabled: false },
@@ -33,21 +39,10 @@ export function buildQaGatewayConfig(params: {
controlUiRoot?: string;
controlUiAllowedOrigins?: string[];
controlUiEnabled?: boolean;
providerMode?: "mock-openai" | "live-openai";
providerMode?: QaProviderMode;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
}): OpenClawConfig {
const splitModelRef = (ref: string) => {
const slash = ref.indexOf("/");
if (slash <= 0 || slash === ref.length - 1) {
return null;
}
return {
provider: ref.slice(0, slash),
model: ref.slice(slash + 1),
};
};
const mockProviderBaseUrl = params.providerBaseUrl ?? "http://127.0.0.1:44080/v1";
const mockOpenAiProvider: ModelProviderConfig = {
baseUrl: mockProviderBaseUrl,
@@ -102,12 +97,9 @@ export function buildQaGatewayConfig(params: {
],
};
const providerMode = params.providerMode ?? "mock-openai";
const primaryModel =
params.primaryModel ??
(providerMode === "live-openai" ? "openai/gpt-5.4" : "mock-openai/gpt-5.4");
const primaryModel = params.primaryModel ?? defaultQaModelForMode(providerMode);
const alternateModel =
params.alternateModel ??
(providerMode === "live-openai" ? "openai/gpt-5.4" : "mock-openai/gpt-5.4-alt");
params.alternateModel ?? defaultQaModelForMode(providerMode, { alternate: true });
const imageGenerationModelRef =
providerMode === "live-openai" ? "openai/gpt-image-1" : "mock-openai/gpt-image-1";
const selectedProviderIds =
@@ -115,7 +107,7 @@ export function buildQaGatewayConfig(params: {
? [
...new Set(
[primaryModel, alternateModel, imageGenerationModelRef]
.map((ref) => splitModelRef(ref)?.provider)
.map((ref) => splitQaModelRef(ref)?.provider)
.filter((provider): provider is string => Boolean(provider)),
),
]
@@ -130,15 +122,15 @@ export function buildQaGatewayConfig(params: {
: ["memory-core", "qa-channel"];
const liveModelParams =
providerMode === "live-openai"
? {
? (modelRef: string) => ({
transport: "sse",
openaiWsWarmup: false,
...(params.fastMode ? { fastMode: true } : {}),
}
: {
...(isQaFastModeModelRef(modelRef) ? { fastMode: true } : {}),
})
: (_modelRef: string) => ({
transport: "sse",
openaiWsWarmup: false,
};
});
const allowedOrigins =
params.controlUiAllowedOrigins && params.controlUiAllowedOrigins.length > 0
? params.controlUiAllowedOrigins
@@ -181,10 +173,10 @@ export function buildQaGatewayConfig(params: {
},
models: {
[primaryModel]: {
params: liveModelParams,
params: liveModelParams(primaryModel),
},
[alternateModel]: {
params: liveModelParams,
params: liveModelParams(alternateModel),
},
},
subagents: {

View File

@@ -49,7 +49,7 @@ describe("qa run config", () => {
providerMode: "live-openai",
primaryModel: "openai/gpt-5.4",
alternateModel: "openai/gpt-5.4",
fastMode: false,
fastMode: true,
scenarioIds: ["thread-lifecycle"],
});
});

View File

@@ -1,8 +1,11 @@
import path from "node:path";
import {
defaultQaModelForMode,
isQaFastModeEnabled,
type QaProviderMode,
} from "./model-selection.js";
import type { QaSeedScenario } from "./scenario-catalog.js";
export type QaProviderMode = "mock-openai" | "live-openai";
export type QaLabRunSelection = {
providerMode: QaProviderMode;
primaryModel: string;
@@ -28,22 +31,18 @@ export type QaLabRunnerSnapshot = {
};
export function createDefaultQaRunSelection(scenarios: QaSeedScenario[]): QaLabRunSelection {
const providerMode: QaProviderMode = "mock-openai";
const primaryModel = defaultQaModelForMode(providerMode);
const alternateModel = defaultQaModelForMode(providerMode, { alternate: true });
return {
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.4",
alternateModel: "mock-openai/gpt-5.4-alt",
fastMode: false,
providerMode,
primaryModel,
alternateModel,
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
scenarioIds: scenarios.map((scenario) => scenario.id),
};
}
function defaultModelForMode(mode: QaProviderMode, alternate = false) {
if (mode === "live-openai") {
return "openai/gpt-5.4";
}
return alternate ? "mock-openai/gpt-5.4-alt" : "mock-openai/gpt-5.4";
}
function normalizeProviderMode(input: unknown): QaProviderMode {
return input === "live-openai" ? "live-openai" : "mock-openai";
}
@@ -72,12 +71,16 @@ export function normalizeQaRunSelection(
): QaLabRunSelection {
const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
const providerMode = normalizeProviderMode(payload.providerMode);
const primaryModel = normalizeModel(payload.primaryModel, defaultQaModelForMode(providerMode));
const alternateModel = normalizeModel(
payload.alternateModel,
defaultQaModelForMode(providerMode, { alternate: true }),
);
return {
providerMode,
primaryModel: normalizeModel(payload.primaryModel, defaultModelForMode(providerMode)),
alternateModel: normalizeModel(payload.alternateModel, defaultModelForMode(providerMode, true)),
fastMode:
typeof payload.fastMode === "boolean" ? payload.fastMode : providerMode === "live-openai",
primaryModel,
alternateModel,
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
scenarioIds: normalizeScenarioIds(payload.scenarioIds, scenarios),
};
}

View File

@@ -14,6 +14,11 @@ import { startQaGatewayChild } from "./gateway-child.js";
import { startQaLabServer } from "./lab-server.js";
import type { QaLabLatestReport, QaLabScenarioOutcome } from "./lab-server.js";
import { startQaMockOpenAiServer } from "./mock-openai-server.js";
import {
defaultQaModelForMode,
isQaFastModeEnabled,
type QaProviderMode,
} from "./model-selection.js";
import { renderQaMarkdownReport, type QaReportCheck, type QaReportScenario } from "./report.js";
import { qaChannelPlugin, type QaBusMessage } from "./runtime-api.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
@@ -1752,22 +1757,18 @@ When the user asks for the drift skill marker exactly, reply with exactly: DRIFT
export async function runQaSuite(params?: {
outputDir?: string;
providerMode?: "mock-openai" | "live-openai";
providerMode?: QaProviderMode;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
scenarioIds?: string[];
lab?: Awaited<ReturnType<typeof startQaLabServer>>;
}) {
const startedAt = new Date();
const providerMode = params?.providerMode ?? "mock-openai";
const fastMode = params?.fastMode ?? providerMode === "live-openai";
const primaryModel =
params?.primaryModel ??
(providerMode === "live-openai" ? "openai/gpt-5.4" : "mock-openai/gpt-5.4");
const primaryModel = params?.primaryModel ?? defaultQaModelForMode(providerMode);
const alternateModel =
params?.alternateModel ??
(providerMode === "live-openai" ? "openai/gpt-5.4" : "mock-openai/gpt-5.4-alt");
params?.alternateModel ?? defaultQaModelForMode(providerMode, { alternate: true });
const fastMode = isQaFastModeEnabled({ primaryModel, alternateModel });
const outputDir =
params?.outputDir ??
path.join(process.cwd(), ".artifacts", "qa-e2e", `suite-${Date.now().toString(36)}`);
@@ -1795,7 +1796,6 @@ export async function runQaSuite(params?: {
providerMode,
primaryModel,
alternateModel,
fastMode,
controlUiEnabled: true,
});
lab.setControlUi({

View File

@@ -1,3 +1,4 @@
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../src/model-selection.js";
import { formatErrorMessage } from "./errors.js";
import {
type Bootstrap,
@@ -43,18 +44,22 @@ function defaultModelsForProviderMode(
mode: RunnerSelection["providerMode"],
bootstrap?: Bootstrap | null,
): Pick<RunnerSelection, "primaryModel" | "alternateModel" | "fastMode"> {
const preferredLiveModel = bootstrap?.runnerCatalog.real[0]?.key;
if (mode === "live-openai") {
const preferred = bootstrap?.runnerCatalog.real[0]?.key;
const primaryModel = defaultQaModelForMode(mode, { preferredLiveModel });
const alternateModel = defaultQaModelForMode(mode, { alternate: true, preferredLiveModel });
return {
primaryModel: preferred ?? "openai/gpt-5.4",
alternateModel: preferred ?? "openai/gpt-5.4",
fastMode: true,
primaryModel,
alternateModel,
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
};
}
const primaryModel = defaultQaModelForMode(mode);
const alternateModel = defaultQaModelForMode(mode, { alternate: true });
return {
primaryModel: "mock-openai/gpt-5.4",
alternateModel: "mock-openai/gpt-5.4-alt",
fastMode: false,
primaryModel,
alternateModel,
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
};
}
@@ -320,7 +325,6 @@ export async function createQaLabApp(root: HTMLDivElement) {
providerMode: state.runnerDraft.providerMode,
primaryModel: state.runnerDraft.primaryModel,
alternateModel: state.runnerDraft.alternateModel,
fastMode: state.runnerDraft.fastMode,
scenarioIds: state.runnerDraft.scenarioIds,
},
);
@@ -531,19 +535,20 @@ export async function createQaLabApp(root: HTMLDivElement) {
...defaultModelsForProviderMode(mode, state.bootstrap),
}));
});
root.querySelector<HTMLInputElement>("#fast-mode")?.addEventListener("change", (e) => {
updateRunnerDraft((d) => ({ ...d, fastMode: (e.currentTarget as HTMLInputElement).checked }));
});
root.querySelector<HTMLSelectElement>("#primary-model")?.addEventListener("change", (e) => {
const primaryModel = (e.currentTarget as HTMLSelectElement).value;
updateRunnerDraft((d) => ({
...d,
primaryModel: (e.currentTarget as HTMLSelectElement).value,
primaryModel,
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel: d.alternateModel }),
}));
});
root.querySelector<HTMLSelectElement>("#alternate-model")?.addEventListener("change", (e) => {
const alternateModel = (e.currentTarget as HTMLSelectElement).value;
updateRunnerDraft((d) => ({
...d,
alternateModel: (e.currentTarget as HTMLSelectElement).value,
alternateModel,
fastMode: isQaFastModeEnabled({ primaryModel: d.primaryModel, alternateModel }),
}));
});

View File

@@ -1116,6 +1116,17 @@ select {
min-width: 0;
}
.inspector-layout {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.85fr);
gap: 24px;
align-items: start;
}
.inspector-main {
min-width: 0;
}
.inspector-empty {
display: flex;
align-items: center;
@@ -1189,6 +1200,117 @@ select {
margin-bottom: 10px;
}
.inspector-live {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.inspector-live-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.inspector-live-subtitle {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.inspector-live-feed {
display: flex;
flex-direction: column;
gap: 10px;
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.inspector-live-message {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-surface);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.inspector-live-message-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.inspector-live-message-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.inspector-live-avatar {
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
flex-shrink: 0;
}
.inspector-live-sender {
font-size: 12px;
font-weight: 700;
color: var(--text);
}
.inspector-live-direction {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 999px;
}
.inspector-live-direction-inbound {
color: var(--msg-inbound-accent);
background: var(--msg-inbound-badge);
}
.inspector-live-direction-outbound {
color: var(--msg-outbound-accent);
background: var(--accent-soft);
}
.inspector-live-time {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.inspector-live-channel {
color: var(--text-tertiary);
font-size: 11px;
margin-bottom: 6px;
}
.inspector-live-text {
color: var(--text);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.criteria-list {
list-style: none;
display: flex;
@@ -1409,6 +1531,18 @@ select {
.results-list {
width: 280px;
}
.inspector-layout {
grid-template-columns: 1fr;
}
.inspector-live {
position: static;
}
.inspector-live-feed {
max-height: 360px;
}
}
@media (max-width: 700px) {
@@ -1434,6 +1568,14 @@ select {
border-bottom: 1px solid var(--border);
}
.results-inspector {
padding: 16px;
}
.inspector-live-feed {
max-height: 280px;
}
.chat-sidebar {
width: 180px;
}

View File

@@ -354,9 +354,6 @@ function renderSidebar(state: UiState): string {
options: modelOptions,
disabled: isRunning,
})}
<div class="config-row">
<label><input id="fast-mode" type="checkbox"${selection?.fastMode ? " checked" : ""}${isRunning ? " disabled" : ""} /> Fast mode</label>
</div>
${
selection?.providerMode === "live-openai"
? `<div class="config-hint">${esc(
@@ -634,6 +631,57 @@ function renderMessage(m: Message): string {
</div>`;
}
function recentInspectorMessages(state: UiState, limit = 18) {
return (state.snapshot?.messages ?? []).slice(-limit).toReversed();
}
function renderInspectorLiveMessage(message: Message): string {
const avatar = messageAvatar(message);
const conversationLabel = message.conversation.title || message.conversation.id;
const threadLabel = message.threadTitle || message.threadId;
return `
<div class="inspector-live-message">
<div class="inspector-live-message-head">
<div class="inspector-live-message-identity">
<span class="inspector-live-avatar" style="background:${avatar.bg}">${avatar.emoji}</span>
<span class="inspector-live-sender">${esc(message.senderName || message.senderId)}</span>
<span class="inspector-live-direction inspector-live-direction-${message.direction}">${message.direction === "inbound" ? "inbound" : "outbound"}</span>
</div>
<span class="inspector-live-time">${formatTime(message.timestamp)}</span>
</div>
<div class="inspector-live-channel">
${esc(conversationLabel)}${threadLabel ? ` · ${esc(threadLabel)}` : ""}
</div>
<div class="inspector-live-text">${esc(message.text)}</div>
</div>`;
}
function renderInspectorLiveTranscript(state: UiState): string {
const messages = recentInspectorMessages(state);
const isLive = state.bootstrap?.runner.status === "running";
return `
<aside class="inspector-live">
<div class="inspector-live-header">
<div>
<div class="inspector-section-title">Live Transcript</div>
<div class="inspector-live-subtitle">
${isLive ? "Latest QA bus messages as the run progresses." : "Latest observed QA bus messages."}
</div>
</div>
${isLive ? '<span class="live-indicator"><span class="live-dot"></span>LIVE</span>' : ""}
</div>
<div class="inspector-live-feed">
${
messages.length > 0
? messages.map((message) => renderInspectorLiveMessage(message)).join("")
: '<div class="empty-state">No transcript yet. Start a run or send a message.</div>'
}
</div>
</aside>`;
}
/* ===== Render: Results tab ===== */
function renderResultsView(state: UiState): string {
@@ -671,71 +719,76 @@ function renderInspector(state: UiState, scenario: SeedScenario): string {
const outcome = findScenarioOutcome(state, scenario);
return `
<div class="inspector-header">
<div>
<div class="inspector-title">${esc(scenario.title)}</div>
${badgeHtml(outcome?.status ?? "pending")}
<div class="inspector-layout">
<div class="inspector-main">
<div class="inspector-header">
<div>
<div class="inspector-title">${esc(scenario.title)}</div>
${badgeHtml(outcome?.status ?? "pending")}
</div>
</div>
<div class="inspector-objective">${esc(scenario.objective)}</div>
<div class="inspector-meta">
<div class="inspector-meta-item"><span class="inspector-meta-label">Surface</span><span class="inspector-meta-value">${esc(scenario.surface)}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Started</span><span class="inspector-meta-value">${esc(formatIso(outcome?.startedAt))}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Finished</span><span class="inspector-meta-value">${esc(formatIso(outcome?.finishedAt))}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Run</span><span class="inspector-meta-value">${esc(state.scenarioRun?.kind ?? "seed only")}</span></div>
</div>
<div class="inspector-section">
<div class="inspector-section-title">Success Criteria</div>
<ul class="criteria-list">
${scenario.successCriteria.map((c) => `<li class="criteria-item"><span class="criteria-bullet"></span>${esc(c)}</li>`).join("")}
</ul>
</div>
<div class="inspector-section">
<div class="inspector-section-title">Observed Outcome</div>
${
outcome
? `
${outcome.details ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${esc(outcome.details)}</div>` : ""}
<div class="step-list">
${
outcome.steps?.length
? outcome.steps
.map(
(step) => `
<div class="step-card">
<div class="step-card-header">
<span class="step-card-name">${esc(step.name)}</span>
${badgeHtml(step.status)}
</div>
${step.details ? `<div class="step-card-details">${esc(step.details)}</div>` : ""}
</div>`,
)
.join("")
: '<div class="empty-state">No step data yet.</div>'
}
</div>`
: '<div class="empty-state">Not executed yet — seed plan only.</div>'
}
</div>
${
scenario.docsRefs?.length
? `<div class="inspector-section">
<div class="inspector-section-title">Docs</div>
<div class="ref-list">${scenario.docsRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
</div>`
: ""
}
${
scenario.codeRefs?.length
? `<div class="inspector-section">
<div class="inspector-section-title">Code</div>
<div class="ref-list">${scenario.codeRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
</div>`
: ""
}
</div>
</div>
<div class="inspector-objective">${esc(scenario.objective)}</div>
<div class="inspector-meta">
<div class="inspector-meta-item"><span class="inspector-meta-label">Surface</span><span class="inspector-meta-value">${esc(scenario.surface)}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Started</span><span class="inspector-meta-value">${esc(formatIso(outcome?.startedAt))}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Finished</span><span class="inspector-meta-value">${esc(formatIso(outcome?.finishedAt))}</span></div>
<div class="inspector-meta-item"><span class="inspector-meta-label">Run</span><span class="inspector-meta-value">${esc(state.scenarioRun?.kind ?? "seed only")}</span></div>
</div>
<div class="inspector-section">
<div class="inspector-section-title">Success Criteria</div>
<ul class="criteria-list">
${scenario.successCriteria.map((c) => `<li class="criteria-item"><span class="criteria-bullet"></span>${esc(c)}</li>`).join("")}
</ul>
</div>
<div class="inspector-section">
<div class="inspector-section-title">Observed Outcome</div>
${
outcome
? `
${outcome.details ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${esc(outcome.details)}</div>` : ""}
<div class="step-list">
${
outcome.steps?.length
? outcome.steps
.map(
(step) => `
<div class="step-card">
<div class="step-card-header">
<span class="step-card-name">${esc(step.name)}</span>
${badgeHtml(step.status)}
</div>
${step.details ? `<div class="step-card-details">${esc(step.details)}</div>` : ""}
</div>`,
)
.join("")
: '<div class="empty-state">No step data yet.</div>'
}
</div>`
: '<div class="empty-state">Not executed yet — seed plan only.</div>'
}
</div>
${
scenario.docsRefs?.length
? `<div class="inspector-section">
<div class="inspector-section-title">Docs</div>
<div class="ref-list">${scenario.docsRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
</div>`
: ""
}
${
scenario.codeRefs?.length
? `<div class="inspector-section">
<div class="inspector-section-title">Code</div>
<div class="ref-list">${scenario.codeRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
</div>`
: ""
}`;
${renderInspectorLiveTranscript(state)}
</div>`;
}
/* ===== Render: Report tab ===== */