fix(qa): reconcile duplicate parity capture rows

This commit is contained in:
Peter Steinberger
2026-07-14 07:00:49 -04:00
parent 01b0989353
commit e23ba67142
4 changed files with 95 additions and 22 deletions
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { compareCapturedToolCallShape } from "./parity-shared.js";
const call = { tool: "image_generate", argsHash: "same-args" };
describe("compareCapturedToolCallShape", () => {
it("accepts exact repeated executions", () => {
expect(compareCapturedToolCallShape([call, call], [call, call])).toBeUndefined();
});
it("accepts a duplicated process-global capture row", () => {
expect(compareCapturedToolCallShape([call, call], [call])).toBeUndefined();
expect(compareCapturedToolCallShape([call, call, call], [call, call])).toBeUndefined();
});
it("preserves canonical execution count", () => {
expect(compareCapturedToolCallShape([call], [call, call])).toBe(
"tool call count differs (1 vs 2)",
);
});
});
+50
View File
@@ -48,3 +48,53 @@ export function compareToolCallShape(
}
return undefined;
}
export function distinctToolCallShapes(toolCalls: readonly ParityToolCallShape[]) {
return toolCalls.filter(
(toolCall, index) =>
toolCalls.findIndex(
(candidate) => candidate.tool === toolCall.tool && candidate.argsHash === toolCall.argsHash,
) === index,
);
}
export function compareCapturedToolCallShape(
left: readonly ParityToolCallShape[],
right: readonly ParityToolCallShape[],
) {
const exactMatch = compareToolCallShape(left, right);
if (exactMatch === undefined) {
return undefined;
}
// Process-global captures can repeat planned rows. The canonical transcript
// must remain an ordered subsequence; unknown shapes still fail comparison.
let rightIndex = 0;
for (const leftCall of left) {
const expected = right[rightIndex];
if (expected?.tool === leftCall.tool && expected.argsHash === leftCall.argsHash) {
rightIndex += 1;
continue;
}
const knownShape = right.some(
(candidate) => candidate.tool === leftCall.tool && candidate.argsHash === leftCall.argsHash,
);
if (!knownShape) {
return exactMatch;
}
}
return rightIndex === right.length ? undefined : exactMatch;
}
export function hasSingleDistinctLeftToolCallShape(
left: readonly ParityToolCallShape[],
right: readonly ParityToolCallShape[],
) {
const distinctLeft = distinctToolCallShapes(left);
return (
distinctLeft.length <= 1 &&
right.length <= 1 &&
(distinctLeft.length === 0 ||
right.length === 0 ||
compareToolCallShape(distinctLeft, right) === undefined)
);
}
+5 -2
View File
@@ -330,9 +330,12 @@ describe("runtime parity", () => {
expect(isRuntimeParityResultPass(result)).toBe(false);
});
it("prefers transcript tool results when mock debug rows are incomplete", async () => {
it("prefers transcript tool results when mock debug rows repeat an incomplete call", async () => {
const cell = await captureRuntimeParityWithMockRequests({
requests: [{ plannedToolName: "image_generate", plannedToolArgs: { prompt: "same" } }],
requests: [
{ plannedToolName: "image_generate", plannedToolArgs: { prompt: "same" } },
{ plannedToolName: "image_generate", plannedToolArgs: { prompt: "same" } },
],
messages: [
{ role: "user", content: "Delegate one bounded QA task to a subagent." },
{
+19 -20
View File
@@ -14,7 +14,7 @@ import {
scanGatewayLogSentinels,
type GatewayLogSentinelFinding,
} from "./gateway-log-sentinel.js";
import { compareToolCallShape, stableHash } from "./parity-shared.js";
import * as parity from "./parity-shared.js";
export type RuntimeId = "openclaw" | "codex";
@@ -469,8 +469,8 @@ function resolveToolCallOrder(records: RuntimeParityTranscriptRecord[]): Runtime
const index =
ordered.push({
tool: call.tool,
argsHash: stableHash(call.args),
resultHash: stableHash(null),
argsHash: parity.stableHash(call.args),
resultHash: parity.stableHash(null),
_resolved: false,
}) - 1;
if (call.id) {
@@ -489,9 +489,9 @@ function resolveToolCallOrder(records: RuntimeParityTranscriptRecord[]): Runtime
"unknown",
argsHash:
pendingIndex !== undefined
? (ordered[pendingIndex]?.argsHash ?? stableHash(null))
: stableHash(null),
resultHash: stableHash(result.result),
? (ordered[pendingIndex]?.argsHash ?? parity.stableHash(null))
: parity.stableHash(null),
resultHash: parity.stableHash(result.result),
...(result.errorClass ? { errorClass: result.errorClass } : {}),
};
if (pendingIndex === undefined || !ordered[pendingIndex]) {
@@ -541,9 +541,9 @@ function resolveToolCallOrderFromMockRequests(
tool: pendingIndex !== undefined ? (ordered[pendingIndex]?.tool ?? "unknown") : "unknown",
argsHash:
pendingIndex !== undefined
? (ordered[pendingIndex]?.argsHash ?? stableHash(null))
: stableHash(null),
resultHash: stableHash(parsedOutput ?? rawToolOutput),
? (ordered[pendingIndex]?.argsHash ?? parity.stableHash(null))
: parity.stableHash(null),
resultHash: parity.stableHash(parsedOutput ?? rawToolOutput),
...(classifyToolResultError({
rawOutput: rawToolOutput,
parsedOutput,
@@ -568,8 +568,8 @@ function resolveToolCallOrderFromMockRequests(
}
ordered.push({
tool: plannedToolName,
argsHash: stableHash(request.plannedToolArgs ?? null),
resultHash: stableHash(null),
argsHash: parity.stableHash(request.plannedToolArgs ?? null),
resultHash: parity.stableHash(null),
_resolved: false,
});
enqueueUnresolved(ordered.length - 1);
@@ -788,7 +788,7 @@ function hasProvenTerminalImageResult(scenarioResult: QaSuiteScenarioLike) {
);
}
const PROVEN_TERMINAL_IMAGE_RESULT_HASH = stableHash({ kind: "media", status: "success" });
const PROVEN_TERMINAL_IMAGE_RESULT_HASH = parity.stableHash({ kind: "media", status: "success" });
function resolveRuntimeParityToolCalls(params: {
mockToolCalls: RuntimeParityToolCall[] | null;
@@ -801,19 +801,18 @@ function resolveRuntimeParityToolCalls(params: {
const transcriptImageCalls = params.transcriptToolCalls.filter(
(toolCall) => toolCall.tool === "image_generate",
);
const imageCaptureIsUnambiguous =
mockImageCalls.length <= 1 &&
transcriptImageCalls.length <= 1 &&
(mockImageCalls.length === 0 ||
transcriptImageCalls.length === 0 ||
compareToolCallShape(mockImageCalls, transcriptImageCalls) === undefined);
const imageCaptureIsUnambiguous = parity.hasSingleDistinctLeftToolCallShape(
mockImageCalls,
transcriptImageCalls,
);
let selected: RuntimeParityToolCall[];
if (!params.mockToolCalls) {
selected = params.transcriptToolCalls;
} else if (
hasMissingToolResult(params.mockToolCalls) &&
!hasMissingToolResult(params.transcriptToolCalls) &&
compareToolCallShape(params.mockToolCalls, params.transcriptToolCalls) === undefined
parity.compareCapturedToolCallShape(params.mockToolCalls, params.transcriptToolCalls) ===
undefined
) {
selected = params.transcriptToolCalls;
} else {
@@ -917,7 +916,7 @@ function classifyRuntimeParityCells(params: {
};
}
const toolCallShapeDetails = compareToolCallShape(
const toolCallShapeDetails = parity.compareToolCallShape(
params.openclaw.toolCalls,
params.codex.toolCalls,
);