From 919befbbb63ca3d32e97c2e9a7e2e219e09c717f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 03:04:53 +0200 Subject: [PATCH] fix(qa): gate character eval on suite summary --- extensions/qa-lab/src/character-eval.test.ts | 67 +++++++++++++++++++- extensions/qa-lab/src/character-eval.ts | 17 +++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/extensions/qa-lab/src/character-eval.test.ts b/extensions/qa-lab/src/character-eval.test.ts index e9d7f93c6b0f..dfe4fd204c89 100644 --- a/extensions/qa-lab/src/character-eval.test.ts +++ b/extensions/qa-lab/src/character-eval.test.ts @@ -90,17 +90,51 @@ function createConcurrencyGate(expectedActive: number) { }; } -function makeSuiteResult(params: { outputDir: string; model: string; transcript: string }) { +async function makeSuiteResult(params: { + outputDir: string; + model: string; + transcript: string; + resultStatus?: "pass" | "fail"; + summaryStatus?: "pass" | "fail"; + summaryFailedCount?: number; +}) { + const resultStatus = params.resultStatus ?? "pass"; + const summaryStatus = params.summaryStatus ?? resultStatus; + const summaryFailedCount = params.summaryFailedCount ?? (summaryStatus === "fail" ? 1 : 0); + const summaryPath = path.join(params.outputDir, "qa-suite-summary.json"); + await fs.mkdir(params.outputDir, { recursive: true }); + await fs.writeFile( + summaryPath, + `${JSON.stringify( + { + counts: { + total: 1, + passed: summaryFailedCount > 0 ? 0 : 1, + failed: summaryFailedCount, + }, + scenarios: [ + { + name: "Character vibes", + status: summaryStatus, + steps: [], + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ); return { outputDir: params.outputDir, reportPath: path.join(params.outputDir, "qa-suite-report.md"), - summaryPath: path.join(params.outputDir, "qa-suite-summary.json"), + summaryPath, report: "# report", watchUrl: "http://127.0.0.1:43124", scenarios: [ { name: "Character vibes", - status: "pass", + status: resultStatus, steps: [ { name: `transcript for ${params.model}`, @@ -430,6 +464,33 @@ describe("runQaCharacterEval", () => { }); }); + it("marks candidates failed when the suite summary has failed scenarios", async () => { + const runSuite = vi.fn(async (params: CharacterRunSuiteParams) => + makeSuiteResult({ + outputDir: params.outputDir, + model: params.primaryModel, + transcript: "USER Alice: hi\n\nASSISTANT openclaw: outwardly fine", + summaryStatus: "fail", + summaryFailedCount: 1, + }), + ); + const runJudge = makeRunJudge([ + { model: "openai/gpt-5.5", rank: 1, score: 0.5, summary: "failed" }, + ]); + + const result = await runQaCharacterEval({ + repoRoot: tempRoot, + outputDir: path.join(tempRoot, "character"), + models: ["openai/gpt-5.5"], + judgeModels: ["openai/gpt-5.5"], + runSuite, + runJudge, + }); + + expect(result.runs[0]?.status).toBe("fail"); + expect(result.runs[0]?.error).toBeUndefined(); + }); + it("marks raw tool failure transcripts as failed output", async () => { const runSuite = vi.fn(async (params: CharacterRunSuiteParams) => makeSuiteResult({ diff --git a/extensions/qa-lab/src/character-eval.ts b/extensions/qa-lab/src/character-eval.ts index be1bc47c84d2..50d417a7ec98 100644 --- a/extensions/qa-lab/src/character-eval.ts +++ b/extensions/qa-lab/src/character-eval.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { runQaManualLane } from "./manual-lane.runtime.js"; import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js"; import { QA_FRONTIER_CHARACTER_EVAL_MODELS, @@ -13,7 +12,7 @@ import { } from "./providers/live-frontier/character-eval.js"; import type { QaThinkingLevel } from "./qa-gateway-config.js"; import { extractQaVisibleReplyLeakText } from "./reply-failure.js"; -import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js"; +import { readQaSuiteFailedScenarioCountFromFile } from "./suite-summary.js"; import type { QaSuiteResult } from "./suite.js"; const DEFAULT_CHARACTER_SCENARIO_ID = "character-vibes-gollum"; @@ -412,6 +411,7 @@ async function defaultRunJudge(params: { prompt: string; timeoutMs: number; }) { + const { runQaManualLane } = await import("./manual-lane.runtime.js"); const result = await runQaManualLane({ repoRoot: params.repoRoot, providerMode: "live-frontier", @@ -425,6 +425,11 @@ async function defaultRunJudge(params: { return result.reply; } +async function defaultRunSuite(params: Parameters[0]) { + const { runQaSuiteFromRuntime } = await import("./suite-launch.runtime.js"); + return await runQaSuiteFromRuntime(params); +} + function renderCharacterEvalReport(params: { scenarioId: string; startedAt: Date; @@ -519,7 +524,7 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { const runsDir = path.join(outputDir, "runs"); await fs.mkdir(runsDir, { recursive: true }); - const runSuite = params.runSuite ?? runQaSuiteFromRuntime; + const runSuite = params.runSuite ?? defaultRunSuite; const candidateConcurrency = normalizeConcurrency( params.candidateConcurrency, DEFAULT_CHARACTER_EVAL_CONCURRENCY, @@ -560,10 +565,8 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { }); const transcript = extractTranscript(result); const transcriptFailure = detectTranscriptFailure(transcript); - const status = - result.scenarios.some((scenario) => scenario.status === "fail") || transcriptFailure - ? "fail" - : "pass"; + const failedScenarioCount = await readQaSuiteFailedScenarioCountFromFile(result.summaryPath); + const status = failedScenarioCount > 0 || transcriptFailure ? "fail" : "pass"; const run = { model, status,