mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
5a795f4dda
* fix(qa): require a fresh reply after switching models * fix(qa): prove successful tool use after model switching * fix(qa): wait for the new persisted model-switch tool result * fix(qa): authenticate canonical model-switch attempt evidence * fix(ai): preserve effective response model evidence Punchcard-Session: golden-valley-workshop-br * fix(agent): publish run-owned terminal receipts Punchcard-Session: golden-valley-workshop-br * fix(qa): require run-owned model-switch evidence Punchcard-Session: golden-valley-workshop-br * fix(agent): record explicit tool completion outcomes Punchcard-Session: golden-valley-workshop-br * fix(agent): exclude unavailable approvals from receipts Punchcard-Session: golden-valley-workshop-br * fix(agent): derive receipt visibility from terminal reply Punchcard-Session: golden-valley-workshop-br * fix(qa): bind model-switch continuity to terminal reply Punchcard-Session: golden-valley-workshop-br * fix(qa): project Crabline Telegram visible text Punchcard-Session: golden-valley-workshop-br * fix(qa): record run-owned delivery evidence Punchcard-Session: golden-valley-workshop-br * fix(qa): bind primary model-switch delivery Punchcard-Session: golden-valley-workshop-br --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
1563 lines
50 KiB
TypeScript
1563 lines
50 KiB
TypeScript
import {
|
|
loadTranscriptEventsSync,
|
|
resolveStorePath,
|
|
} from "openclaw/plugin-sdk/session-store-runtime";
|
|
import {
|
|
loadSqliteTrajectoryRuntimeEvents,
|
|
type SqliteTrajectoryRuntimeEventForTest,
|
|
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
|
// Qa Lab plugin module implements runtime parity behavior.
|
|
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import {
|
|
asFiniteNumber as readFiniteNumber,
|
|
isRecord as isMessageRecord,
|
|
normalizeOptionalString as readNonEmptyString,
|
|
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import {
|
|
scanDirectReplyTranscriptSentinels,
|
|
scanGatewayLogSentinels,
|
|
type GatewayLogSentinelFinding,
|
|
} from "./gateway-log-sentinel.js";
|
|
import { discardIgnoredResponseBody } from "./ignored-response-body.js";
|
|
import * as parity from "./parity-shared.js";
|
|
import {
|
|
buildRuntimeParityCacheDiagnostics,
|
|
type RuntimeParityCacheDiagnostics,
|
|
} from "./runtime-parity-cache-diagnostics.js";
|
|
import type { RuntimeParityUsage } from "./runtime-parity-usage.js";
|
|
import { readRawQaSessionStore } from "./suite-runtime-agent-session.js";
|
|
|
|
export type { RuntimeParityUsage } from "./runtime-parity-usage.js";
|
|
|
|
// These are the canonical QA comparison cells, not the extensible product
|
|
// AgentHarness registry. Broader harness coverage needs its own explicit lane.
|
|
export type RuntimeId = "openclaw" | "codex";
|
|
|
|
type RuntimeParityStatus = "pass" | "fail" | "skip";
|
|
|
|
const CANONICAL_RUNTIME_IDS = ["openclaw", "codex"] as const satisfies readonly RuntimeId[];
|
|
|
|
export type RuntimeParityToolCall = {
|
|
tool: string;
|
|
argsHash: string;
|
|
resultHash: string;
|
|
errorClass?: string;
|
|
};
|
|
|
|
export type RuntimeParityUsagePolicy =
|
|
| { expectation: "assistant-message-required" }
|
|
| { expectation: "not-applicable"; reason: string };
|
|
|
|
export type RuntimeParityCell = {
|
|
runtime: RuntimeId;
|
|
transcriptBytes: string;
|
|
toolCalls: RuntimeParityToolCall[];
|
|
providerPlanToolCalls?: RuntimeParityToolCall[];
|
|
finalText: string;
|
|
usage: RuntimeParityUsage;
|
|
cacheDiagnostics?: RuntimeParityCacheDiagnostics;
|
|
wallClockMs: number;
|
|
bootstrapWallClockMs?: number;
|
|
transportErrorClass?: string;
|
|
runtimeErrorClass?: string;
|
|
bootStateLines: string[];
|
|
sentinelFindings?: GatewayLogSentinelFinding[];
|
|
modelSwitchEvidence?: Record<string, unknown>;
|
|
};
|
|
|
|
type RuntimeParityResultCell = RuntimeParityCell & {
|
|
status: RuntimeParityStatus;
|
|
details?: string;
|
|
};
|
|
|
|
// Runtime-tool fixtures reserve this prefix for tracked harness limitations.
|
|
// Matching only that explicit skip keeps unexpected coverage loss blocking.
|
|
const KNOWN_HARNESS_GAP_DETAILS_PREFIX = "known-harness-gap ";
|
|
|
|
function isKnownHarnessGapSkip(
|
|
cell: Pick<RuntimeParityResultCell, "details" | "status"> | undefined,
|
|
) {
|
|
return (
|
|
cell?.status === "skip" &&
|
|
cell.details?.trimStart().startsWith(KNOWN_HARNESS_GAP_DETAILS_PREFIX) === true
|
|
);
|
|
}
|
|
|
|
export type RuntimeParityDrift =
|
|
| "none"
|
|
| "text-only"
|
|
| "tool-call-shape"
|
|
| "tool-result-shape"
|
|
| "structural"
|
|
| "failure-mode";
|
|
|
|
export type RuntimeParityResult = {
|
|
scenarioId: string;
|
|
runtimeParityUsage?: RuntimeParityUsagePolicy;
|
|
cells: Record<RuntimeId, RuntimeParityResultCell>;
|
|
drift: RuntimeParityDrift;
|
|
driftDetails?: string;
|
|
};
|
|
|
|
export function resolveRuntimeParityUsagePolicy(value: unknown): RuntimeParityUsagePolicy {
|
|
// Legacy or malformed summaries must not silently disable live-usage proof.
|
|
if (!value || typeof value !== "object") {
|
|
return { expectation: "assistant-message-required" };
|
|
}
|
|
const candidate = value as { expectation?: unknown; reason?: unknown };
|
|
if (
|
|
candidate.expectation === "not-applicable" &&
|
|
typeof candidate.reason === "string" &&
|
|
candidate.reason.trim()
|
|
) {
|
|
return { expectation: "not-applicable", reason: candidate.reason.trim() };
|
|
}
|
|
return { expectation: "assistant-message-required" };
|
|
}
|
|
|
|
export type RuntimeParityScenarioExecution = {
|
|
status: RuntimeParityStatus;
|
|
details?: string;
|
|
cell: RuntimeParityCell;
|
|
};
|
|
|
|
export function runtimeParityCellStatus(
|
|
cell: RuntimeParityCell | undefined,
|
|
): "pass" | "fail" | "missing" {
|
|
if (!cell) {
|
|
return "missing";
|
|
}
|
|
return cell.runtimeErrorClass || cell.transportErrorClass ? "fail" : "pass";
|
|
}
|
|
|
|
export function isRuntimeParityResultPass(result: RuntimeParityResult) {
|
|
if (result.drift === "failure-mode") {
|
|
return false;
|
|
}
|
|
const cells = CANONICAL_RUNTIME_IDS.map((runtime) => result.cells[runtime]);
|
|
const knownHarnessGapSkips = cells.filter((cell) => isKnownHarnessGapSkip(cell));
|
|
return (
|
|
knownHarnessGapSkips.length <= 1 &&
|
|
cells.every(
|
|
(cell) =>
|
|
isRuntimeParityCellPassable(cell) &&
|
|
(cell.status === "pass" || isKnownHarnessGapSkip(cell)),
|
|
)
|
|
);
|
|
}
|
|
|
|
type QaGatewayLike = {
|
|
logs?: () => string;
|
|
tempRoot: string;
|
|
};
|
|
|
|
type QaSuiteScenarioLike = {
|
|
details?: string;
|
|
status: "pass" | "fail" | "skip";
|
|
steps?: Array<{ details?: string; status?: "pass" | "fail" | "skip" }>;
|
|
modelSwitchEvidence?: Record<string, unknown>;
|
|
};
|
|
|
|
type RuntimeParityCaptureParams = {
|
|
runtime: RuntimeId;
|
|
gateway: QaGatewayLike;
|
|
scenarioResult: QaSuiteScenarioLike;
|
|
wallClockMs: number;
|
|
bootstrapWallClockMs?: number;
|
|
agentId?: string;
|
|
mockBaseUrl?: string;
|
|
};
|
|
|
|
type RuntimeParitySessionEntry = {
|
|
createdAt?: number;
|
|
sessionId?: string;
|
|
sessionFile?: string;
|
|
updatedAt?: number;
|
|
heartbeatIsolatedBaseSessionKey?: string;
|
|
spawnedBy?: string;
|
|
parentSessionKey?: string;
|
|
spawnDepth?: number;
|
|
subagentRole?: string;
|
|
};
|
|
|
|
type RuntimeParitySessionCandidate = {
|
|
entry: RuntimeParitySessionEntry;
|
|
sessionKey: string;
|
|
};
|
|
|
|
type RuntimeParityTranscriptRecord = {
|
|
message: Record<string, unknown>;
|
|
role: "user" | "assistant" | "tool" | "toolResult";
|
|
};
|
|
|
|
type RuntimeParityMockRequestSnapshot = {
|
|
prompt?: string;
|
|
allInputText?: string;
|
|
plannedToolName?: string;
|
|
plannedToolArgs?: unknown;
|
|
toolOutput?: string;
|
|
};
|
|
|
|
type RuntimeParityObservedToolCall = RuntimeParityToolCall & {
|
|
callId?: string;
|
|
hasArguments?: boolean;
|
|
hasResult?: boolean;
|
|
};
|
|
|
|
type RuntimeParityPendingToolCall = RuntimeParityObservedToolCall & {
|
|
_resolved: boolean;
|
|
};
|
|
|
|
type RuntimeParityCaptureSources = {
|
|
sessions: Array<{
|
|
transcriptBytes: string;
|
|
trajectoryToolCalls: RuntimeParityObservedToolCall[];
|
|
}>;
|
|
transcriptBytes: string;
|
|
};
|
|
|
|
const DEFAULT_AGENT_ID = "qa";
|
|
const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
|
|
const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]";
|
|
const HEARTBEAT_TASK_PROMPT_PREFIX =
|
|
"Run the following periodic tasks (only those due based on their intervals):";
|
|
const TOOL_RESULT_MISSING_ERROR_CLASS = "tool-result-missing";
|
|
const RUNTIME_PARITY_SESSION_KEY_DETAIL_PREFIX = "RUNTIME_PARITY_SESSION_KEY=";
|
|
const BOOT_STATE_LINE_RE =
|
|
/\b(?:FailoverError|No API key found|Codex app-server|auth profile|runtime policy|restart mode:|plugin|doctor)\b/i;
|
|
const TOOL_RESULT_ERROR_RE = /\b(?:error|failed|failure|timeout|denied|enoent|not found)\b/i;
|
|
|
|
function normalizeTextForParity(text: string) {
|
|
return text.replace(/\s+/gu, " ").trim();
|
|
}
|
|
|
|
function readUsageTotals(raw: unknown): RuntimeParityUsage {
|
|
const usage = isMessageRecord(raw) ? raw : {};
|
|
const inputTokens =
|
|
readFiniteNumber(usage.input) ??
|
|
readFiniteNumber(usage.inputTokens) ??
|
|
readFiniteNumber(usage.input_tokens) ??
|
|
0;
|
|
const outputTokens =
|
|
readFiniteNumber(usage.output) ??
|
|
readFiniteNumber(usage.outputTokens) ??
|
|
readFiniteNumber(usage.output_tokens) ??
|
|
0;
|
|
const cacheTelemetryUnavailable =
|
|
isMessageRecord(usage.cacheTelemetry) && usage.cacheTelemetry.state === "unavailable";
|
|
const cacheRead = cacheTelemetryUnavailable
|
|
? undefined
|
|
: (readFiniteNumber(usage.cacheRead) ?? readFiniteNumber(usage.cache_read_tokens));
|
|
const cacheWrite = cacheTelemetryUnavailable
|
|
? undefined
|
|
: (readFiniteNumber(usage.cacheWrite) ?? readFiniteNumber(usage.cache_write_tokens));
|
|
const componentTotal = inputTokens + outputTokens + (cacheRead ?? 0) + (cacheWrite ?? 0);
|
|
const totalTokens =
|
|
readFiniteNumber(usage.total) ??
|
|
readFiniteNumber(usage.totalTokens) ??
|
|
readFiniteNumber(usage.total_tokens) ??
|
|
componentTotal;
|
|
return {
|
|
inputTokens,
|
|
outputTokens,
|
|
totalTokens,
|
|
...(cacheRead !== undefined ? { cacheRead } : {}),
|
|
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
|
};
|
|
}
|
|
|
|
function readAssistantUsage(message: Record<string, unknown>): RuntimeParityUsage {
|
|
const usage = readUsageTotals(message.usage ?? null);
|
|
if (!isMessageRecord(message.usage) || isMessageRecord(message.usage.cacheTelemetry)) {
|
|
return usage;
|
|
}
|
|
const provider = readNonEmptyString(message.provider)?.toLowerCase();
|
|
const api = readNonEmptyString(message.api)?.toLowerCase();
|
|
if (
|
|
(provider === "ollama" || api === "ollama") &&
|
|
usage.cacheRead === 0 &&
|
|
usage.cacheWrite === 0
|
|
) {
|
|
// Transcripts written before cacheTelemetry was added contain Ollama's
|
|
// required placeholder zeros. Explicit current provenance always wins.
|
|
delete usage.cacheRead;
|
|
delete usage.cacheWrite;
|
|
}
|
|
return usage;
|
|
}
|
|
|
|
function addUsage(target: RuntimeParityUsage, next: RuntimeParityUsage) {
|
|
target.inputTokens += next.inputTokens;
|
|
target.outputTokens += next.outputTokens;
|
|
target.totalTokens += next.totalTokens;
|
|
if (next.cacheRead !== undefined) {
|
|
target.cacheRead = (target.cacheRead ?? 0) + next.cacheRead;
|
|
}
|
|
if (next.cacheWrite !== undefined) {
|
|
target.cacheWrite = (target.cacheWrite ?? 0) + next.cacheWrite;
|
|
}
|
|
}
|
|
|
|
function extractAssistantText(message: Record<string, unknown>) {
|
|
const rawContent = message.content;
|
|
if (typeof rawContent === "string") {
|
|
return rawContent.trim();
|
|
}
|
|
if (!Array.isArray(rawContent)) {
|
|
return "";
|
|
}
|
|
const parts: string[] = [];
|
|
for (const block of rawContent) {
|
|
if (typeof block === "string") {
|
|
if (block.trim()) {
|
|
parts.push(block.trim());
|
|
}
|
|
continue;
|
|
}
|
|
if (!isMessageRecord(block)) {
|
|
continue;
|
|
}
|
|
const text = readNonEmptyString(block.text);
|
|
if (text) {
|
|
parts.push(text);
|
|
continue;
|
|
}
|
|
const nestedText = readNonEmptyString(block.content);
|
|
if (
|
|
nestedText &&
|
|
(block.type === "output_text" || block.type === "text" || block.type === "message")
|
|
) {
|
|
parts.push(nestedText);
|
|
}
|
|
}
|
|
return parts.join("\n").trim();
|
|
}
|
|
|
|
function normalizeToolCallId(value: unknown) {
|
|
return readNonEmptyString(value);
|
|
}
|
|
|
|
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
|
|
if (!value.trim()) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
return isMessageRecord(parsed) ? parsed : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function extractToolCalls(message: Record<string, unknown>): Array<{
|
|
id?: string;
|
|
tool: string;
|
|
args: unknown;
|
|
}> {
|
|
const calls: Array<{ id?: string; tool: string; args: unknown }> = [];
|
|
const rawContent = message.content;
|
|
if (Array.isArray(rawContent)) {
|
|
for (const block of rawContent) {
|
|
if (!isMessageRecord(block)) {
|
|
continue;
|
|
}
|
|
const type = readNonEmptyString(block.type)?.toLowerCase();
|
|
if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") {
|
|
continue;
|
|
}
|
|
const tool = readNonEmptyString(block.name) ?? "unknown";
|
|
calls.push({
|
|
id:
|
|
normalizeToolCallId(block.id) ??
|
|
normalizeToolCallId(block.toolCallId) ??
|
|
normalizeToolCallId(block.toolUseId),
|
|
tool,
|
|
args: block.input ?? block.arguments ?? block.args ?? block.payload ?? null,
|
|
});
|
|
}
|
|
}
|
|
const rawToolCalls =
|
|
message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall;
|
|
const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls : rawToolCalls ? [rawToolCalls] : [];
|
|
for (const call of toolCalls) {
|
|
if (!isMessageRecord(call)) {
|
|
continue;
|
|
}
|
|
const functionRecord = isMessageRecord(call.function) ? call.function : undefined;
|
|
const tool =
|
|
readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name) ?? "unknown";
|
|
calls.push({
|
|
id:
|
|
normalizeToolCallId(call.id) ??
|
|
normalizeToolCallId(call.toolCallId) ??
|
|
normalizeToolCallId(call.toolUseId),
|
|
tool,
|
|
args:
|
|
call.arguments ?? functionRecord?.arguments ?? call.input ?? functionRecord?.input ?? null,
|
|
});
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
function extractToolResults(message: Record<string, unknown>): Array<{
|
|
id?: string;
|
|
tool?: string;
|
|
result: unknown;
|
|
errorClass?: string;
|
|
}> {
|
|
const results: Array<{ id?: string; tool?: string; result: unknown; errorClass?: string }> = [];
|
|
const toolName =
|
|
readNonEmptyString(message.toolName) ??
|
|
readNonEmptyString(message.tool_name) ??
|
|
readNonEmptyString(message.name) ??
|
|
readNonEmptyString(message.tool);
|
|
if ((message.role === "tool" || message.role === "toolResult") && message.content !== undefined) {
|
|
const contentText = extractAssistantText(message);
|
|
results.push({
|
|
tool: toolName,
|
|
result: message.content,
|
|
...(message.isError === true || TOOL_RESULT_ERROR_RE.test(contentText)
|
|
? { errorClass: "tool-result-error" }
|
|
: {}),
|
|
});
|
|
}
|
|
const rawContent = message.content;
|
|
if (!Array.isArray(rawContent)) {
|
|
return results;
|
|
}
|
|
for (const block of rawContent) {
|
|
if (!isMessageRecord(block)) {
|
|
continue;
|
|
}
|
|
const type = readNonEmptyString(block.type)?.toLowerCase();
|
|
if (type !== "tool_result" && type !== "tool_result_error") {
|
|
continue;
|
|
}
|
|
const content = block.content ?? block.result ?? block.output ?? block.text ?? null;
|
|
const contentText =
|
|
typeof content === "string"
|
|
? content
|
|
: Array.isArray(content)
|
|
? JSON.stringify(content)
|
|
: JSON.stringify(content ?? "");
|
|
results.push({
|
|
id:
|
|
normalizeToolCallId(block.tool_use_id) ??
|
|
normalizeToolCallId(block.toolUseId) ??
|
|
normalizeToolCallId(block.tool_call_id) ??
|
|
normalizeToolCallId(block.toolCallId),
|
|
tool: toolName,
|
|
result: content,
|
|
...(block.is_error === true ||
|
|
type === "tool_result_error" ||
|
|
TOOL_RESULT_ERROR_RE.test(contentText)
|
|
? { errorClass: "tool-result-error" }
|
|
: {}),
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function classifyToolResultError(params: {
|
|
rawOutput: string;
|
|
parsedOutput: Record<string, unknown> | undefined;
|
|
}) {
|
|
const error = readNonEmptyString(params.parsedOutput?.error);
|
|
if (error) {
|
|
return "tool-result-error";
|
|
}
|
|
const status = readNonEmptyString(params.parsedOutput?.status);
|
|
if (status && /\b(?:error|failed|failure)\b/i.test(status)) {
|
|
return "tool-result-error";
|
|
}
|
|
if (!params.parsedOutput) {
|
|
const normalized = params.rawOutput.trim().toLowerCase();
|
|
if (
|
|
normalized.startsWith("error:") ||
|
|
normalized.startsWith("failed:") ||
|
|
normalized.includes("unsupported call:") ||
|
|
normalized.includes("permission denied") ||
|
|
normalized.includes("no such file") ||
|
|
normalized.includes("enoent")
|
|
) {
|
|
return "tool-result-error";
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function finalizeToolCallOrder(
|
|
ordered: RuntimeParityPendingToolCall[],
|
|
): RuntimeParityObservedToolCall[] {
|
|
return ordered.map(({ _resolved, ...toolCall }) =>
|
|
_resolved
|
|
? toolCall
|
|
: {
|
|
...toolCall,
|
|
errorClass: toolCall.errorClass ?? TOOL_RESULT_MISSING_ERROR_CLASS,
|
|
},
|
|
);
|
|
}
|
|
|
|
function resolveToolCallOrder(
|
|
records: RuntimeParityTranscriptRecord[],
|
|
): RuntimeParityObservedToolCall[] {
|
|
const ordered: RuntimeParityPendingToolCall[] = [];
|
|
const byId = new Map<string, number>();
|
|
const unresolvedByTool = new Map<string, number[]>();
|
|
const unresolvedOrder: number[] = [];
|
|
|
|
const enqueueUnresolved = (tool: string, index: number) => {
|
|
const indices = unresolvedByTool.get(tool) ?? [];
|
|
indices.push(index);
|
|
unresolvedByTool.set(tool, indices);
|
|
unresolvedOrder.push(index);
|
|
};
|
|
|
|
const markResolved = (index: number) => {
|
|
const pending = ordered[index];
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
ordered[index] = { ...pending, _resolved: true };
|
|
const unresolvedIndex = unresolvedOrder.indexOf(index);
|
|
if (unresolvedIndex >= 0) {
|
|
unresolvedOrder.splice(unresolvedIndex, 1);
|
|
}
|
|
const toolIndices = unresolvedByTool.get(pending.tool);
|
|
if (!toolIndices) {
|
|
return;
|
|
}
|
|
const nextIndices = toolIndices.filter((candidate) => candidate !== index);
|
|
if (nextIndices.length > 0) {
|
|
unresolvedByTool.set(pending.tool, nextIndices);
|
|
return;
|
|
}
|
|
unresolvedByTool.delete(pending.tool);
|
|
};
|
|
|
|
const matchPendingIndex = (result: { id?: string; tool?: string }) => {
|
|
if (result.id && byId.has(result.id)) {
|
|
return byId.get(result.id);
|
|
}
|
|
if (result.tool) {
|
|
const toolIndices = unresolvedByTool.get(result.tool);
|
|
if (toolIndices && toolIndices.length > 0) {
|
|
return toolIndices[0];
|
|
}
|
|
}
|
|
return unresolvedOrder[0];
|
|
};
|
|
|
|
for (const record of records) {
|
|
if (record.role === "assistant") {
|
|
for (const call of extractToolCalls(record.message)) {
|
|
const index =
|
|
ordered.push({
|
|
tool: call.tool,
|
|
argsHash: parity.stableHash(call.args),
|
|
resultHash: parity.stableHash(null),
|
|
callId: call.id,
|
|
hasArguments: true,
|
|
hasResult: false,
|
|
_resolved: false,
|
|
}) - 1;
|
|
if (call.id) {
|
|
byId.set(call.id, index);
|
|
}
|
|
enqueueUnresolved(call.tool, index);
|
|
}
|
|
}
|
|
if (record.role === "user" || record.role === "tool" || record.role === "toolResult") {
|
|
for (const result of extractToolResults(record.message)) {
|
|
const pendingIndex = matchPendingIndex(result);
|
|
const nextValue: RuntimeParityObservedToolCall = {
|
|
tool:
|
|
result.tool ??
|
|
(pendingIndex !== undefined ? ordered[pendingIndex]?.tool : undefined) ??
|
|
"unknown",
|
|
argsHash:
|
|
pendingIndex !== undefined
|
|
? (ordered[pendingIndex]?.argsHash ?? parity.stableHash(null))
|
|
: parity.stableHash(null),
|
|
resultHash: parity.stableHash(result.result),
|
|
callId: pendingIndex !== undefined ? ordered[pendingIndex]?.callId : result.id,
|
|
hasArguments:
|
|
pendingIndex !== undefined ? ordered[pendingIndex]?.hasArguments === true : false,
|
|
hasResult: true,
|
|
...(result.errorClass ? { errorClass: result.errorClass } : {}),
|
|
};
|
|
if (pendingIndex === undefined || !ordered[pendingIndex]) {
|
|
ordered.push({ ...nextValue, _resolved: true });
|
|
continue;
|
|
}
|
|
ordered[pendingIndex] = {
|
|
...nextValue,
|
|
_resolved: true,
|
|
};
|
|
markResolved(pendingIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
return finalizeToolCallOrder(ordered);
|
|
}
|
|
|
|
function resolveToolCallOrderFromMockRequests(
|
|
requests: RuntimeParityMockRequestSnapshot[],
|
|
): RuntimeParityToolCall[] {
|
|
const ordered: RuntimeParityPendingToolCall[] = [];
|
|
const unresolvedOrder: number[] = [];
|
|
|
|
const enqueueUnresolved = (index: number) => {
|
|
unresolvedOrder.push(index);
|
|
};
|
|
|
|
const markResolved = (index: number) => {
|
|
const pending = ordered[index];
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
ordered[index] = { ...pending, _resolved: true };
|
|
const unresolvedIndex = unresolvedOrder.indexOf(index);
|
|
if (unresolvedIndex >= 0) {
|
|
unresolvedOrder.splice(unresolvedIndex, 1);
|
|
}
|
|
};
|
|
|
|
for (const request of requests) {
|
|
const rawToolOutput = readNonEmptyString(request.toolOutput) ?? "";
|
|
if (rawToolOutput) {
|
|
const pendingIndex = unresolvedOrder[0];
|
|
const parsedOutput = parseJsonRecord(rawToolOutput);
|
|
const resolvedCall: RuntimeParityToolCall = {
|
|
tool: pendingIndex !== undefined ? (ordered[pendingIndex]?.tool ?? "unknown") : "unknown",
|
|
argsHash:
|
|
pendingIndex !== undefined
|
|
? (ordered[pendingIndex]?.argsHash ?? parity.stableHash(null))
|
|
: parity.stableHash(null),
|
|
resultHash: parity.stableHash(parsedOutput ?? rawToolOutput),
|
|
...(classifyToolResultError({
|
|
rawOutput: rawToolOutput,
|
|
parsedOutput,
|
|
})
|
|
? { errorClass: "tool-result-error" }
|
|
: {}),
|
|
};
|
|
if (pendingIndex === undefined || !ordered[pendingIndex]) {
|
|
ordered.push({ ...resolvedCall, _resolved: true });
|
|
} else {
|
|
ordered[pendingIndex] = {
|
|
...resolvedCall,
|
|
_resolved: true,
|
|
};
|
|
markResolved(pendingIndex);
|
|
}
|
|
}
|
|
|
|
const plannedToolName = readNonEmptyString(request.plannedToolName);
|
|
if (!plannedToolName) {
|
|
continue;
|
|
}
|
|
ordered.push({
|
|
tool: plannedToolName,
|
|
argsHash: parity.stableHash(request.plannedToolArgs ?? null),
|
|
resultHash: parity.stableHash(null),
|
|
_resolved: false,
|
|
});
|
|
enqueueUnresolved(ordered.length - 1);
|
|
}
|
|
|
|
return finalizeToolCallOrder(ordered);
|
|
}
|
|
|
|
function trajectoryToolCallId(data: Record<string, unknown>) {
|
|
return (
|
|
normalizeToolCallId(data.toolCallId) ??
|
|
normalizeToolCallId(data.itemId) ??
|
|
normalizeToolCallId(data.id)
|
|
);
|
|
}
|
|
|
|
function trajectoryToolResultValue(data: Record<string, unknown>) {
|
|
if (Object.hasOwn(data, "result")) {
|
|
return data.result;
|
|
}
|
|
if (Object.hasOwn(data, "output")) {
|
|
return data.output;
|
|
}
|
|
if (Object.hasOwn(data, "contentItems")) {
|
|
return data.contentItems;
|
|
}
|
|
return {
|
|
...(Object.hasOwn(data, "status") ? { status: data.status } : {}),
|
|
...(Object.hasOwn(data, "success") ? { success: data.success } : {}),
|
|
};
|
|
}
|
|
|
|
function isTrajectoryToolResultError(data: Record<string, unknown>) {
|
|
if (data.isError === true || data.success === false) {
|
|
return true;
|
|
}
|
|
const status = readNonEmptyString(data.status)?.toLowerCase();
|
|
return (
|
|
status === "blocked" ||
|
|
status === "cancelled" ||
|
|
status === "declined" ||
|
|
status === "error" ||
|
|
status === "failed"
|
|
);
|
|
}
|
|
|
|
function resolveTrajectoryToolCallOrder(
|
|
events: readonly SqliteTrajectoryRuntimeEventForTest[],
|
|
): RuntimeParityObservedToolCall[] {
|
|
const ordered: Array<{
|
|
call: RuntimeParityObservedToolCall;
|
|
resolved: boolean;
|
|
}> = [];
|
|
|
|
const matchPendingIndex = (data: Record<string, unknown>, tool?: string) => {
|
|
const id = trajectoryToolCallId(data);
|
|
if (id) {
|
|
const idMatch = ordered.findIndex((pending) => pending.call.callId === id);
|
|
if (idMatch >= 0) {
|
|
return idMatch;
|
|
}
|
|
return undefined;
|
|
}
|
|
const toolMatch = ordered.findIndex(
|
|
(pending) => !pending.resolved && (!tool || pending.call.tool === tool),
|
|
);
|
|
if (toolMatch >= 0) {
|
|
return toolMatch;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
for (const event of events) {
|
|
const data = event.data ?? {};
|
|
if (event.type === "tool.call") {
|
|
const tool = readNonEmptyString(data.name) ?? "unknown";
|
|
ordered.push({
|
|
call: {
|
|
tool,
|
|
argsHash: parity.stableHash(data.arguments ?? null),
|
|
resultHash: parity.stableHash(null),
|
|
callId: trajectoryToolCallId(data),
|
|
hasArguments: true,
|
|
hasResult: false,
|
|
},
|
|
resolved: false,
|
|
});
|
|
continue;
|
|
}
|
|
if (event.type !== "tool.result") {
|
|
continue;
|
|
}
|
|
const tool = readNonEmptyString(data.name);
|
|
const pendingIndex = matchPendingIndex(data, tool);
|
|
const pendingCall = pendingIndex !== undefined ? ordered[pendingIndex]?.call : undefined;
|
|
const nextValue: RuntimeParityObservedToolCall = {
|
|
tool: tool ?? pendingCall?.tool ?? "unknown",
|
|
argsHash: pendingCall?.argsHash ?? parity.stableHash(null),
|
|
resultHash: parity.stableHash(trajectoryToolResultValue(data)),
|
|
callId: trajectoryToolCallId(data) ?? pendingCall?.callId,
|
|
hasArguments: pendingCall?.hasArguments === true,
|
|
hasResult: true,
|
|
...(isTrajectoryToolResultError(data) ? { errorClass: "tool-result-error" } : {}),
|
|
};
|
|
if (pendingIndex === undefined) {
|
|
ordered.push({
|
|
call: nextValue,
|
|
resolved: true,
|
|
});
|
|
continue;
|
|
}
|
|
ordered[pendingIndex] = {
|
|
...ordered[pendingIndex],
|
|
call: nextValue,
|
|
resolved: true,
|
|
};
|
|
}
|
|
|
|
for (const pending of ordered) {
|
|
if (!pending.resolved) {
|
|
pending.call.errorClass ??= TOOL_RESULT_MISSING_ERROR_CLASS;
|
|
}
|
|
}
|
|
return ordered.map((pending) => pending.call);
|
|
}
|
|
|
|
function mergeRuntimeParityToolCalls(params: {
|
|
transcriptToolCalls: RuntimeParityObservedToolCall[];
|
|
trajectoryToolCalls: RuntimeParityObservedToolCall[];
|
|
}): RuntimeParityObservedToolCall[] {
|
|
if (params.trajectoryToolCalls.length === 0) {
|
|
return params.transcriptToolCalls;
|
|
}
|
|
if (params.transcriptToolCalls.length === 0) {
|
|
return params.trajectoryToolCalls;
|
|
}
|
|
|
|
const transcript = params.transcriptToolCalls;
|
|
const trajectory = params.trajectoryToolCalls;
|
|
const callsMatch = (
|
|
transcriptCall: RuntimeParityObservedToolCall,
|
|
trajectoryCall: RuntimeParityObservedToolCall,
|
|
) => {
|
|
if (transcriptCall.callId && trajectoryCall.callId) {
|
|
return transcriptCall.callId === trajectoryCall.callId;
|
|
}
|
|
if (transcriptCall.callId || trajectoryCall.callId) {
|
|
return false;
|
|
}
|
|
return (
|
|
transcriptCall.tool === trajectoryCall.tool &&
|
|
transcriptCall.argsHash === trajectoryCall.argsHash
|
|
);
|
|
};
|
|
const mergeMatchingCalls = (
|
|
transcriptCall: RuntimeParityObservedToolCall,
|
|
trajectoryCall: RuntimeParityObservedToolCall,
|
|
): RuntimeParityObservedToolCall => {
|
|
const invocation = transcriptCall.hasArguments ? transcriptCall : trajectoryCall;
|
|
const completion = transcriptCall.hasResult ? transcriptCall : trajectoryCall;
|
|
return {
|
|
tool: invocation.tool,
|
|
argsHash: invocation.argsHash,
|
|
resultHash: completion.resultHash,
|
|
callId: transcriptCall.callId ?? trajectoryCall.callId,
|
|
hasArguments: invocation.hasArguments,
|
|
hasResult: completion.hasResult,
|
|
...(completion.errorClass ? { errorClass: completion.errorClass } : {}),
|
|
};
|
|
};
|
|
const sharedSuffixLengths = Array.from({ length: transcript.length + 1 }, () =>
|
|
Array<number>(trajectory.length + 1).fill(0),
|
|
);
|
|
for (let transcriptIndex = transcript.length - 1; transcriptIndex >= 0; transcriptIndex -= 1) {
|
|
for (let trajectoryIndex = trajectory.length - 1; trajectoryIndex >= 0; trajectoryIndex -= 1) {
|
|
sharedSuffixLengths[transcriptIndex]![trajectoryIndex] = callsMatch(
|
|
transcript[transcriptIndex]!,
|
|
trajectory[trajectoryIndex]!,
|
|
)
|
|
? 1 + sharedSuffixLengths[transcriptIndex + 1]![trajectoryIndex + 1]!
|
|
: Math.max(
|
|
sharedSuffixLengths[transcriptIndex + 1]![trajectoryIndex]!,
|
|
sharedSuffixLengths[transcriptIndex]![trajectoryIndex + 1]!,
|
|
);
|
|
}
|
|
}
|
|
|
|
const merged: RuntimeParityObservedToolCall[] = [];
|
|
let transcriptIndex = 0;
|
|
let trajectoryIndex = 0;
|
|
while (transcriptIndex < transcript.length && trajectoryIndex < trajectory.length) {
|
|
const transcriptCall = transcript[transcriptIndex]!;
|
|
const trajectoryCall = trajectory[trajectoryIndex]!;
|
|
if (callsMatch(transcriptCall, trajectoryCall)) {
|
|
merged.push(mergeMatchingCalls(transcriptCall, trajectoryCall));
|
|
transcriptIndex += 1;
|
|
trajectoryIndex += 1;
|
|
continue;
|
|
}
|
|
if (
|
|
sharedSuffixLengths[transcriptIndex + 1]![trajectoryIndex]! >=
|
|
sharedSuffixLengths[transcriptIndex]![trajectoryIndex + 1]!
|
|
) {
|
|
merged.push(transcriptCall);
|
|
transcriptIndex += 1;
|
|
continue;
|
|
}
|
|
merged.push(trajectoryCall);
|
|
trajectoryIndex += 1;
|
|
}
|
|
return [...merged, ...transcript.slice(transcriptIndex), ...trajectory.slice(trajectoryIndex)];
|
|
}
|
|
|
|
function removeRuntimeParityToolCallIdentity(
|
|
toolCalls: RuntimeParityObservedToolCall[],
|
|
): RuntimeParityToolCall[] {
|
|
return toolCalls.map(
|
|
({ callId: _callId, hasArguments: _hasArguments, hasResult: _hasResult, ...toolCall }) =>
|
|
toolCall,
|
|
);
|
|
}
|
|
|
|
function classifyScenarioError(details: string | undefined): string | undefined {
|
|
const normalized = normalizeTextForParity(details ?? "").toLowerCase();
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
if (normalized.includes("no api key found")) {
|
|
return "missing-api-key";
|
|
}
|
|
if (normalized.includes("failover")) {
|
|
return "failover";
|
|
}
|
|
if (normalized.includes("timeout") || normalized.includes("timed out")) {
|
|
return "timeout";
|
|
}
|
|
if (normalized.includes("codex app-server")) {
|
|
return "codex-app-server";
|
|
}
|
|
if (
|
|
normalized.includes("auth profile") ||
|
|
normalized.includes("oauth") ||
|
|
normalized.includes("api key")
|
|
) {
|
|
return "auth";
|
|
}
|
|
if (normalized.includes("tool")) {
|
|
return "tool-error";
|
|
}
|
|
return "scenario-failure";
|
|
}
|
|
|
|
function extractBootStateLines(logs: string | undefined): string[] {
|
|
if (!logs) {
|
|
return [];
|
|
}
|
|
return logs
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0 && BOOT_STATE_LINE_RE.test(line))
|
|
.slice(-30);
|
|
}
|
|
|
|
function buildTranscriptRecords(transcriptBytes: string): RuntimeParityTranscriptRecord[] {
|
|
const records: RuntimeParityTranscriptRecord[] = [];
|
|
for (const line of transcriptBytes.split(/\r?\n/u)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
const message = isMessageRecord(parsed.message) ? parsed.message : undefined;
|
|
const role = readNonEmptyString(message?.role);
|
|
if (
|
|
!message ||
|
|
(role !== "user" && role !== "assistant" && role !== "tool" && role !== "toolResult")
|
|
) {
|
|
continue;
|
|
}
|
|
records.push({
|
|
message,
|
|
role,
|
|
});
|
|
} catch {
|
|
// Ignore malformed QA transcript rows and keep the classifier deterministic.
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function isHeartbeatOnlyRuntimeTranscript(transcriptBytes: string) {
|
|
const records = buildTranscriptRecords(transcriptBytes);
|
|
if (records.length === 0) {
|
|
return false;
|
|
}
|
|
const userTexts = records
|
|
.filter((record) => record.role === "user" && !isToolResultLikeMessage(record.message))
|
|
.map((record) => extractAssistantText(record.message));
|
|
return userTexts.length > 0 && userTexts.every(isHeartbeatRuntimeUserText);
|
|
}
|
|
|
|
function isToolResultLikeMessage(message: Record<string, unknown>) {
|
|
if (message.role === "tool" || message.role === "toolResult") {
|
|
return true;
|
|
}
|
|
const rawContent = message.content;
|
|
if (!Array.isArray(rawContent)) {
|
|
return false;
|
|
}
|
|
return rawContent.some((block) => {
|
|
if (!isMessageRecord(block)) {
|
|
return false;
|
|
}
|
|
const type = readNonEmptyString(block.type)?.toLowerCase();
|
|
return type === "tool_result" || type === "toolresult" || type === "tool_result_error";
|
|
});
|
|
}
|
|
|
|
function isHeartbeatRuntimeUserText(text: string) {
|
|
const normalized = normalizeTextForParity(text).toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (normalized === HEARTBEAT_TRANSCRIPT_PROMPT.toLowerCase()) {
|
|
return true;
|
|
}
|
|
if (normalized.startsWith("read heartbeat.md") && normalized.includes("heartbeat_ok")) {
|
|
return true;
|
|
}
|
|
if (
|
|
normalized.startsWith("read heartbeat.md") &&
|
|
normalized.includes(HEARTBEAT_RESPONSE_TOOL_NAME)
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
normalized.startsWith(HEARTBEAT_TASK_PROMPT_PREFIX.toLowerCase()) &&
|
|
(normalized.includes("heartbeat_ok") || normalized.includes(HEARTBEAT_RESPONSE_TOOL_NAME))
|
|
);
|
|
}
|
|
|
|
function extractFinalAssistantText(records: RuntimeParityTranscriptRecord[]) {
|
|
let lastAssistantText = "";
|
|
for (const record of records) {
|
|
if (record.role !== "assistant") {
|
|
continue;
|
|
}
|
|
const text = extractAssistantText(record.message);
|
|
if (text) {
|
|
lastAssistantText = text;
|
|
}
|
|
}
|
|
return normalizeTextForParity(lastAssistantText);
|
|
}
|
|
|
|
function aggregateUsage(records: RuntimeParityTranscriptRecord[]): RuntimeParityUsage {
|
|
const totals: RuntimeParityUsage = {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
totalTokens: 0,
|
|
};
|
|
for (const record of records) {
|
|
if (record.role !== "assistant") {
|
|
continue;
|
|
}
|
|
const usage = readAssistantUsage(record.message);
|
|
addUsage(totals, usage);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
function compareToolResultShape(
|
|
left: RuntimeParityToolCall[],
|
|
right: RuntimeParityToolCall[],
|
|
): string | undefined {
|
|
const total = Math.min(left.length, right.length);
|
|
for (let index = 0; index < total; index += 1) {
|
|
const leftCall = left[index];
|
|
const rightCall = right[index];
|
|
if (!leftCall || !rightCall) {
|
|
continue;
|
|
}
|
|
if (
|
|
leftCall.errorClass === "tool-result-error" &&
|
|
rightCall.errorClass === "tool-result-error"
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
leftCall.resultHash !== rightCall.resultHash ||
|
|
(leftCall.errorClass ?? "") !== (rightCall.errorClass ?? "")
|
|
) {
|
|
return `tool result ${index + 1} differs (${leftCall.tool})`;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isHardFailureRuntimeError(errorClass: string | undefined) {
|
|
return (
|
|
errorClass === "missing-api-key" ||
|
|
errorClass === "failover" ||
|
|
errorClass === "codex-app-server" ||
|
|
errorClass === "auth" ||
|
|
errorClass === "capture-missing" ||
|
|
errorClass?.startsWith("sentinel:") === true
|
|
);
|
|
}
|
|
|
|
export function isRuntimeParityCellPassable(cell: RuntimeParityCell | undefined) {
|
|
if (!cell || cell.transportErrorClass || isHardFailureRuntimeError(cell.runtimeErrorClass)) {
|
|
return false;
|
|
}
|
|
return !cell.runtimeErrorClass || cell.runtimeErrorClass === "tool-error";
|
|
}
|
|
|
|
function hasMissingToolResult(toolCalls: readonly RuntimeParityToolCall[]) {
|
|
return toolCalls.some((toolCall) => toolCall.errorClass === TOOL_RESULT_MISSING_ERROR_CLASS);
|
|
}
|
|
|
|
function hasProvenTerminalImageResult(scenarioResult: QaSuiteScenarioLike) {
|
|
return (
|
|
scenarioResult.status === "pass" &&
|
|
(scenarioResult.steps ?? []).some(
|
|
(step) =>
|
|
step.status === "pass" &&
|
|
/(?:^|\n)image_generate=true\r?\nMEDIA:\S+/u.test(step.details ?? ""),
|
|
)
|
|
);
|
|
}
|
|
|
|
const PROVEN_TERMINAL_IMAGE_RESULT_HASH = parity.stableHash({ kind: "media", status: "success" });
|
|
|
|
function resolveRuntimeParityToolCalls(params: {
|
|
transcriptToolCalls: RuntimeParityToolCall[];
|
|
terminalImageResultProven?: boolean;
|
|
}): RuntimeParityToolCall[] {
|
|
let selected = params.transcriptToolCalls;
|
|
const imageCalls = selected.filter((toolCall) => toolCall.tool === "image_generate");
|
|
if (params.terminalImageResultProven && imageCalls.length === 1) {
|
|
selected = selected.map((toolCall) => {
|
|
if (
|
|
toolCall.tool !== "image_generate" ||
|
|
(toolCall.errorClass !== undefined &&
|
|
toolCall.errorClass !== TOOL_RESULT_MISSING_ERROR_CLASS)
|
|
) {
|
|
return toolCall;
|
|
}
|
|
return {
|
|
...toolCall,
|
|
resultHash: PROVEN_TERMINAL_IMAGE_RESULT_HASH,
|
|
errorClass: undefined,
|
|
};
|
|
});
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function filterMockRequestsForParentPrompt(
|
|
requests: RuntimeParityMockRequestSnapshot[],
|
|
parentPrompt: string,
|
|
parentPrompts: readonly string[] = [parentPrompt],
|
|
) {
|
|
const normalizedParentPrompts = parentPrompts
|
|
.map(normalizeTextForParity)
|
|
.filter((prompt) => prompt.length > 0);
|
|
if (normalizedParentPrompts.length === 0) {
|
|
return requests;
|
|
}
|
|
const matching = requests.filter((request) => {
|
|
const normalizedPrompt = normalizeTextForParity(request.prompt ?? "");
|
|
if (normalizedPrompt) {
|
|
return normalizedParentPrompts.some((prompt) => normalizedPrompt.includes(prompt));
|
|
}
|
|
const normalizedHistory = normalizeTextForParity(request.allInputText ?? "");
|
|
return normalizedParentPrompts.some((prompt) => normalizedHistory.includes(prompt));
|
|
});
|
|
return matching.length > 0 ? matching : requests;
|
|
}
|
|
|
|
function summarizeSentinelErrorClass(findings: readonly GatewayLogSentinelFinding[]) {
|
|
if (findings.length === 0) {
|
|
return undefined;
|
|
}
|
|
return `sentinel:${findings
|
|
.map((finding) => finding.kind)
|
|
.toSorted((left, right) => left.localeCompare(right))
|
|
.join(",")}`;
|
|
}
|
|
|
|
function classifyRuntimeParityCells(params: {
|
|
openclaw: RuntimeParityCell;
|
|
codex: RuntimeParityCell;
|
|
openclawStatus: RuntimeParityStatus;
|
|
codexStatus: RuntimeParityStatus;
|
|
openclawDetails?: string;
|
|
codexDetails?: string;
|
|
}): Pick<RuntimeParityResult, "drift" | "driftDetails"> {
|
|
if (
|
|
isHardFailureRuntimeError(params.openclaw.runtimeErrorClass) ||
|
|
isHardFailureRuntimeError(params.codex.runtimeErrorClass) ||
|
|
params.openclaw.transportErrorClass ||
|
|
params.codex.transportErrorClass
|
|
) {
|
|
return {
|
|
drift: "failure-mode",
|
|
driftDetails:
|
|
params.openclaw.transportErrorClass || params.codex.transportErrorClass
|
|
? "at least one runtime hit a transport failure"
|
|
: "at least one runtime hit a hard runtime failure",
|
|
};
|
|
}
|
|
|
|
if (
|
|
hasMissingToolResult(params.openclaw.toolCalls) ||
|
|
hasMissingToolResult(params.codex.toolCalls)
|
|
) {
|
|
return {
|
|
drift: "failure-mode",
|
|
driftDetails: "at least one runtime planned a tool call without a tool result",
|
|
};
|
|
}
|
|
|
|
const openclawKnownHarnessGap = isKnownHarnessGapSkip({
|
|
status: params.openclawStatus,
|
|
details: params.openclawDetails,
|
|
});
|
|
const codexKnownHarnessGap = isKnownHarnessGapSkip({
|
|
status: params.codexStatus,
|
|
details: params.codexDetails,
|
|
});
|
|
if (
|
|
openclawKnownHarnessGap !== codexKnownHarnessGap &&
|
|
(openclawKnownHarnessGap ? params.codexStatus : params.openclawStatus) === "pass" &&
|
|
isRuntimeParityCellPassable(params.openclaw) &&
|
|
isRuntimeParityCellPassable(params.codex)
|
|
) {
|
|
const skippedRuntime = openclawKnownHarnessGap ? "openclaw" : "codex";
|
|
return {
|
|
drift: "structural",
|
|
driftDetails: `known harness gap in ${skippedRuntime} runtime; paired runtime passed`,
|
|
};
|
|
}
|
|
|
|
if (
|
|
params.openclawStatus !== "pass" ||
|
|
params.codexStatus !== "pass" ||
|
|
!isRuntimeParityCellPassable(params.openclaw) ||
|
|
!isRuntimeParityCellPassable(params.codex)
|
|
) {
|
|
return {
|
|
drift: "failure-mode",
|
|
driftDetails:
|
|
params.openclawStatus === params.codexStatus
|
|
? params.openclawStatus === "skip"
|
|
? "both canonical runtime-pair cells skipped"
|
|
: params.openclawStatus === "fail"
|
|
? "both canonical runtime-pair cells failed"
|
|
: "at least one runtime failed"
|
|
: `runtime-pair cell status differs (${params.openclawStatus} vs ${params.codexStatus})`,
|
|
};
|
|
}
|
|
|
|
const toolCallShapeDetails = parity.compareToolCallShape(
|
|
params.openclaw.toolCalls,
|
|
params.codex.toolCalls,
|
|
);
|
|
if (toolCallShapeDetails) {
|
|
return { drift: "tool-call-shape", driftDetails: toolCallShapeDetails };
|
|
}
|
|
|
|
const toolResultShapeDetails = compareToolResultShape(
|
|
params.openclaw.toolCalls,
|
|
params.codex.toolCalls,
|
|
);
|
|
if (toolResultShapeDetails) {
|
|
return { drift: "tool-result-shape", driftDetails: toolResultShapeDetails };
|
|
}
|
|
|
|
const openclawTranscriptLines = params.openclaw.transcriptBytes.trim().length
|
|
? params.openclaw.transcriptBytes.trim().split(/\r?\n/u).length
|
|
: 0;
|
|
const codexTranscriptLines = params.codex.transcriptBytes.trim().length
|
|
? params.codex.transcriptBytes.trim().split(/\r?\n/u).length
|
|
: 0;
|
|
if (
|
|
openclawTranscriptLines !== codexTranscriptLines ||
|
|
(!params.openclaw.finalText && Boolean(params.codex.finalText)) ||
|
|
(Boolean(params.openclaw.finalText) && !params.codex.finalText)
|
|
) {
|
|
return {
|
|
drift: "structural",
|
|
driftDetails: `transcript/final-text structure differs (${openclawTranscriptLines} lines vs ${codexTranscriptLines})`,
|
|
};
|
|
}
|
|
|
|
if (
|
|
normalizeTextForParity(params.openclaw.finalText) ===
|
|
normalizeTextForParity(params.codex.finalText)
|
|
) {
|
|
return { drift: "none" };
|
|
}
|
|
|
|
return { drift: "text-only", driftDetails: "final text differs after whitespace normalization" };
|
|
}
|
|
|
|
function isRuntimeParityRootSession(entry: RuntimeParitySessionEntry) {
|
|
if (readNonEmptyString(entry.spawnedBy) || readNonEmptyString(entry.parentSessionKey)) {
|
|
return false;
|
|
}
|
|
if (typeof entry.spawnDepth === "number" && entry.spawnDepth > 0) {
|
|
return false;
|
|
}
|
|
if (readNonEmptyString(entry.subagentRole)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function runtimeParitySessionEnv(stateDir: string): NodeJS.ProcessEnv {
|
|
return { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
}
|
|
|
|
async function readRuntimeParitySessionEntries(params: {
|
|
gateway: QaGatewayLike;
|
|
agentId: string;
|
|
preferredSessionKeys?: ReadonlySet<string>;
|
|
}): Promise<RuntimeParitySessionCandidate[]> {
|
|
// This feeds release evidence: after bounded FTS-settle retries, a persistent
|
|
// store failure must fail capture instead of becoming an empty false green.
|
|
const store = await readRawQaSessionStore(
|
|
{ gateway: params.gateway },
|
|
{ agentId: params.agentId },
|
|
);
|
|
const entries = Object.entries(store)
|
|
.filter(([, entry]) => readNonEmptyString(entry.sessionId))
|
|
.map(([sessionKey, entry]) => ({
|
|
entry: entry as RuntimeParitySessionEntry,
|
|
sessionKey,
|
|
}))
|
|
.filter(({ entry }) => !readNonEmptyString(entry.heartbeatIsolatedBaseSessionKey));
|
|
const selectedEntries = params.preferredSessionKeys
|
|
? entries.filter(({ sessionKey }) => params.preferredSessionKeys?.has(sessionKey))
|
|
: entries;
|
|
const rootEntries = selectedEntries.filter(({ entry }) => isRuntimeParityRootSession(entry));
|
|
const candidates = rootEntries.length > 0 ? rootEntries : selectedEntries;
|
|
return candidates.toSorted((left, right) => {
|
|
const leftCreatedAt = left.entry.createdAt ?? left.entry.updatedAt ?? 0;
|
|
const rightCreatedAt = right.entry.createdAt ?? right.entry.updatedAt ?? 0;
|
|
return leftCreatedAt - rightCreatedAt || left.sessionKey.localeCompare(right.sessionKey);
|
|
});
|
|
}
|
|
|
|
async function loadRuntimeParityCaptureSources(params: {
|
|
gateway: QaGatewayLike;
|
|
agentId: string;
|
|
preferredSessionKeys?: readonly string[];
|
|
}): Promise<RuntimeParityCaptureSources> {
|
|
const stateDir = `${params.gateway.tempRoot}/state`;
|
|
const env = runtimeParitySessionEnv(stateDir);
|
|
const storePath = resolveStorePath(undefined, { agentId: params.agentId, env });
|
|
const sessionEntries = await readRuntimeParitySessionEntries({
|
|
gateway: params.gateway,
|
|
agentId: params.agentId,
|
|
...(params.preferredSessionKeys?.length
|
|
? { preferredSessionKeys: new Set(params.preferredSessionKeys) }
|
|
: {}),
|
|
});
|
|
const sessions: RuntimeParityCaptureSources["sessions"] = [];
|
|
for (const { entry, sessionKey } of sessionEntries) {
|
|
const sessionId = readNonEmptyString(entry.sessionId);
|
|
if (!sessionId) {
|
|
continue;
|
|
}
|
|
let transcriptBytes = "";
|
|
try {
|
|
const events = loadTranscriptEventsSync({
|
|
agentId: params.agentId,
|
|
env,
|
|
sessionId,
|
|
sessionKey,
|
|
});
|
|
transcriptBytes = events
|
|
.map((event) => JSON.stringify(event))
|
|
.join("\n")
|
|
.trimEnd();
|
|
if (transcriptBytes && isHeartbeatOnlyRuntimeTranscript(transcriptBytes)) {
|
|
continue;
|
|
}
|
|
} catch {
|
|
// Ignore missing transcript files so failed cells still render.
|
|
}
|
|
let trajectoryToolCalls: RuntimeParityObservedToolCall[] = [];
|
|
try {
|
|
const trajectoryEvents = await loadSqliteTrajectoryRuntimeEvents({
|
|
agentId: params.agentId,
|
|
env,
|
|
sessionId,
|
|
storePath,
|
|
});
|
|
trajectoryToolCalls = resolveTrajectoryToolCallOrder(trajectoryEvents);
|
|
} catch {
|
|
// Transcript evidence remains authoritative when diagnostics are unavailable.
|
|
}
|
|
if (transcriptBytes || trajectoryToolCalls.length > 0) {
|
|
sessions.push({ transcriptBytes, trajectoryToolCalls });
|
|
}
|
|
}
|
|
return {
|
|
sessions,
|
|
transcriptBytes: sessions
|
|
.map((session) => session.transcriptBytes)
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
};
|
|
}
|
|
|
|
function runtimeParitySessionKeysFromScenarioResult(result: QaSuiteScenarioLike) {
|
|
const sessionKeys = new Set<string>();
|
|
const detailBlocks = [result.details, ...(result.steps ?? []).map((step) => step.details)];
|
|
for (const detailBlock of detailBlocks) {
|
|
for (const line of detailBlock?.split(/\r?\n/u) ?? []) {
|
|
if (!line.startsWith(RUNTIME_PARITY_SESSION_KEY_DETAIL_PREFIX)) {
|
|
continue;
|
|
}
|
|
const sessionKey = readNonEmptyString(
|
|
line.slice(RUNTIME_PARITY_SESSION_KEY_DETAIL_PREFIX.length),
|
|
);
|
|
if (sessionKey) {
|
|
sessionKeys.add(sessionKey);
|
|
}
|
|
}
|
|
}
|
|
return [...sessionKeys];
|
|
}
|
|
|
|
async function loadRuntimeParityMockToolCalls(
|
|
mockBaseUrl: string | undefined,
|
|
parentPrompt: string,
|
|
parentPrompts: readonly string[] = [parentPrompt],
|
|
): Promise<RuntimeParityToolCall[] | null> {
|
|
const normalizedBaseUrl = mockBaseUrl?.trim().replace(/\/+$/u, "");
|
|
if (!normalizedBaseUrl) {
|
|
return null;
|
|
}
|
|
try {
|
|
const { response, release } = await fetchWithSsrFGuard({
|
|
url: `${normalizedBaseUrl}/debug/requests`,
|
|
policy: { allowPrivateNetwork: true },
|
|
auditContext: "qa-lab-runtime-parity-mock-tool-calls",
|
|
});
|
|
let payload: unknown;
|
|
try {
|
|
if (!response.ok) {
|
|
await discardIgnoredResponseBody(response);
|
|
return null;
|
|
}
|
|
payload = await response.json();
|
|
} finally {
|
|
await release();
|
|
}
|
|
if (!Array.isArray(payload)) {
|
|
return null;
|
|
}
|
|
const requests = payload.filter(isMessageRecord).map(
|
|
(entry): RuntimeParityMockRequestSnapshot => ({
|
|
prompt: readNonEmptyString(entry.prompt),
|
|
allInputText: readNonEmptyString(entry.allInputText),
|
|
plannedToolName: readNonEmptyString(entry.plannedToolName),
|
|
plannedToolArgs: entry.plannedToolArgs ?? null,
|
|
toolOutput: readNonEmptyString(entry.toolOutput) ?? "",
|
|
}),
|
|
);
|
|
return resolveToolCallOrderFromMockRequests(
|
|
filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function captureRuntimeParityCell(
|
|
params: RuntimeParityCaptureParams,
|
|
): Promise<RuntimeParityCell> {
|
|
const agentId = params.agentId ?? DEFAULT_AGENT_ID;
|
|
const { sessions, transcriptBytes } = await loadRuntimeParityCaptureSources({
|
|
gateway: params.gateway,
|
|
agentId,
|
|
preferredSessionKeys: runtimeParitySessionKeysFromScenarioResult(params.scenarioResult),
|
|
});
|
|
const transcriptRecords = buildTranscriptRecords(transcriptBytes);
|
|
// Runtime-tool fixtures split happy and failure paths across root sessions.
|
|
// Resolve each session separately so repeated tool-call ids cannot cross-link.
|
|
const runtimeToolCalls = removeRuntimeParityToolCallIdentity(
|
|
sessions.flatMap((session) =>
|
|
mergeRuntimeParityToolCalls({
|
|
transcriptToolCalls: resolveToolCallOrder(buildTranscriptRecords(session.transcriptBytes)),
|
|
trajectoryToolCalls: session.trajectoryToolCalls,
|
|
}),
|
|
),
|
|
);
|
|
const parentPrompts = transcriptRecords
|
|
.filter((record) => record.role === "user")
|
|
.map((record) => extractAssistantText(record.message))
|
|
.filter((prompt) => prompt.length > 0);
|
|
const parentPrompt = parentPrompts[0] ?? "";
|
|
const mockToolCalls = await loadRuntimeParityMockToolCalls(
|
|
params.mockBaseUrl,
|
|
parentPrompt,
|
|
parentPrompts,
|
|
);
|
|
const gatewayLogs = params.gateway.logs?.();
|
|
const sentinelFindings = [
|
|
...scanGatewayLogSentinels(gatewayLogs),
|
|
...scanDirectReplyTranscriptSentinels(transcriptBytes),
|
|
];
|
|
// Retry passes retain first-attempt diagnostics; only terminal failures may
|
|
// classify that historical text as the cell's runtime error.
|
|
const scenarioErrorClass =
|
|
params.scenarioResult.status === "fail"
|
|
? classifyScenarioError(params.scenarioResult.details)
|
|
: undefined;
|
|
const sentinelErrorClass = summarizeSentinelErrorClass(sentinelFindings);
|
|
const terminalImageResultProven = hasProvenTerminalImageResult(params.scenarioResult);
|
|
return {
|
|
runtime: params.runtime,
|
|
transcriptBytes,
|
|
toolCalls: resolveRuntimeParityToolCalls({
|
|
transcriptToolCalls: runtimeToolCalls,
|
|
terminalImageResultProven,
|
|
}),
|
|
...(mockToolCalls ? { providerPlanToolCalls: mockToolCalls } : {}),
|
|
finalText: extractFinalAssistantText(transcriptRecords),
|
|
usage: aggregateUsage(transcriptRecords),
|
|
cacheDiagnostics: buildRuntimeParityCacheDiagnostics(
|
|
transcriptRecords
|
|
.filter((record) => record.role === "assistant")
|
|
.map((record) => readAssistantUsage(record.message)),
|
|
),
|
|
wallClockMs: params.wallClockMs,
|
|
...(params.bootstrapWallClockMs === undefined
|
|
? {}
|
|
: { bootstrapWallClockMs: params.bootstrapWallClockMs }),
|
|
...(scenarioErrorClass || sentinelErrorClass
|
|
? { runtimeErrorClass: scenarioErrorClass ?? sentinelErrorClass }
|
|
: {}),
|
|
bootStateLines: extractBootStateLines(gatewayLogs),
|
|
...(sentinelFindings.length > 0 ? { sentinelFindings } : {}),
|
|
...(params.scenarioResult.modelSwitchEvidence
|
|
? { modelSwitchEvidence: params.scenarioResult.modelSwitchEvidence }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export async function runRuntimeParityScenario(params: {
|
|
scenarioId: string;
|
|
runtimeParityUsage?: RuntimeParityUsagePolicy;
|
|
runtimePair?: readonly [RuntimeId, RuntimeId];
|
|
runCell: (runtime: RuntimeId) => Promise<RuntimeParityScenarioExecution>;
|
|
}): Promise<RuntimeParityResult> {
|
|
const [firstRuntime, secondRuntime] = params.runtimePair ?? CANONICAL_RUNTIME_IDS;
|
|
if (firstRuntime === secondRuntime) {
|
|
throw new Error("Runtime parity must compare two different runtimes.");
|
|
}
|
|
const first = await params.runCell(firstRuntime);
|
|
const second = await params.runCell(secondRuntime);
|
|
const [openclaw, codex] = firstRuntime === "openclaw" ? [first, second] : [second, first];
|
|
const drift = classifyRuntimeParityCells({
|
|
openclaw: openclaw.cell,
|
|
codex: codex.cell,
|
|
openclawStatus: openclaw.status,
|
|
codexStatus: codex.status,
|
|
openclawDetails: openclaw.details,
|
|
codexDetails: codex.details,
|
|
});
|
|
return {
|
|
scenarioId: params.scenarioId,
|
|
runtimeParityUsage: resolveRuntimeParityUsagePolicy(params.runtimeParityUsage),
|
|
cells: {
|
|
openclaw: {
|
|
...openclaw.cell,
|
|
status: openclaw.status,
|
|
...(openclaw.details ? { details: openclaw.details } : {}),
|
|
},
|
|
codex: {
|
|
...codex.cell,
|
|
status: codex.status,
|
|
...(codex.details ? { details: codex.details } : {}),
|
|
},
|
|
},
|
|
drift: drift.drift,
|
|
...(drift.driftDetails ? { driftDetails: drift.driftDetails } : {}),
|
|
};
|
|
}
|
|
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|