mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(qa): reconcile duplicate parity capture rows
This commit is contained in:
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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." },
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user