mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
refactor(qa-lab): split mock and Slack runtimes (#107377)
* refactor(qa-lab): split mock and Slack runtimes * refactor(qa-lab): narrow Slack helper exports
This commit is contained in:
committed by
GitHub
parent
236a6b815c
commit
dd42e80480
@@ -0,0 +1,201 @@
|
||||
// QA Lab Slack approval checkpoint and gateway decision RPC.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
formatApprovalResultValue,
|
||||
readAcceptedApprovalRequestId,
|
||||
} from "../shared/live-approval-result.js";
|
||||
import {
|
||||
SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
SLACK_QA_APPROVAL_CHECKPOINT_DEFAULT_TIMEOUT_MS,
|
||||
type SlackQaScenarioId,
|
||||
type SlackQaApprovalKind,
|
||||
type SlackQaApprovalDecision,
|
||||
type SlackQaApprovalScenarioRun,
|
||||
type SlackQaScenarioContext,
|
||||
type SlackApprovalCheckpointState,
|
||||
type SlackApprovalCheckpointAck,
|
||||
SLACK_QA_APPROVAL_CHECKPOINT_DIR_ENV,
|
||||
SLACK_QA_APPROVAL_CHECKPOINT_TIMEOUT_MS_ENV,
|
||||
type SlackMessage,
|
||||
} from "./slack-live.contracts.js";
|
||||
import { buildSlackApprovalCheckpointMessage } from "./slack-live.observations.js";
|
||||
|
||||
export function resolveSlackApprovalCheckpointConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
const checkpointDir = env[SLACK_QA_APPROVAL_CHECKPOINT_DIR_ENV]?.trim();
|
||||
if (!checkpointDir) {
|
||||
return undefined;
|
||||
}
|
||||
const rawTimeout = env[SLACK_QA_APPROVAL_CHECKPOINT_TIMEOUT_MS_ENV]?.trim();
|
||||
const timeoutMs = rawTimeout
|
||||
? parseStrictPositiveInteger(rawTimeout)
|
||||
: SLACK_QA_APPROVAL_CHECKPOINT_DEFAULT_TIMEOUT_MS;
|
||||
if (timeoutMs === undefined) {
|
||||
throw new Error(`${SLACK_QA_APPROVAL_CHECKPOINT_TIMEOUT_MS_ENV} must be a positive integer.`);
|
||||
}
|
||||
return {
|
||||
checkpointDir,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSlackApprovalCheckpointAck(params: {
|
||||
ackPath: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<SlackApprovalCheckpointAck> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(params.ackPath, "utf8")) as {
|
||||
capturedAt?: unknown;
|
||||
error?: unknown;
|
||||
screenshotPath?: unknown;
|
||||
};
|
||||
if (typeof parsed.error === "string" && parsed.error.trim().length > 0) {
|
||||
throw new Error(`Slack approval checkpoint watcher failed: ${parsed.error}`);
|
||||
}
|
||||
return {
|
||||
capturedAt: typeof parsed.capturedAt === "string" ? parsed.capturedAt : undefined,
|
||||
screenshotPath:
|
||||
typeof parsed.screenshotPath === "string" ? parsed.screenshotPath : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
throw new Error(`timed out after ${params.timeoutMs}ms waiting for ${params.ackPath}`);
|
||||
}
|
||||
|
||||
export async function writeSlackApprovalCheckpoint(params: {
|
||||
approvalId: string;
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
channelId: string;
|
||||
decision?: SlackQaApprovalDecision;
|
||||
message: SlackMessage;
|
||||
observedAt: string;
|
||||
scenarioId: SlackQaScenarioId;
|
||||
state: SlackApprovalCheckpointState;
|
||||
}) {
|
||||
const config = resolveSlackApprovalCheckpointConfig();
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
await fs.mkdir(config.checkpointDir, { recursive: true });
|
||||
const checkpointPath = path.join(
|
||||
config.checkpointDir,
|
||||
`${params.scenarioId}.${params.state}.json`,
|
||||
);
|
||||
const ackPath = path.join(config.checkpointDir, `${params.scenarioId}.${params.state}.ack.json`);
|
||||
await fs.rm(ackPath, { force: true }).catch(() => {});
|
||||
await fs.writeFile(
|
||||
checkpointPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
scenarioId: params.scenarioId,
|
||||
approvalKind: params.approvalKind,
|
||||
state: params.state,
|
||||
approvalId: params.approvalId,
|
||||
channelId: params.channelId,
|
||||
messageTs: params.message.ts,
|
||||
threadTs: params.message.thread_ts ?? null,
|
||||
decision: params.decision ?? null,
|
||||
observedAt: params.observedAt,
|
||||
message: buildSlackApprovalCheckpointMessage(params.message),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const ack = await waitForSlackApprovalCheckpointAck({
|
||||
ackPath,
|
||||
timeoutMs: config.timeoutMs,
|
||||
});
|
||||
return {
|
||||
ackPath,
|
||||
checkpointPath,
|
||||
screenshotPath: ack.screenshotPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestSlackApproval(params: {
|
||||
approvalId: string;
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
run: SlackQaApprovalScenarioRun;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const commonParams = {
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
turnSourceAccountId: params.sutAccountId,
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: `channel:${params.channelId}`,
|
||||
twoPhase: true,
|
||||
};
|
||||
if (params.run.approvalKind === "exec") {
|
||||
const result = await params.context.gateway.call(
|
||||
"exec.approval.request",
|
||||
{
|
||||
...commonParams,
|
||||
ask: "always",
|
||||
command: `printf '%s\\n' '${params.run.token}'`,
|
||||
host: "gateway",
|
||||
id: params.approvalId,
|
||||
security: "full",
|
||||
},
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
const acceptedId = readAcceptedApprovalRequestId(result);
|
||||
if (acceptedId !== params.approvalId) {
|
||||
throw new Error(
|
||||
`accepted exec approval id was ${formatApprovalResultValue(
|
||||
acceptedId,
|
||||
)} instead of ${params.approvalId}`,
|
||||
);
|
||||
}
|
||||
return acceptedId;
|
||||
}
|
||||
const result = await params.context.gateway.call(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
...commonParams,
|
||||
agentId: "qa",
|
||||
description: `Slack plugin approval QA request ${params.run.token}`,
|
||||
pluginId: "qa-slack-plugin",
|
||||
severity: "warning",
|
||||
title: `Slack plugin approval QA ${params.run.token}`,
|
||||
toolName: "slack_qa_tool",
|
||||
},
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
return readAcceptedApprovalRequestId(result);
|
||||
}
|
||||
|
||||
export async function waitForApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
kind: SlackQaApprovalKind;
|
||||
}) {
|
||||
const method =
|
||||
params.kind === "exec" ? "exec.approval.waitDecision" : "plugin.approval.waitDecision";
|
||||
return await params.context.gateway.call(
|
||||
method,
|
||||
{ id: params.approvalId },
|
||||
{
|
||||
expectFinal: true,
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// QA Lab Slack native approval observation and resolution.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { assertApprovalDecisionResult } from "../shared/live-approval-result.js";
|
||||
import {
|
||||
writeSlackApprovalCheckpoint,
|
||||
requestSlackApproval,
|
||||
waitForApprovalDecision,
|
||||
} from "./slack-live.approval-checkpoint.js";
|
||||
import {
|
||||
SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
type SlackQaApprovalKind,
|
||||
type SlackQaApprovalDecision,
|
||||
type SlackQaApprovalScenarioRun,
|
||||
type SlackQaScenarioContext,
|
||||
type SlackQaScenarioDefinition,
|
||||
type SlackAuthIdentity,
|
||||
type SlackObservedMessage,
|
||||
type SlackApprovalArtifact,
|
||||
type SlackMessage,
|
||||
} from "./slack-live.contracts.js";
|
||||
import {
|
||||
listSlackMessages,
|
||||
collectSlackBlockText,
|
||||
collectSlackActionValues,
|
||||
parseSlackNativeApprovalAction,
|
||||
hasSlackNativeApprovalActions,
|
||||
extractSlackNativeApprovalId,
|
||||
isSutSlackMessage,
|
||||
} from "./slack-live.observations.js";
|
||||
|
||||
function resolveApprovalDecisionLabel(decision: SlackQaApprovalDecision) {
|
||||
return decision === "allow-once"
|
||||
? "Allowed once"
|
||||
: decision === "allow-always"
|
||||
? "Allowed always"
|
||||
: "Denied";
|
||||
}
|
||||
|
||||
function resolveApprovalHeading(params: {
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
state: "pending" | "resolved";
|
||||
decision?: SlackQaApprovalDecision;
|
||||
}) {
|
||||
if (params.state === "pending") {
|
||||
return params.approvalKind === "exec" ? "Exec approval required" : "Plugin approval required";
|
||||
}
|
||||
const label = resolveApprovalDecisionLabel(params.decision ?? "allow-once");
|
||||
return params.approvalKind === "exec" ? `Exec approval: ${label}` : `Plugin approval: ${label}`;
|
||||
}
|
||||
|
||||
function getSlackMessageSearchText(message: SlackMessage) {
|
||||
return [message.text ?? "", ...collectSlackBlockText(message.blocks)].join("\n");
|
||||
}
|
||||
|
||||
function pushObservedApprovalMessage(params: {
|
||||
channelId: string;
|
||||
matchedScenario: boolean;
|
||||
message: SlackMessage;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
scenarioId: string;
|
||||
scenarioTitle: string;
|
||||
}) {
|
||||
if (!params.message.ts) {
|
||||
return;
|
||||
}
|
||||
params.observedMessages.push({
|
||||
actionValues: collectSlackActionValues(params.message.blocks),
|
||||
blockText: collectSlackBlockText(params.message.blocks),
|
||||
botId: params.message.bot_id,
|
||||
channelId: params.channelId,
|
||||
matchedScenario: params.matchedScenario,
|
||||
scenarioId: params.scenarioId,
|
||||
scenarioTitle: params.scenarioTitle,
|
||||
text: params.message.text ?? "",
|
||||
threadTs: params.message.thread_ts,
|
||||
ts: params.message.ts,
|
||||
userId: params.message.user,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForSlackApprovalPrompt(params: {
|
||||
approvalId?: string;
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
decision: SlackQaApprovalDecision;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
oldestTs: string;
|
||||
scenarioId: string;
|
||||
scenarioTitle: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
timeoutMs: number;
|
||||
token?: string;
|
||||
extraTextMatches?: string[];
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const seenObservedMessages = new Set<string>();
|
||||
let lastMatchedWithoutActions = "";
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const messages = await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.oldestTs,
|
||||
});
|
||||
for (const message of messages) {
|
||||
if (!message.ts || !isSutSlackMessage(message, params.sutIdentity)) {
|
||||
continue;
|
||||
}
|
||||
const text = getSlackMessageSearchText(message);
|
||||
const actionValues = collectSlackActionValues(message.blocks);
|
||||
const matchedScenario = matchesSlackApprovalPromptText({
|
||||
approvalKind: params.approvalKind,
|
||||
extraTextMatches: params.extraTextMatches,
|
||||
text,
|
||||
token: params.token,
|
||||
});
|
||||
const observedKey = `${message.ts}:${message.text ?? ""}:${actionValues.join("|")}`;
|
||||
if (matchedScenario || hasSlackNativeApprovalActions({ ...params, actionValues })) {
|
||||
if (!seenObservedMessages.has(observedKey)) {
|
||||
seenObservedMessages.add(observedKey);
|
||||
pushObservedApprovalMessage({
|
||||
channelId: params.channelId,
|
||||
matchedScenario,
|
||||
message,
|
||||
observedMessages: params.observedMessages,
|
||||
scenarioId: params.scenarioId,
|
||||
scenarioTitle: params.scenarioTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!matchedScenario) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!hasSlackNativeApprovalActions({
|
||||
actionValues,
|
||||
approvalId: params.approvalId,
|
||||
decision: params.decision,
|
||||
})
|
||||
) {
|
||||
lastMatchedWithoutActions = `message ${message.ts} matched approval text but did not expose native approval button values`;
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
actionValues,
|
||||
approvalId:
|
||||
params.approvalId ??
|
||||
extractSlackNativeApprovalId({
|
||||
actionValues,
|
||||
decision: params.decision,
|
||||
}),
|
||||
message,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`timed out after ${params.timeoutMs}ms waiting for Slack ${params.approvalKind} approval prompt`,
|
||||
lastMatchedWithoutActions,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesSlackApprovalPromptText(params: {
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
extraTextMatches?: string[];
|
||||
text: string;
|
||||
token?: string;
|
||||
}) {
|
||||
return (
|
||||
params.text.includes(
|
||||
resolveApprovalHeading({ approvalKind: params.approvalKind, state: "pending" }),
|
||||
) &&
|
||||
(!params.token || params.text.includes(params.token)) &&
|
||||
(params.extraTextMatches ?? []).every((match) => params.text.includes(match))
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForSlackApprovalResolvedUpdate(params: {
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
decision: SlackQaApprovalDecision;
|
||||
messageTs: string;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
oldestTs: string;
|
||||
scenarioId: string;
|
||||
scenarioTitle: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
timeoutMs: number;
|
||||
token?: string;
|
||||
extraTextMatches?: string[];
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const seenObservedMessages = new Set<string>();
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const messages = await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.oldestTs,
|
||||
});
|
||||
const message = messages.find((entry) => entry.ts === params.messageTs);
|
||||
if (message && isSutSlackMessage(message, params.sutIdentity)) {
|
||||
const text = getSlackMessageSearchText(message);
|
||||
const actionValues = collectSlackActionValues(message.blocks);
|
||||
const matchedScenario = matchesSlackApprovalResolvedUpdate({
|
||||
actionValues,
|
||||
approvalKind: params.approvalKind,
|
||||
decision: params.decision,
|
||||
extraTextMatches: params.extraTextMatches,
|
||||
text,
|
||||
token: params.token,
|
||||
});
|
||||
const observedKey = `${message.ts}:${message.text ?? ""}:${actionValues.join("|")}`;
|
||||
if (!seenObservedMessages.has(observedKey)) {
|
||||
seenObservedMessages.add(observedKey);
|
||||
pushObservedApprovalMessage({
|
||||
channelId: params.channelId,
|
||||
matchedScenario,
|
||||
message,
|
||||
observedMessages: params.observedMessages,
|
||||
scenarioId: params.scenarioId,
|
||||
scenarioTitle: params.scenarioTitle,
|
||||
});
|
||||
}
|
||||
if (matchedScenario) {
|
||||
return {
|
||||
actionValues,
|
||||
message,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timed out after ${params.timeoutMs}ms waiting for Slack ${params.approvalKind} approval resolution update`,
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesSlackApprovalResolvedUpdate(params: {
|
||||
actionValues: string[];
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
decision: SlackQaApprovalDecision;
|
||||
extraTextMatches?: string[];
|
||||
text: string;
|
||||
token?: string;
|
||||
}) {
|
||||
return (
|
||||
params.text.includes(
|
||||
resolveApprovalHeading({
|
||||
approvalKind: params.approvalKind,
|
||||
decision: params.decision,
|
||||
state: "resolved",
|
||||
}),
|
||||
) &&
|
||||
(!params.token || params.text.includes(params.token)) &&
|
||||
(params.extraTextMatches ?? []).every((match) => params.text.includes(match)) &&
|
||||
!params.actionValues.some((value) => parseSlackNativeApprovalAction(value))
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
decision: SlackQaApprovalDecision;
|
||||
kind: SlackQaApprovalKind;
|
||||
}) {
|
||||
const method = params.kind === "exec" ? "exec.approval.resolve" : "plugin.approval.resolve";
|
||||
return await params.context.gateway.call(
|
||||
method,
|
||||
{ decision: params.decision, id: params.approvalId },
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runSlackApprovalScenario(params: {
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
run: SlackQaApprovalScenarioRun;
|
||||
scenario: SlackQaScenarioDefinition;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const requestStartedAt = new Date();
|
||||
const oldestTs = ((requestStartedAt.getTime() - 5_000) / 1_000).toFixed(6);
|
||||
const requestedApprovalId =
|
||||
params.run.approvalKind === "exec"
|
||||
? `slack-qa-exec-${randomUUID()}`
|
||||
: `slack-qa-plugin-${randomUUID()}`;
|
||||
const approvalId = await requestSlackApproval({
|
||||
approvalId: requestedApprovalId,
|
||||
channelId: params.channelId,
|
||||
context: params.context,
|
||||
run: params.run,
|
||||
sutAccountId: params.sutAccountId,
|
||||
});
|
||||
const pending = await waitForSlackApprovalPrompt({
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
client: params.context.sutReadClient,
|
||||
decision: params.run.decision,
|
||||
observedMessages: params.observedMessages,
|
||||
oldestTs,
|
||||
scenarioId: params.scenario.id,
|
||||
scenarioTitle: params.scenario.title,
|
||||
sutIdentity: params.context.sutIdentity,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
token: params.run.token,
|
||||
});
|
||||
const pendingCheckpoint = await writeSlackApprovalCheckpoint({
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
message: pending.message,
|
||||
observedAt: pending.observedAt,
|
||||
scenarioId: params.scenario.id,
|
||||
state: "pending",
|
||||
});
|
||||
await resolveApprovalDecision({
|
||||
approvalId,
|
||||
context: params.context,
|
||||
decision: params.run.decision,
|
||||
kind: params.run.approvalKind,
|
||||
});
|
||||
assertApprovalDecisionResult({
|
||||
decision: params.run.decision,
|
||||
result: await waitForApprovalDecision({
|
||||
approvalId,
|
||||
context: params.context,
|
||||
kind: params.run.approvalKind,
|
||||
}),
|
||||
});
|
||||
const resolved = await waitForSlackApprovalResolvedUpdate({
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
client: params.context.sutReadClient,
|
||||
decision: params.run.decision,
|
||||
messageTs: pending.message.ts,
|
||||
observedMessages: params.observedMessages,
|
||||
oldestTs,
|
||||
scenarioId: params.scenario.id,
|
||||
scenarioTitle: params.scenario.title,
|
||||
sutIdentity: params.context.sutIdentity,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
token: params.run.token,
|
||||
});
|
||||
const resolvedCheckpoint = await writeSlackApprovalCheckpoint({
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
decision: params.run.decision,
|
||||
message: resolved.message,
|
||||
observedAt: resolved.observedAt,
|
||||
scenarioId: params.scenario.id,
|
||||
state: "resolved",
|
||||
});
|
||||
const responseObservedAt = new Date(resolved.observedAt);
|
||||
return {
|
||||
artifact: {
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
decision: params.run.decision,
|
||||
pendingActionValues: pending.actionValues,
|
||||
pendingCheckpointPath: pendingCheckpoint?.checkpointPath,
|
||||
pendingMessageTs: pending.message.ts,
|
||||
pendingScreenshotPath: pendingCheckpoint?.screenshotPath,
|
||||
pendingText: pending.message.text,
|
||||
resolvedActionValues: resolved.actionValues,
|
||||
resolvedCheckpointPath: resolvedCheckpoint?.checkpointPath,
|
||||
resolvedMessageTs: resolved.message.ts,
|
||||
resolvedScreenshotPath: resolvedCheckpoint?.screenshotPath,
|
||||
resolvedText: resolved.message.text,
|
||||
threadTs: pending.message.thread_ts,
|
||||
} satisfies SlackApprovalArtifact,
|
||||
requestStartedAt,
|
||||
responseObservedAt,
|
||||
rttMs: responseObservedAt.getTime() - requestStartedAt.getTime(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// QA Lab Slack artifact shaping and report rendering.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { appendQaLiveLaneIssue as appendLiveLaneIssue } from "../shared/live-artifacts.js";
|
||||
import type {
|
||||
SlackQaGatewayHarness,
|
||||
SlackObservedMessage,
|
||||
SlackObservedMessageArtifact,
|
||||
SlackQaScenarioResult,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
export function isRetryableSlackQaScenarioError(error: unknown) {
|
||||
return /timed out after \d+ms waiting for Slack message/iu.test(formatErrorMessage(error));
|
||||
}
|
||||
|
||||
export function toObservedSlackArtifacts(params: {
|
||||
includeContent: boolean;
|
||||
messages: SlackObservedMessage[];
|
||||
redactMetadata: boolean;
|
||||
}): SlackObservedMessageArtifact[] {
|
||||
return params.messages.map((message) => ({
|
||||
actionValues: params.includeContent ? message.actionValues : undefined,
|
||||
blockText: params.includeContent ? message.blockText : undefined,
|
||||
botId: params.redactMetadata ? undefined : message.botId,
|
||||
channelId: params.redactMetadata ? undefined : message.channelId,
|
||||
matchedScenario: message.matchedScenario,
|
||||
scenarioId: message.scenarioId,
|
||||
scenarioTitle: message.scenarioTitle,
|
||||
text: params.includeContent ? message.text : undefined,
|
||||
threadTs: params.redactMetadata ? undefined : message.threadTs,
|
||||
ts: params.redactMetadata ? undefined : message.ts,
|
||||
userId: params.redactMetadata ? undefined : message.userId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toSlackQaScenarioArtifactResults(params: {
|
||||
includeContent: boolean;
|
||||
redactMetadata: boolean;
|
||||
scenarios: SlackQaScenarioResult[];
|
||||
}): SlackQaScenarioResult[] {
|
||||
return params.scenarios.map((scenario) => {
|
||||
if (!scenario.approval) {
|
||||
return scenario;
|
||||
}
|
||||
const approval = scenario.approval;
|
||||
return {
|
||||
...scenario,
|
||||
approval: {
|
||||
approvalId: params.redactMetadata ? "<redacted>" : approval.approvalId,
|
||||
approvalKind: approval.approvalKind,
|
||||
appServerMethod: approval.appServerMethod,
|
||||
channelId: params.redactMetadata ? undefined : approval.channelId,
|
||||
codexModelKey: approval.codexModelKey,
|
||||
decision: approval.decision,
|
||||
finalCodexTurnStatus: approval.finalCodexTurnStatus,
|
||||
operationVerified: approval.operationVerified,
|
||||
pendingActionValues: params.includeContent ? approval.pendingActionValues : undefined,
|
||||
pendingCheckpointPath: approval.pendingCheckpointPath,
|
||||
pendingMessageTs: params.redactMetadata ? undefined : approval.pendingMessageTs,
|
||||
pendingScreenshotPath: approval.pendingScreenshotPath,
|
||||
pendingText: params.includeContent ? approval.pendingText : undefined,
|
||||
resolvedActionValues: params.includeContent ? approval.resolvedActionValues : undefined,
|
||||
resolvedCheckpointPath: approval.resolvedCheckpointPath,
|
||||
resolvedMessageTs: params.redactMetadata ? undefined : approval.resolvedMessageTs,
|
||||
resolvedScreenshotPath: approval.resolvedScreenshotPath,
|
||||
resolvedText: params.includeContent ? approval.resolvedText : undefined,
|
||||
threadTs: params.redactMetadata ? undefined : approval.threadTs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSlackQaMarkdown(params: {
|
||||
channelId: string;
|
||||
cleanupIssues: string[];
|
||||
credentialSource: "convex" | "env";
|
||||
finishedAt: string;
|
||||
gatewayDebugDirPath?: string;
|
||||
redactMetadata: boolean;
|
||||
scenarios: SlackQaScenarioResult[];
|
||||
startedAt: string;
|
||||
}) {
|
||||
const lines = [
|
||||
"# Slack QA Report",
|
||||
"",
|
||||
`- Credential source: \`${params.credentialSource}\``,
|
||||
`- Channel: \`${params.redactMetadata ? "<redacted>" : params.channelId}\``,
|
||||
`- Metadata redaction: \`${params.redactMetadata ? "enabled" : "disabled"}\``,
|
||||
`- Started: ${params.startedAt}`,
|
||||
`- Finished: ${params.finishedAt}`,
|
||||
];
|
||||
if (params.gatewayDebugDirPath) {
|
||||
lines.push(`- Gateway debug artifacts: \`${params.gatewayDebugDirPath}\``);
|
||||
}
|
||||
if (params.cleanupIssues.length > 0) {
|
||||
lines.push("", "## Cleanup issues", "");
|
||||
for (const issue of params.cleanupIssues) {
|
||||
lines.push(`- ${issue}`);
|
||||
}
|
||||
}
|
||||
lines.push("", "## Scenarios", "");
|
||||
for (const scenario of params.scenarios) {
|
||||
lines.push(`### ${scenario.title}`, "");
|
||||
lines.push(`- Status: ${scenario.status}`);
|
||||
lines.push(`- Details: ${scenario.details}`);
|
||||
if (scenario.rttMs !== undefined) {
|
||||
lines.push(`- RTT: ${scenario.rttMs}ms`);
|
||||
}
|
||||
if (scenario.approval) {
|
||||
lines.push(`- Approval kind: ${scenario.approval.approvalKind}`);
|
||||
if (scenario.approval.appServerMethod) {
|
||||
lines.push(`- Codex app-server method: \`${scenario.approval.appServerMethod}\``);
|
||||
}
|
||||
if (scenario.approval.codexModelKey) {
|
||||
lines.push(`- Codex model: \`${scenario.approval.codexModelKey}\``);
|
||||
}
|
||||
if (scenario.approval.finalCodexTurnStatus) {
|
||||
lines.push(`- Codex turn status: ${scenario.approval.finalCodexTurnStatus}`);
|
||||
}
|
||||
if (scenario.approval.operationVerified) {
|
||||
lines.push("- Codex operation marker: verified");
|
||||
}
|
||||
lines.push(`- Approval ID: \`${scenario.approval.approvalId}\``);
|
||||
lines.push(`- Decision: ${scenario.approval.decision}`);
|
||||
if (scenario.approval.pendingScreenshotPath) {
|
||||
lines.push(`- Pending screenshot: \`${scenario.approval.pendingScreenshotPath}\``);
|
||||
}
|
||||
if (scenario.approval.resolvedScreenshotPath) {
|
||||
lines.push(`- Resolved screenshot: \`${scenario.approval.resolvedScreenshotPath}\``);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function preserveSlackGatewayDebugArtifacts(params: {
|
||||
cleanupIssues: string[];
|
||||
gatewayDebugDirPath: string;
|
||||
gatewayHarness: SlackQaGatewayHarness;
|
||||
}) {
|
||||
try {
|
||||
await params.gatewayHarness.stop({ preserveToDir: params.gatewayDebugDirPath });
|
||||
return true;
|
||||
} catch (error) {
|
||||
appendLiveLaneIssue(params.cleanupIssues, "gateway debug preservation failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// QA Lab Slack Codex approval scenario orchestration.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { writeSlackApprovalCheckpoint } from "./slack-live.approval-checkpoint.js";
|
||||
import {
|
||||
waitForSlackApprovalPrompt,
|
||||
waitForSlackApprovalResolvedUpdate,
|
||||
resolveApprovalDecision,
|
||||
} from "./slack-live.approvals.js";
|
||||
import {
|
||||
assertCodexApprovalOperationSucceeded,
|
||||
assertPendingCodexPluginApproval,
|
||||
startCodexApprovalAgentRun,
|
||||
buildCodexApprovalSessionKey,
|
||||
waitForCodexApprovalAgentRun,
|
||||
quiesceCodexApprovalAgentRun,
|
||||
resolveCodexFileApprovalTargetPath,
|
||||
} from "./slack-live.codex-approval.js";
|
||||
import type {
|
||||
SlackQaCodexApprovalScenarioRun,
|
||||
SlackQaScenarioContext,
|
||||
SlackQaScenarioDefinition,
|
||||
SlackObservedMessage,
|
||||
SlackApprovalArtifact,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
export async function runSlackCodexApprovalScenario(params: {
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
primaryModel: string;
|
||||
run: SlackQaCodexApprovalScenarioRun;
|
||||
scenario: SlackQaScenarioDefinition;
|
||||
stopGateway: (preserveDebugArtifacts: boolean) => Promise<void>;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const codexRun = {
|
||||
runId: `slack-qa-codex-approval-${randomUUID()}`,
|
||||
sessionKey: buildCodexApprovalSessionKey({
|
||||
scenario: params.scenario,
|
||||
token: params.run.token,
|
||||
}),
|
||||
};
|
||||
const targetPath =
|
||||
params.run.appServerMethod === "item/fileChange/requestApproval"
|
||||
? resolveCodexFileApprovalTargetPath(params.run.token)
|
||||
: undefined;
|
||||
if (targetPath) {
|
||||
await fs.rm(targetPath, { force: true });
|
||||
}
|
||||
const outcome = await runSlackCodexApprovalScenarioInner({ ...params, codexRun }).then(
|
||||
(result) => ({ kind: "success", result }) as const,
|
||||
(error: unknown) => ({ error, kind: "failure" }) as const,
|
||||
);
|
||||
// Kill the gateway process tree before deleting the probe. Agent completion
|
||||
// does not prove the native Codex turn has stopped writing after an interrupt.
|
||||
const cleanupErrors: unknown[] = [];
|
||||
try {
|
||||
await quiesceCodexApprovalAgentRun({
|
||||
context: params.context,
|
||||
preserveDebugArtifacts: outcome.kind === "failure",
|
||||
stopGateway: params.stopGateway,
|
||||
...codexRun,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
if (cleanupErrors.length === 0 && targetPath) {
|
||||
try {
|
||||
await fs.rm(targetPath, { force: true });
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
}
|
||||
if (cleanupErrors.length > 0) {
|
||||
const cleanupSummary = cleanupErrors.map(formatErrorMessage).join("; ");
|
||||
if (outcome.kind === "failure") {
|
||||
throw new AggregateError(
|
||||
[outcome.error, ...cleanupErrors],
|
||||
`Codex approval scenario failed: ${formatErrorMessage(outcome.error)}; cleanup also failed: ${cleanupSummary}`,
|
||||
{ cause: outcome.error },
|
||||
);
|
||||
}
|
||||
throw new AggregateError(cleanupErrors, `Codex approval cleanup failed: ${cleanupSummary}`);
|
||||
}
|
||||
if (outcome.kind === "failure") {
|
||||
throw outcome.error;
|
||||
}
|
||||
return outcome.result;
|
||||
}
|
||||
|
||||
async function runSlackCodexApprovalScenarioInner(params: {
|
||||
channelId: string;
|
||||
codexRun: { runId: string; sessionKey: string };
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
primaryModel: string;
|
||||
run: SlackQaCodexApprovalScenarioRun;
|
||||
scenario: SlackQaScenarioDefinition;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const requestStartedAt = new Date();
|
||||
const oldestTs = ((requestStartedAt.getTime() - 5_000) / 1_000).toFixed(6);
|
||||
await startCodexApprovalAgentRun({
|
||||
channelId: params.channelId,
|
||||
context: params.context,
|
||||
primaryModel: params.primaryModel,
|
||||
run: params.run,
|
||||
runId: params.codexRun.runId,
|
||||
scenario: params.scenario,
|
||||
sessionKey: params.codexRun.sessionKey,
|
||||
sutAccountId: params.sutAccountId,
|
||||
});
|
||||
const expectedTitle =
|
||||
params.run.appServerMethod === "item/commandExecution/requestApproval"
|
||||
? "Codex app-server command approval"
|
||||
: "Codex app-server file approval";
|
||||
const pending = await waitForSlackApprovalPrompt({
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
client: params.context.sutReadClient,
|
||||
decision: params.run.decision,
|
||||
extraTextMatches: ["openclaw-codex-app-server", expectedTitle],
|
||||
observedMessages: params.observedMessages,
|
||||
oldestTs,
|
||||
scenarioId: params.scenario.id,
|
||||
scenarioTitle: params.scenario.title,
|
||||
sutIdentity: params.context.sutIdentity,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
});
|
||||
const approvalId = pending.approvalId;
|
||||
if (!approvalId) {
|
||||
throw new Error(
|
||||
"Codex Slack approval prompt exposed native actions but no plugin approval id.",
|
||||
);
|
||||
}
|
||||
await assertPendingCodexPluginApproval({
|
||||
approvalId,
|
||||
appServerMethod: params.run.appServerMethod,
|
||||
channelId: params.channelId,
|
||||
context: params.context,
|
||||
sessionKey: params.codexRun.sessionKey,
|
||||
sutAccountId: params.sutAccountId,
|
||||
});
|
||||
const pendingCheckpoint = await writeSlackApprovalCheckpoint({
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
message: pending.message,
|
||||
observedAt: pending.observedAt,
|
||||
scenarioId: params.scenario.id,
|
||||
state: "pending",
|
||||
});
|
||||
await resolveApprovalDecision({
|
||||
approvalId,
|
||||
context: params.context,
|
||||
decision: params.run.decision,
|
||||
kind: params.run.approvalKind,
|
||||
});
|
||||
const finalCodexTurnStatus = await waitForCodexApprovalAgentRun({
|
||||
context: params.context,
|
||||
runId: params.codexRun.runId,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
});
|
||||
if (finalCodexTurnStatus !== "ok") {
|
||||
throw new Error(
|
||||
`Codex approval run ${params.codexRun.runId} finished with status ${finalCodexTurnStatus}`,
|
||||
);
|
||||
}
|
||||
await assertCodexApprovalOperationSucceeded({
|
||||
context: params.context,
|
||||
run: params.run,
|
||||
sessionKey: params.codexRun.sessionKey,
|
||||
});
|
||||
const resolved = await waitForSlackApprovalResolvedUpdate({
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
client: params.context.sutReadClient,
|
||||
decision: params.run.decision,
|
||||
messageTs: pending.message.ts,
|
||||
observedMessages: params.observedMessages,
|
||||
oldestTs,
|
||||
scenarioId: params.scenario.id,
|
||||
scenarioTitle: params.scenario.title,
|
||||
sutIdentity: params.context.sutIdentity,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
extraTextMatches: ["openclaw-codex-app-server", expectedTitle],
|
||||
});
|
||||
const resolvedCheckpoint = await writeSlackApprovalCheckpoint({
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
channelId: params.channelId,
|
||||
decision: params.run.decision,
|
||||
message: resolved.message,
|
||||
observedAt: resolved.observedAt,
|
||||
scenarioId: params.scenario.id,
|
||||
state: "resolved",
|
||||
});
|
||||
const responseObservedAt = new Date(resolved.observedAt);
|
||||
return {
|
||||
artifact: {
|
||||
approvalId,
|
||||
approvalKind: params.run.approvalKind,
|
||||
appServerMethod: params.run.appServerMethod,
|
||||
channelId: params.channelId,
|
||||
codexModelKey: params.primaryModel,
|
||||
decision: params.run.decision,
|
||||
finalCodexTurnStatus,
|
||||
operationVerified: true,
|
||||
pendingActionValues: pending.actionValues,
|
||||
pendingCheckpointPath: pendingCheckpoint?.checkpointPath,
|
||||
pendingMessageTs: pending.message.ts,
|
||||
pendingScreenshotPath: pendingCheckpoint?.screenshotPath,
|
||||
pendingText: pending.message.text,
|
||||
resolvedActionValues: resolved.actionValues,
|
||||
resolvedCheckpointPath: resolvedCheckpoint?.checkpointPath,
|
||||
resolvedMessageTs: resolved.message.ts,
|
||||
resolvedScreenshotPath: resolvedCheckpoint?.screenshotPath,
|
||||
resolvedText: resolved.message.text,
|
||||
threadTs: pending.message.thread_ts,
|
||||
} satisfies SlackApprovalArtifact,
|
||||
requestStartedAt,
|
||||
responseObservedAt,
|
||||
rttMs: responseObservedAt.getTime() - requestStartedAt.getTime(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// QA Lab Slack Codex approval helpers and transcript validation.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { listSlackReactions } from "@openclaw/slack/api.js";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { extractGatewayMessageText } from "../../gateway-log-sentinel.js";
|
||||
import { formatApprovalResultValue } from "../shared/live-approval-result.js";
|
||||
import { asPlainRecord } from "./slack-live.config.js";
|
||||
import {
|
||||
SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
type SlackQaCodexApprovalMethod,
|
||||
type SlackQaCodexApprovalScenarioRun,
|
||||
type SlackQaScenarioContext,
|
||||
type SlackQaScenarioDefinition,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
export function resolveCodexFileApprovalTargetPath(token: string) {
|
||||
return path.join(os.homedir(), `.openclaw-qa-codex-file-approval-${token.toLowerCase()}.txt`);
|
||||
}
|
||||
|
||||
export function buildCodexApprovalInstruction(params: {
|
||||
appServerMethod: SlackQaCodexApprovalMethod;
|
||||
token: string;
|
||||
}) {
|
||||
if (params.appServerMethod === "item/commandExecution/requestApproval") {
|
||||
return [
|
||||
"Slack QA Codex command approval scenario.",
|
||||
`Marker: ${params.token}`,
|
||||
"Use the shell tool exactly once and request approval before running it.",
|
||||
`Run exactly: printf '${params.token}\\n'`,
|
||||
`After the command succeeds, reply exactly ${params.token} and nothing else.`,
|
||||
].join("\n");
|
||||
}
|
||||
const targetPath = resolveCodexFileApprovalTargetPath(params.token);
|
||||
return [
|
||||
"Slack QA Codex file approval scenario.",
|
||||
`Marker: ${params.token}`,
|
||||
"Use apply_patch exactly once to create this file outside the workspace.",
|
||||
`Target file: ${targetPath}`,
|
||||
`File contents: ${params.token}`,
|
||||
"Invoke apply_patch now. Do not ask for approval in chat; the harness will resolve the native tool approval.",
|
||||
`After the file change succeeds, reply exactly ${params.token} and nothing else.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function readAcceptedAgentRunId(result: unknown) {
|
||||
const started =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { runId?: unknown; status?: unknown })
|
||||
: null;
|
||||
if (started?.status !== "accepted") {
|
||||
throw new Error(
|
||||
`Codex agent run status was ${formatApprovalResultValue(started?.status)} instead of accepted`,
|
||||
);
|
||||
}
|
||||
if (typeof started.runId !== "string" || started.runId.trim().length === 0) {
|
||||
throw new Error(`Codex agent run id was ${formatApprovalResultValue(started.runId)}`);
|
||||
}
|
||||
return started.runId;
|
||||
}
|
||||
|
||||
function readAgentWaitStatus(result: unknown) {
|
||||
if (typeof result !== "object" || result === null) {
|
||||
return "unknown";
|
||||
}
|
||||
const status = (result as { status?: unknown }).status;
|
||||
return typeof status === "string" && status.trim() ? status : "unknown";
|
||||
}
|
||||
|
||||
export async function waitForSlackReaction(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
expectedReactionName: string;
|
||||
messageId: string;
|
||||
sutUserId: string;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const deadline = Date.now() + params.timeoutMs;
|
||||
while (true) {
|
||||
const reactions = await listSlackReactions(params.channelId, params.messageId, {
|
||||
client: params.client,
|
||||
});
|
||||
const reaction = reactions?.find(
|
||||
(entry) =>
|
||||
entry.name === params.expectedReactionName && entry.users?.includes(params.sutUserId),
|
||||
);
|
||||
if (reaction) {
|
||||
return reaction;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Slack message ${params.messageId} did not receive ${params.expectedReactionName} from ${params.sutUserId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCodexApprovalTranscriptSucceeded(
|
||||
messages: unknown,
|
||||
run: SlackQaCodexApprovalScenarioRun,
|
||||
) {
|
||||
const records = Array.isArray(messages) ? messages.map(asPlainRecord) : [];
|
||||
const assistantReply = records
|
||||
.toReversed()
|
||||
.find((message) => message.role === "assistant" && extractGatewayMessageText(message));
|
||||
if (!assistantReply || extractGatewayMessageText(assistantReply) !== run.token) {
|
||||
throw new Error(`Codex approval run did not finish with assistant marker ${run.token}`);
|
||||
}
|
||||
if (run.appServerMethod !== "item/commandExecution/requestApproval") {
|
||||
return;
|
||||
}
|
||||
const commandSucceeded = records.some((message) => {
|
||||
if (message.role !== "toolResult" || message.isError === true) {
|
||||
return false;
|
||||
}
|
||||
return extractGatewayMessageText(message)
|
||||
.split(/\r?\n/u)
|
||||
.some((line) => line.trim() === run.token);
|
||||
});
|
||||
if (!commandSucceeded) {
|
||||
throw new Error(`Codex command result did not contain marker ${run.token}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertCodexApprovalOperationSucceeded(params: {
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
run: SlackQaCodexApprovalScenarioRun;
|
||||
sessionKey: string;
|
||||
}) {
|
||||
const history = asPlainRecord(
|
||||
await params.context.gateway.call(
|
||||
"chat.history",
|
||||
{ sessionKey: params.sessionKey, limit: 24 },
|
||||
{ timeoutMs: 10_000 },
|
||||
),
|
||||
);
|
||||
assertCodexApprovalTranscriptSucceeded(history.messages, params.run);
|
||||
if (params.run.appServerMethod !== "item/fileChange/requestApproval") {
|
||||
return;
|
||||
}
|
||||
const targetPath = resolveCodexFileApprovalTargetPath(params.run.token);
|
||||
const contents = await fs.readFile(targetPath, "utf8");
|
||||
if (contents.trim() !== params.run.token) {
|
||||
throw new Error(`Codex file result at ${targetPath} did not contain the expected marker`);
|
||||
}
|
||||
}
|
||||
|
||||
export function findPendingCodexPluginApprovalRecord(params: {
|
||||
approvalId: string;
|
||||
appServerMethod: SlackQaCodexApprovalMethod;
|
||||
channelId: string;
|
||||
records: unknown;
|
||||
sessionKey: string;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const list = Array.isArray(params.records) ? params.records : [];
|
||||
const expectedTitle =
|
||||
params.appServerMethod === "item/commandExecution/requestApproval"
|
||||
? "Codex app-server command approval"
|
||||
: "Codex app-server file approval";
|
||||
const expectedToolName =
|
||||
params.appServerMethod === "item/commandExecution/requestApproval"
|
||||
? "codex_command_approval"
|
||||
: "codex_file_approval";
|
||||
for (const entry of list) {
|
||||
const record = asPlainRecord(entry);
|
||||
if (record.id !== params.approvalId) {
|
||||
continue;
|
||||
}
|
||||
const request = asPlainRecord(record.request);
|
||||
if (
|
||||
request.pluginId === "openclaw-codex-app-server" &&
|
||||
request.title === expectedTitle &&
|
||||
request.toolName === expectedToolName &&
|
||||
request.sessionKey === params.sessionKey &&
|
||||
request.turnSourceChannel === "slack" &&
|
||||
request.turnSourceTo === `channel:${params.channelId}` &&
|
||||
request.turnSourceAccountId === params.sutAccountId
|
||||
) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function assertPendingCodexPluginApproval(params: {
|
||||
approvalId: string;
|
||||
appServerMethod: SlackQaCodexApprovalMethod;
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
sessionKey: string;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const records = await params.context.gateway.call(
|
||||
"plugin.approval.list",
|
||||
{},
|
||||
{
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
const record = findPendingCodexPluginApprovalRecord({
|
||||
approvalId: params.approvalId,
|
||||
appServerMethod: params.appServerMethod,
|
||||
channelId: params.channelId,
|
||||
records,
|
||||
sessionKey: params.sessionKey,
|
||||
sutAccountId: params.sutAccountId,
|
||||
});
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`Pending Codex plugin approval ${params.approvalId} did not match the expected app-server route and Slack turn source.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexApprovalAgentRun(params: {
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
primaryModel: string;
|
||||
run: SlackQaCodexApprovalScenarioRun;
|
||||
runId: string;
|
||||
scenario: SlackQaScenarioDefinition;
|
||||
sessionKey: string;
|
||||
sutAccountId: string;
|
||||
}) {
|
||||
const result = await params.context.gateway.call(
|
||||
"agent",
|
||||
{
|
||||
accountId: params.sutAccountId,
|
||||
agentId: "qa",
|
||||
channel: "slack",
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
deliver: false,
|
||||
idempotencyKey: params.runId,
|
||||
message: buildCodexApprovalInstruction({
|
||||
appServerMethod: params.run.appServerMethod,
|
||||
token: params.run.token,
|
||||
}),
|
||||
model: params.primaryModel,
|
||||
sessionKey: params.sessionKey,
|
||||
thinking: "low",
|
||||
timeout: Math.ceil(params.scenario.timeoutMs / 1_000),
|
||||
to: `channel:${params.channelId}`,
|
||||
},
|
||||
{
|
||||
timeoutMs: SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
const acceptedRunId = readAcceptedAgentRunId(result);
|
||||
if (acceptedRunId !== params.runId) {
|
||||
throw new Error(`Codex agent run id was ${acceptedRunId} instead of ${params.runId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexApprovalSessionKey(params: {
|
||||
scenario: SlackQaScenarioDefinition;
|
||||
token: string;
|
||||
}) {
|
||||
return `agent:qa:${params.scenario.id}-${params.token.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export async function waitForCodexApprovalAgentRun(params: {
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
runId: string;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const result = await params.context.gateway.call(
|
||||
"agent.wait",
|
||||
{
|
||||
runId: params.runId,
|
||||
timeoutMs: params.timeoutMs,
|
||||
},
|
||||
{
|
||||
timeoutMs: params.timeoutMs + 5_000,
|
||||
},
|
||||
);
|
||||
return readAgentWaitStatus(result);
|
||||
}
|
||||
|
||||
export async function quiesceCodexApprovalAgentRun(params: {
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
preserveDebugArtifacts: boolean;
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
stopGateway: (preserveDebugArtifacts: boolean) => Promise<void>;
|
||||
}) {
|
||||
try {
|
||||
await params.context.gateway.call(
|
||||
"chat.abort",
|
||||
{ runId: params.runId, sessionKey: params.sessionKey },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
} catch {
|
||||
// The bounded terminal wait and gateway process-group teardown do not depend on this ack.
|
||||
}
|
||||
try {
|
||||
await params.context.gateway.call(
|
||||
"agent.wait",
|
||||
{ runId: params.runId, timeoutMs: 10_000 },
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
} catch {
|
||||
// QA-owned Codex app-server processes inherit the gateway cleanup process group.
|
||||
}
|
||||
await params.stopGateway(params.preserveDebugArtifacts);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// QA Lab Slack credentials, instrumentation, and channel config.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
type SlackQaRuntimeEnv,
|
||||
type SlackQaConfigOverrides,
|
||||
SLACK_QA_ENV_KEYS,
|
||||
slackQaCredentialPayloadSchema,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)[number]) {
|
||||
const value = env[key]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${key}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSlackId(value: string, label: string) {
|
||||
const normalized = value.trim();
|
||||
if (!/^[A-Z][A-Z0-9]+$/.test(normalized)) {
|
||||
throw new Error(`${label} must be a Slack id like C123 or U123.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateSlackQaRuntimeEnv(runtimeEnv: SlackQaRuntimeEnv, label: string) {
|
||||
normalizeSlackId(runtimeEnv.channelId, `${label} channelId`);
|
||||
return runtimeEnv;
|
||||
}
|
||||
|
||||
export function resolveSlackQaRuntimeEnv(env: NodeJS.ProcessEnv = process.env): SlackQaRuntimeEnv {
|
||||
const runtimeEnv = {
|
||||
channelId: resolveEnvValue(env, "OPENCLAW_QA_SLACK_CHANNEL_ID"),
|
||||
driverBotToken: resolveEnvValue(env, "OPENCLAW_QA_SLACK_DRIVER_BOT_TOKEN"),
|
||||
sutBotToken: resolveEnvValue(env, "OPENCLAW_QA_SLACK_SUT_BOT_TOKEN"),
|
||||
sutAppToken: resolveEnvValue(env, "OPENCLAW_QA_SLACK_SUT_APP_TOKEN"),
|
||||
};
|
||||
return validateSlackQaRuntimeEnv(runtimeEnv, "OPENCLAW_QA_SLACK");
|
||||
}
|
||||
|
||||
export function parseSlackQaCredentialPayload(payload: unknown): SlackQaRuntimeEnv {
|
||||
const parsed = slackQaCredentialPayloadSchema.parse(payload);
|
||||
const runtimeEnv = {
|
||||
channelId: parsed.channelId,
|
||||
driverBotToken: parsed.driverBotToken,
|
||||
sutBotToken: parsed.sutBotToken,
|
||||
sutAppToken: parsed.sutAppToken,
|
||||
};
|
||||
return validateSlackQaRuntimeEnv(runtimeEnv, "Slack credential payload");
|
||||
}
|
||||
|
||||
export function asPlainRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
type SlackQaPostMessageAttempt = {
|
||||
failureCode?: string;
|
||||
formattingDisabled: boolean;
|
||||
nativeDataBlockCount: number;
|
||||
status: "failed" | "sent";
|
||||
};
|
||||
|
||||
export function countSlackNativeDataBlocks(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value.filter((block) => {
|
||||
const type = asPlainRecord(block).type;
|
||||
return type === "data_table" || type === "data_visualization";
|
||||
}).length;
|
||||
}
|
||||
|
||||
function readSlackApiFailureCode(error: unknown) {
|
||||
const record = asPlainRecord(error);
|
||||
const data = asPlainRecord(record.data);
|
||||
const code = data.error ?? record.error;
|
||||
return typeof code === "string" && /^[a-z0-9_]{1,64}$/u.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export function instrumentSlackPostMessage(client: WebClient) {
|
||||
const originalPostMessage = client.chat.postMessage;
|
||||
const attempts: SlackQaPostMessageAttempt[] = [];
|
||||
client.chat.postMessage = (async (payload) => {
|
||||
const payloadRecord = payload as { blocks?: unknown; mrkdwn?: boolean };
|
||||
const attempt = {
|
||||
formattingDisabled: payloadRecord.mrkdwn === false,
|
||||
nativeDataBlockCount: countSlackNativeDataBlocks(payloadRecord.blocks),
|
||||
};
|
||||
try {
|
||||
const response = await originalPostMessage.call(client.chat, payload);
|
||||
attempts.push({ ...attempt, status: "sent" });
|
||||
return response;
|
||||
} catch (error) {
|
||||
const failureCode = readSlackApiFailureCode(error);
|
||||
attempts.push({
|
||||
...attempt,
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
status: "failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}) as typeof client.chat.postMessage;
|
||||
return {
|
||||
attempts,
|
||||
restore: () => {
|
||||
client.chat.postMessage = originalPostMessage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSlackQaConfig(
|
||||
baseCfg: OpenClawConfig,
|
||||
params: {
|
||||
channelId: string;
|
||||
driverBotUserId: string;
|
||||
overrides?: SlackQaConfigOverrides;
|
||||
primaryModel?: string;
|
||||
sutAccountId: string;
|
||||
sutAppToken: string;
|
||||
sutBotToken: string;
|
||||
},
|
||||
): OpenClawConfig {
|
||||
const codexApprovalConfig = params.overrides?.codexApproval === true;
|
||||
const progressOverrides = params.overrides?.progress;
|
||||
const primaryModel = params.primaryModel;
|
||||
const pluginAllow = uniqueStrings([
|
||||
...(baseCfg.plugins?.allow ?? []),
|
||||
"slack",
|
||||
...(codexApprovalConfig ? ["codex"] : []),
|
||||
]);
|
||||
const approvalOverrides = params.overrides?.approvals;
|
||||
const codexEntry = baseCfg.plugins?.entries?.codex;
|
||||
const codexEntryConfig = asPlainRecord(codexEntry?.config);
|
||||
const codexAppServerConfig = asPlainRecord(codexEntryConfig.appServer);
|
||||
const approvalForwardingConfig =
|
||||
approvalOverrides?.exec || approvalOverrides?.plugin
|
||||
? {
|
||||
approvals: {
|
||||
...baseCfg.approvals,
|
||||
...(approvalOverrides.exec
|
||||
? {
|
||||
exec: {
|
||||
...baseCfg.approvals?.exec,
|
||||
enabled: true,
|
||||
mode: "session" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(approvalOverrides.plugin
|
||||
? {
|
||||
plugin: {
|
||||
...baseCfg.approvals?.plugin,
|
||||
enabled: true,
|
||||
mode: "session" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const codexAgentDefaults =
|
||||
codexApprovalConfig && primaryModel
|
||||
? {
|
||||
...baseCfg.agents?.defaults,
|
||||
models: {
|
||||
...baseCfg.agents?.defaults?.models,
|
||||
[primaryModel]: {
|
||||
...baseCfg.agents?.defaults?.models?.[primaryModel],
|
||||
agentRuntime: { id: "codex" as const },
|
||||
},
|
||||
},
|
||||
}
|
||||
: baseCfg.agents?.defaults;
|
||||
const qaAgentDefaults = progressOverrides
|
||||
? {
|
||||
...codexAgentDefaults,
|
||||
...(progressOverrides.verboseDefault
|
||||
? { verboseDefault: progressOverrides.verboseDefault }
|
||||
: {}),
|
||||
}
|
||||
: codexAgentDefaults;
|
||||
const qaAgentList = progressOverrides
|
||||
? baseCfg.agents?.list?.map((agent) => {
|
||||
if (agent.id !== "qa") {
|
||||
return agent;
|
||||
}
|
||||
// Slack draft edits cannot preserve custom authorship. Remove the
|
||||
// synthetic QA identity so progress scenarios reach the draft path.
|
||||
const qaAgent = { ...agent };
|
||||
delete qaAgent.identity;
|
||||
return qaAgent;
|
||||
})
|
||||
: baseCfg.agents?.list;
|
||||
const execApprovalsConfig = approvalOverrides
|
||||
? {
|
||||
enabled: true,
|
||||
approvers: [params.driverBotUserId],
|
||||
target: approvalOverrides.target ?? ("channel" as const),
|
||||
}
|
||||
: undefined;
|
||||
const explicitToolAllow = baseCfg.tools?.allow;
|
||||
const messageToolPolicy = params.overrides?.messageTool
|
||||
? explicitToolAllow && explicitToolAllow.length > 0
|
||||
? { allow: uniqueStrings([...explicitToolAllow, "message"]) }
|
||||
: { alsoAllow: uniqueStrings([...(baseCfg.tools?.alsoAllow ?? []), "message"]) }
|
||||
: {};
|
||||
const toolsConfig =
|
||||
codexApprovalConfig || params.overrides?.messageTool
|
||||
? {
|
||||
tools: {
|
||||
...baseCfg.tools,
|
||||
...messageToolPolicy,
|
||||
...(codexApprovalConfig
|
||||
? {
|
||||
exec: {
|
||||
...baseCfg.tools?.exec,
|
||||
mode: "ask" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
...baseCfg,
|
||||
...approvalForwardingConfig,
|
||||
...toolsConfig,
|
||||
plugins: {
|
||||
...baseCfg.plugins,
|
||||
allow: pluginAllow,
|
||||
entries: {
|
||||
...baseCfg.plugins?.entries,
|
||||
slack: { enabled: true },
|
||||
...(codexApprovalConfig
|
||||
? {
|
||||
codex: {
|
||||
...codexEntry,
|
||||
enabled: true,
|
||||
config: {
|
||||
...codexEntryConfig,
|
||||
appServer: {
|
||||
...codexAppServerConfig,
|
||||
mode: "guardian" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
...(codexApprovalConfig || progressOverrides
|
||||
? {
|
||||
agents: {
|
||||
...baseCfg.agents,
|
||||
...(qaAgentDefaults ? { defaults: qaAgentDefaults } : {}),
|
||||
...(qaAgentList ? { list: qaAgentList } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
groupChat: {
|
||||
...baseCfg.messages?.groupChat,
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
slack: {
|
||||
enabled: true,
|
||||
defaultAccount: params.sutAccountId,
|
||||
accounts: {
|
||||
[params.sutAccountId]: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
botToken: params.sutBotToken,
|
||||
appToken: params.sutAppToken,
|
||||
allowFrom: params.overrides?.allowFrom ?? [params.driverBotUserId],
|
||||
groupPolicy: "allowlist",
|
||||
allowBots: true,
|
||||
replyToMode: params.overrides?.replyToMode ?? "off",
|
||||
...(progressOverrides
|
||||
? {
|
||||
streaming: {
|
||||
mode: "progress" as const,
|
||||
progress: {
|
||||
label: false,
|
||||
maxLines: 4,
|
||||
toolProgress: progressOverrides.toolProgress,
|
||||
...(progressOverrides.commentary === undefined
|
||||
? {}
|
||||
: { commentary: progressOverrides.commentary }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(execApprovalsConfig ? { execApprovals: execApprovalsConfig } : {}),
|
||||
channels: {
|
||||
[params.channelId]: {
|
||||
enabled: params.overrides?.channelEnabled ?? true,
|
||||
requireMention: true,
|
||||
allowBots: true,
|
||||
users: params.overrides?.users ?? [params.driverBotUserId],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// QA Lab Slack live domain contracts and wire schemas.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { z } from "zod";
|
||||
import type { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { splitQaModelRef } from "../../model-selection.js";
|
||||
import type { RuntimeId } from "../../runtime-parity.js";
|
||||
import type {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import type { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import type { LiveTransportScenarioDefinition } from "../shared/live-transport-scenarios.js";
|
||||
|
||||
export type SlackQaRuntimeEnv = {
|
||||
channelId: string;
|
||||
driverBotToken: string;
|
||||
sutBotToken: string;
|
||||
sutAppToken: string;
|
||||
};
|
||||
|
||||
export type SlackChannelStatus = {
|
||||
connected?: boolean;
|
||||
lastConnectedAt?: number;
|
||||
lastDisconnect?: unknown;
|
||||
lastError?: string | null;
|
||||
restartPending?: boolean;
|
||||
running?: boolean;
|
||||
};
|
||||
|
||||
export type SlackChannelReadinessMode = "connected" | "started";
|
||||
|
||||
export const SLACK_QA_DEFAULT_READY_TIMEOUT_MS = 45_000;
|
||||
export const SLACK_QA_READY_STABILITY_MS = 3_000;
|
||||
export const SLACK_QA_GATEWAY_STOP_SETTLE_MS = 3_000;
|
||||
export const SLACK_QA_RETRYABLE_SCENARIO_ATTEMPTS = 2;
|
||||
export const SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS = 30_000;
|
||||
export const SLACK_QA_APPROVAL_CHECKPOINT_DEFAULT_TIMEOUT_MS = 120_000;
|
||||
export const SLACK_QA_REACTION_VERIFY_TIMEOUT_MS = 15_000;
|
||||
export const SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS = 15_000;
|
||||
export const SLACK_QA_INVALID_TABLE_DATA_ROW_COUNT = 101;
|
||||
export const SLACK_QA_LOG_TAIL_TIMEOUT_MS = 20_000;
|
||||
export const SLACK_QA_INVALID_TABLE_CAPTION = "QA invalid_blocks fallback";
|
||||
export const SLACK_QA_INVALID_TABLE_HEADERS = ["Row", "Value"] as const;
|
||||
export const SLACK_QA_CHART_TITLE = "QA latency trend";
|
||||
export const SLACK_QA_CHART_CATEGORIES = ["P50", "P95"] as const;
|
||||
export const SLACK_QA_CHART_SERIES_NAME = "Latency";
|
||||
export const SLACK_QA_CHART_VALUES = [120, 240] as const;
|
||||
export const SLACK_QA_CHART_X_LABEL = "Percentile";
|
||||
export const SLACK_QA_CHART_Y_LABEL = "Milliseconds";
|
||||
export const SLACK_QA_TABLE_CAPTION = "QA pipeline report";
|
||||
export const SLACK_QA_TABLE_HEADERS = ["Account", "Stage", "ARR"] as const;
|
||||
export const SLACK_QA_TABLE_ROWS = [
|
||||
["Acme", "Won", 125_000],
|
||||
["Globex", "Review", 82_000],
|
||||
] as const;
|
||||
export const SLACK_QA_NATIVE_CHART = {
|
||||
type: "data_visualization",
|
||||
title: SLACK_QA_CHART_TITLE,
|
||||
chart: {
|
||||
type: "line",
|
||||
series: [
|
||||
{
|
||||
name: SLACK_QA_CHART_SERIES_NAME,
|
||||
data: [
|
||||
{ label: SLACK_QA_CHART_CATEGORIES[0], value: SLACK_QA_CHART_VALUES[0] },
|
||||
{ label: SLACK_QA_CHART_CATEGORIES[1], value: SLACK_QA_CHART_VALUES[1] },
|
||||
],
|
||||
},
|
||||
],
|
||||
axis_config: {
|
||||
categories: [...SLACK_QA_CHART_CATEGORIES],
|
||||
x_label: SLACK_QA_CHART_X_LABEL,
|
||||
y_label: SLACK_QA_CHART_Y_LABEL,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
export const SLACK_QA_NATIVE_TABLE = {
|
||||
type: "data_table",
|
||||
caption: SLACK_QA_TABLE_CAPTION,
|
||||
rows: [
|
||||
SLACK_QA_TABLE_HEADERS.map((text) => ({ type: "raw_text", text })),
|
||||
...SLACK_QA_TABLE_ROWS.map((row) =>
|
||||
row.map((cell) =>
|
||||
typeof cell === "number"
|
||||
? { type: "raw_number", value: cell, text: String(cell) }
|
||||
: { type: "raw_text", text: cell },
|
||||
),
|
||||
),
|
||||
],
|
||||
row_header_column_index: 0,
|
||||
} as const;
|
||||
// These scenarios force the Codex harness, whose default provider set is intentionally narrow.
|
||||
const SLACK_QA_CODEX_PROVIDER_IDS = new Set(["codex", "openai"]);
|
||||
|
||||
export type SlackQaScenarioId =
|
||||
| "slack-allowlist-block"
|
||||
| "slack-approval-exec-native"
|
||||
| "slack-approval-plugin-native"
|
||||
| "slack-canary"
|
||||
| "slack-codex-approval-exec-native"
|
||||
| "slack-codex-approval-plugin-native"
|
||||
| "slack-chart-presentation-native"
|
||||
| "slack-channel-disabled-warning"
|
||||
| "slack-mention-gating"
|
||||
| "slack-progress-commentary-false"
|
||||
| "slack-progress-commentary-omitted"
|
||||
| "slack-progress-commentary-true"
|
||||
| "slack-progress-commentary-verbose-dedupe"
|
||||
| "slack-reaction-glyph-native"
|
||||
| "slack-table-invalid-blocks-fallback"
|
||||
| "slack-table-presentation-native"
|
||||
| "slack-top-level-reply-shape";
|
||||
|
||||
export type SlackQaApprovalKind = "exec" | "plugin";
|
||||
export type SlackQaApprovalDecision = "allow-always" | "allow-once" | "deny";
|
||||
export const SLACK_QA_APPROVAL_ACTION_PREFIX = "openclaw:approval:v1:";
|
||||
export const SlackQaApprovalActionValueSchema = z
|
||||
.object({
|
||||
approvalId: z.string().min(1),
|
||||
approvalKind: z.enum(["exec", "plugin"]),
|
||||
decision: z.enum(["allow-always", "allow-once", "deny"]),
|
||||
})
|
||||
.strict();
|
||||
export type SlackQaCodexApprovalMethod =
|
||||
| "item/commandExecution/requestApproval"
|
||||
| "item/fileChange/requestApproval";
|
||||
|
||||
export function assertSlackCodexApprovalModelSupported(modelRef: string) {
|
||||
const provider = splitQaModelRef(modelRef)?.provider.trim().toLowerCase();
|
||||
if (provider && SLACK_QA_CODEX_PROVIDER_IDS.has(provider)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Slack Codex approval scenarios require an openai/* or codex/* model; received "${modelRef}".`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSlackQaSutAccountId(value?: string) {
|
||||
return normalizeAccountId(value?.trim() || "sut");
|
||||
}
|
||||
|
||||
export type SlackQaMessageScenarioRun = {
|
||||
afterNoReply?: (context: SlackQaScenarioContext) => Promise<string | void>;
|
||||
kind?: "message";
|
||||
expectReply: boolean;
|
||||
input: string;
|
||||
matchText: string;
|
||||
preserveGatewayDebug?: boolean;
|
||||
settleObservedMs?: number;
|
||||
verify?: (message: SlackMessage, context: { requestThreadTs: string; sentTs: string }) => void;
|
||||
verifyObserved?: (params: {
|
||||
finalMessage: SlackMessage;
|
||||
messages: readonly SlackObservedMessage[];
|
||||
}) => string | void;
|
||||
beforeRun?: (context: Omit<SlackQaScenarioContext, "sentTs">) => Promise<SlackQaBeforeRunResult>;
|
||||
afterReply?: (message: SlackMessage, context: SlackQaScenarioContext) => Promise<string | void>;
|
||||
};
|
||||
|
||||
type SlackQaDirectTransportScenarioRun = {
|
||||
kind: "direct-transport";
|
||||
execute: (
|
||||
context: SlackQaDirectTransportScenarioContext,
|
||||
) => Promise<SlackQaDirectTransportScenarioResult>;
|
||||
};
|
||||
|
||||
export type SlackQaDirectTransportScenarioContext = {
|
||||
cfg: OpenClawConfig;
|
||||
channelId: string;
|
||||
sutAccountId: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
sutReadClient: WebClient;
|
||||
sutWriteClient: WebClient;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type SlackQaDirectTransportScenarioResult = {
|
||||
details: string;
|
||||
message: SlackMessage;
|
||||
};
|
||||
|
||||
export type SlackQaApprovalScenarioRun = {
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
decision: SlackQaApprovalDecision;
|
||||
kind: "approval";
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type SlackQaCodexApprovalScenarioRun = {
|
||||
approvalKind: "plugin";
|
||||
appServerMethod: SlackQaCodexApprovalMethod;
|
||||
decision: "allow-once";
|
||||
kind: "codex-approval";
|
||||
token: string;
|
||||
};
|
||||
|
||||
type SlackQaScenarioRun =
|
||||
| SlackQaApprovalScenarioRun
|
||||
| SlackQaCodexApprovalScenarioRun
|
||||
| SlackQaDirectTransportScenarioRun
|
||||
| SlackQaMessageScenarioRun;
|
||||
|
||||
type SlackQaBeforeRunResult =
|
||||
| string
|
||||
| void
|
||||
| {
|
||||
details?: string;
|
||||
inputThreadTs?: string;
|
||||
};
|
||||
|
||||
export type SlackQaConfigOverrides = {
|
||||
allowFrom?: string[];
|
||||
channelEnabled?: boolean;
|
||||
approvals?: {
|
||||
exec?: boolean;
|
||||
plugin?: boolean;
|
||||
target?: "both" | "channel" | "dm";
|
||||
};
|
||||
codexApproval?: boolean;
|
||||
messageTool?: boolean;
|
||||
progress?: {
|
||||
commentary?: boolean;
|
||||
toolProgress: boolean;
|
||||
verboseDefault?: "off" | "on" | "full";
|
||||
};
|
||||
replyToMode?: "all" | "off";
|
||||
users?: string[];
|
||||
};
|
||||
|
||||
export type SlackQaScenarioContext = {
|
||||
channelId: string;
|
||||
driverClient: WebClient;
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>;
|
||||
postSlackMessage: (params: { text: string; threadTs?: string }) => Promise<{ ts: string }>;
|
||||
sentTs: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
sutReadClient: WebClient;
|
||||
waitForReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type SlackQaScenarioDefinition = LiveTransportScenarioDefinition<SlackQaScenarioId> & {
|
||||
buildRun: (sutUserId: string) => SlackQaScenarioRun;
|
||||
configOverrides?: SlackQaConfigOverrides;
|
||||
defaultEnabled?: boolean;
|
||||
forcedRuntime?: RuntimeId;
|
||||
};
|
||||
|
||||
export type SlackQaGatewayHarness = Awaited<ReturnType<typeof startQaLiveLaneGateway>>;
|
||||
|
||||
export type SlackAuthIdentity = {
|
||||
botId?: string;
|
||||
teamId?: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SlackObservedMessage = {
|
||||
botId?: string;
|
||||
channelId: string;
|
||||
matchedScenario?: boolean;
|
||||
scenarioId?: string;
|
||||
scenarioTitle?: string;
|
||||
text: string;
|
||||
actionValues?: string[];
|
||||
blockText?: string[];
|
||||
threadTs?: string;
|
||||
ts: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type SlackObservedMessageArtifact = {
|
||||
botId?: string;
|
||||
channelId?: string;
|
||||
matchedScenario?: boolean;
|
||||
scenarioId?: string;
|
||||
scenarioTitle?: string;
|
||||
text?: string;
|
||||
actionValues?: string[];
|
||||
blockText?: string[];
|
||||
threadTs?: string;
|
||||
ts?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type SlackApprovalArtifact = {
|
||||
approvalId: string;
|
||||
approvalKind: SlackQaApprovalKind;
|
||||
appServerMethod?: SlackQaCodexApprovalMethod;
|
||||
channelId?: string;
|
||||
codexModelKey?: string;
|
||||
decision: SlackQaApprovalDecision;
|
||||
finalCodexTurnStatus?: string;
|
||||
operationVerified?: boolean;
|
||||
pendingActionValues?: string[];
|
||||
pendingCheckpointPath?: string;
|
||||
pendingMessageTs?: string;
|
||||
pendingScreenshotPath?: string;
|
||||
pendingText?: string;
|
||||
resolvedActionValues?: string[];
|
||||
resolvedCheckpointPath?: string;
|
||||
resolvedMessageTs?: string;
|
||||
resolvedScreenshotPath?: string;
|
||||
resolvedText?: string;
|
||||
threadTs?: string;
|
||||
};
|
||||
|
||||
export type SlackApprovalCheckpointState = "pending" | "resolved";
|
||||
|
||||
export type SlackApprovalCheckpointAck = {
|
||||
capturedAt?: string;
|
||||
screenshotPath?: string;
|
||||
};
|
||||
|
||||
export type SlackApprovalCheckpointMessage = {
|
||||
actionLabels: string[];
|
||||
blockText: string[];
|
||||
hasNativeActions: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SlackQaScenarioResult = {
|
||||
approval?: SlackApprovalArtifact;
|
||||
details: string;
|
||||
id: string;
|
||||
requestStartedAt?: string;
|
||||
responseObservedAt?: string;
|
||||
rttMs?: number;
|
||||
rttMeasurement?: {
|
||||
finalMatchedReplyRttMs: number;
|
||||
requestStartedAt: string;
|
||||
responseObservedAt: string;
|
||||
source: "approval-request-to-resolution" | "request-to-observed-message";
|
||||
};
|
||||
standardId?: string;
|
||||
status: "fail" | "pass";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type SlackQaRunResult = {
|
||||
gatewayDebugDirPath?: string;
|
||||
observedMessagesPath: string;
|
||||
outputDir: string;
|
||||
reportPath: string;
|
||||
scenarios: SlackQaScenarioResult[];
|
||||
summaryPath: string;
|
||||
};
|
||||
|
||||
export type SlackCredentialLease = Awaited<
|
||||
ReturnType<typeof acquireQaCredentialLease<SlackQaRuntimeEnv>>
|
||||
>;
|
||||
export type SlackCredentialHeartbeat = ReturnType<typeof startQaCredentialLeaseHeartbeat>;
|
||||
|
||||
export const SLACK_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_SLACK_CAPTURE_CONTENT";
|
||||
export const SLACK_QA_APPROVAL_CHECKPOINT_DIR_ENV = "OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR";
|
||||
export const SLACK_QA_APPROVAL_CHECKPOINT_TIMEOUT_MS_ENV =
|
||||
"OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS";
|
||||
export const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
|
||||
export const SLACK_QA_WEB_API_TIMEOUT_MS = 45_000;
|
||||
export const SLACK_QA_ENV_KEYS = [
|
||||
"OPENCLAW_QA_SLACK_CHANNEL_ID",
|
||||
"OPENCLAW_QA_SLACK_DRIVER_BOT_TOKEN",
|
||||
"OPENCLAW_QA_SLACK_SUT_BOT_TOKEN",
|
||||
"OPENCLAW_QA_SLACK_SUT_APP_TOKEN",
|
||||
] as const;
|
||||
|
||||
export const slackQaCredentialPayloadSchema = z.object({
|
||||
channelId: z.string().trim().min(1),
|
||||
driverBotToken: z.string().trim().min(1),
|
||||
sutBotToken: z.string().trim().min(1),
|
||||
sutAppToken: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const slackAuthTestSchema = z.object({
|
||||
ok: z.boolean().optional(),
|
||||
user_id: z.string().optional(),
|
||||
bot_id: z.string().optional(),
|
||||
team_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export const slackPostMessageSchema = z.object({
|
||||
ok: z.boolean().optional(),
|
||||
channel: z.string().optional(),
|
||||
ts: z.string().min(1),
|
||||
});
|
||||
|
||||
const slackHistoryMessageSchema = z.object({
|
||||
bot_id: z.string().optional(),
|
||||
blocks: z.array(z.unknown()).optional(),
|
||||
text: z.string().optional(),
|
||||
thread_ts: z.string().optional(),
|
||||
ts: z.string().min(1),
|
||||
user: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SlackMessage = Omit<z.infer<typeof slackHistoryMessageSchema>, "ts"> & { ts?: string };
|
||||
|
||||
export const slackHistorySchema = z.object({
|
||||
ok: z.boolean().optional(),
|
||||
messages: z.array(slackHistoryMessageSchema).optional(),
|
||||
});
|
||||
|
||||
export const slackRepliesSchema = z.object({
|
||||
ok: z.boolean().optional(),
|
||||
messages: z.array(slackHistoryMessageSchema).optional(),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// QA Lab Slack invalid-blocks fallback fixture.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
SLACK_QA_INVALID_TABLE_DATA_ROW_COUNT,
|
||||
SLACK_QA_INVALID_TABLE_CAPTION,
|
||||
SLACK_QA_INVALID_TABLE_HEADERS,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
function buildSlackInvalidBlocksTableRow(index: number) {
|
||||
const rowId = String(index).padStart(3, "0");
|
||||
return [`row-${rowId}`, `value-${rowId}`] as const;
|
||||
}
|
||||
|
||||
export function buildSlackInvalidBlocksTableProbe() {
|
||||
const summaryText = `SLACK_QA_TABLE_INVALID_BLOCKS_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
const dataRows = Array.from({ length: SLACK_QA_INVALID_TABLE_DATA_ROW_COUNT }, (_entry, index) =>
|
||||
buildSlackInvalidBlocksTableRow(index + 1),
|
||||
);
|
||||
const block = {
|
||||
type: "data_table",
|
||||
caption: SLACK_QA_INVALID_TABLE_CAPTION,
|
||||
rows: [
|
||||
SLACK_QA_INVALID_TABLE_HEADERS.map((text) => ({ type: "raw_text", text })),
|
||||
...dataRows.map((row) => row.map((text) => ({ type: "raw_text", text }))),
|
||||
],
|
||||
row_header_column_index: 0,
|
||||
} as const;
|
||||
const fallbackText = [
|
||||
summaryText,
|
||||
"",
|
||||
`${SLACK_QA_INVALID_TABLE_CAPTION} (table)`,
|
||||
SLACK_QA_INVALID_TABLE_HEADERS.join("\t"),
|
||||
...dataRows.map((row) => row.join("\t")),
|
||||
].join("\n");
|
||||
return {
|
||||
block,
|
||||
dataRowCount: dataRows.length,
|
||||
fallbackText,
|
||||
firstRowText: buildSlackInvalidBlocksTableRow(1).join("\t"),
|
||||
finalRowText: buildSlackInvalidBlocksTableRow(SLACK_QA_INVALID_TABLE_DATA_ROW_COUNT).join("\t"),
|
||||
summaryText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// QA Lab Slack scenario reply observation and channel readiness.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import {
|
||||
type SlackChannelStatus,
|
||||
type SlackChannelReadinessMode,
|
||||
SLACK_QA_DEFAULT_READY_TIMEOUT_MS,
|
||||
SLACK_QA_READY_STABILITY_MS,
|
||||
type SlackAuthIdentity,
|
||||
type SlackObservedMessage,
|
||||
type SlackMessage,
|
||||
} from "./slack-live.contracts.js";
|
||||
import {
|
||||
listSlackMessages,
|
||||
listSlackThreadMessages,
|
||||
collectSlackBlockText,
|
||||
collectSlackActionValues,
|
||||
isSutSlackMessage,
|
||||
} from "./slack-live.observations.js";
|
||||
|
||||
type SlackScenarioObservationContext = {
|
||||
channelId: string;
|
||||
matchText: string;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
observationScenarioId: string;
|
||||
observationScenarioTitle: string;
|
||||
sentTs: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
};
|
||||
|
||||
function recordSlackScenarioMessages(
|
||||
params: SlackScenarioObservationContext & { messages: SlackMessage[] },
|
||||
) {
|
||||
let matchedMessage: SlackMessage | undefined;
|
||||
for (const message of params.messages) {
|
||||
const text = message.text ?? "";
|
||||
if (
|
||||
!message.ts ||
|
||||
message.ts === params.sentTs ||
|
||||
!isSutSlackMessage(message, params.sutIdentity)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchedScenario = text.includes(params.matchText);
|
||||
params.observedMessages.push({
|
||||
actionValues: collectSlackActionValues(message.blocks),
|
||||
blockText: collectSlackBlockText(message.blocks),
|
||||
botId: message.bot_id,
|
||||
channelId: params.channelId,
|
||||
matchedScenario,
|
||||
scenarioId: params.observationScenarioId,
|
||||
scenarioTitle: params.observationScenarioTitle,
|
||||
text,
|
||||
threadTs: message.thread_ts,
|
||||
ts: message.ts,
|
||||
userId: message.user,
|
||||
});
|
||||
if (matchedScenario && !matchedMessage) {
|
||||
matchedMessage = message;
|
||||
}
|
||||
}
|
||||
return matchedMessage;
|
||||
}
|
||||
|
||||
export async function waitForSlackScenarioReply(
|
||||
params: SlackScenarioObservationContext & {
|
||||
client: WebClient;
|
||||
threadTs?: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
) {
|
||||
const observationContext: SlackScenarioObservationContext = params;
|
||||
const startedAt = Date.now();
|
||||
const inspectMessages = (messages: SlackMessage[]) => {
|
||||
const matchedMessage = recordSlackScenarioMessages({ ...observationContext, messages });
|
||||
return matchedMessage
|
||||
? { message: matchedMessage, observedAt: new Date().toISOString() }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const channelMessages = await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.sentTs,
|
||||
});
|
||||
const channelReply = inspectMessages(channelMessages);
|
||||
if (channelReply) {
|
||||
return channelReply;
|
||||
}
|
||||
|
||||
try {
|
||||
const threadMessages = await listSlackThreadMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
threadTs: params.threadTs ?? params.sentTs,
|
||||
});
|
||||
const threadReply = inspectMessages(threadMessages);
|
||||
if (threadReply) {
|
||||
return threadReply;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Slack conversations.replies failed while waiting for ${params.observationScenarioId}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
}
|
||||
throw new Error(`timed out after ${params.timeoutMs}ms waiting for Slack message`);
|
||||
}
|
||||
|
||||
export async function observeSlackScenarioMessages(
|
||||
params: SlackScenarioObservationContext & {
|
||||
client: WebClient;
|
||||
settleMs: number;
|
||||
threadTs?: string;
|
||||
},
|
||||
) {
|
||||
const observationContext: SlackScenarioObservationContext = params;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (true) {
|
||||
recordSlackScenarioMessages({
|
||||
...observationContext,
|
||||
messages: await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.sentTs,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
recordSlackScenarioMessages({
|
||||
...observationContext,
|
||||
messages: await listSlackThreadMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
threadTs: params.threadTs ?? params.sentTs,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Slack conversations.replies failed while settling ${params.observationScenarioId}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const remainingMs = params.settleMs - (Date.now() - startedAt);
|
||||
if (remainingMs <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(1_000, remainingMs));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForSlackNoReply(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
matchText: string;
|
||||
observedMessages: SlackObservedMessage[];
|
||||
observationScenarioId: string;
|
||||
observationScenarioTitle: string;
|
||||
sentTs: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const observedKeys = new Set(
|
||||
params.observedMessages
|
||||
.map((message) => `${message.channelId ?? params.channelId}:${message.ts ?? ""}`)
|
||||
.filter((key) => !key.endsWith(":")),
|
||||
);
|
||||
let elapsedMs = Date.now() - startedAt;
|
||||
while (elapsedMs < params.timeoutMs) {
|
||||
const messages = await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.sentTs,
|
||||
});
|
||||
for (const message of messages) {
|
||||
const text = message.text ?? "";
|
||||
if (
|
||||
!message.ts ||
|
||||
message.ts === params.sentTs ||
|
||||
!isSutSlackMessage(message, params.sutIdentity)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchedScenario = text.includes(params.matchText);
|
||||
const observedKey = `${params.channelId}:${message.ts}`;
|
||||
if (!observedKeys.has(observedKey)) {
|
||||
observedKeys.add(observedKey);
|
||||
params.observedMessages.push({
|
||||
actionValues: collectSlackActionValues(message.blocks),
|
||||
blockText: collectSlackBlockText(message.blocks),
|
||||
botId: message.bot_id,
|
||||
channelId: params.channelId,
|
||||
matchedScenario,
|
||||
scenarioId: params.observationScenarioId,
|
||||
scenarioTitle: params.observationScenarioTitle,
|
||||
text,
|
||||
threadTs: message.thread_ts,
|
||||
ts: message.ts,
|
||||
userId: message.user,
|
||||
});
|
||||
}
|
||||
if (matchedScenario) {
|
||||
throw new Error("unexpected Slack SUT reply observed");
|
||||
}
|
||||
}
|
||||
elapsedMs = Date.now() - startedAt;
|
||||
const remainingMs = params.timeoutMs - elapsedMs;
|
||||
if (remainingMs > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(1_000, remainingMs));
|
||||
});
|
||||
}
|
||||
elapsedMs = Date.now() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSlackChannelRunning(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
accountId: string,
|
||||
mode: SlackChannelReadinessMode,
|
||||
): Promise<SlackChannelStatus> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = resolveSlackQaReadyTimeoutMs();
|
||||
let lastStatus: SlackChannelStatus | undefined;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const payload = (await gateway.call(
|
||||
"channels.status",
|
||||
{ probe: false, timeoutMs: 2_000 },
|
||||
{ timeoutMs: 5_000 },
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{
|
||||
accountId?: string;
|
||||
connected?: boolean;
|
||||
lastConnectedAt?: number;
|
||||
lastDisconnect?: unknown;
|
||||
lastError?: string | null;
|
||||
restartPending?: boolean;
|
||||
running?: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.slack ?? [];
|
||||
const match = accounts.find((entry) => entry.accountId === accountId);
|
||||
lastStatus = match
|
||||
? {
|
||||
connected: match.connected,
|
||||
lastConnectedAt: match.lastConnectedAt,
|
||||
lastDisconnect: match.lastDisconnect,
|
||||
lastError: match.lastError,
|
||||
restartPending: match.restartPending,
|
||||
running: match.running,
|
||||
}
|
||||
: undefined;
|
||||
if (isSlackChannelReadyForQa(lastStatus, mode)) {
|
||||
if (!lastStatus) {
|
||||
throw new Error(`slack account "${accountId}" status disappeared after readiness check`);
|
||||
}
|
||||
return lastStatus;
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`slack account "${accountId}" did not become ready` +
|
||||
(lastStatus ? `; last status: ${JSON.stringify(lastStatus)}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForSlackChannelStable(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
accountId: string,
|
||||
mode: SlackChannelReadinessMode,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = resolveSlackQaReadyTimeoutMs();
|
||||
let readySince: number | undefined;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const status = await waitForSlackChannelRunning(gateway, accountId, mode);
|
||||
const observedAt = Date.now();
|
||||
readySince = resolveSlackChannelReadySince({
|
||||
observedAt,
|
||||
previousReadySince: readySince,
|
||||
status,
|
||||
});
|
||||
const readyForMs = observedAt - readySince;
|
||||
if (readyForMs >= SLACK_QA_READY_STABILITY_MS) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.max(500, SLACK_QA_READY_STABILITY_MS - readyForMs));
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`slack account "${accountId}" did not remain ready for ${SLACK_QA_READY_STABILITY_MS}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function isSlackChannelReadyForQa(
|
||||
status: SlackChannelStatus | undefined,
|
||||
mode: SlackChannelReadinessMode,
|
||||
): boolean {
|
||||
if (
|
||||
!status?.running ||
|
||||
status.restartPending === true ||
|
||||
status.lastError != null ||
|
||||
status.connected === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return mode === "started" || status.connected === true;
|
||||
}
|
||||
|
||||
export function resolveSlackChannelReadySince(params: {
|
||||
observedAt: number;
|
||||
previousReadySince: number | undefined;
|
||||
status: SlackChannelStatus;
|
||||
}): number {
|
||||
if (typeof params.status.lastConnectedAt === "number" && params.status.lastConnectedAt > 0) {
|
||||
return params.status.lastConnectedAt;
|
||||
}
|
||||
return params.previousReadySince ?? params.observedAt;
|
||||
}
|
||||
|
||||
export function resolveSlackQaReadyTimeoutMs(env: NodeJS.ProcessEnv = process.env) {
|
||||
const raw = env.OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return SLACK_QA_DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
return parseStrictPositiveInteger(raw) ?? SLACK_QA_DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// QA Lab Slack Web API and stored-message observations.
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createSlackWebClient, sendSlackMessage } from "@openclaw/slack/api.js";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import {
|
||||
asPlainRecord,
|
||||
countSlackNativeDataBlocks,
|
||||
instrumentSlackPostMessage,
|
||||
} from "./slack-live.config.js";
|
||||
import {
|
||||
SLACK_QA_NATIVE_CHART,
|
||||
SLACK_QA_NATIVE_TABLE,
|
||||
type SlackQaApprovalDecision,
|
||||
SLACK_QA_APPROVAL_ACTION_PREFIX,
|
||||
SlackQaApprovalActionValueSchema,
|
||||
type SlackQaDirectTransportScenarioContext,
|
||||
type SlackQaDirectTransportScenarioResult,
|
||||
type SlackAuthIdentity,
|
||||
type SlackApprovalCheckpointMessage,
|
||||
SLACK_QA_WEB_API_TIMEOUT_MS,
|
||||
slackAuthTestSchema,
|
||||
slackPostMessageSchema,
|
||||
type SlackMessage,
|
||||
slackHistorySchema,
|
||||
slackRepliesSchema,
|
||||
} from "./slack-live.contracts.js";
|
||||
import { buildSlackInvalidBlocksTableProbe } from "./slack-live.invalid-blocks.js";
|
||||
|
||||
export async function getSlackIdentity(token: string): Promise<SlackAuthIdentity> {
|
||||
const client = createSlackWebClient(token, { timeout: SLACK_QA_WEB_API_TIMEOUT_MS });
|
||||
const auth = slackAuthTestSchema.parse(await client.auth.test());
|
||||
if (!auth.user_id) {
|
||||
throw new Error("Slack auth.test did not return user_id.");
|
||||
}
|
||||
return {
|
||||
userId: auth.user_id,
|
||||
botId: auth.bot_id,
|
||||
teamId: auth.team_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendSlackChannelMessage(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
text: string;
|
||||
threadTs?: string;
|
||||
}) {
|
||||
const postSlackMessage = params.client.chat.postMessage.bind(params.client.chat);
|
||||
const sent = slackPostMessageSchema.parse(
|
||||
await postSlackMessage({
|
||||
channel: params.channelId,
|
||||
text: params.text,
|
||||
thread_ts: params.threadTs,
|
||||
unfurl_links: false,
|
||||
unfurl_media: false,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: sent.channel ?? params.channelId,
|
||||
ts: sent.ts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSlackMessages(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
oldestTs: string;
|
||||
}) {
|
||||
const history = slackHistorySchema.parse(
|
||||
await params.client.conversations.history({
|
||||
channel: params.channelId,
|
||||
inclusive: true,
|
||||
limit: 50,
|
||||
oldest: params.oldestTs,
|
||||
}),
|
||||
);
|
||||
return history.messages ?? [];
|
||||
}
|
||||
|
||||
export async function listSlackThreadMessages(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
threadTs: string;
|
||||
}) {
|
||||
const replies = slackRepliesSchema.parse(
|
||||
await params.client.conversations.replies({
|
||||
channel: params.channelId,
|
||||
inclusive: true,
|
||||
limit: 50,
|
||||
ts: params.threadTs,
|
||||
}),
|
||||
);
|
||||
return replies.messages ?? [];
|
||||
}
|
||||
|
||||
function collectSlackBlockStringFields(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
values: string[] = [],
|
||||
): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectSlackBlockStringFields(entry, fieldName, values);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return values;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === fieldName && typeof entry === "string" && entry.trim().length > 0) {
|
||||
values.push(entry);
|
||||
continue;
|
||||
}
|
||||
collectSlackBlockStringFields(entry, fieldName, values);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function collectSlackBlockText(blocks?: unknown[]) {
|
||||
return collectSlackBlockStringFields(blocks ?? [], "text");
|
||||
}
|
||||
|
||||
export function collectSlackActionValues(blocks?: unknown[]) {
|
||||
return collectSlackBlockStringFields(blocks ?? [], "value");
|
||||
}
|
||||
|
||||
export function parseSlackNativeApprovalAction(value: string) {
|
||||
if (!value.startsWith(SLACK_QA_APPROVAL_ACTION_PREFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(value.slice(SLACK_QA_APPROVAL_ACTION_PREFIX.length));
|
||||
const parsed = SlackQaApprovalActionValueSchema.safeParse(decoded);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectSlackButtonLabels(blocks?: unknown[]) {
|
||||
const labels: string[] = [];
|
||||
function visit(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
visit(entry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.type === "button") {
|
||||
const text = candidate.text;
|
||||
if (text && typeof text === "object") {
|
||||
const label = (text as { text?: unknown }).text;
|
||||
if (typeof label === "string" && label.trim().length > 0) {
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of Object.values(candidate)) {
|
||||
visit(entry);
|
||||
}
|
||||
}
|
||||
visit(blocks ?? []);
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function buildSlackApprovalCheckpointMessage(
|
||||
message: SlackMessage,
|
||||
): SlackApprovalCheckpointMessage {
|
||||
const actionValues = collectSlackActionValues(message.blocks);
|
||||
return {
|
||||
actionLabels: collectSlackButtonLabels(message.blocks),
|
||||
blockText: collectSlackBlockText(message.blocks),
|
||||
hasNativeActions: actionValues.some((value) => parseSlackNativeApprovalAction(value)),
|
||||
text: message.text ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function hasSlackNativeApprovalActions(params: {
|
||||
actionValues: string[];
|
||||
approvalId?: string;
|
||||
decision: SlackQaApprovalDecision;
|
||||
}) {
|
||||
return params.actionValues.some((value) => {
|
||||
const action = parseSlackNativeApprovalAction(value);
|
||||
return (
|
||||
action?.decision === params.decision &&
|
||||
(!params.approvalId || action.approvalId === params.approvalId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function extractSlackNativeApprovalId(params: {
|
||||
actionValues: string[];
|
||||
decision: SlackQaApprovalDecision;
|
||||
}) {
|
||||
for (const value of params.actionValues) {
|
||||
const action = parseSlackNativeApprovalAction(value);
|
||||
if (action?.decision === params.decision) {
|
||||
return action.approvalId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSutSlackMessage(message: SlackMessage, sutIdentity: SlackAuthIdentity) {
|
||||
return (
|
||||
(message.user !== undefined && message.user === sutIdentity.userId) ||
|
||||
(message.bot_id !== undefined && message.bot_id === sutIdentity.botId)
|
||||
);
|
||||
}
|
||||
|
||||
// Slack history can flatten top-level accessibility newlines on readback.
|
||||
// Normalize only whitespace; the native chart structure stays byte-for-byte strict below.
|
||||
function normalizeSlackAccessibleText(value: string) {
|
||||
return value.trim().replace(/\s+/gu, " ");
|
||||
}
|
||||
|
||||
export function isExpectedSlackNativeChartMessage(
|
||||
message: SlackMessage,
|
||||
expectedAccessibleText: string,
|
||||
) {
|
||||
if (
|
||||
normalizeSlackAccessibleText(message.text ?? "") !==
|
||||
normalizeSlackAccessibleText(expectedAccessibleText)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (message.blocks ?? []).some((value) => {
|
||||
const block = asPlainRecord(value);
|
||||
return isDeepStrictEqual(
|
||||
{ type: block.type, title: block.title, chart: block.chart },
|
||||
SLACK_QA_NATIVE_CHART,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForSlackStoredMessage(params: {
|
||||
channelId: string;
|
||||
client: WebClient;
|
||||
description: string;
|
||||
matchesMessage: (message: SlackMessage) => boolean;
|
||||
oldestTs: string;
|
||||
sutIdentity: SlackAuthIdentity;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
while (true) {
|
||||
const messages = await listSlackMessages({
|
||||
channelId: params.channelId,
|
||||
client: params.client,
|
||||
oldestTs: params.oldestTs,
|
||||
});
|
||||
const message = messages.find(
|
||||
(entry) =>
|
||||
entry.ts !== params.oldestTs &&
|
||||
isSutSlackMessage(entry, params.sutIdentity) &&
|
||||
params.matchesMessage(entry),
|
||||
);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
const remainingMs = params.timeoutMs - (Date.now() - startedAt);
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(1_000, remainingMs));
|
||||
});
|
||||
}
|
||||
throw new Error(`timed out after ${params.timeoutMs}ms waiting for Slack ${params.description}`);
|
||||
}
|
||||
|
||||
export function isExpectedSlackNativeTableMessage(
|
||||
message: SlackMessage,
|
||||
expectedAccessibleText: string,
|
||||
) {
|
||||
if (
|
||||
normalizeSlackAccessibleText(message.text ?? "") !==
|
||||
normalizeSlackAccessibleText(expectedAccessibleText)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (message.blocks ?? []).some((value) => {
|
||||
const block = asPlainRecord(value);
|
||||
return isDeepStrictEqual(
|
||||
{
|
||||
type: block.type,
|
||||
caption: block.caption,
|
||||
rows: block.rows,
|
||||
row_header_column_index: block.row_header_column_index,
|
||||
},
|
||||
SLACK_QA_NATIVE_TABLE,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSlackTableInvalidBlocksFallbackScenario(
|
||||
context: SlackQaDirectTransportScenarioContext,
|
||||
): Promise<SlackQaDirectTransportScenarioResult> {
|
||||
const probe = buildSlackInvalidBlocksTableProbe();
|
||||
const oldestTs = ((Date.now() - 5_000) / 1_000).toFixed(6);
|
||||
const instrumentation = instrumentSlackPostMessage(context.sutWriteClient);
|
||||
let sent: Awaited<ReturnType<typeof sendSlackMessage>>;
|
||||
try {
|
||||
try {
|
||||
sent = await sendSlackMessage(`channel:${context.channelId}`, probe.summaryText, {
|
||||
accountId: context.sutAccountId,
|
||||
blocks: [probe.block] as never,
|
||||
cfg: context.cfg,
|
||||
client: context.sutWriteClient,
|
||||
nativeDataFallbackBaseText: probe.summaryText,
|
||||
});
|
||||
} catch {
|
||||
const [nativeAttempt, fallbackAttempt] = instrumentation.attempts;
|
||||
if (nativeAttempt?.failureCode !== "invalid_blocks") {
|
||||
throw new Error(
|
||||
`expected first Slack API failure code invalid_blocks; observed ${nativeAttempt?.failureCode ?? "none"}`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Slack fallback failed after invalid_blocks; observed ${fallbackAttempt?.failureCode ?? "no fallback API failure code"}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
instrumentation.restore();
|
||||
}
|
||||
|
||||
const [nativeAttempt, fallbackAttempt] = instrumentation.attempts;
|
||||
if (instrumentation.attempts.length !== 2) {
|
||||
throw new Error(
|
||||
`expected exactly two Slack API attempts; observed ${instrumentation.attempts.length}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
nativeAttempt?.status !== "failed" ||
|
||||
nativeAttempt.failureCode !== "invalid_blocks" ||
|
||||
nativeAttempt.nativeDataBlockCount !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`expected first Slack API attempt to fail with invalid_blocks for one native data block; observed ${nativeAttempt?.failureCode ?? "none"}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
fallbackAttempt?.status !== "sent" ||
|
||||
fallbackAttempt.nativeDataBlockCount !== 0 ||
|
||||
!fallbackAttempt.formattingDisabled
|
||||
) {
|
||||
throw new Error("Slack fallback did not use one formatting-disabled blockless API request");
|
||||
}
|
||||
|
||||
const message = await waitForSlackStoredMessage({
|
||||
channelId: context.channelId,
|
||||
client: context.sutReadClient,
|
||||
description: "stored invalid_blocks fallback message",
|
||||
matchesMessage: (candidate) => candidate.ts === sent.messageId,
|
||||
oldestTs,
|
||||
sutIdentity: context.sutIdentity,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
const storedText = message.text ?? "";
|
||||
if (countSlackNativeDataBlocks(message.blocks) !== 0) {
|
||||
throw new Error("stored Slack fallback retained a native data block");
|
||||
}
|
||||
const storedLines = storedText.split("\n");
|
||||
if (!storedLines.includes(probe.firstRowText)) {
|
||||
throw new Error("stored Slack fallback omitted the exact first data row");
|
||||
}
|
||||
if (!storedLines.includes(probe.finalRowText)) {
|
||||
throw new Error("stored Slack fallback omitted the exact final data row");
|
||||
}
|
||||
if (storedText !== probe.fallbackText) {
|
||||
throw new Error(
|
||||
`stored Slack fallback was incomplete: expected ${probe.fallbackText.length} characters, observed ${storedText.length}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
details: [
|
||||
"direct transport",
|
||||
"first API failure=invalid_blocks",
|
||||
"API attempts=2",
|
||||
`data rows=${probe.dataRowCount}`,
|
||||
"fallback formatting disabled=true",
|
||||
"stored native data blocks=0",
|
||||
"first row=present",
|
||||
"final row=present",
|
||||
"complete delivery=true",
|
||||
].join("; "),
|
||||
message,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
// QA Lab Slack presentation and progress scenario fixtures.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
SLACK_QA_CHART_TITLE,
|
||||
SLACK_QA_CHART_CATEGORIES,
|
||||
SLACK_QA_CHART_SERIES_NAME,
|
||||
SLACK_QA_CHART_VALUES,
|
||||
SLACK_QA_CHART_X_LABEL,
|
||||
SLACK_QA_CHART_Y_LABEL,
|
||||
SLACK_QA_TABLE_CAPTION,
|
||||
SLACK_QA_TABLE_HEADERS,
|
||||
SLACK_QA_TABLE_ROWS,
|
||||
type SlackQaMessageScenarioRun,
|
||||
} from "./slack-live.contracts.js";
|
||||
|
||||
export function buildSlackChartMessageToolArgs(summaryText: string) {
|
||||
return {
|
||||
action: "send",
|
||||
message: summaryText,
|
||||
presentation: {
|
||||
blocks: [
|
||||
{
|
||||
type: "chart",
|
||||
chartType: "line",
|
||||
title: SLACK_QA_CHART_TITLE,
|
||||
categories: [...SLACK_QA_CHART_CATEGORIES],
|
||||
series: [{ name: SLACK_QA_CHART_SERIES_NAME, values: [...SLACK_QA_CHART_VALUES] }],
|
||||
xLabel: SLACK_QA_CHART_X_LABEL,
|
||||
yLabel: SLACK_QA_CHART_Y_LABEL,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSlackChartAccessibleText(summaryText: string) {
|
||||
return [
|
||||
summaryText,
|
||||
"",
|
||||
`${SLACK_QA_CHART_TITLE} (line chart)`,
|
||||
`X axis: ${SLACK_QA_CHART_X_LABEL}`,
|
||||
`Y axis: ${SLACK_QA_CHART_Y_LABEL}`,
|
||||
`- ${SLACK_QA_CHART_SERIES_NAME}: ${SLACK_QA_CHART_CATEGORIES[0]}: ${SLACK_QA_CHART_VALUES[0]}; ${SLACK_QA_CHART_CATEGORIES[1]}: ${SLACK_QA_CHART_VALUES[1]}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildSlackTableMessageToolArgs(summaryText: string) {
|
||||
return {
|
||||
action: "send",
|
||||
message: summaryText,
|
||||
presentation: {
|
||||
blocks: [
|
||||
{
|
||||
type: "table",
|
||||
caption: SLACK_QA_TABLE_CAPTION,
|
||||
headers: [...SLACK_QA_TABLE_HEADERS],
|
||||
rows: SLACK_QA_TABLE_ROWS.map((row) => [...row]),
|
||||
rowHeaderColumnIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSlackTableAccessibleText(summaryText: string) {
|
||||
return [
|
||||
summaryText,
|
||||
"",
|
||||
`${SLACK_QA_TABLE_CAPTION} (table)`,
|
||||
SLACK_QA_TABLE_HEADERS.join("\t"),
|
||||
...SLACK_QA_TABLE_ROWS.map((row) => row.join("\t")),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type SlackProgressCommentaryExpectation = {
|
||||
commentary: "absent" | "draft" | "standalone";
|
||||
toolProgress: "absent" | "draft" | "standalone";
|
||||
};
|
||||
|
||||
export function buildSlackProgressCommentaryRun(
|
||||
sutUserId: string,
|
||||
expectation: SlackProgressCommentaryExpectation,
|
||||
): SlackQaMessageScenarioRun {
|
||||
const suffix = randomUUID().slice(0, 8).toUpperCase();
|
||||
// Slack mrkdwn escapes underscores in progress drafts. Hyphenated markers
|
||||
// stay byte-identical across draft edits and final-message reads.
|
||||
const commentaryMarker = `SLACK-QA-COMMENTARY-${suffix}`;
|
||||
const toolMarker = `SLACK-QA-TOOL-${suffix}`;
|
||||
const finalMarker = `SLACK-QA-COMMENTARY-DONE-${suffix}`;
|
||||
return {
|
||||
expectReply: true,
|
||||
input: [
|
||||
`<@${sutUserId}> This is a Slack progress protocol test. First, emit an assistant commentary message whose entire text is exactly ${commentaryMarker}.`,
|
||||
"Do not call any tool until that commentary message is complete.",
|
||||
`Then use the exec tool exactly once to run: grep '${toolMarker}' /dev/null || sleep 5.`,
|
||||
`After the command finishes, reply with only this exact marker: ${finalMarker}`,
|
||||
].join(" "),
|
||||
matchText: finalMarker,
|
||||
settleObservedMs: 3_000,
|
||||
verifyObserved: ({ finalMessage, messages }) => {
|
||||
if (!finalMessage.ts) {
|
||||
throw new Error("Slack progress commentary final message had no ts");
|
||||
}
|
||||
if ((finalMessage.text ?? "").trim() !== finalMarker) {
|
||||
throw new Error("expected the Slack final answer to contain only the final marker");
|
||||
}
|
||||
const progressMessages = messages.filter((message) => !message.text.includes(finalMarker));
|
||||
const commentaryMessages = progressMessages.filter((message) =>
|
||||
message.text.includes(commentaryMarker),
|
||||
);
|
||||
const commentaryTimestamps = new Set(commentaryMessages.map((message) => message.ts));
|
||||
if (expectation.commentary === "absent" && commentaryTimestamps.size !== 0) {
|
||||
throw new Error("expected commentary to stay out of Slack progress messages");
|
||||
}
|
||||
if (expectation.commentary !== "absent" && commentaryTimestamps.size !== 1) {
|
||||
throw new Error(
|
||||
`expected exactly one Slack message identity containing commentary; got ${commentaryTimestamps.size}`,
|
||||
);
|
||||
}
|
||||
const commentaryTs = [...commentaryTimestamps][0];
|
||||
if (expectation.commentary === "draft" && commentaryTs !== finalMessage.ts) {
|
||||
throw new Error("expected commentary on the progress draft finalized as the answer");
|
||||
}
|
||||
if (expectation.commentary === "standalone" && commentaryTs === finalMessage.ts) {
|
||||
throw new Error("expected commentary only in the standalone verbose message");
|
||||
}
|
||||
const toolTimestamps = new Set(
|
||||
progressMessages
|
||||
.filter((message) => message.text.includes(toolMarker))
|
||||
.map((message) => message.ts),
|
||||
);
|
||||
if (expectation.toolProgress === "draft") {
|
||||
if (toolTimestamps.size !== 1 || !toolTimestamps.has(finalMessage.ts)) {
|
||||
throw new Error("expected tool progress on the progress draft finalized as the answer");
|
||||
}
|
||||
} else if (expectation.toolProgress === "standalone") {
|
||||
if (toolTimestamps.size === 0 || toolTimestamps.has(finalMessage.ts)) {
|
||||
throw new Error("expected tool progress only in standalone verbose messages");
|
||||
}
|
||||
} else if (toolTimestamps.size !== 0) {
|
||||
throw new Error("expected tool progress to stay out of Slack progress messages");
|
||||
}
|
||||
const finalTimestamps = new Set(
|
||||
messages
|
||||
.filter((message) => message.text.includes(finalMarker))
|
||||
.map((message) => message.ts),
|
||||
);
|
||||
if (finalTimestamps.size !== 1 || !finalTimestamps.has(finalMessage.ts)) {
|
||||
throw new Error(
|
||||
"expected one final-marker Slack message identity matching the final answer",
|
||||
);
|
||||
}
|
||||
const commentaryDetails =
|
||||
expectation.commentary === "draft"
|
||||
? "commentary on progress/final identity"
|
||||
: expectation.commentary === "standalone"
|
||||
? "one standalone commentary identity"
|
||||
: "commentary absent from Slack progress";
|
||||
return `verified ${commentaryDetails}; tool progress ${expectation.toolProgress}; final identity unique`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// QA Lab Slack live scenario catalog.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
selectLiveTransportScenarios,
|
||||
} from "../shared/live-transport-scenarios.js";
|
||||
import { waitForSlackReaction } from "./slack-live.codex-approval.js";
|
||||
import {
|
||||
SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
|
||||
SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
|
||||
SLACK_QA_LOG_TAIL_TIMEOUT_MS,
|
||||
type SlackQaScenarioDefinition,
|
||||
} from "./slack-live.contracts.js";
|
||||
import {
|
||||
isExpectedSlackNativeChartMessage,
|
||||
isExpectedSlackNativeTableMessage,
|
||||
runSlackTableInvalidBlocksFallbackScenario,
|
||||
waitForSlackStoredMessage,
|
||||
} from "./slack-live.observations.js";
|
||||
import {
|
||||
buildSlackChartMessageToolArgs,
|
||||
renderSlackChartAccessibleText,
|
||||
buildSlackTableMessageToolArgs,
|
||||
renderSlackTableAccessibleText,
|
||||
buildSlackProgressCommentaryRun,
|
||||
} from "./slack-live.scenario-fixtures.js";
|
||||
|
||||
const SLACK_QA_SCENARIOS: SlackQaScenarioDefinition[] = [
|
||||
{
|
||||
id: "slack-canary",
|
||||
standardId: "canary",
|
||||
title: "Slack canary echo",
|
||||
timeoutMs: 45_000,
|
||||
buildRun: (sutUserId) => {
|
||||
const token = `SLACK_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
expectReply: true,
|
||||
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
|
||||
matchText: token,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-mention-gating",
|
||||
standardId: "mention-gating",
|
||||
title: "Slack unmentioned bot message does not trigger",
|
||||
timeoutMs: 8_000,
|
||||
buildRun: () => {
|
||||
const token = `SLACK_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
expectReply: false,
|
||||
input: `reply with only this exact marker: ${token}`,
|
||||
matchText: token,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-allowlist-block",
|
||||
standardId: "allowlist-block",
|
||||
title: "Slack non-allowlisted sender does not trigger",
|
||||
timeoutMs: 8_000,
|
||||
configOverrides: {
|
||||
allowFrom: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
|
||||
users: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
|
||||
},
|
||||
buildRun: (sutUserId) => {
|
||||
const token = `SLACK_QA_BLOCK_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
expectReply: false,
|
||||
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
|
||||
matchText: token,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-channel-disabled-warning",
|
||||
title: "Slack disabled channel warns and does not trigger",
|
||||
timeoutMs: 8_000,
|
||||
defaultEnabled: false,
|
||||
configOverrides: { channelEnabled: false },
|
||||
buildRun: (sutUserId) => {
|
||||
const marker = `SLACK_QA_DISABLED_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
let logCursor = 0;
|
||||
return {
|
||||
expectReply: false,
|
||||
input: `<@${sutUserId}> reply with only this exact marker: ${marker}`,
|
||||
matchText: marker,
|
||||
preserveGatewayDebug: true,
|
||||
beforeRun: async ({ gateway }) => {
|
||||
const gatewayLogTail = (await gateway.call(
|
||||
"logs.tail",
|
||||
{ limit: 1, maxBytes: 32_000 },
|
||||
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
|
||||
)) as { cursor?: unknown };
|
||||
logCursor = typeof gatewayLogTail.cursor === "number" ? gatewayLogTail.cursor : 0;
|
||||
},
|
||||
afterNoReply: async ({ gateway }) => {
|
||||
const gatewayLogTail = (await gateway.call(
|
||||
"logs.tail",
|
||||
{ cursor: logCursor, limit: 200, maxBytes: 256_000 },
|
||||
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
|
||||
)) as { lines?: unknown };
|
||||
const gatewayLogLines = Array.isArray(gatewayLogTail.lines)
|
||||
? gatewayLogTail.lines.filter((line): line is string => typeof line === "string")
|
||||
: [];
|
||||
const expectedFields = [
|
||||
"Slack channel denied by configuration",
|
||||
"channel_not_allowed",
|
||||
"channel_disabled",
|
||||
];
|
||||
if (
|
||||
!gatewayLogLines.some((line) => expectedFields.every((field) => line.includes(field)))
|
||||
) {
|
||||
throw new Error("disabled Slack channel did not emit the structured warning");
|
||||
}
|
||||
return "structured disabled-channel warning observed";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-top-level-reply-shape",
|
||||
standardId: "top-level-reply-shape",
|
||||
title: "Slack top-level reply stays top-level",
|
||||
timeoutMs: 45_000,
|
||||
configOverrides: { replyToMode: "off" },
|
||||
buildRun: (sutUserId) => {
|
||||
const token = `SLACK_QA_TOPLEVEL_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
expectReply: true,
|
||||
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
|
||||
matchText: token,
|
||||
verify: (message) => {
|
||||
if (message.thread_ts) {
|
||||
throw new Error(
|
||||
`expected top-level Slack reply without thread_ts; got ${message.thread_ts}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-progress-commentary-true",
|
||||
title: "Slack progress commentary true is independent from tool progress",
|
||||
defaultEnabled: false,
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: {
|
||||
progress: { commentary: true, toolProgress: false },
|
||||
},
|
||||
buildRun: (sutUserId) =>
|
||||
buildSlackProgressCommentaryRun(sutUserId, {
|
||||
commentary: "draft",
|
||||
toolProgress: "absent",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-progress-commentary-false",
|
||||
title: "Slack progress commentary false stays out of the progress draft",
|
||||
defaultEnabled: false,
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: {
|
||||
progress: { commentary: false, toolProgress: false },
|
||||
},
|
||||
buildRun: (sutUserId) =>
|
||||
buildSlackProgressCommentaryRun(sutUserId, {
|
||||
commentary: "absent",
|
||||
toolProgress: "absent",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-progress-commentary-omitted",
|
||||
title: "Slack omitted progress commentary preserves the tool-progress default",
|
||||
defaultEnabled: false,
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: {
|
||||
progress: { toolProgress: true },
|
||||
},
|
||||
buildRun: (sutUserId) =>
|
||||
buildSlackProgressCommentaryRun(sutUserId, {
|
||||
commentary: "draft",
|
||||
toolProgress: "draft",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-progress-commentary-verbose-dedupe",
|
||||
title: "Slack explicit commentary yields to durable verbose progress",
|
||||
defaultEnabled: false,
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: {
|
||||
progress: { commentary: true, toolProgress: false, verboseDefault: "on" },
|
||||
},
|
||||
buildRun: (sutUserId) =>
|
||||
buildSlackProgressCommentaryRun(sutUserId, {
|
||||
commentary: "standalone",
|
||||
toolProgress: "standalone",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-chart-presentation-native",
|
||||
title: "Slack portable chart renders as a native data visualization",
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: { messageTool: true },
|
||||
buildRun: (sutUserId) => {
|
||||
const suffix = randomUUID().slice(0, 8).toUpperCase();
|
||||
const summaryText = `SLACK_QA_CHART_SUMMARY_${suffix}`;
|
||||
const finalMarker = `SLACK_QA_CHART_DONE_${suffix}`;
|
||||
const messageToolArgs = buildSlackChartMessageToolArgs(summaryText);
|
||||
return {
|
||||
expectReply: true,
|
||||
input: [
|
||||
`<@${sutUserId}> Slack native chart QA check ${summaryText}.`,
|
||||
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
|
||||
`After the chart send succeeds, reply with only this exact marker: ${finalMarker}`,
|
||||
].join(" "),
|
||||
matchText: finalMarker,
|
||||
afterReply: async (_message, context) => {
|
||||
await waitForSlackStoredMessage({
|
||||
channelId: context.channelId,
|
||||
client: context.sutReadClient,
|
||||
description: "message with native chart",
|
||||
matchesMessage: (message) =>
|
||||
isExpectedSlackNativeChartMessage(
|
||||
message,
|
||||
renderSlackChartAccessibleText(summaryText),
|
||||
),
|
||||
oldestTs: context.sentTs,
|
||||
sutIdentity: context.sutIdentity,
|
||||
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
|
||||
});
|
||||
return "verified native data_visualization block and deterministic accessible text";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-table-presentation-native",
|
||||
title: "Slack portable table renders as a native data table",
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: { messageTool: true },
|
||||
buildRun: (sutUserId) => {
|
||||
const suffix = randomUUID().slice(0, 8).toUpperCase();
|
||||
const summaryText = `SLACK_QA_TABLE_SUMMARY_${suffix}`;
|
||||
const finalMarker = `SLACK_QA_TABLE_DONE_${suffix}`;
|
||||
const messageToolArgs = buildSlackTableMessageToolArgs(summaryText);
|
||||
return {
|
||||
expectReply: true,
|
||||
input: [
|
||||
`<@${sutUserId}> Slack native table QA check ${summaryText}.`,
|
||||
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
|
||||
`After the table send succeeds, reply with only this exact marker: ${finalMarker}`,
|
||||
].join(" "),
|
||||
matchText: finalMarker,
|
||||
afterReply: async (_message, context) => {
|
||||
await waitForSlackStoredMessage({
|
||||
channelId: context.channelId,
|
||||
client: context.sutReadClient,
|
||||
description: "message with native table",
|
||||
matchesMessage: (message) =>
|
||||
isExpectedSlackNativeTableMessage(
|
||||
message,
|
||||
renderSlackTableAccessibleText(summaryText),
|
||||
),
|
||||
oldestTs: context.sentTs,
|
||||
sutIdentity: context.sutIdentity,
|
||||
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
|
||||
});
|
||||
return "verified native data_table block and deterministic accessible text";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-table-invalid-blocks-fallback",
|
||||
title: "Slack rejects an over-limit native table and stores its complete fallback",
|
||||
defaultEnabled: false,
|
||||
timeoutMs: 45_000,
|
||||
buildRun: () => ({
|
||||
kind: "direct-transport",
|
||||
execute: runSlackTableInvalidBlocksFallbackScenario,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-reaction-glyph-native",
|
||||
title: "Slack message tool normalizes an emoji glyph reaction",
|
||||
timeoutMs: 90_000,
|
||||
configOverrides: { messageTool: true },
|
||||
buildRun: (sutUserId) => {
|
||||
const token = `SLACK_QA_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
expectReply: true,
|
||||
input: [
|
||||
`<@${sutUserId}> use the message tool exactly once to react to this message.`,
|
||||
'Set action to "react", channel to "slack", and emoji to exactly "✅".',
|
||||
"Do not substitute a shortcode.",
|
||||
`After the reaction succeeds, reply with only this exact marker: ${token}`,
|
||||
].join(" "),
|
||||
matchText: token,
|
||||
afterReply: async (_message, context) => {
|
||||
await waitForSlackReaction({
|
||||
channelId: context.channelId,
|
||||
client: context.sutReadClient,
|
||||
expectedReactionName: "white_check_mark",
|
||||
messageId: context.sentTs,
|
||||
sutUserId: context.sutIdentity.userId,
|
||||
timeoutMs: SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
|
||||
});
|
||||
return "verified SUT white_check_mark reaction from exact glyph instruction";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack-approval-exec-native",
|
||||
title: "Slack native exec approval prompt resolves",
|
||||
timeoutMs: 60_000,
|
||||
configOverrides: {
|
||||
approvals: {
|
||||
exec: true,
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
buildRun: () => ({
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
kind: "approval",
|
||||
token: `SLACK_QA_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-approval-plugin-native",
|
||||
title: "Slack native plugin approval prompt resolves with exec approvals enabled",
|
||||
timeoutMs: 60_000,
|
||||
configOverrides: {
|
||||
approvals: {
|
||||
exec: true,
|
||||
plugin: true,
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
buildRun: () => ({
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
kind: "approval",
|
||||
token: `SLACK_QA_PLUGIN_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-codex-approval-exec-native",
|
||||
title: "Slack native Codex command approval prompt resolves",
|
||||
timeoutMs: 180_000,
|
||||
configOverrides: {
|
||||
approvals: {
|
||||
exec: true,
|
||||
plugin: true,
|
||||
target: "channel",
|
||||
},
|
||||
codexApproval: true,
|
||||
},
|
||||
forcedRuntime: "codex",
|
||||
buildRun: () => ({
|
||||
approvalKind: "plugin",
|
||||
appServerMethod: "item/commandExecution/requestApproval",
|
||||
decision: "allow-once",
|
||||
kind: "codex-approval",
|
||||
token: `SLACK_QA_CODEX_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "slack-codex-approval-plugin-native",
|
||||
title: "Slack native Codex file approval prompt resolves",
|
||||
timeoutMs: 180_000,
|
||||
configOverrides: {
|
||||
approvals: {
|
||||
exec: true,
|
||||
plugin: true,
|
||||
target: "channel",
|
||||
},
|
||||
codexApproval: true,
|
||||
},
|
||||
forcedRuntime: "codex",
|
||||
buildRun: () => ({
|
||||
approvalKind: "plugin",
|
||||
appServerMethod: "item/fileChange/requestApproval",
|
||||
decision: "allow-once",
|
||||
kind: "codex-approval",
|
||||
token: `SLACK_QA_CODEX_FILE_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const SLACK_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({
|
||||
scenarios: SLACK_QA_SCENARIOS,
|
||||
});
|
||||
|
||||
export function listSlackQaScenarioCatalog() {
|
||||
return SLACK_QA_SCENARIOS.map((scenario) => ({ id: scenario.id }));
|
||||
}
|
||||
|
||||
export function findScenario(ids?: string[]) {
|
||||
const selected = selectLiveTransportScenarios({
|
||||
ids,
|
||||
laneLabel: "Slack",
|
||||
scenarios: SLACK_QA_SCENARIOS,
|
||||
});
|
||||
return ids?.length ? selected : selected.filter((scenario) => scenario.defaultEnabled !== false);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
convertAnthropicMessagesToResponsesInput,
|
||||
type ExtractedAssistantOutput,
|
||||
extractFinalAssistantOutputFromEvents,
|
||||
buildAnthropicMessageResponse,
|
||||
buildAnthropicThinkingErrorResponse,
|
||||
buildAnthropicThinkingErrorStreamEvents,
|
||||
buildAnthropicMessageStreamEvents,
|
||||
} from "./mock-anthropic-wire.js";
|
||||
// QA Lab Anthropic Messages request dispatcher.
|
||||
import {
|
||||
type ResponsesInputItem,
|
||||
type StreamEvent,
|
||||
type AnthropicMessagesRequest,
|
||||
QA_ANTHROPIC_THINKING_ERROR_RECOVERY_PROMPT_RE,
|
||||
type MockScenarioState,
|
||||
type AnthropicStreamEvent,
|
||||
} from "./mock-openai-contracts.js";
|
||||
import { buildAssistantEvents } from "./mock-openai-events.js";
|
||||
import { extractToolOutput, extractAllRequestTexts } from "./mock-openai-input.js";
|
||||
import { buildToolCallEventsWithArgs } from "./mock-openai-tooling.js";
|
||||
|
||||
export async function buildMessagesPayload(
|
||||
body: AnthropicMessagesRequest,
|
||||
scenarioState: MockScenarioState,
|
||||
dispatchResponses: (
|
||||
body: Record<string, unknown>,
|
||||
scenarioState: MockScenarioState,
|
||||
) => Promise<StreamEvent[]>,
|
||||
): Promise<{
|
||||
events: StreamEvent[];
|
||||
input: ResponsesInputItem[];
|
||||
extracted: ExtractedAssistantOutput;
|
||||
responseBody: Record<string, unknown>;
|
||||
streamEvents: AnthropicStreamEvent[];
|
||||
model: string;
|
||||
}> {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const input = convertAnthropicMessagesToResponsesInput({
|
||||
system: body.system,
|
||||
messages,
|
||||
});
|
||||
// Treat empty-string model the same as absent. A bare typeof check lets
|
||||
// `""` leak through to `responseBody.model` and `lastRequest.model`,
|
||||
// which then confuses parity consumers that assume the mock always
|
||||
// echoes the real provider label. Normalize once and reuse everywhere.
|
||||
const normalizedModel =
|
||||
typeof body.model === "string" && body.model.trim() !== "" ? body.model : "claude-opus-4-8";
|
||||
// Dispatch through the same scenario logic the /v1/responses route uses.
|
||||
// Preserve declared tools so route-specific adapters mirror what the
|
||||
// real provider request made available to the model.
|
||||
const dispatchBody: Record<string, unknown> = {
|
||||
input,
|
||||
model: normalizedModel,
|
||||
stream: false,
|
||||
...(Array.isArray(body.tools) ? { tools: body.tools } : {}),
|
||||
};
|
||||
const allInputText = extractAllRequestTexts(input, dispatchBody);
|
||||
if (QA_ANTHROPIC_THINKING_ERROR_RECOVERY_PROMPT_RE.test(allInputText)) {
|
||||
const toolOutput = extractToolOutput(input);
|
||||
const shouldEmitThinkingError =
|
||||
toolOutput.length > 0 && scenarioState.anthropicThinkingErrorPhase === 0;
|
||||
const events =
|
||||
toolOutput.length === 0
|
||||
? buildToolCallEventsWithArgs("read", { path: "QA_KICKOFF_TASK.md" })
|
||||
: shouldEmitThinkingError
|
||||
? (() => {
|
||||
scenarioState.anthropicThinkingErrorPhase = 1;
|
||||
return buildAssistantEvents("");
|
||||
})()
|
||||
: buildAssistantEvents("ANTHROPIC-THINKING-ERROR-RECOVERED-OK");
|
||||
const extracted = extractFinalAssistantOutputFromEvents(events);
|
||||
const responseBody = shouldEmitThinkingError
|
||||
? buildAnthropicThinkingErrorResponse({ model: normalizedModel })
|
||||
: buildAnthropicMessageResponse({
|
||||
model: normalizedModel,
|
||||
extracted,
|
||||
});
|
||||
const streamEvents = shouldEmitThinkingError
|
||||
? buildAnthropicThinkingErrorStreamEvents({ model: normalizedModel })
|
||||
: buildAnthropicMessageStreamEvents({
|
||||
model: normalizedModel,
|
||||
extracted,
|
||||
});
|
||||
return { events, input, extracted, responseBody, streamEvents, model: normalizedModel };
|
||||
}
|
||||
const events = await dispatchResponses(dispatchBody, scenarioState);
|
||||
const extracted = extractFinalAssistantOutputFromEvents(events);
|
||||
const responseBody = buildAnthropicMessageResponse({
|
||||
model: normalizedModel,
|
||||
extracted,
|
||||
});
|
||||
const streamEvents = buildAnthropicMessageStreamEvents({
|
||||
model: normalizedModel,
|
||||
extracted,
|
||||
});
|
||||
return { events, input, extracted, responseBody, streamEvents, model: normalizedModel };
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// QA Lab Anthropic Messages wire conversion and response events.
|
||||
import {
|
||||
type ResponsesInputItem,
|
||||
type StreamEvent,
|
||||
type AnthropicMessageContentBlock,
|
||||
type AnthropicMessage,
|
||||
type AnthropicMessagesRequest,
|
||||
type AnthropicStreamEvent,
|
||||
countApproxTokens,
|
||||
} from "./mock-openai-contracts.js";
|
||||
|
||||
// Anthropic Messages conversion preserves role and tool ordering while reusing
|
||||
// the shared Responses scenario dispatcher for provider parity.
|
||||
|
||||
function normalizeAnthropicSystemToString(
|
||||
system: AnthropicMessagesRequest["system"],
|
||||
): string | undefined {
|
||||
if (typeof system === "string") {
|
||||
return system.trim() || undefined;
|
||||
}
|
||||
if (Array.isArray(system)) {
|
||||
const joined = system
|
||||
.map((block) => (block?.type === "text" ? block.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringifyToolResultContent(
|
||||
content: Extract<AnthropicMessageContentBlock, { type: "tool_result" }>["content"],
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => (block?.type === "text" ? block.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function convertAnthropicMessagesToResponsesInput(params: {
|
||||
system?: AnthropicMessagesRequest["system"];
|
||||
messages: AnthropicMessage[];
|
||||
}): ResponsesInputItem[] {
|
||||
const items: ResponsesInputItem[] = [];
|
||||
const systemText = normalizeAnthropicSystemToString(params.system);
|
||||
if (systemText) {
|
||||
items.push({
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: systemText }],
|
||||
});
|
||||
}
|
||||
for (const message of params.messages) {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
items.push({
|
||||
role: message.role,
|
||||
content: [
|
||||
message.role === "assistant"
|
||||
? { type: "output_text", text: content }
|
||||
: { type: "input_text", text: content },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
// Buffer each block type so we can push in OpenAI-Responses order instead
|
||||
// of the order they appear in the Anthropic content array. The parent
|
||||
// role message must precede any function_call_output items from the same
|
||||
// turn, otherwise extractToolOutput() (which scans for
|
||||
// function_call_output AFTER the last user-role index) will not see the
|
||||
// output and the downstream scenario dispatcher will behave as if no
|
||||
// tool output was returned. Similarly, assistant tool_use blocks become
|
||||
// function_call items that must follow the assistant text message they
|
||||
// narrate.
|
||||
const textPieces: Array<{ type: "input_text" | "output_text"; text: string }> = [];
|
||||
const imagePieces: Array<{ type: "input_image"; image_url: string }> = [];
|
||||
const toolResultItems: ResponsesInputItem[] = [];
|
||||
const toolUseItems: ResponsesInputItem[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
textPieces.push({
|
||||
type: message.role === "assistant" ? "output_text" : "input_text",
|
||||
text: block.text ?? "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
// Mock only needs to count image inputs; a placeholder URL is fine.
|
||||
imagePieces.push({ type: "input_image", image_url: "anthropic-mock:image" });
|
||||
continue;
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const output = stringifyToolResultContent(block.content);
|
||||
if (output.trim()) {
|
||||
toolResultItems.push({
|
||||
type: "function_call_output",
|
||||
call_id: block.tool_use_id,
|
||||
output,
|
||||
...(block.is_error === true ? { is_error: true } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
// Mirror OpenAI's function_call output_item shape so downstream
|
||||
// prompt extraction still sees "the assistant just emitted a tool
|
||||
// call". The scenario dispatcher looks for tool_output on the next
|
||||
// user turn, not the assistant's prior tool_use, so a minimal
|
||||
// placeholder is enough.
|
||||
toolUseItems.push({
|
||||
type: "function_call",
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input ?? {}),
|
||||
call_id: block.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (textPieces.length > 0 || imagePieces.length > 0) {
|
||||
const combinedContent: Array<Record<string, unknown>> = [...textPieces, ...imagePieces];
|
||||
items.push({ role: message.role, content: combinedContent });
|
||||
}
|
||||
// Emit tool_use (assistant prior calls) and tool_result (user-side
|
||||
// returns) AFTER the parent role message so extractLastUserText and
|
||||
// extractToolOutput walk the array in the order they expect. For a
|
||||
// tool_result-only user turn with no text/image blocks, the parent
|
||||
// message is intentionally omitted — the function_call_output itself
|
||||
// represents the user's "return the tool output" turn.
|
||||
for (const toolUse of toolUseItems) {
|
||||
items.push(toolUse);
|
||||
}
|
||||
for (const toolResult of toolResultItems) {
|
||||
items.push(toolResult);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export type ExtractedAssistantOutput = {
|
||||
text: string;
|
||||
toolCalls: Array<{ id: string; name: string; input: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
export function extractFinalAssistantOutputFromEvents(
|
||||
events: StreamEvent[],
|
||||
): ExtractedAssistantOutput {
|
||||
const toolCalls: ExtractedAssistantOutput["toolCalls"] = [];
|
||||
let text = "";
|
||||
for (const event of events) {
|
||||
if (event.type !== "response.output_item.done") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as {
|
||||
type?: unknown;
|
||||
name?: unknown;
|
||||
call_id?: unknown;
|
||||
id?: unknown;
|
||||
arguments?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
if (item.type === "function_call" && typeof item.name === "string") {
|
||||
let input: Record<string, unknown> = {};
|
||||
if (typeof item.arguments === "string" && item.arguments.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.arguments) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
input = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// keep empty input on malformed args — mock dispatcher owns arg shape
|
||||
}
|
||||
}
|
||||
toolCalls.push({
|
||||
id: typeof item.call_id === "string" ? item.call_id : `toolu_mock_${toolCalls.length + 1}`,
|
||||
name: item.name,
|
||||
input,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const piece of item.content as Array<{ type?: unknown; text?: unknown }>) {
|
||||
if (piece?.type === "output_text" && typeof piece.text === "string") {
|
||||
text = piece.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text, toolCalls };
|
||||
}
|
||||
|
||||
export function buildAnthropicMessageResponse(params: {
|
||||
model: string;
|
||||
extracted: ExtractedAssistantOutput;
|
||||
}): Record<string, unknown> {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (params.extracted.text) {
|
||||
content.push({ type: "text", text: params.extracted.text });
|
||||
}
|
||||
for (const call of params.extracted.toolCalls) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input: call.input,
|
||||
});
|
||||
}
|
||||
if (content.length === 0) {
|
||||
content.push({ type: "text", text: "" });
|
||||
}
|
||||
const stopReason = params.extracted.toolCalls.length > 0 ? "tool_use" : "end_turn";
|
||||
const approxInputTokens = 64;
|
||||
const approxOutputTokens = Math.max(
|
||||
16,
|
||||
countApproxTokens(params.extracted.text) + params.extracted.toolCalls.length * 16,
|
||||
);
|
||||
return {
|
||||
id: `msg_mock_${Math.floor(Math.random() * 1_000_000).toString(16)}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: params.model || "claude-opus-4-8",
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: approxInputTokens,
|
||||
output_tokens: approxOutputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const QA_ANTHROPIC_THINKING_ERROR_TEXT =
|
||||
"QA replay-safe read completed, but the provider stream failed after signed thinking.";
|
||||
const QA_ANTHROPIC_THINKING_ERROR_SIGNATURE = "qa_signed_thinking_block_91953";
|
||||
const QA_ANTHROPIC_THINKING_ERROR_MESSAGE = "QA injected provider stream failure";
|
||||
|
||||
export function buildAnthropicThinkingErrorResponse(params: {
|
||||
model: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
type: "error",
|
||||
error: {
|
||||
type: "api_error",
|
||||
message: QA_ANTHROPIC_THINKING_ERROR_MESSAGE,
|
||||
},
|
||||
model: params.model || "claude-opus-4-8",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicThinkingErrorStreamEvents(params: {
|
||||
model: string;
|
||||
}): AnthropicStreamEvent[] {
|
||||
const messageId = `msg_mock_${Math.floor(Math.random() * 1_000_000).toString(16)}`;
|
||||
return [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: params.model || "claude-opus-4-8",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 64,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
signature: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: {
|
||||
type: "thinking_delta",
|
||||
thinking: QA_ANTHROPIC_THINKING_ERROR_TEXT,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: {
|
||||
type: "signature_delta",
|
||||
signature: QA_ANTHROPIC_THINKING_ERROR_SIGNATURE,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_stop",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: {},
|
||||
usage: {
|
||||
input_tokens: 64,
|
||||
output_tokens: 1120,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "error",
|
||||
error: {
|
||||
type: "api_error",
|
||||
message: QA_ANTHROPIC_THINKING_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAnthropicMessageStreamEvents(params: {
|
||||
model: string;
|
||||
extracted: ExtractedAssistantOutput;
|
||||
}): AnthropicStreamEvent[] {
|
||||
const approxInputTokens = 64;
|
||||
const approxOutputTokens = Math.max(
|
||||
16,
|
||||
countApproxTokens(params.extracted.text) + params.extracted.toolCalls.length * 16,
|
||||
);
|
||||
const messageId = `msg_mock_${Math.floor(Math.random() * 1_000_000).toString(16)}`;
|
||||
const events: AnthropicStreamEvent[] = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: params.model || "claude-opus-4-8",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: approxInputTokens,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
if (params.extracted.text || params.extracted.toolCalls.length === 0) {
|
||||
events.push({
|
||||
type: "content_block_start",
|
||||
index,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
if (params.extracted.text) {
|
||||
events.push({
|
||||
type: "content_block_delta",
|
||||
index,
|
||||
delta: {
|
||||
type: "text_delta",
|
||||
text: params.extracted.text,
|
||||
},
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "content_block_stop",
|
||||
index,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
for (const call of params.extracted.toolCalls) {
|
||||
events.push({
|
||||
type: "content_block_start",
|
||||
index,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input: {},
|
||||
},
|
||||
});
|
||||
events.push({
|
||||
type: "content_block_delta",
|
||||
index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: JSON.stringify(call.input ?? {}),
|
||||
},
|
||||
});
|
||||
events.push({
|
||||
type: "content_block_stop",
|
||||
index,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
events.push({
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: params.extracted.toolCalls.length > 0 ? "tool_use" : "end_turn",
|
||||
},
|
||||
usage: {
|
||||
input_tokens: approxInputTokens,
|
||||
output_tokens: approxOutputTokens,
|
||||
},
|
||||
});
|
||||
events.push({
|
||||
type: "message_stop",
|
||||
});
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// QA Lab mock provider assistant text fixtures.
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
type ResponsesInputItem,
|
||||
QA_STRANDED_FINAL_RECOVERY_PROMPT_RE,
|
||||
QA_STRANDED_FINAL_RETRY_PROMPT_RE,
|
||||
QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE,
|
||||
buildStrandedFinalRecoveryText,
|
||||
buildStrandedFinalRetryFailureText,
|
||||
isStrandedFinalRetryFailureRequest,
|
||||
QA_SUBAGENT_DIRECT_FALLBACK_MARKER,
|
||||
QA_IMAGE_GENERATION_PROMPT_RE,
|
||||
QA_SKILL_WORKSHOP_GIF_PROMPT_RE,
|
||||
QA_TOOL_SEARCH_PROMPT_RE,
|
||||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE,
|
||||
type MockScenarioState,
|
||||
} from "./mock-openai-contracts.js";
|
||||
import {
|
||||
extractExactReplyDirective,
|
||||
extractFinishExactlyDirective,
|
||||
extractExactMarkerDirective,
|
||||
extractWhatsAppLocationMarkerDirective,
|
||||
extractWhatsAppContactMarkerDirective,
|
||||
extractWhatsAppStickerMarkerDirective,
|
||||
shouldUseWhatsAppLocationMarker,
|
||||
shouldUseWhatsAppContactMarker,
|
||||
shouldUseWhatsAppStickerMarker,
|
||||
extractToolErrorForNamedCall,
|
||||
isHeartbeatPrompt,
|
||||
readFirstMediaPath,
|
||||
} from "./mock-openai-directives.js";
|
||||
import {
|
||||
extractLastUserText,
|
||||
extractToolOutput,
|
||||
extractLatestToolOutput,
|
||||
extractAllUserTexts,
|
||||
extractAllRequestTexts,
|
||||
extractLatestImageUserTurn,
|
||||
parseToolOutputJson,
|
||||
} from "./mock-openai-input.js";
|
||||
import {
|
||||
extractRememberedFact,
|
||||
extractOrbitCode,
|
||||
extractActiveMemorySummary,
|
||||
extractToolSearchTarget,
|
||||
extractSnackPreference,
|
||||
} from "./mock-openai-tooling.js";
|
||||
export function buildAssistantText(
|
||||
input: ResponsesInputItem[],
|
||||
body: Record<string, unknown>,
|
||||
scenarioState: MockScenarioState,
|
||||
) {
|
||||
const prompt = extractLastUserText(input);
|
||||
const toolOutput = extractToolOutput(input);
|
||||
const scenarioToolOutput =
|
||||
toolOutput ||
|
||||
(/thread memory check|session memory ranking check|memory tools check|repo contract followthrough check/i.test(
|
||||
extractAllRequestTexts(input, body),
|
||||
)
|
||||
? extractLatestToolOutput(input)
|
||||
: "");
|
||||
const toolJson = parseToolOutputJson(scenarioToolOutput);
|
||||
const userTexts = extractAllUserTexts(input);
|
||||
const allInputText = extractAllRequestTexts(input, body);
|
||||
const rememberedFact = extractRememberedFact(userTexts);
|
||||
const model = typeof body.model === "string" ? body.model : "";
|
||||
const memorySnippet =
|
||||
typeof toolJson?.text === "string"
|
||||
? toolJson.text
|
||||
: Array.isArray(toolJson?.results)
|
||||
? JSON.stringify(toolJson.results)
|
||||
: scenarioToolOutput;
|
||||
const orbitCode = extractOrbitCode(memorySnippet) ?? extractOrbitCode(allInputText);
|
||||
const mediaPath =
|
||||
typeof toolJson?.details === "object" &&
|
||||
toolJson.details !== null &&
|
||||
!Array.isArray(toolJson.details)
|
||||
? readFirstMediaPath((toolJson.details as { media?: unknown }).media)
|
||||
: "";
|
||||
const promptExactReplyDirective = extractExactReplyDirective(prompt);
|
||||
const promptExactMarkerDirective = extractExactMarkerDirective(prompt);
|
||||
const exactReplyDirective = promptExactReplyDirective ?? extractExactReplyDirective(allInputText);
|
||||
const exactMarkerDirective =
|
||||
promptExactMarkerDirective ?? extractExactMarkerDirective(allInputText);
|
||||
const whatsAppLocationMarker = shouldUseWhatsAppLocationMarker(prompt)
|
||||
? extractWhatsAppLocationMarkerDirective(allInputText)
|
||||
: "";
|
||||
const whatsAppContactMarker = shouldUseWhatsAppContactMarker(prompt)
|
||||
? extractWhatsAppContactMarkerDirective(allInputText)
|
||||
: "";
|
||||
const whatsAppStickerMarker = shouldUseWhatsAppStickerMarker(prompt)
|
||||
? extractWhatsAppStickerMarkerDirective(allInputText)
|
||||
: "";
|
||||
const finishExactlyDirective =
|
||||
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
|
||||
const latestImageUserTurn = extractLatestImageUserTurn(input);
|
||||
const activeMemorySummary = extractActiveMemorySummary(allInputText);
|
||||
const snackPreference = extractSnackPreference(activeMemorySummary ?? memorySnippet);
|
||||
const sessionsSpawnError = extractToolErrorForNamedCall({
|
||||
input,
|
||||
name: "sessions_spawn",
|
||||
toolJson,
|
||||
});
|
||||
|
||||
if (/what was the qa canary code/i.test(prompt) && rememberedFact) {
|
||||
return `Protocol note: the QA canary code was ${rememberedFact}.`;
|
||||
}
|
||||
if (sessionsSpawnError) {
|
||||
return `Protocol note: sessions_spawn failed: ${sessionsSpawnError}`;
|
||||
}
|
||||
if (/remember this fact/i.test(prompt) && exactReplyDirective) {
|
||||
return exactReplyDirective;
|
||||
}
|
||||
if (/remember this fact/i.test(prompt) && rememberedFact) {
|
||||
return `Protocol note: acknowledged. I will remember ${rememberedFact}.`;
|
||||
}
|
||||
if (/memory unavailable check/i.test(prompt)) {
|
||||
return "Protocol note: I checked the available runtime context but could not confirm the hidden memory-only fact, so I will not guess.";
|
||||
}
|
||||
if (isHeartbeatPrompt(prompt)) {
|
||||
return "HEARTBEAT_OK";
|
||||
}
|
||||
if (
|
||||
/roundtrip image inspection check/i.test(latestImageUserTurn.text) &&
|
||||
latestImageUserTurn.imageInputCount > 0
|
||||
) {
|
||||
return "Protocol note: the generated attachment shows the same QA lighthouse scene from the previous step.";
|
||||
}
|
||||
if (
|
||||
/image understanding check/i.test(latestImageUserTurn.text) &&
|
||||
latestImageUserTurn.imageInputCount > 0
|
||||
) {
|
||||
return "Protocol note: the attached image is split horizontally, with red on top and blue on the bottom.";
|
||||
}
|
||||
if (whatsAppLocationMarker) {
|
||||
return whatsAppLocationMarker;
|
||||
}
|
||||
if (whatsAppContactMarker) {
|
||||
return whatsAppContactMarker;
|
||||
}
|
||||
if (whatsAppStickerMarker) {
|
||||
return whatsAppStickerMarker;
|
||||
}
|
||||
if (/\bmarker\b/i.test(prompt) && promptExactMarkerDirective) {
|
||||
return promptExactMarkerDirective;
|
||||
}
|
||||
if (/\bmarker\b/i.test(prompt) && promptExactReplyDirective) {
|
||||
return promptExactReplyDirective;
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && promptExactReplyDirective) {
|
||||
return promptExactReplyDirective;
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && exactMarkerDirective) {
|
||||
return exactMarkerDirective;
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
|
||||
return exactReplyDirective;
|
||||
}
|
||||
if (promptExactReplyDirective) {
|
||||
return promptExactReplyDirective;
|
||||
}
|
||||
if (/visible skill marker/i.test(prompt)) {
|
||||
return "VISIBLE-SKILL-OK";
|
||||
}
|
||||
if (/hot install marker/i.test(prompt)) {
|
||||
return "HOT-INSTALL-OK";
|
||||
}
|
||||
if (/memory tools check/i.test(prompt) && orbitCode) {
|
||||
return `Protocol note: I checked memory and the project codename is ${orbitCode}.`;
|
||||
}
|
||||
if (/silent snack recall check/i.test(prompt) && snackPreference) {
|
||||
return `Protocol note: you usually want ${snackPreference} for QA movie night.`;
|
||||
}
|
||||
if (/silent snack recall check/i.test(prompt)) {
|
||||
return "Protocol note: I do not have enough context to say what you usually want for QA movie night.";
|
||||
}
|
||||
if (/qa private final reply warning check/i.test(prompt)) {
|
||||
return [
|
||||
"QA-STRANDED-85714 confirms this is a substantive private final reply that intentionally stays outside the message tool path for the warning check.",
|
||||
"The response is long enough to exercise message_tool_only private-final detection while remaining private to the agent transcript.",
|
||||
].join(" ");
|
||||
}
|
||||
if (isStrandedFinalRetryFailureRequest(allInputText)) {
|
||||
return buildStrandedFinalRetryFailureText();
|
||||
}
|
||||
if (QA_STRANDED_FINAL_RECOVERY_PROMPT_RE.test(allInputText)) {
|
||||
return QA_STRANDED_FINAL_RETRY_PROMPT_RE.test(allInputText)
|
||||
? "QA-STRANDED-85714"
|
||||
: buildStrandedFinalRecoveryText();
|
||||
}
|
||||
if (/tool continuity check/i.test(prompt) && toolOutput) {
|
||||
return `Protocol note: model switch handoff confirmed on ${model || "the requested model"}. QA mission from QA_KICKOFF_TASK.md still applies: understand this OpenClaw repo from source + docs before acting.`;
|
||||
}
|
||||
if (toolOutput && promptExactReplyDirective) {
|
||||
return promptExactReplyDirective;
|
||||
}
|
||||
if ((toolOutput || allInputText) && /repo contract followthrough check/i.test(allInputText)) {
|
||||
const repoEvidenceText = [scenarioToolOutput, allInputText].filter(Boolean).join("\n");
|
||||
if (
|
||||
/successfully (?:wrote|created|updated|replaced)/i.test(repoEvidenceText) ||
|
||||
/status:\s*complete/i.test(repoEvidenceText)
|
||||
) {
|
||||
return [
|
||||
"Read: AGENT.md, SOUL.md, FOLLOWTHROUGH_INPUT.md",
|
||||
"Wrote: repo-contract-summary.txt",
|
||||
"Status: complete",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
"Read: AGENT.md, SOUL.md, FOLLOWTHROUGH_INPUT.md",
|
||||
"Wrote: repo-contract-summary.txt",
|
||||
"Status: blocked",
|
||||
].join("\n");
|
||||
}
|
||||
if (toolOutput && /personal task followthrough check/i.test(allInputText)) {
|
||||
const taskEvidenceText = scenarioToolOutput;
|
||||
if (/successfully (?:wrote|created|updated|replaced)/i.test(taskEvidenceText)) {
|
||||
return [
|
||||
"Pending: maintainer feedback before publishing",
|
||||
"Blocked: publishing needs explicit user approval",
|
||||
"Done: local evidence captured in personal-task-status.txt",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
"Pending: maintainer feedback before publishing",
|
||||
"Blocked: publishing needs explicit user approval",
|
||||
"Done: blocked until personal-task-status.txt exists",
|
||||
].join("\n");
|
||||
}
|
||||
if (/session memory ranking check/i.test(prompt) && orbitCode) {
|
||||
return `Protocol note: I checked memory and the current Project Nebula codename is ${orbitCode}.`;
|
||||
}
|
||||
if (/thread memory check/i.test(allInputText) && orbitCode) {
|
||||
return `Protocol note: I checked memory in-thread and the hidden thread codename is ${orbitCode}.`;
|
||||
}
|
||||
if (/switch(?:ing)? models?/i.test(prompt)) {
|
||||
return `Protocol note: model switch acknowledged. Continuing on ${model || "the requested model"}.`;
|
||||
}
|
||||
if (QA_IMAGE_GENERATION_PROMPT_RE.test(allInputText) && mediaPath) {
|
||||
return `Protocol note: generated the QA lighthouse image successfully. Attachment: ${mediaPath}`;
|
||||
}
|
||||
if (QA_SKILL_WORKSHOP_GIF_PROMPT_RE.test(prompt) && toolOutput) {
|
||||
return [
|
||||
"Animated GIF QA checklist ready.",
|
||||
"- Confirm true animation, not a static preview.",
|
||||
"- Verify dimensions and product UI fit.",
|
||||
"- Record attribution and license.",
|
||||
"- Keep a local copy before using the asset.",
|
||||
"- Re-open the copied file for final verification.",
|
||||
].join("\n");
|
||||
}
|
||||
if (
|
||||
/interrupted by a gateway reload/i.test(prompt) &&
|
||||
/subagent recovery worker/i.test(allInputText)
|
||||
) {
|
||||
return "RECOVERED-SUBAGENT-OK";
|
||||
}
|
||||
if (/subagent recovery worker/i.test(prompt)) {
|
||||
return "RECOVERED-SUBAGENT-OK";
|
||||
}
|
||||
if (/fanout worker alpha/i.test(prompt)) {
|
||||
return "ALPHA-OK";
|
||||
}
|
||||
if (/fanout worker beta/i.test(prompt)) {
|
||||
return "BETA-OK";
|
||||
}
|
||||
if (QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE.test(prompt)) {
|
||||
return QA_SUBAGENT_DIRECT_FALLBACK_MARKER;
|
||||
}
|
||||
if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) {
|
||||
return "FORKED-CONTEXT-ALPHA";
|
||||
}
|
||||
const fanoutCompleteReply = "subagent-1: ok\nsubagent-2: ok";
|
||||
if (scenarioState.subagentFanoutPhase === 2 && prompt) {
|
||||
scenarioState.subagentFanoutPhase = 3;
|
||||
return fanoutCompleteReply;
|
||||
}
|
||||
if (
|
||||
/forked subagent context qa check/i.test(prompt) &&
|
||||
/FORKED-CONTEXT-ALPHA/i.test(allInputText)
|
||||
) {
|
||||
return [
|
||||
"Worked",
|
||||
"- FORKED-CONTEXT-ALPHA",
|
||||
"Evidence",
|
||||
"- The forked child recovered the visible code from requester transcript context.",
|
||||
"Blocked",
|
||||
"- None.",
|
||||
].join("\n");
|
||||
}
|
||||
if (
|
||||
toolOutput &&
|
||||
(/delegate (?:one |a )bounded qa task/i.test(allInputText) ||
|
||||
/subagent handoff/i.test(allInputText))
|
||||
) {
|
||||
const compact = toolOutput.replace(/\s+/g, " ").trim() || "no delegated output";
|
||||
return `Delegated task:\n- Inspect the QA workspace via a bounded subagent.\nResult:\n- ${compact}\nEvidence:\n- The child result was folded back into the main thread exactly once.`;
|
||||
}
|
||||
if (toolOutput && /worked, failed, blocked|worked\/failed\/blocked|follow-up/i.test(prompt)) {
|
||||
return `Worked:\n- Read seeded QA material.\n- Expanded the report structure.\nFailed:\n- None observed in mock mode.\nBlocked:\n- No live provider evidence in this lane.\nFollow-up:\n- Re-run with a real model for qualitative coverage.`;
|
||||
}
|
||||
if (toolOutput && /lobster invaders/i.test(prompt)) {
|
||||
if (toolOutput.includes("QA mission") || toolOutput.includes("Testing")) {
|
||||
return "";
|
||||
}
|
||||
return `Protocol note: Lobster Invaders built at lobster-invaders.html.`;
|
||||
}
|
||||
if (
|
||||
toolOutput &&
|
||||
(/compaction retry mutating tool check/i.test(allInputText) ||
|
||||
/compaction-retry-summary\.txt/i.test(toolOutput))
|
||||
) {
|
||||
if (
|
||||
toolOutput.includes("Replay safety: unsafe after write.") ||
|
||||
/compaction-retry-summary\.txt/i.test(toolOutput) ||
|
||||
/successfully (?:wrote|replaced)/i.test(toolOutput) ||
|
||||
/\bwrote\b.*\bcompaction-retry-summary\.txt\b/i.test(toolOutput)
|
||||
) {
|
||||
return "Protocol note: replay unsafe after write.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
toolOutput &&
|
||||
(QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) ||
|
||||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText))
|
||||
) {
|
||||
const targetTool = extractToolSearchTarget(allInputText);
|
||||
if (targetTool && toolOutput.includes(targetTool) && toolOutput.includes("FAKE_PLUGIN_OK")) {
|
||||
return `FAKE_PLUGIN_OK ${targetTool}`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
toolOutput &&
|
||||
/(worked, failed, blocked|worked\/failed\/blocked|source and docs)/i.test(allInputText)
|
||||
) {
|
||||
return [
|
||||
"Worked:",
|
||||
"- Read all three seeded files: repo/qa/scenarios/index.yaml, repo/extensions/qa-lab/src/suite.ts, and repo/docs/help/testing.md.",
|
||||
"- Extra QA scenario candidates: config restart capability flip and image generation roundtrip.",
|
||||
"Failed:",
|
||||
"- None observed in mock mode.",
|
||||
"Blocked:",
|
||||
"- No live provider evidence in this lane.",
|
||||
"Follow-up:",
|
||||
"- Re-run with a real model for qualitative coverage.",
|
||||
].join("\n");
|
||||
}
|
||||
if (toolOutput) {
|
||||
const snippet = truncateUtf16Safe(toolOutput.replace(/\s+/g, " ").trim(), 220);
|
||||
return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`;
|
||||
}
|
||||
if (finishExactlyDirective) {
|
||||
return finishExactlyDirective;
|
||||
}
|
||||
if (prompt) {
|
||||
return `Protocol note: acknowledged. Continue with the QA scenario plan and report worked, failed, and blocked items.`;
|
||||
}
|
||||
return "Protocol note: mock OpenAI server ready.";
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// QA Lab mock provider contracts, wire helpers, and scenario constants.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { writeJson } from "../shared/http-json.js";
|
||||
|
||||
export type ResponsesInputItem = Record<string, unknown>;
|
||||
|
||||
export type StreamEvent =
|
||||
| { type: "response.output_item.added"; item: Record<string, unknown> }
|
||||
| {
|
||||
type: "response.output_text.delta";
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
content_index: number;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: "response.output_text.done";
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
content_index: number;
|
||||
text: string;
|
||||
}
|
||||
| { type: "response.function_call_arguments.delta"; delta: string }
|
||||
| { type: "response.output_item.done"; item: Record<string, unknown> }
|
||||
| {
|
||||
type: "response.completed";
|
||||
response: {
|
||||
id: string;
|
||||
status: "completed";
|
||||
output: Array<Record<string, unknown>>;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider variant tag for `body.model`. The mock previously ignored
|
||||
* `body.model` for dispatch and only echoed it in the prose output, which
|
||||
* made the parity gate tautological when run against the mock alone
|
||||
* (both providers produced identical scenario plans by construction).
|
||||
* Tagging requests with a normalized variant lets individual scenario
|
||||
* branches opt into provider-specific behavior while the rest of the
|
||||
* dispatcher stays shared, and lets `/debug/requests` consumers verify
|
||||
* which provider lane a given request came from without re-parsing the
|
||||
* raw model string.
|
||||
*
|
||||
* Policy:
|
||||
* - `openai/*`, `gpt-*`, `o1-*`, anything starting with `gpt-` → `"openai"`
|
||||
* - `anthropic/*`, `claude-*` → `"anthropic"`
|
||||
* - Everything else (including empty strings) → `"unknown"`
|
||||
*
|
||||
* The `/v1/messages` route always feeds `body.model` straight through,
|
||||
* so an Anthropic request with an `openai/gpt-5.6-luna` model string is still
|
||||
* classified as `"openai"`. That matches the parity program's convention
|
||||
* where the provider label is the source of truth, not the HTTP route.
|
||||
*/
|
||||
type MockOpenAiProviderVariant = "openai" | "anthropic" | "unknown";
|
||||
|
||||
export function resolveProviderVariant(model: string | undefined): MockOpenAiProviderVariant {
|
||||
if (typeof model !== "string") {
|
||||
return "unknown";
|
||||
}
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
if (trimmed.length === 0) {
|
||||
return "unknown";
|
||||
}
|
||||
// Prefer the explicit `provider/model` or `provider:model` prefix when
|
||||
// the caller supplied one — that's the most reliable signal.
|
||||
const separatorMatch = /^([^/:]+)[/:]/.exec(trimmed);
|
||||
const provider = separatorMatch?.[1] ?? trimmed;
|
||||
if (provider === "openai") {
|
||||
return "openai";
|
||||
}
|
||||
if (provider === "anthropic" || provider === "claude-cli") {
|
||||
return "anthropic";
|
||||
}
|
||||
// Fall back to model-name prefix matching for bare model strings like
|
||||
// `gpt-5.6-luna` or `claude-opus-4-8`.
|
||||
if (/^(?:gpt-|o1-|openai-)/.test(trimmed)) {
|
||||
return "openai";
|
||||
}
|
||||
if (/^(?:claude-|anthropic-)/.test(trimmed)) {
|
||||
return "anthropic";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export type MockOpenAiRequestSnapshot = {
|
||||
cursor: number;
|
||||
raw: string;
|
||||
body: Record<string, unknown>;
|
||||
prompt: string;
|
||||
allInputText: string;
|
||||
instructions?: string;
|
||||
toolOutput: string;
|
||||
model: string;
|
||||
providerVariant: MockOpenAiProviderVariant;
|
||||
imageInputCount: number;
|
||||
plannedToolCallId?: string;
|
||||
plannedToolName?: string;
|
||||
plannedToolArgs?: Record<string, unknown>;
|
||||
toolOutputCallId?: string;
|
||||
toolOutputStructuredError?: true;
|
||||
};
|
||||
|
||||
export type MockOpenAiRequestSnapshotInput = Omit<MockOpenAiRequestSnapshot, "cursor">;
|
||||
|
||||
// Runtime-context delimiters are owned by src/agents/internal-runtime-context.ts.
|
||||
// This mock mirrors the wire shape so delimiter drift fails through QA timeouts.
|
||||
export const INTERNAL_RUNTIME_CONTEXT_BEGIN = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
|
||||
export const INTERNAL_RUNTIME_CONTEXT_END = "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
||||
|
||||
// Anthropic /v1/messages request/response shapes the mock actually needs.
|
||||
// This is a subset of the real Anthropic Messages API — just enough so the
|
||||
// QA suite can run its parity pack against a "baseline" Anthropic provider
|
||||
// without needing real API keys. The scenarios drive their dispatch through
|
||||
// the shared mock scenario logic (buildResponsesPayload), with `model`
|
||||
// preserved so provider-aware branches can intentionally diverge.
|
||||
export type AnthropicMessageContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
is_error?: boolean;
|
||||
content: string | Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
| { type: "image"; source: Record<string, unknown> };
|
||||
|
||||
export type AnthropicMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string | AnthropicMessageContentBlock[];
|
||||
};
|
||||
|
||||
export type AnthropicMessagesRequest = {
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
system?: string | Array<{ type: "text"; text: string }>;
|
||||
messages?: AnthropicMessage[];
|
||||
tools?: Array<Record<string, unknown>>;
|
||||
stream?: boolean;
|
||||
};
|
||||
|
||||
export const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0nQAAAAASUVORK5CYII=";
|
||||
export const QA_REASONING_ONLY_RECOVERY_PROMPT_RE = /reasoning-only continuation qa check/i;
|
||||
export const QA_REASONING_ONLY_SIDE_EFFECT_PROMPT_RE = /reasoning-only after write safety check/i;
|
||||
export const QA_ANTHROPIC_THINKING_ERROR_RECOVERY_PROMPT_RE = /anthropic thinking error qa check/i;
|
||||
export const QA_THINKING_VISIBILITY_OFF_PROMPT_RE = /qa thinking visibility check off/i;
|
||||
export const QA_THINKING_VISIBILITY_MAX_PROMPT_RE = /qa thinking visibility check max/i;
|
||||
export const QA_EMPTY_RESPONSE_RECOVERY_PROMPT_RE = /empty response continuation qa check/i;
|
||||
export const QA_EMPTY_RESPONSE_EXHAUSTION_PROMPT_RE = /empty response exhaustion qa check/i;
|
||||
export const QA_STREAMING_PROMPT_RE = /(?:partial|quiet) streaming qa check/i;
|
||||
export const QA_FINAL_ONLY_MARKER_STREAMING_PROMPT_RE = /final-only marker streaming qa check/i;
|
||||
export const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
|
||||
export const QA_TOOL_PROGRESS_ERROR_PROMPT_RE = /tool progress error qa check/i;
|
||||
export const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i;
|
||||
export const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i;
|
||||
export const QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE = /qa a2a message-tool mirror check/i;
|
||||
export const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE =
|
||||
/qa group message unavailable fallback check/i;
|
||||
export const QA_STRANDED_FINAL_RECOVERY_PROMPT_RE = /qa stranded final recovery check/i;
|
||||
const QA_STRANDED_FINAL_RETRY_FAILURE_PROMPT_RE = /qa stranded final retry failure check/i;
|
||||
export const QA_STRANDED_FINAL_RETRY_PROMPT_RE = /you did not call message\(action=send\)/i;
|
||||
const QA_STRANDED_FINAL_RETRY_FAILURE_MARKER = "QA-STRANDED-RETRY-FAIL-RAW";
|
||||
export const QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE =
|
||||
/telegram current session_status qa check/i;
|
||||
export const QA_TELEGRAM_STREAM_SINGLE_MARKER = "QA-TELEGRAM-STREAM-SINGLE-OK";
|
||||
export const QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE =
|
||||
/telegram long final three chunk qa check/i;
|
||||
export const QA_TELEGRAM_LONG_FINAL_PROMPT_RE = /telegram long final qa check/i;
|
||||
export const QA_WHATSAPP_LONG_FINAL_PROMPT_RE = /whatsapp long final qa check/i;
|
||||
export const QA_SLACK_CHART_PRESENTATION_PROMPT_RE =
|
||||
/Slack native chart QA check\s+(SLACK_QA_CHART_SUMMARY_[A-Z0-9]+)[\s\S]*?reply with only this exact marker:\s*(SLACK_QA_CHART_DONE_[A-Z0-9]+)/i;
|
||||
export const QA_WHATSAPP_AGENT_MESSAGE_ACTION_REACT_PROMPT_RE =
|
||||
/react to this whatsapp(?: group)? message with thumbs up for qa action check\s+(?:WHATSAPP_QA_AGENT_REACT|WHATSAPP_QA_GROUP_AGENT_REACT)_[A-Z0-9]+/i;
|
||||
export const QA_WHATSAPP_AGENT_MESSAGE_ACTION_UPLOAD_PROMPT_RE =
|
||||
/upload-file action to send a PNG with caption\s+((?:WHATSAPP_QA_AGENT_UPLOAD|WHATSAPP_QA_GROUP_AGENT_UPLOAD)_[A-Z0-9]+)/i;
|
||||
export const QA_WHATSAPP_PENDING_HISTORY_TRIGGER_MARKER_RE =
|
||||
/\bWHATSAPP_QA_PENDING_HISTORY_TRIGGER_([A-Z0-9]+)\b/u;
|
||||
export const QA_WHATSAPP_PENDING_HISTORY_STRUCTURED_LABEL =
|
||||
"Chat history since last reply (untrusted, for context):";
|
||||
export const QA_WHATSAPP_BROADCAST_PROMPT_RE =
|
||||
/\bopenclawqa broadcast fanout check\s+([A-Z0-9_]+)\b/i;
|
||||
export const QA_WHATSAPP_RUNTIME_AGENT_RE = /\bRuntime:\s*[^\n]*\bagent=([A-Za-z0-9_-]+)/i;
|
||||
export const QA_WHATSAPP_ACTIVATION_ALWAYS_MARKER_RE =
|
||||
/\bWHATSAPP_QA_ACTIVATION_ALWAYS_([A-Z0-9]+)\b/u;
|
||||
export const QA_WHATSAPP_REPLY_TO_BOT_SEED_MARKER_RE =
|
||||
/\bWHATSAPP_QA_REPLY_TO_BOT_SEED_[A-Z0-9]+\b/u;
|
||||
export const QA_WHATSAPP_REPLY_TO_BOT_TRIGGER_MARKER_RE =
|
||||
/\bWHATSAPP_QA_REPLY_TO_BOT_TRIGGER_[A-Z0-9]+\b/u;
|
||||
export const QA_WHATSAPP_BATCHED_FINAL_MARKER_RE = /\bWHATSAPP_QA_BATCHED_FINAL_([A-Z0-9]+)\b/u;
|
||||
export const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
|
||||
export const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
|
||||
|
||||
export function buildStrandedFinalRecoveryText(): string {
|
||||
return [
|
||||
"QA-STRANDED-85714 confirms this is a substantive private final reply that initially skipped the message tool.",
|
||||
"The reply is intentionally long enough to exercise message_tool_only stranded-final recovery before the retry delivers it visibly.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildStrandedFinalRetryFailureText(): string {
|
||||
return [
|
||||
"QA-STRANDED-RETRY-FAIL-RAW confirms this retry also produced a substantive private final reply instead of calling the message tool.",
|
||||
"This text must remain private so the gateway can deliver only its sanitized failure diagnostic to the source chat.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function isStrandedFinalRetryFailureRequest(allInputText: string): boolean {
|
||||
return (
|
||||
QA_STRANDED_FINAL_RETRY_FAILURE_PROMPT_RE.test(allInputText) ||
|
||||
(QA_STRANDED_FINAL_RETRY_PROMPT_RE.test(allInputText) &&
|
||||
allInputText.includes(QA_STRANDED_FINAL_RETRY_FAILURE_MARKER))
|
||||
);
|
||||
}
|
||||
export const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
|
||||
export const QA_NATIVE_STOP_DELAY_PROMPT_RE =
|
||||
/subagent recovery worker native command target proof\.\s*wait until stopped\./i;
|
||||
export const QA_NATIVE_STOP_DELAY_MS = 180_000;
|
||||
export const QA_IMAGE_GENERATION_PROMPT_RE =
|
||||
/image generation check|capability flip image check|\/tool\s+image_generate/i;
|
||||
export const QA_REASONING_ONLY_RETRY_NEEDLE =
|
||||
"recorded reasoning but did not produce a user-visible answer";
|
||||
export const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
|
||||
"The previous attempt did not produce a user-visible answer.";
|
||||
export const QA_SKILL_WORKSHOP_GIF_PROMPT_RE =
|
||||
/externally sourced animated GIF asset|animated GIF asset in a product UI/i;
|
||||
export const QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE = /Review transcript for durable skill updates/i;
|
||||
export const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small project/i;
|
||||
export const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
|
||||
export const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i;
|
||||
export const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i;
|
||||
export const QA_RESTART_CODE_MODE_WAIT_PROMPT_RE = /code mode restart wait qa check/i;
|
||||
export const QA_RESTART_RECOVERY_PROMPT_RE = /previous turn was interrupted by a gateway restart/i;
|
||||
const QA_AUDIO_TRANSCRIPTION_TEXT =
|
||||
"Reply with only this exact marker: WHATSAPP_QA_AUDIO_TRANSCRIPT_OK";
|
||||
const QA_GROUP_AUDIO_TRANSCRIPTION_TEXT =
|
||||
"openclawqa reply with only this exact marker after group audio preflight: WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_OK";
|
||||
const QA_GROUP_AUDIO_TRIGGER_SENTINEL = "OPENCLAW_QA_GROUP_AUDIO_TRIGGER";
|
||||
export const QA_MCP_CODE_MODE_API_FILE_PROMPT_RE = /mcp code mode api file qa check/i;
|
||||
|
||||
export type MockScenarioState = {
|
||||
anthropicThinkingErrorPhase: number;
|
||||
subagentFanoutPhase: number;
|
||||
subagentHandoffSpawned: boolean;
|
||||
};
|
||||
|
||||
export function sourceDiscoveryReadPathForProvider(providerVariant: MockOpenAiProviderVariant) {
|
||||
return providerVariant === "anthropic"
|
||||
? "repo/docs/help/testing.md"
|
||||
: "repo/qa/scenarios/index.yaml";
|
||||
}
|
||||
|
||||
export function subagentHandoffTaskForProvider(providerVariant: MockOpenAiProviderVariant) {
|
||||
return providerVariant === "anthropic"
|
||||
? "Inspect the QA docs fixture and return one concise protocol note."
|
||||
: "Inspect the QA workspace and return one concise protocol note.";
|
||||
}
|
||||
|
||||
export function subagentFanoutTaskForProvider(
|
||||
providerVariant: MockOpenAiProviderVariant,
|
||||
worker: "alpha" | "beta",
|
||||
) {
|
||||
const marker = worker === "alpha" ? "ALPHA-OK" : "BETA-OK";
|
||||
const scope = providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
|
||||
return `Fanout worker ${worker}: inspect ${scope} and finish with exactly ${marker}.`;
|
||||
}
|
||||
|
||||
const MOCK_OPENAI_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
const MOCK_OPENAI_BODY_TIMEOUT_MS = 30_000;
|
||||
export const MOCK_OPENAI_DEBUG_REQUEST_LIMIT = 2_000;
|
||||
|
||||
export function readBody(req: IncomingMessage): Promise<string> {
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes: MOCK_OPENAI_MAX_BODY_BYTES,
|
||||
timeoutMs: MOCK_OPENAI_BODY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonObjectBody(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : {};
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAiMalformedJsonError(res: ServerResponse, label: string) {
|
||||
writeJson(res, 400, {
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message: `Malformed JSON body for ${label} request.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function transcriptionTextForAudioRequest(rawBody: string) {
|
||||
if (rawBody.includes(QA_GROUP_AUDIO_TRIGGER_SENTINEL)) {
|
||||
return QA_GROUP_AUDIO_TRANSCRIPTION_TEXT;
|
||||
}
|
||||
return QA_AUDIO_TRANSCRIPTION_TEXT;
|
||||
}
|
||||
|
||||
export function writeSse(res: ServerResponse, events: StreamEvent[]) {
|
||||
const body = `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function isRemoteCompactionV2Request(input: ResponsesInputItem[]) {
|
||||
// Codex sends compaction through /responses with a trigger item. Keep it
|
||||
// outside scenario dispatch so maintenance calls never become tool evidence.
|
||||
return input.some((item) => item.type === "compaction_trigger");
|
||||
}
|
||||
|
||||
export function buildRemoteCompactionV2Events(): [
|
||||
Extract<StreamEvent, { type: "response.output_item.done" }>,
|
||||
Extract<StreamEvent, { type: "response.completed" }>,
|
||||
] {
|
||||
const item = {
|
||||
type: "compaction",
|
||||
encrypted_content: "QA_MOCK_REMOTE_COMPACTION_SUMMARY",
|
||||
};
|
||||
return [
|
||||
{ type: "response.output_item.done", item },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_mock_compaction_1",
|
||||
status: "completed",
|
||||
output: [item],
|
||||
usage: { input_tokens: 64, output_tokens: 16, total_tokens: 80 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function writeSseWithPreviewPause(
|
||||
res: ServerResponse,
|
||||
events: StreamEvent[],
|
||||
pauseMs: number,
|
||||
) {
|
||||
const completionIndex = events.findIndex((event) => event.type === "response.output_text.done");
|
||||
if (completionIndex < 0) {
|
||||
writeSse(res, events);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
for (const event of events.slice(0, completionIndex)) {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
await sleep(pauseMs);
|
||||
for (const event of events.slice(completionIndex)) {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
res.end("data: [DONE]\n\n");
|
||||
}
|
||||
|
||||
export type AnthropicStreamEvent = Record<string, unknown> & {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export function writeAnthropicSse(res: ServerResponse, events: AnthropicStreamEvent[]) {
|
||||
const body = events
|
||||
.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
.join("");
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function countApproxTokens(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.ceil(trimmed.length / 4));
|
||||
}
|
||||
|
||||
export function extractEmbeddingInputTexts(input: unknown): string[] {
|
||||
if (typeof input === "string") {
|
||||
return [input];
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input.flatMap((entry) => extractEmbeddingInputTexts(entry));
|
||||
}
|
||||
if (
|
||||
input &&
|
||||
typeof input === "object" &&
|
||||
typeof (input as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return [(input as { text: string }).text];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildDeterministicEmbedding(text: string, dimensions = 16) {
|
||||
const values = Array.from({ length: dimensions }, () => 0);
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const embeddingIndex = index % dimensions;
|
||||
values[embeddingIndex] = (values[embeddingIndex] ?? 0) + text.charCodeAt(index) / 255;
|
||||
}
|
||||
const magnitude = Math.hypot(...values) || 1;
|
||||
return values.map((value) => Number((value / magnitude).toFixed(8)));
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// QA Lab mock provider prompt directives and tool declarations.
|
||||
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
type ResponsesInputItem,
|
||||
QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE,
|
||||
QA_TOOL_SEARCH_PROMPT_RE,
|
||||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE,
|
||||
} from "./mock-openai-contracts.js";
|
||||
import { extractInstructionsText } from "./mock-openai-input.js";
|
||||
function extractLastCapture(text: string, pattern: RegExp) {
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
||||
const globalPattern = new RegExp(pattern.source, flags);
|
||||
for (let match = globalPattern.exec(text); match; match = globalPattern.exec(text)) {
|
||||
lastMatch = match;
|
||||
}
|
||||
return lastMatch?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function extractCaptures(text: string, pattern: RegExp) {
|
||||
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
||||
const globalPattern = new RegExp(pattern.source, flags);
|
||||
return Array.from(text.matchAll(globalPattern), (match) => match[1]?.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function extractLastMatchingUserText(texts: string[], pattern: RegExp) {
|
||||
for (let index = texts.length - 1; index >= 0; index -= 1) {
|
||||
const text = texts[index] ?? "";
|
||||
if (pattern.test(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractExactReplyDirective(text: string) {
|
||||
const backtickedMatch = extractLastCapture(text, /reply(?: with)? exactly\s+`([^`]+)`/i);
|
||||
if (backtickedMatch) {
|
||||
return backtickedMatch;
|
||||
}
|
||||
return (
|
||||
extractLastCapture(text, /reply(?: with)? exactly:\s*([^\n]+)/i) ??
|
||||
extractLastCapture(text, /reply(?: with)? exactly\s+(?!with\b)([^\s`.,;:!?]+)/i)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractFinishExactlyDirective(text: string) {
|
||||
const backtickedMatch = extractLastCapture(text, /finish with exactly\s+`([^`]+)`/i);
|
||||
if (backtickedMatch) {
|
||||
return backtickedMatch;
|
||||
}
|
||||
return extractLastCapture(text, /finish with exactly\s+([^\s`.,;:!?]+)/i);
|
||||
}
|
||||
|
||||
export function extractExactMarkerDirective(text: string) {
|
||||
const backtickedMatch = extractLastCapture(text, /exact marker\b[^:\n]{0,120}:\s*`([^`]+)`/i);
|
||||
if (backtickedMatch) {
|
||||
return backtickedMatch;
|
||||
}
|
||||
return extractLastCapture(
|
||||
text,
|
||||
/exact marker\b[^:\n]{0,120}:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i,
|
||||
);
|
||||
}
|
||||
|
||||
export function extractWhatsAppLocationMarkerDirective(text: string) {
|
||||
return extractLastCapture(
|
||||
text,
|
||||
/WhatsApp location marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i,
|
||||
);
|
||||
}
|
||||
|
||||
export function extractWhatsAppContactMarkerDirective(text: string) {
|
||||
return extractLastCapture(text, /WhatsApp contact marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i);
|
||||
}
|
||||
|
||||
export function extractWhatsAppStickerMarkerDirective(text: string) {
|
||||
return extractLastCapture(text, /WhatsApp sticker marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i);
|
||||
}
|
||||
|
||||
export function shouldUseWhatsAppLocationMarker(prompt: string) {
|
||||
return /(?:^|[\n:]\s*)📍\s*37\.774900,\s*-122\.419400\b/u.test(prompt.trim());
|
||||
}
|
||||
|
||||
export function shouldUseWhatsAppContactMarker(prompt: string) {
|
||||
return /(?:^|[\n:]\s*)<contacts?(?::|>)/iu.test(prompt.trim());
|
||||
}
|
||||
|
||||
export function shouldUseWhatsAppStickerMarker(prompt: string) {
|
||||
return /(?:^|[\n:]\s*)<media:sticker>(?:\s|$)/iu.test(prompt.trim());
|
||||
}
|
||||
|
||||
function extractLabeledMarkerDirective(text: string, label: string) {
|
||||
const escapedLabel = escapeRegExp(label);
|
||||
const backtickedMatch = extractLastCapture(
|
||||
text,
|
||||
new RegExp(`${escapedLabel}:\\s*\`([^\\\`]+)\``, "i"),
|
||||
);
|
||||
if (backtickedMatch) {
|
||||
return backtickedMatch;
|
||||
}
|
||||
return extractLastCapture(
|
||||
text,
|
||||
new RegExp(`${escapedLabel}:\\s*([^\\s\\\`.,;:!?]+(?:-[^\\s\\\`.,;:!?]+)*)`, "i"),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractBlockStreamingMarkerDirectives(text: string) {
|
||||
const firstLabeledMarker = extractLabeledMarkerDirective(text, "first exact marker");
|
||||
const secondLabeledMarker = extractLabeledMarkerDirective(text, "second exact marker");
|
||||
if (firstLabeledMarker && secondLabeledMarker) {
|
||||
return {
|
||||
first: firstLabeledMarker,
|
||||
second: secondLabeledMarker,
|
||||
};
|
||||
}
|
||||
|
||||
const markers = extractCaptures(text, /exact marker\b[^:\n]{0,120}:\s*`([^`]+)`/i);
|
||||
if (markers.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const [first, second] = markers.slice(-2);
|
||||
return first && second
|
||||
? {
|
||||
first,
|
||||
second,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractQuotedToolArg(text: string, name: string) {
|
||||
const escapedName = escapeRegExp(name);
|
||||
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*"([^"]+)"`, "i"));
|
||||
}
|
||||
|
||||
function extractBareToolArg(text: string, name: string) {
|
||||
const escapedName = escapeRegExp(name);
|
||||
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*([^\\s\\\`.,;:!?]+)`, "i"));
|
||||
}
|
||||
|
||||
export function hasDeclaredTool(body: Record<string, unknown>, name: string) {
|
||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||
const dynamicTools = Array.isArray(body.dynamicTools) ? body.dynamicTools : [];
|
||||
if (
|
||||
[...tools, ...dynamicTools].some((tool) => toolDefinitionMentionsName(tool, name)) ||
|
||||
instructionTextMentionsToolName(extractInstructionsText(body), name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasToolDefinition(body: Record<string, unknown>, name: string) {
|
||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||
const dynamicTools = Array.isArray(body.dynamicTools) ? body.dynamicTools : [];
|
||||
return [...tools, ...dynamicTools].some((tool) => toolDefinitionMentionsName(tool, name));
|
||||
}
|
||||
|
||||
function toolDefinitionMentionsName(value: unknown, name: string, depth = 0): boolean {
|
||||
if (depth > 6 || !value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => toolDefinitionMentionsName(item, name, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["name", "tool", "functionName"]) {
|
||||
if (record[key] === name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return Object.values(record).some((item) => toolDefinitionMentionsName(item, name, depth + 1));
|
||||
}
|
||||
|
||||
function instructionTextMentionsToolName(text: string, name: string) {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const escapedName = escapeRegExp(name);
|
||||
return new RegExp(`(^|[^A-Za-z0-9_])${escapedName}([^A-Za-z0-9_]|$)`).test(text);
|
||||
}
|
||||
|
||||
export function isQaToolSearchFixture(text: string) {
|
||||
return QA_TOOL_SEARCH_PROMPT_RE.test(text) || QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(text);
|
||||
}
|
||||
|
||||
export function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> | null {
|
||||
if (!/\bsessions_spawn\b/i.test(text)) {
|
||||
return null;
|
||||
}
|
||||
const task = extractQuotedToolArg(text, "task");
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
const label = extractQuotedToolArg(text, "label") ?? extractBareToolArg(text, "label");
|
||||
const mode = extractBareToolArg(text, "mode")?.toLowerCase();
|
||||
const context = extractBareToolArg(text, "context")?.toLowerCase();
|
||||
return {
|
||||
task,
|
||||
...(label ? { label } : {}),
|
||||
...(extractBareToolArg(text, "thread")?.toLowerCase() === "true" ? { thread: true } : {}),
|
||||
...(mode === "session" || mode === "run" ? { mode } : {}),
|
||||
...(context === "fork" || context === "isolated" ? { context } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQaA2aMessageToolMirrorSessionsSendArgs(
|
||||
text: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (!QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE.test(text)) {
|
||||
return null;
|
||||
}
|
||||
const sessionKey =
|
||||
extractQuotedToolArg(text, "sessionKey") ?? extractBareToolArg(text, "sessionKey");
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const marker =
|
||||
extractExactMarkerDirective(text) ??
|
||||
extractExactReplyDirective(text) ??
|
||||
"QA-A2A-MESSAGE-TOOL-MIRROR-OK";
|
||||
return {
|
||||
sessionKey,
|
||||
message: `qa group visible reply tool check. Use the visible room reply path. exact marker: \`${marker}\``,
|
||||
timeoutSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractToolErrorForNamedCall(params: {
|
||||
input: ResponsesInputItem[];
|
||||
name: string;
|
||||
toolJson: Record<string, unknown> | null;
|
||||
}) {
|
||||
const error = typeof params.toolJson?.error === "string" ? params.toolJson.error.trim() : "";
|
||||
if (!error) {
|
||||
return undefined;
|
||||
}
|
||||
const namedFunctionCall = params.input.some(
|
||||
(item) => item.type === "function_call" && item.name === params.name,
|
||||
);
|
||||
if (namedFunctionCall) {
|
||||
return error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasToolErrorOutput(toolJson: Record<string, unknown> | null, toolOutput: string) {
|
||||
if (typeof toolJson?.error === "string" && toolJson.error.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
typeof toolJson?.status === "string" &&
|
||||
/\b(?:error|failed|failure)\b/i.test(toolJson.status)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /\b(?:error|failed|failure|not found|no such file|enoent)\b/i.test(toolOutput);
|
||||
}
|
||||
|
||||
export function extractSessionStatusSessionKey(
|
||||
toolJson: Record<string, unknown> | null,
|
||||
toolOutput: string,
|
||||
) {
|
||||
const details = toolJson?.details;
|
||||
if (details && typeof details === "object") {
|
||||
const sessionKey = (details as { sessionKey?: unknown }).sessionKey;
|
||||
if (typeof sessionKey === "string" && sessionKey.trim()) {
|
||||
return sessionKey.trim();
|
||||
}
|
||||
}
|
||||
const topLevelSessionKey = toolJson?.sessionKey;
|
||||
if (typeof topLevelSessionKey === "string" && topLevelSessionKey.trim()) {
|
||||
return topLevelSessionKey.trim();
|
||||
}
|
||||
const statusLineSessionKey = /(?:^|\n)[^\n]*Session:\s*([^\s•\n]+)/u.exec(toolOutput)?.[1];
|
||||
if (statusLineSessionKey?.trim()) {
|
||||
return statusLineSessionKey.trim();
|
||||
}
|
||||
return /"sessionKey"\s*:\s*"([^"]+)"/.exec(toolOutput)?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function isHeartbeatPrompt(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || /remember this fact/i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return /(?:^|\n)Read HEARTBEAT\.md if it exists\b/i.test(trimmed);
|
||||
}
|
||||
|
||||
export function readFirstMediaPath(value: unknown): string {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return "";
|
||||
}
|
||||
const media = value as {
|
||||
mediaUrl?: unknown;
|
||||
mediaUrls?: unknown;
|
||||
path?: unknown;
|
||||
filePath?: unknown;
|
||||
attachments?: unknown;
|
||||
};
|
||||
for (const candidate of [media.mediaUrl, media.path, media.filePath]) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
if (Array.isArray(media.mediaUrls)) {
|
||||
const mediaUrl = media.mediaUrls.find(
|
||||
(candidate) => typeof candidate === "string" && candidate.trim(),
|
||||
);
|
||||
if (typeof mediaUrl === "string" && mediaUrl.trim()) {
|
||||
return mediaUrl.trim();
|
||||
}
|
||||
}
|
||||
if (Array.isArray(media.attachments)) {
|
||||
for (const attachment of media.attachments) {
|
||||
const mediaPath = readFirstMediaPath(attachment);
|
||||
if (mediaPath) {
|
||||
return mediaPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// QA Lab mock provider output event builders.
|
||||
|
||||
import type { StreamEvent } from "./mock-openai-contracts.js";
|
||||
import {
|
||||
readTargetFromPrompt,
|
||||
buildMockFunctionCall,
|
||||
buildToolCallEventsWithArgs,
|
||||
} from "./mock-openai-tooling.js";
|
||||
export function buildToolCallEvents(prompt: string): StreamEvent[] {
|
||||
const targetPath = readTargetFromPrompt(prompt);
|
||||
return buildToolCallEventsWithArgs("read", { path: targetPath });
|
||||
}
|
||||
|
||||
export function buildReleaseAuditJson() {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
verified: false,
|
||||
findings: [
|
||||
{
|
||||
id: "REL-GATEWAY-417",
|
||||
source: "src/gateway/reconnect.ts",
|
||||
status: "retry jitter verified, resume token fallback still needs manual spot check",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-CHANNEL-238",
|
||||
source: "src/channels/delivery.ts",
|
||||
status: "thread replies preserve ordering, root-channel fallback needs handoff note",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-CRON-904",
|
||||
source: "src/scheduling/cron.ts",
|
||||
status: "single-run lock verified for restart wakeups",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-MEMORY-552",
|
||||
source: "src/memory/recall.ts",
|
||||
status:
|
||||
"fallback summary survives empty memory search; ranking sample needs second reviewer",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-PLUGIN-319",
|
||||
source: "src/plugins/runtime.ts",
|
||||
status: "bundled runtime manifest loads cleanly after restart",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-INSTALL-846",
|
||||
source: "install/update.ts",
|
||||
status: "update smoke passed from previous stable tag",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-DOCS-611",
|
||||
source: "docs/operator-notes.md",
|
||||
status:
|
||||
"docs mention reconnect, cron, memory, plugin, and installer checks; channel ordering and UI notes need maintainer handoff",
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: "REL-UI-BLOCKED",
|
||||
source: "ui/control-panel.ts",
|
||||
status: "blocked: source file was referenced by checklist but missing from the fixture",
|
||||
verified: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildReleaseHandoffMarkdown() {
|
||||
return [
|
||||
"# Release Handoff",
|
||||
"",
|
||||
"Ready:",
|
||||
"- REL-GATEWAY-417: gateway reconnect handling checked in `src/gateway/reconnect.ts`.",
|
||||
"- REL-CRON-904: cron duplicate prevention checked in `src/scheduling/cron.ts`.",
|
||||
"- REL-PLUGIN-319: plugin runtime loading checked in `src/plugins/runtime.ts`.",
|
||||
"- REL-INSTALL-846: installer update path checked in `install/update.ts`.",
|
||||
"",
|
||||
"Follow-up:",
|
||||
"- REL-CHANNEL-238: channel delivery ordering needs maintainer handoff.",
|
||||
"- REL-MEMORY-552: memory recall fallback ranking sample needs a second reviewer.",
|
||||
"- REL-DOCS-611: docs update status needs channel ordering and UI notes.",
|
||||
"- `ui/control-panel.ts` is blocked/not found in the fixture.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function extractPlannedToolName(events: StreamEvent[]) {
|
||||
for (const event of events) {
|
||||
if (event.type !== "response.output_item.done") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as { type?: unknown; name?: unknown };
|
||||
if (item.type === "function_call" && typeof item.name === "string") {
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractPlannedToolCallId(events: StreamEvent[]) {
|
||||
for (const event of events) {
|
||||
if (event.type !== "response.output_item.done") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as { type?: unknown; call_id?: unknown };
|
||||
if (item.type === "function_call" && typeof item.call_id === "string") {
|
||||
return item.call_id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractPlannedToolArgs(events: StreamEvent[]) {
|
||||
for (const event of events) {
|
||||
if (event.type !== "response.output_item.done") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as { type?: unknown; arguments?: unknown };
|
||||
if (item.type !== "function_call" || typeof item.arguments !== "string") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(item.arguments);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type MockAssistantMessageSpec = {
|
||||
id: string;
|
||||
phase?: "commentary" | "final_answer";
|
||||
streamDeltas?: string[];
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function splitMockStreamingText(text: string, parts = 3) {
|
||||
if (text.length <= 1) {
|
||||
return [text];
|
||||
}
|
||||
const chunkSize = Math.max(1, Math.ceil(text.length / parts));
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < text.length; index += chunkSize) {
|
||||
chunks.push(text.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks.length > 1 ? chunks : [text.slice(0, 1), text.slice(1)];
|
||||
}
|
||||
|
||||
export function buildQaLongFinalText({
|
||||
endMarker = "TELEGRAM-LONG-FINAL-END",
|
||||
segmentPrefix = "telegram-long-final-segment",
|
||||
segmentCount = 42,
|
||||
startMarker = "TELEGRAM-LONG-FINAL-BEGIN",
|
||||
}: {
|
||||
endMarker?: string;
|
||||
segmentPrefix?: string;
|
||||
segmentCount?: number;
|
||||
startMarker?: string;
|
||||
} = {}) {
|
||||
const body = Array.from(
|
||||
{ length: segmentCount },
|
||||
(_, index) => `${segmentPrefix}-${String(index + 1).padStart(3, "0")} ${"x".repeat(54)}`,
|
||||
).join("\n");
|
||||
return `${startMarker}\n${body}\n${endMarker}`;
|
||||
}
|
||||
|
||||
function buildAssistantOutputItem(spec: MockAssistantMessageSpec) {
|
||||
return {
|
||||
type: "message",
|
||||
id: spec.id,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
...(spec.phase ? { phase: spec.phase } : {}),
|
||||
content: [{ type: "output_text", text: spec.text, annotations: [] }],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function appendAssistantMessageEvents(events: StreamEvent[], spec: MockAssistantMessageSpec) {
|
||||
events.push({
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: spec.id,
|
||||
role: "assistant",
|
||||
...(spec.phase ? { phase: spec.phase } : {}),
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
});
|
||||
for (const delta of spec.streamDeltas ?? []) {
|
||||
events.push({
|
||||
type: "response.output_text.delta",
|
||||
item_id: spec.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
if ((spec.streamDeltas ?? []).length > 0) {
|
||||
events.push({
|
||||
type: "response.output_text.done",
|
||||
item_id: spec.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
text: spec.text,
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "response.output_item.done",
|
||||
item: buildAssistantOutputItem(spec),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAssistantThenToolCallEvents(
|
||||
spec: MockAssistantMessageSpec,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): StreamEvent[] {
|
||||
const call = buildMockFunctionCall(name, args);
|
||||
const message = buildAssistantOutputItem(spec);
|
||||
const events: StreamEvent[] = [];
|
||||
appendAssistantMessageEvents(events, spec);
|
||||
events.push({
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId,
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
});
|
||||
events.push({ type: "response.function_call_arguments.delta", delta: call.serialized });
|
||||
events.push({
|
||||
type: "response.output_item.done",
|
||||
item: call.item,
|
||||
});
|
||||
events.push({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: call.responseId,
|
||||
status: "completed",
|
||||
output: [message, call.item],
|
||||
usage: { input_tokens: 64, output_tokens: 32, total_tokens: 96 },
|
||||
},
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
export function buildAssistantEvents(
|
||||
specsOrText: MockAssistantMessageSpec[] | string,
|
||||
): StreamEvent[] {
|
||||
const specs =
|
||||
typeof specsOrText === "string"
|
||||
? [
|
||||
{
|
||||
id: "msg_mock_1",
|
||||
text: specsOrText,
|
||||
},
|
||||
]
|
||||
: specsOrText;
|
||||
const renderedSpecs = specs.map((spec) => ({ spec, item: buildAssistantOutputItem(spec) }));
|
||||
const output = renderedSpecs.map(({ item }) => item);
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
for (const [outputIndex, { spec, item }] of renderedSpecs.entries()) {
|
||||
events.push({
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: spec.id,
|
||||
role: "assistant",
|
||||
...(spec.phase ? { phase: spec.phase } : {}),
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
});
|
||||
for (const delta of spec.streamDeltas ?? []) {
|
||||
events.push({
|
||||
type: "response.output_text.delta",
|
||||
item_id: spec.id,
|
||||
output_index: outputIndex,
|
||||
content_index: 0,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
if ((spec.streamDeltas ?? []).length > 0) {
|
||||
events.push({
|
||||
type: "response.output_text.done",
|
||||
item_id: spec.id,
|
||||
output_index: outputIndex,
|
||||
content_index: 0,
|
||||
text: spec.text,
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "response.output_item.done",
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_mock_msg_1",
|
||||
status: "completed",
|
||||
output,
|
||||
usage: { input_tokens: 64, output_tokens: 24, total_tokens: 88 },
|
||||
},
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
export function buildReasoningOnlyEvents(summaryText: string, id: string): StreamEvent[] {
|
||||
const reasoningItem = {
|
||||
type: "reasoning",
|
||||
id,
|
||||
summary: [{ text: summaryText }],
|
||||
} as const;
|
||||
return [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id,
|
||||
summary: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: reasoningItem,
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${id}`,
|
||||
status: "completed",
|
||||
output: [reasoningItem],
|
||||
usage: { input_tokens: 64, output_tokens: 8, total_tokens: 72 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildReasoningAndAssistantEvents(params: {
|
||||
reasoningId: string;
|
||||
answerText: string;
|
||||
answerId?: string;
|
||||
}): StreamEvent[] {
|
||||
const reasoningItem = {
|
||||
type: "reasoning",
|
||||
id: params.reasoningId,
|
||||
summary: [],
|
||||
} as const;
|
||||
const answerItem = buildAssistantOutputItem({
|
||||
id: params.answerId ?? "msg_mock_reasoned_answer",
|
||||
phase: "final_answer",
|
||||
text: params.answerText,
|
||||
});
|
||||
return [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: params.reasoningId,
|
||||
summary: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: reasoningItem,
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: answerItem.id,
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_text.delta",
|
||||
item_id: answerItem.id,
|
||||
output_index: 1,
|
||||
content_index: 0,
|
||||
delta: params.answerText,
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: answerItem.id,
|
||||
output_index: 1,
|
||||
content_index: 0,
|
||||
text: params.answerText,
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: answerItem,
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${params.reasoningId}`,
|
||||
status: "completed",
|
||||
output: [reasoningItem, answerItem],
|
||||
usage: { input_tokens: 64, output_tokens: 16, total_tokens: 80 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// QA Lab mock provider input and tool-output extraction.
|
||||
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
type ResponsesInputItem,
|
||||
INTERNAL_RUNTIME_CONTEXT_BEGIN,
|
||||
INTERNAL_RUNTIME_CONTEXT_END,
|
||||
QA_WHATSAPP_PENDING_HISTORY_TRIGGER_MARKER_RE,
|
||||
QA_WHATSAPP_PENDING_HISTORY_STRUCTURED_LABEL,
|
||||
QA_WHATSAPP_BROADCAST_PROMPT_RE,
|
||||
QA_WHATSAPP_RUNTIME_AGENT_RE,
|
||||
QA_WHATSAPP_ACTIVATION_ALWAYS_MARKER_RE,
|
||||
QA_WHATSAPP_REPLY_TO_BOT_SEED_MARKER_RE,
|
||||
QA_WHATSAPP_REPLY_TO_BOT_TRIGGER_MARKER_RE,
|
||||
QA_WHATSAPP_BATCHED_FINAL_MARKER_RE,
|
||||
} from "./mock-openai-contracts.js";
|
||||
export function extractLastUserText(input: ResponsesInputItem[]) {
|
||||
for (const item of input.toReversed()) {
|
||||
if (item.role !== "user" || !Array.isArray(item.content)) {
|
||||
continue;
|
||||
}
|
||||
const text = extractInputText(item.content);
|
||||
if (text && !isInternalRuntimeContextCarrierText(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function findLastUserIndex(input: ResponsesInputItem[]) {
|
||||
return input.findLastIndex(
|
||||
(item) =>
|
||||
item.role === "user" &&
|
||||
Array.isArray(item.content) &&
|
||||
!isInternalRuntimeContextCarrierText(extractInputText(item.content)),
|
||||
);
|
||||
}
|
||||
|
||||
function isInternalRuntimeContextCarrierText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
return (
|
||||
trimmed.includes(INTERNAL_RUNTIME_CONTEXT_BEGIN) &&
|
||||
trimmed.endsWith(INTERNAL_RUNTIME_CONTEXT_END)
|
||||
);
|
||||
}
|
||||
|
||||
function isToolOutputContinuationText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/^(?:continue|keep going|resume|retry|carry on)(?:[.!?])?$/i.test(trimmed) ||
|
||||
/\b(?:continue|continuation|compaction|post-compaction|retry|resume)\b/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyFunctionCallOutput(output: unknown): string {
|
||||
if (typeof output === "string") {
|
||||
return output;
|
||||
}
|
||||
if (Array.isArray(output)) {
|
||||
return output
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return entry;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return "";
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
if (typeof record.text === "string") {
|
||||
return record.text;
|
||||
}
|
||||
if (typeof record.output_text === "string") {
|
||||
return record.output_text;
|
||||
}
|
||||
if (typeof record.content === "string") {
|
||||
return record.content;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (output && typeof output === "object") {
|
||||
const record = output as Record<string, unknown>;
|
||||
if (typeof record.text === "string") {
|
||||
return record.text;
|
||||
}
|
||||
if (typeof record.output_text === "string") {
|
||||
return record.output_text;
|
||||
}
|
||||
if (typeof record.content === "string") {
|
||||
return record.content;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(output);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractFunctionCallOutputText(item: ResponsesInputItem) {
|
||||
if (item.type !== "function_call_output") {
|
||||
return "";
|
||||
}
|
||||
return stringifyFunctionCallOutput(item.output);
|
||||
}
|
||||
|
||||
function extractFunctionCallOutputCallId(item: ResponsesInputItem) {
|
||||
if (item.type !== "function_call_output") {
|
||||
return "";
|
||||
}
|
||||
const record = item as {
|
||||
call_id?: unknown;
|
||||
tool_call_id?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
};
|
||||
return (
|
||||
[record.call_id, record.tool_call_id, record.tool_use_id].find(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function functionCallOutputIsStructuredError(item: ResponsesInputItem) {
|
||||
if (item.type !== "function_call_output") {
|
||||
return false;
|
||||
}
|
||||
return item.is_error === true || item.isError === true;
|
||||
}
|
||||
|
||||
export function extractToolOutput(input: ResponsesInputItem[]) {
|
||||
const lastUserIndex = findLastUserIndex(input);
|
||||
for (const item of input.slice(lastUserIndex + 1).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(item);
|
||||
if (output) {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
for (const [candidateIndex, candidateItem] of Array.from(input.entries()).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(candidateItem);
|
||||
if (output) {
|
||||
const laterUserTexts = input
|
||||
.slice(candidateIndex + 1)
|
||||
.filter((laterItem) => laterItem.role === "user" && Array.isArray(laterItem.content))
|
||||
.map((laterItem) => extractInputText(laterItem.content as unknown[]))
|
||||
.filter(Boolean);
|
||||
if (
|
||||
laterUserTexts.length > 0 &&
|
||||
laterUserTexts.every((text) => isToolOutputContinuationText(text))
|
||||
) {
|
||||
return output;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractToolOutputStructuredError(input: ResponsesInputItem[]) {
|
||||
const lastUserIndex = findLastUserIndex(input);
|
||||
for (const item of input.slice(lastUserIndex + 1).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(item);
|
||||
if (output) {
|
||||
return functionCallOutputIsStructuredError(item);
|
||||
}
|
||||
}
|
||||
for (const [candidateIndex, candidateItem] of Array.from(input.entries()).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(candidateItem);
|
||||
if (output) {
|
||||
const laterUserTexts = input
|
||||
.slice(candidateIndex + 1)
|
||||
.filter((laterItem) => laterItem.role === "user" && Array.isArray(laterItem.content))
|
||||
.map((laterItem) => extractInputText(laterItem.content as unknown[]))
|
||||
.filter(Boolean);
|
||||
if (
|
||||
laterUserTexts.length > 0 &&
|
||||
laterUserTexts.every((text) => isToolOutputContinuationText(text))
|
||||
) {
|
||||
return functionCallOutputIsStructuredError(candidateItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractToolOutputCallId(input: ResponsesInputItem[]) {
|
||||
const lastUserIndex = findLastUserIndex(input);
|
||||
for (const item of input.slice(lastUserIndex + 1).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(item);
|
||||
if (output) {
|
||||
return extractFunctionCallOutputCallId(item);
|
||||
}
|
||||
}
|
||||
for (const [candidateIndex, candidateItem] of Array.from(input.entries()).toReversed()) {
|
||||
const output = extractFunctionCallOutputText(candidateItem);
|
||||
if (output) {
|
||||
const laterUserTexts = input
|
||||
.slice(candidateIndex + 1)
|
||||
.filter((laterItem) => laterItem.role === "user" && Array.isArray(laterItem.content))
|
||||
.map((laterItem) => extractInputText(laterItem.content as unknown[]))
|
||||
.filter(Boolean);
|
||||
if (
|
||||
laterUserTexts.length > 0 &&
|
||||
laterUserTexts.every((text) => isToolOutputContinuationText(text))
|
||||
) {
|
||||
return extractFunctionCallOutputCallId(candidateItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractLatestToolOutput(input: ResponsesInputItem[]) {
|
||||
for (const item of input.toReversed()) {
|
||||
const output = extractFunctionCallOutputText(item);
|
||||
if (output) {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractAllToolOutputText(input: ResponsesInputItem[]) {
|
||||
return input
|
||||
.map((item) => extractFunctionCallOutputText(item))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function extractUserTextAfterLatestToolOutput(input: ResponsesInputItem[]) {
|
||||
const latestToolOutputIndex = input.findLastIndex((item) =>
|
||||
Boolean(extractFunctionCallOutputText(item)),
|
||||
);
|
||||
if (latestToolOutputIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
return input
|
||||
.slice(latestToolOutputIndex + 1)
|
||||
.filter((item) => item.role === "user" && Array.isArray(item.content))
|
||||
.map((item) => extractInputText(item.content as unknown[]))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractInputText(content: unknown[]): string {
|
||||
return content
|
||||
.filter(
|
||||
(entry): entry is { type: "input_text"; text: string } =>
|
||||
Boolean(entry) &&
|
||||
typeof entry === "object" &&
|
||||
(entry as { type?: unknown }).type === "input_text" &&
|
||||
typeof (entry as { text?: unknown }).text === "string",
|
||||
)
|
||||
.map((entry) => entry.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractAllUserTexts(input: ResponsesInputItem[]) {
|
||||
const texts: string[] = [];
|
||||
for (const item of input) {
|
||||
if (item.role !== "user" || !Array.isArray(item.content)) {
|
||||
continue;
|
||||
}
|
||||
const text = extractInputText(item.content);
|
||||
if (text) {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
export function extractSystemInputText(input: ResponsesInputItem[]) {
|
||||
const texts: string[] = [];
|
||||
for (const item of input) {
|
||||
if (item.role !== "system") {
|
||||
continue;
|
||||
}
|
||||
if (typeof item.content === "string" && item.content.trim()) {
|
||||
texts.push(item.content.trim());
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(item.content)) {
|
||||
continue;
|
||||
}
|
||||
const text = extractInputText(item.content);
|
||||
if (text) {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
export function extractAllInputTexts(input: ResponsesInputItem[]) {
|
||||
const texts: string[] = [];
|
||||
for (const item of input) {
|
||||
if (typeof item.output === "string" && item.output.trim()) {
|
||||
texts.push(item.output.trim());
|
||||
}
|
||||
if (!Array.isArray(item.content)) {
|
||||
continue;
|
||||
}
|
||||
const text = extractInputText(item.content);
|
||||
if (text) {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
export function extractInstructionsText(body: Record<string, unknown>) {
|
||||
return typeof body.instructions === "string" ? body.instructions.trim() : "";
|
||||
}
|
||||
|
||||
export function extractAllRequestTexts(input: ResponsesInputItem[], body: Record<string, unknown>) {
|
||||
const texts: string[] = [];
|
||||
const instructions = extractInstructionsText(body);
|
||||
if (instructions) {
|
||||
texts.push(instructions);
|
||||
}
|
||||
const inputText = extractAllInputTexts(input);
|
||||
if (inputText) {
|
||||
texts.push(inputText);
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
export function buildWhatsAppPendingHistoryReply(allInputText: string) {
|
||||
const triggerMatch = QA_WHATSAPP_PENDING_HISTORY_TRIGGER_MARKER_RE.exec(allInputText);
|
||||
if (!triggerMatch?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = triggerMatch[1];
|
||||
const beforeTrigger = allInputText.slice(0, triggerMatch.index);
|
||||
const priorGroupContext = extractStructuredWhatsAppPendingHistoryContext(beforeTrigger);
|
||||
const quietMarkerPattern = new RegExp(`\\bWHATSAPP_QA_PENDING_HISTORY_QUIET_${suffix}\\b`, "u");
|
||||
const contextSentinelPattern = new RegExp(
|
||||
`\\bWHATSAPP_QA_PENDING_HISTORY_CONTEXT_ONLY_${suffix}\\b`,
|
||||
"u",
|
||||
);
|
||||
if (
|
||||
!quietMarkerPattern.test(priorGroupContext) ||
|
||||
!contextSentinelPattern.test(priorGroupContext)
|
||||
) {
|
||||
return "WHATSAPP_QA_PENDING_HISTORY_MISSING_CONTEXT";
|
||||
}
|
||||
return `WHATSAPP_QA_PENDING_HISTORY_OK_${suffix}`;
|
||||
}
|
||||
|
||||
function extractStructuredWhatsAppPendingHistoryContext(beforeTrigger: string) {
|
||||
const blockRe = new RegExp(
|
||||
`${escapeRegExp(QA_WHATSAPP_PENDING_HISTORY_STRUCTURED_LABEL)}\\n((?:(?!\\n\\n)[\\s\\S])+)\\n\\n`,
|
||||
"gu",
|
||||
);
|
||||
return Array.from(beforeTrigger.matchAll(blockRe), (match) => match[1]?.trim())
|
||||
.filter((block): block is string => Boolean(block))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildWhatsAppBroadcastReply(allInputText: string) {
|
||||
const promptMatch = QA_WHATSAPP_BROADCAST_PROMPT_RE.exec(allInputText);
|
||||
const token = promptMatch?.[1];
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = QA_WHATSAPP_RUNTIME_AGENT_RE.exec(allInputText)?.[1];
|
||||
if (agentId === "main") {
|
||||
return `${token}_MAIN`;
|
||||
}
|
||||
if (agentId === "qa-second") {
|
||||
return `${token}_SECOND`;
|
||||
}
|
||||
return "WHATSAPP_QA_BROADCAST_AGENT_CONTEXT_MISSING";
|
||||
}
|
||||
|
||||
export function buildWhatsAppGroupDispatchReply(allInputText: string) {
|
||||
const activationMatch = QA_WHATSAPP_ACTIVATION_ALWAYS_MARKER_RE.exec(allInputText);
|
||||
if (activationMatch?.[1]) {
|
||||
return `WHATSAPP_QA_ACTIVATION_ALWAYS_${activationMatch[1]}`;
|
||||
}
|
||||
const triggerMatch = QA_WHATSAPP_REPLY_TO_BOT_TRIGGER_MARKER_RE.exec(allInputText);
|
||||
if (triggerMatch?.[0]) {
|
||||
return triggerMatch[0];
|
||||
}
|
||||
return QA_WHATSAPP_REPLY_TO_BOT_SEED_MARKER_RE.exec(allInputText)?.[0];
|
||||
}
|
||||
|
||||
export function buildWhatsAppBatchedReply(allInputText: string) {
|
||||
const finalMatch = QA_WHATSAPP_BATCHED_FINAL_MARKER_RE.exec(allInputText);
|
||||
const suffix = finalMatch?.[1];
|
||||
if (!suffix) {
|
||||
return undefined;
|
||||
}
|
||||
const firstMarker = `WHATSAPP_QA_BATCHED_FIRST_${suffix}`;
|
||||
if (!allInputText.includes(firstMarker)) {
|
||||
return `WHATSAPP_QA_BATCHED_MISSING_CONTEXT_${suffix}`;
|
||||
}
|
||||
return finalMatch[0];
|
||||
}
|
||||
|
||||
export function countImageInputs(value: unknown): number {
|
||||
const seen = new WeakSet<object>();
|
||||
const stack = [value];
|
||||
let count = 0;
|
||||
let visited = 0;
|
||||
while (stack.length > 0 && visited < 50_000) {
|
||||
visited += 1;
|
||||
const current = stack.pop();
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) {
|
||||
stack.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
const record = current as Record<string, unknown>;
|
||||
const type = typeof record.type === "string" ? record.type : "";
|
||||
if (type === "input_image" || type === "image" || type === "image_url" || type === "media") {
|
||||
count += 1;
|
||||
}
|
||||
stack.push(record.content, record.image_url, record.source);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function extractLatestImageUserTurn(input: ResponsesInputItem[]) {
|
||||
const latestUserIndex = findLastUserIndex(input);
|
||||
if (latestUserIndex < 0) {
|
||||
return { text: "", imageInputCount: 0 };
|
||||
}
|
||||
|
||||
const latestUserItem = input[latestUserIndex];
|
||||
if (!latestUserItem) {
|
||||
return { text: "", imageInputCount: 0 };
|
||||
}
|
||||
|
||||
const imageTurnItems = [latestUserItem];
|
||||
const imageInputCount = countImageInputs(imageTurnItems.map((item) => item.content));
|
||||
if (imageInputCount === 0) {
|
||||
return { text: "", imageInputCount: 0 };
|
||||
}
|
||||
return {
|
||||
text: imageTurnItems
|
||||
.map((item) => extractInputText(item.content as unknown[]))
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
imageInputCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseToolOutputJson(toolOutput: string): Record<string, unknown> | null {
|
||||
if (!toolOutput.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(toolOutput) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// QA Lab mock provider tool planning and memory fixtures.
|
||||
import { createHash } from "node:crypto";
|
||||
import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js";
|
||||
import type { StreamEvent } from "./mock-openai-contracts.js";
|
||||
|
||||
let mockFunctionCallSequence = 0;
|
||||
|
||||
function normalizePromptPathCandidate(candidate: string) {
|
||||
const trimmed = candidate.trim().replace(/^`+|`+$/g, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/^\.\//, "");
|
||||
if (
|
||||
normalized.includes("/") ||
|
||||
/\.(?:md|json|ts|tsx|js|mjs|cjs|txt|yaml|yml)$/i.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readTargetFromPrompt(prompt: string) {
|
||||
const backtickedMatches = Array.from(prompt.matchAll(/`([^`]+)`/g))
|
||||
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (backtickedMatches.length > 0) {
|
||||
return backtickedMatches[0];
|
||||
}
|
||||
|
||||
const quotedMatches = Array.from(prompt.matchAll(/"([^"]+)"/g))
|
||||
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (quotedMatches.length > 0) {
|
||||
return quotedMatches[0];
|
||||
}
|
||||
|
||||
const repoScoped = /\b(?:repo\/[^\s`",)]+|QA_[A-Z_]+\.md)\b/.exec(prompt)?.[0]?.trim();
|
||||
if (repoScoped) {
|
||||
return repoScoped;
|
||||
}
|
||||
|
||||
const loosePath = /\b[A-Za-z0-9._-]+\.(?:md|json|ts|tsx|js|mjs|cjs|txt|yaml|yml)\b/i
|
||||
.exec(prompt)?.[0]
|
||||
?.trim();
|
||||
if (loosePath) {
|
||||
return loosePath;
|
||||
}
|
||||
|
||||
if (/\bdocs?\b/i.test(prompt)) {
|
||||
return "repo/docs/help/testing.md";
|
||||
}
|
||||
if (/\bscenario|kickoff|qa\b/i.test(prompt)) {
|
||||
return "QA_KICKOFF_TASK.md";
|
||||
}
|
||||
return "repo/package.json";
|
||||
}
|
||||
|
||||
export function execCommandFromToolProgressPrompt(prompt: string) {
|
||||
return (
|
||||
/call the exec tool exactly once with this exact command before answering:\s*`([^`]+)`/i
|
||||
.exec(prompt)?.[1]
|
||||
?.trim() || null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMockFunctionCall(name: string, args: Record<string, unknown>) {
|
||||
const serialized = JSON.stringify(args);
|
||||
const callSuffix = createHash("sha256")
|
||||
.update(name)
|
||||
.update("\0")
|
||||
.update(serialized)
|
||||
.digest("hex")
|
||||
.slice(0, 10);
|
||||
const sequence = ++mockFunctionCallSequence;
|
||||
const uniqueSuffix = `${callSuffix}_${sequence}`;
|
||||
const callId = `call_mock_${name}_${uniqueSuffix}`;
|
||||
const itemId = `fc_mock_${name}_${uniqueSuffix}`;
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: serialized,
|
||||
};
|
||||
return {
|
||||
callId,
|
||||
item,
|
||||
itemId,
|
||||
responseId: `resp_mock_${name}_${uniqueSuffix}`,
|
||||
serialized,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolCallEventsWithArgs(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): StreamEvent[] {
|
||||
const call = buildMockFunctionCall(name, args);
|
||||
return [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: call.itemId,
|
||||
call_id: call.callId,
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", delta: call.serialized },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: call.item,
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: call.responseId,
|
||||
status: "completed",
|
||||
output: [call.item],
|
||||
usage: { input_tokens: 64, output_tokens: 16, total_tokens: 80 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function extractRememberedFact(userTexts: string[]) {
|
||||
for (const text of userTexts) {
|
||||
const qaCanaryMatch = /\bqa canary code is\s+([A-Za-z0-9-]+)/i.exec(text);
|
||||
if (qaCanaryMatch?.[1]) {
|
||||
return qaCanaryMatch[1];
|
||||
}
|
||||
}
|
||||
for (const text of userTexts) {
|
||||
const match = /remember(?: this fact for later)?:\s*([A-Za-z0-9-]+)/i.exec(text);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractOrbitCode(text: string) {
|
||||
return /\bORBIT-\d+\b/i.exec(text)?.[0]?.toUpperCase() ?? null;
|
||||
}
|
||||
|
||||
function decodeXmlEntities(text: string) {
|
||||
return text
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function extractActiveMemorySummary(text: string) {
|
||||
const match = /<active_memory_plugin>\s*([\s\S]*?)\s*<\/active_memory_plugin>/i.exec(text);
|
||||
return match?.[1] ? decodeXmlEntities(match[1]).trim() : null;
|
||||
}
|
||||
|
||||
export function extractToolSearchTarget(text: string): string | null {
|
||||
const match = /\btarget=([A-Za-z0-9_.:-]+)\b/.exec(text);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
export function buildQaToolSearchArgs(
|
||||
targetTool: string,
|
||||
failureMode: boolean,
|
||||
): Record<string, unknown> {
|
||||
if (failureMode && targetTool === "web_search") {
|
||||
return { query: QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY };
|
||||
}
|
||||
if (failureMode) {
|
||||
return { __qaFailureMode: "denied-input" };
|
||||
}
|
||||
if (targetTool === "exec") {
|
||||
return { command: "echo runtime-tool-fixture", timeout: 5 };
|
||||
}
|
||||
if (targetTool === "read") {
|
||||
return { path: "QA_KICKOFF_TASK.md" };
|
||||
}
|
||||
if (targetTool === "write") {
|
||||
return { path: "runtime-tool-fixture-write.txt", content: "runtime tool fixture\n" };
|
||||
}
|
||||
if (targetTool === "edit") {
|
||||
return {
|
||||
path: "runtime-tool-fixture-edit.txt",
|
||||
edits: [{ oldText: "before edit\n", newText: "after edit\n" }],
|
||||
};
|
||||
}
|
||||
if (targetTool === "apply_patch") {
|
||||
return {
|
||||
input: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: runtime-tool-fixture-patch.txt",
|
||||
"+runtime patch",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (targetTool === "web_search") {
|
||||
return { query: "OpenClaw runtime parity fixed query", count: 1 };
|
||||
}
|
||||
if (targetTool === "web_fetch") {
|
||||
return { url: "https://example.com/", maxChars: 500 };
|
||||
}
|
||||
if (targetTool === "image_generate") {
|
||||
return { prompt: "QA lighthouse runtime parity fixture", filename: "runtime-tool-fixture" };
|
||||
}
|
||||
if (targetTool === "tts") {
|
||||
return { text: "Runtime parity voice fixture." };
|
||||
}
|
||||
if (targetTool === "message") {
|
||||
return { action: "send", message: "runtime parity message fixture" };
|
||||
}
|
||||
if (targetTool === "session_status") {
|
||||
return { sessionKey: "current" };
|
||||
}
|
||||
if (targetTool === "sessions_spawn") {
|
||||
return {
|
||||
task: "Runtime tool fixture subagent: reply exactly RUNTIME-TOOL-FIXTURE.",
|
||||
label: "runtime-tool-fixture",
|
||||
mode: "run",
|
||||
thread: false,
|
||||
};
|
||||
}
|
||||
if (targetTool === "memory_recall") {
|
||||
return { query: "runtime parity memory fixture" };
|
||||
}
|
||||
return { marker: "normal" };
|
||||
}
|
||||
|
||||
export function isActiveMemorySubagentPrompt(text: string) {
|
||||
return text.includes("You are a memory search agent.");
|
||||
}
|
||||
|
||||
export function extractSnackPreference(text: string) {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const match =
|
||||
/(lemon pepper wings(?:\s+with\s+blue cheese)?|blue cheese(?:\s+with\s+lemon pepper wings)?)/i.exec(
|
||||
normalized,
|
||||
);
|
||||
return match?.[0]?.trim() ?? null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user