Skip to content

Commit 03f1bf9

Browse files
committed
fix(qa-lab): fail missing parity tool results
1 parent 0e1d2b3 commit 03f1bf9

3 files changed

Lines changed: 128 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
4444
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
4545
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
4646
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
47+
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
4748
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
4849

4950
## 2026.6.2
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Qa Lab tests cover runtime parity classification behavior.
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
__testing,
5+
runRuntimeParityScenario,
6+
type RuntimeId,
7+
type RuntimeParityCell,
8+
type RuntimeParityToolCall,
9+
} from "./runtime-parity.js";
10+
11+
function makeRuntimeParityCell(
12+
runtime: RuntimeId,
13+
toolCalls: RuntimeParityToolCall[],
14+
): RuntimeParityCell {
15+
return {
16+
runtime,
17+
transcriptBytes: '{"message":{"role":"assistant","content":"done"}}\n',
18+
toolCalls,
19+
finalText: "done",
20+
usage: {
21+
inputTokens: 1,
22+
outputTokens: 1,
23+
totalTokens: 2,
24+
},
25+
wallClockMs: 10,
26+
bootStateLines: [],
27+
};
28+
}
29+
30+
describe("runtime parity", () => {
31+
it("marks planned mock tool calls without outputs as missing tool results", () => {
32+
const toolCalls = __testing.resolveToolCallOrderFromMockRequests([
33+
{
34+
plannedToolName: "read_file",
35+
plannedToolArgs: { path: "README.md" },
36+
},
37+
]);
38+
39+
expect(toolCalls).toHaveLength(1);
40+
expect(toolCalls[0]).toMatchObject({
41+
tool: "read_file",
42+
errorClass: "tool-result-missing",
43+
});
44+
});
45+
46+
it("keeps resolved mock tool calls eligible for no-drift parity", async () => {
47+
const toolCalls = __testing.resolveToolCallOrderFromMockRequests([
48+
{
49+
plannedToolName: "read_file",
50+
plannedToolArgs: { path: "README.md" },
51+
},
52+
{
53+
toolOutput: JSON.stringify({ ok: true }),
54+
},
55+
]);
56+
57+
expect(toolCalls).toHaveLength(1);
58+
expect(toolCalls[0]?.errorClass).toBeUndefined();
59+
60+
const result = await runRuntimeParityScenario({
61+
scenarioId: "resolved-tool",
62+
runCell: async (runtime) => ({
63+
scenarioStatus: "pass",
64+
cell: makeRuntimeParityCell(runtime, toolCalls),
65+
}),
66+
});
67+
68+
expect(result.drift).toBe("none");
69+
});
70+
71+
it("classifies planned-only matching tool calls as failure-mode", async () => {
72+
const toolCalls = __testing.resolveToolCallOrderFromMockRequests([
73+
{
74+
plannedToolName: "read_file",
75+
plannedToolArgs: { path: "README.md" },
76+
},
77+
]);
78+
79+
const result = await runRuntimeParityScenario({
80+
scenarioId: "planned-only-tool",
81+
runCell: async (runtime) => ({
82+
scenarioStatus: "pass",
83+
cell: makeRuntimeParityCell(runtime, toolCalls),
84+
}),
85+
});
86+
87+
expect(result).toMatchObject({
88+
drift: "failure-mode",
89+
driftDetails: "at least one runtime planned a tool call without a tool result",
90+
});
91+
});
92+
});

extensions/qa-lab/src/runtime-parity.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
134134
const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]";
135135
const HEARTBEAT_TASK_PROMPT_PREFIX =
136136
"Run the following periodic tasks (only those due based on their intervals):";
137+
const TOOL_RESULT_MISSING_ERROR_CLASS = "tool-result-missing";
137138
const BOOT_STATE_LINE_RE =
138139
/\b(?:FailoverError|No API key found|Codex app-server|auth profile|runtime policy|restart mode:|plugin|doctor)\b/i;
139140
const TOOL_RESULT_ERROR_RE = /\b(?:error|failed|failure|timeout|denied|enoent|not found)\b/i;
@@ -393,6 +394,17 @@ function classifyToolResultError(params: {
393394
return undefined;
394395
}
395396

397+
function finalizeToolCallOrder(ordered: RuntimeParityPendingToolCall[]): RuntimeParityToolCall[] {
398+
return ordered.map(({ _resolved, ...toolCall }) =>
399+
_resolved
400+
? toolCall
401+
: {
402+
...toolCall,
403+
errorClass: toolCall.errorClass ?? TOOL_RESULT_MISSING_ERROR_CLASS,
404+
},
405+
);
406+
}
407+
396408
function resolveToolCallOrder(records: RuntimeParityTranscriptRecord[]): RuntimeParityToolCall[] {
397409
const ordered: RuntimeParityPendingToolCall[] = [];
398410
const byId = new Map<string, number>();
@@ -481,7 +493,7 @@ function resolveToolCallOrder(records: RuntimeParityTranscriptRecord[]): Runtime
481493
}
482494
}
483495

484-
return ordered.map(({ _resolved: _ignored, ...toolCall }) => toolCall);
496+
return finalizeToolCallOrder(ordered);
485497
}
486498

487499
function resolveToolCallOrderFromMockRequests(
@@ -545,7 +557,7 @@ function resolveToolCallOrderFromMockRequests(
545557
enqueueUnresolved(ordered.length - 1);
546558
}
547559

548-
return ordered.map(({ _resolved: _ignored, ...toolCall }) => toolCall);
560+
return finalizeToolCallOrder(ordered);
549561
}
550562

551563
function classifyScenarioError(details: string | undefined): string | undefined {
@@ -750,6 +762,10 @@ function isHardFailureRuntimeError(errorClass: string | undefined) {
750762
);
751763
}
752764

765+
function hasMissingToolResult(toolCalls: readonly RuntimeParityToolCall[]) {
766+
return toolCalls.some((toolCall) => toolCall.errorClass === TOOL_RESULT_MISSING_ERROR_CLASS);
767+
}
768+
753769
function summarizeSentinelErrorClass(findings: readonly GatewayLogSentinelFinding[]) {
754770
if (findings.length === 0) {
755771
return undefined;
@@ -781,6 +797,16 @@ function classifyRuntimeParityCells(params: {
781797
};
782798
}
783799

800+
if (
801+
hasMissingToolResult(params.openclaw.toolCalls) ||
802+
hasMissingToolResult(params.codex.toolCalls)
803+
) {
804+
return {
805+
drift: "failure-mode",
806+
driftDetails: "at least one runtime planned a tool call without a tool result",
807+
};
808+
}
809+
784810
const toolCallShapeDetails = compareToolCallShape(
785811
params.openclaw.toolCalls,
786812
params.codex.toolCalls,
@@ -1025,3 +1051,10 @@ export async function runRuntimeParityScenario(params: {
10251051
...(drift.driftDetails ? { driftDetails: drift.driftDetails } : {}),
10261052
};
10271053
}
1054+
1055+
export const testing = {
1056+
classifyRuntimeParityCells,
1057+
resolveToolCallOrderFromMockRequests,
1058+
};
1059+
1060+
export { testing as __testing };

0 commit comments

Comments
 (0)