Compare commits

..

13 Commits

Author SHA1 Message Date
Dallin Romney
38112cf433 chore(qa): update crabline provider pin 2026-06-16 13:59:46 -07:00
Dallin Romney
ca55a88111 refactor(qa): pass channel driver metadata directly 2026-06-16 13:53:37 -07:00
Dallin Romney
cd47086457 fix(qa): adapt crabline driver to chat sdk cli 2026-06-16 13:53:37 -07:00
Dallin Romney
ad8164a790 Revert "feat(qa): treat unsupported profile channels as coverage gaps"
This reverts commit 65a9701655.
2026-06-16 13:53:37 -07:00
Dallin Romney
0df7a057d6 feat(qa): treat unsupported profile channels as coverage gaps 2026-06-16 13:53:37 -07:00
Dallin Romney
21f002b55a feat(qa): resolve crabline channel from scenarios 2026-06-16 13:53:37 -07:00
Dallin Romney
44c2db1681 fix(qa): declare crabline runtime peer 2026-06-16 13:53:37 -07:00
Dallin Romney
be23bf66d4 feat(qa): drive channel driver from profiles 2026-06-16 13:53:17 -07:00
Dallin Romney
ef058dcbbc chore(qa): pin crabline to merged driver API 2026-06-16 13:53:17 -07:00
Dallin Romney
4afdb60bc6 refactor(qa): keep crabline driver details opaque 2026-06-16 13:53:17 -07:00
Dallin Romney
ce173cb7d6 chore: keep crabline qa dependency dev-only 2026-06-16 13:53:17 -07:00
Dallin Romney
5af338204c feat: run crabline channel driver smoke 2026-06-16 13:53:17 -07:00
Dallin Romney
beb97c163d feat(qa): add crabline channel driver seam 2026-06-16 13:53:17 -07:00
75 changed files with 3465 additions and 2160 deletions

View File

@@ -76,6 +76,14 @@ the root profile before the QA command:
pnpm openclaw --profile work qa run --qa-profile smoke-ci
```
The selected QA profile owns its channel driver. Individual scenarios own any
specific channel requirement through `execution.channel`; if a scenario does not
specify one, the driver default is used. `smoke-ci` uses the internal host-only
Crabline driver for deterministic channel proof; `release` uses the live driver
for release-lane evidence. Direct `qa suite --channel-driver crabline --channel
telegram` runs are maintainer-oriented probes for overriding that same host
driver path.
## Operator flow
The current QA operator flow is a two-pane QA site:

View File

@@ -12,16 +12,35 @@
"zod": "4.4.3"
},
"devDependencies": {
"@beeper/chat-adapter-matrix": "^0.1.0",
"@chat-adapter/discord": "^4.30.0",
"@chat-adapter/gchat": "^4.30.0",
"@chat-adapter/shared": "^4.30.0",
"@chat-adapter/slack": "^4.30.0",
"@chat-adapter/state-memory": "^4.30.0",
"@chat-adapter/teams": "^4.30.0",
"@chat-adapter/telegram": "^4.30.0",
"@chat-adapter/whatsapp": "^4.30.0",
"@larksuite/vercel-chat-adapter": "^0.1.2",
"@openclaw/discord": "workspace:*",
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/slack": "workspace:*",
"@openclaw/whatsapp": "workspace:*",
"chat": "^4.30.0",
"chat-adapter-imessage": "^0.1.1",
"chat-adapter-mattermost": "^1.1.3",
"chat-adapter-zalo": "^0.1.0",
"crabline": "github:openclaw/crabline#01a2a2e41f982920ae434cff5aa1a0647e4d16c4",
"openclaw": "2026.5.28"
},
"peerDependencies": {
"crabline": "*",
"openclaw": ">=2026.6.9"
},
"peerDependenciesMeta": {
"crabline": {
"optional": true
},
"openclaw": {
"optional": true
}

View File

@@ -339,6 +339,8 @@ describe("qa cli runtime", () => {
repoRoot: process.cwd(),
outputDir: path.join(process.cwd(), ".artifacts", "qa-e2e", "scenario-test"),
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
primaryModel: "mock-openai/gpt-5.5",
alternateModel: undefined,
fastMode: undefined,
@@ -441,10 +443,17 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa-e2e/smoke-ci"),
transportId: "qa-channel",
channelDriver: "crabline",
providerMode: "mock-openai",
fastMode: true,
concurrency: 2,
});
expect(suiteArgs.channelDriverSelection).toEqual({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
});
expect(suiteArgs.scenarioIds).toEqual(expect.arrayContaining(["dm-chat-baseline"]));
expect(suiteArgs.scenarioIds).not.toContain("thinking-slash-model-remap");
expect(process.env.OPENCLAW_QA_PROFILE).toBe("release");
@@ -492,6 +501,20 @@ describe("qa cli runtime", () => {
}
});
it("passes non-Crabline profile channel drivers as declarative suite metadata", async () => {
await runQaProfileCommand({
repoRoot: "/tmp/openclaw-repo",
profile: "release",
surface: "agent-runtime-and-provider-execution",
category: "agent-runtime-and-provider-execution.agent-turn-execution",
providerMode: "mock-openai",
});
const suiteArgs = mockFirstObjectArg(runQaSuite);
expect(suiteArgs.channelDriver).toBe("live");
expect(suiteArgs.channelDriverSelection).toBeUndefined();
});
it("rejects qa profile runs that do not match taxonomy categories", async () => {
await expect(
runQaProfileCommand({
@@ -531,6 +554,8 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa/frontier"),
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
providerMode: "live-frontier",
primaryModel: "openai/gpt-5.5",
alternateModel: "anthropic/claude-sonnet-4-6",
@@ -540,6 +565,48 @@ describe("qa cli runtime", () => {
});
});
it("resolves Crabline channel-driver channel from selected scenario execution", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
outputDir: ".artifacts/qa/multipass-telegram",
providerMode: "mock-openai",
channelDriver: "crabline",
scenarioIds: ["channel-chat-baseline"],
});
expect(runQaSuite).toHaveBeenCalledWith({
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa/multipass-telegram"),
transportId: "qa-channel",
channelDriver: "crabline",
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
fastMode: undefined,
scenarioIds: ["channel-chat-baseline"],
});
});
it("keeps Crabline channel-driver independent from the VM runner", async () => {
await expect(
runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
providerMode: "mock-openai",
channelDriver: "crabline",
channel: "telegram",
runner: "multipass",
}),
).rejects.toThrow("--channel-driver crabline requires --runner host.");
expect(runQaSuite).not.toHaveBeenCalled();
expect(runQaMultipass).not.toHaveBeenCalled();
});
it("passes explicit suite plugin enablements into the host gateway run", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
@@ -552,6 +619,8 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: undefined,
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
@@ -573,6 +642,8 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: undefined,
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
@@ -623,6 +694,8 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: undefined,
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
@@ -2018,6 +2091,8 @@ describe("qa cli runtime", () => {
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: undefined,
transportId: "qa-channel",
channelDriver: undefined,
channelDriverSelection: undefined,
providerMode: "mock-openai",
primaryModel: "openai/gpt-5.5",
alternateModel: "anthropic/claude-opus-4-8",

View File

@@ -26,6 +26,10 @@ import {
renderQaCoverageMarkdownReport,
renderQaScenarioMatchesMarkdownReport,
} from "./coverage-report.js";
import {
QA_CRABLINE_DEFAULT_CHANNEL,
resolveQaCrablineChannelDriverSelection,
} from "./crabline-channel-driver.js";
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
import { runQaDockerUp } from "./docker-up.runtime.js";
import { QaSuiteArtifactError, QaSuiteInfraError } from "./errors.js";
@@ -70,13 +74,15 @@ import {
import { resolveQaScenarioPackScenarioIds } from "./scenario-packs.js";
import { attachQaProfileScorecardEvidenceToFile } from "./scorecard-evidence.js";
import {
qaScorecardChannelDriverSchema,
readQaScorecardTaxonomyReport,
type QaScorecardCategoryCoverageReport,
type QaScorecardChannelDriver,
type QaScorecardEvidenceMode,
} from "./scorecard-taxonomy.js";
import { isQaSelfCheckSuccessful } from "./self-check.js";
import { runQaFlowSuiteFromRuntime, runQaSuite } from "./suite-launch.runtime.js";
import { scenarioMatchesQaProviderLane } from "./suite-planning.js";
import { resolveQaSuiteScenarioChannel, scenarioMatchesQaProviderLane } from "./suite-planning.js";
import { readQaSuiteFailedOrSkippedScenarioCountFromFile } from "./suite-summary.js";
import {
buildTokenEfficiencyReport,
@@ -131,6 +137,8 @@ export type QaProfileCommandOptions = QaScenarioRunCommandOptions & {
};
export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
channelDriver?: string;
channel?: string;
runner?: string;
thinking?: string;
cliAuthMode?: string;
@@ -147,6 +155,20 @@ export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
runtimeParityTier?: string[];
};
function normalizeQaSuiteChannelDriver(
input?: string | null,
): QaScorecardChannelDriver | undefined {
const normalized = input?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const parsed = qaScorecardChannelDriverSchema.safeParse(normalized);
if (parsed.success) {
return parsed.data;
}
throw new Error(`--channel-driver must be one of qa-channel, crabline, or live, got "${input}".`);
}
function resolveQaManualLaneModels(opts: {
providerMode: QaProviderMode;
primaryModel?: string;
@@ -634,6 +656,10 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
opts.profile,
scorecardReport.profiles.map((entry) => entry.id),
);
const profileReport = scorecardReport.profiles.find((entry) => entry.id === profile);
if (!profileReport) {
throw new Error(`taxonomy.yaml does not define QA run profile ${profile}.`);
}
const categories = scorecardReport.categories.filter((category) =>
qaScorecardCategoryMatchesRunProfile(category, {
profile,
@@ -684,6 +710,7 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
scenarioIds: scenarios.map((scenario) => scenario.id),
concurrency: opts.concurrency,
allowFailures: opts.allowFailures,
channelDriver: profileReport.channelDriver,
});
evidencePath =
suiteResult && "evidencePath" in suiteResult ? suiteResult.evidencePath : undefined;
@@ -704,6 +731,30 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
process.stdout.write(`QA profile scorecard: ${evidencePath}\n`);
}
function selectQaScenarioDefinitionsForChannelResolution(params: {
scenarioIds: string[];
providerMode: QaProviderMode;
primaryModel: string;
claudeCliAuthMode?: QaCliBackendAuthMode;
}) {
const scenarios = readQaScenarioPack().scenarios;
if (params.scenarioIds.length > 0) {
const scenarioById = new Map(scenarios.map((scenario) => [scenario.id, scenario]));
return params.scenarioIds.flatMap((scenarioId) => {
const scenario = scenarioById.get(scenarioId);
return scenario ? [scenario] : [];
});
}
return scenarios.filter((scenario) =>
scenarioMatchesQaProviderLane({
scenario,
providerMode: params.providerMode,
primaryModel: params.primaryModel,
claudeCliAuthMode: params.claudeCliAuthMode,
}),
);
}
function normalizeQaRunProfile(value: string, profileIds: readonly string[]) {
if (profileIds.length === 0) {
throw new Error("taxonomy.yaml does not define QA run profiles.");
@@ -785,12 +836,39 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
const claudeCliAuthMode = parseQaCliBackendAuthMode(opts.cliAuthMode);
const primaryModel = normalizeQaOptionalModelRef(opts.primaryModel);
const alternateModel = normalizeQaOptionalModelRef(opts.alternateModel);
const channelDriver = normalizeQaSuiteChannelDriver(opts.channelDriver);
if (opts.channel?.trim() && channelDriver !== "crabline") {
throw new Error("--channel requires --channel-driver crabline.");
}
const selectedScenarioChannel =
channelDriver === "crabline"
? resolveQaSuiteScenarioChannel({
defaultChannel: QA_CRABLINE_DEFAULT_CHANNEL,
explicitChannel: opts.channel,
scenarios: selectQaScenarioDefinitionsForChannelResolution({
scenarioIds,
providerMode,
primaryModel: primaryModel ?? defaultQaModelForMode(providerMode),
claudeCliAuthMode,
}),
})
: opts.channel;
const channelDriverSelection =
channelDriver === "crabline"
? await resolveQaCrablineChannelDriverSelection({
channel: selectedScenarioChannel,
channelDriver,
})
: undefined;
if (runner !== "host" && runner !== "multipass") {
throw new Error(`--runner must be one of host or multipass, got "${opts.runner}".`);
}
if (opts.preflight === true && runner !== "host") {
throw new Error("--preflight requires --runner host.");
}
if (channelDriverSelection && runner !== "host") {
throw new Error("--channel-driver crabline requires --runner host.");
}
if (
runner === "host" &&
(opts.image !== undefined ||
@@ -859,6 +937,8 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
outputDir: resolveRepoRelativeOutputDir(repoRoot, opts.outputDir),
evidenceMode: opts.evidenceMode,
transportId,
channelDriver,
channelDriverSelection,
...(opts.providerMode !== undefined ? { providerMode } : {}),
primaryModel,
alternateModel,

View File

@@ -62,6 +62,8 @@ const QA_RUN_PROFILE_ONLY_OPTIONS = [
const QA_RUN_SELF_CHECK_ONLY_OPTIONS = [{ optionName: "output", flag: "--output" }] as const;
type QaSuiteCliOptions = QaScenarioRunCliOptions & {
channelDriver?: QaSuiteCommandOptions["channelDriver"];
channel?: QaSuiteCommandOptions["channel"];
runner?: QaSuiteCommandOptions["runner"];
thinking?: QaSuiteCommandOptions["thinking"];
cliAuthMode?: QaSuiteCommandOptions["cliAuthMode"];
@@ -453,6 +455,11 @@ export function registerQaLabCli(program: Command) {
.option("--output-dir <path>", "Suite artifact directory")
.option("--runner <kind>", "Execution runner: host or multipass", "host")
.option("--transport <id>", "QA transport id", "qa-channel")
.option("--channel-driver <id>", "Internal host QA channel SDK driver id; currently crabline")
.option(
"--channel <id>",
"Internal host QA channel override for --channel-driver; defaults to scenario/default",
)
.option("--provider-mode <mode>", formatQaProviderModeHelp())
.option("--model <ref>", "Primary provider/model ref")
.option("--alt-model <ref>", "Alternate provider/model ref")
@@ -504,6 +511,8 @@ export function registerQaLabCli(program: Command) {
repoRoot: opts.repoRoot,
outputDir: opts.outputDir,
transportId: opts.transport,
channelDriver: opts.channelDriver,
channel: opts.channel,
runner: opts.runner,
providerMode: opts.providerMode,
primaryModel: opts.model,

View File

@@ -33,12 +33,14 @@ function testMaturityTaxonomy(params?: {
id: "smoke-ci",
description: "Test smoke profile.",
includeAllCategories: false,
channelDriver: "qa-channel" as const,
categoryIds: [],
},
{
id: "release",
description: "Test release profile.",
includeAllCategories: params?.includeAllCategories ?? false,
channelDriver: "qa-channel" as const,
categoryIds: [
...(params?.includeAllCategories ? [] : (params?.profileCategoryIds ?? [categoryId])),
],
@@ -119,6 +121,17 @@ describe("qa coverage report", () => {
"whatsapp",
]);
expect(inventory.scorecardTaxonomy.profileCount).toBe(2);
expect(
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "smoke-ci"),
).toMatchObject({
channelDriver: "crabline",
evidenceMode: "slim",
});
expect(
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "release"),
).toMatchObject({
channelDriver: "live",
});
expect(inventory.scorecardTaxonomy.categoryCount).toBeGreaterThan(200);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeLessThanOrEqual(

View File

@@ -0,0 +1,123 @@
// Qa Lab tests cover Crabline channel-driver metadata behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
runQaCrablineChannelDriverSmoke,
resolveQaCrablineChannelDriverSelection,
} from "./crabline-channel-driver.js";
describe("crabline channel driver metadata", () => {
it("returns null when no channel driver is selected", async () => {
await expect(resolveQaCrablineChannelDriverSelection({})).resolves.toBeNull();
});
it("resolves the Telegram SDK-backed channel driver", async () => {
const selection = await resolveQaCrablineChannelDriverSelection({
channel: "telegram",
channelDriver: "crabline",
});
expect(selection).toEqual({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
});
});
it("accepts channels reported ready by Crabline", async () => {
await expect(
resolveQaCrablineChannelDriverSelection({
channel: "slack",
channelDriver: "crabline",
}),
).resolves.toMatchObject({
channel: "slack",
channelDriver: "crabline",
});
});
it("runs Crabline's Chat SDK provider doctor through the package CLI", async () => {
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-driver-"));
try {
const result = await runQaCrablineChannelDriverSmoke(
{
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
{
env: {
...process.env,
TELEGRAM_BOT_TOKEN: "telegram-token",
},
outputDir,
},
);
expect(result.capabilityReport).toMatchObject({
result: {
configured: [expect.objectContaining({ adapter: "telegram", platform: "telegram" })],
},
});
expect(result.smoke).toMatchObject({
result: {
findings: [],
ok: true,
},
});
} finally {
await fs.rm(outputDir, { recursive: true, force: true });
}
});
it("fails Crabline's Chat SDK provider doctor when required env is unavailable", async () => {
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-driver-"));
try {
await expect(
runQaCrablineChannelDriverSmoke(
{
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
{
env: {
...process.env,
TELEGRAM_BOT_TOKEN: "",
},
outputDir,
},
),
).rejects.toThrow("provider telegram missing telegram.botToken or TELEGRAM_BOT_TOKEN");
} finally {
await fs.rm(outputDir, { recursive: true, force: true });
}
});
it("defaults to Telegram and rejects channels not reported ready by Crabline", async () => {
await expect(
resolveQaCrablineChannelDriverSelection({ channelDriver: "crabline" }),
).resolves.toEqual({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
});
await expect(
resolveQaCrablineChannelDriverSelection({
channel: "signal",
channelDriver: "crabline",
}),
).rejects.toThrow("--channel must be one of");
});
it("rejects channel identity without a channel driver", async () => {
await expect(resolveQaCrablineChannelDriverSelection({ channel: "telegram" })).rejects.toThrow(
"--channel requires --channel-driver crabline",
);
});
});

View File

@@ -0,0 +1,251 @@
// Qa Lab plugin module models SDK-backed Crabline channel-driver metadata.
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export type QaChannelDriverId = "crabline";
export type QaCrablineChannelId = string;
export type QaCrablineChannelDriverSelection = {
channel: QaCrablineChannelId;
channelDriver: QaChannelDriverId;
capabilityMatrixPath: typeof QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH;
smokeArtifactPath: typeof QA_CRABLINE_CHANNEL_SMOKE_PATH;
};
export const QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH = "crabline-channel-capability-matrix.json";
export const QA_CRABLINE_CHANNEL_SMOKE_PATH = "crabline-channel-smoke.json";
export const QA_CRABLINE_MANIFEST_PATH = "crabline-smoke.json";
export const QA_CRABLINE_DEFAULT_CHANNEL = "telegram";
let supportedCrablineChannelsPromise: Promise<QaCrablineChannelId[]> | undefined;
export function normalizeQaChannelDriverId(input?: string | null): QaChannelDriverId | null {
const normalized = input?.trim().toLowerCase();
if (!normalized) {
return null;
}
if (normalized === "crabline") {
return "crabline";
}
throw new Error(`--channel-driver must be crabline, got "${input}".`);
}
export async function normalizeQaCrablineChannel(
input?: string | null,
): Promise<QaCrablineChannelId> {
const normalized = input?.trim().toLowerCase() || QA_CRABLINE_DEFAULT_CHANNEL;
const supportedChannels = await listSupportedCrablineChannels();
if (supportedChannels.includes(normalized)) {
return normalized;
}
throw new Error(
`--channel must be one of ${supportedChannels.join(", ")} for --channel-driver crabline, got "${input}".`,
);
}
export async function resolveQaCrablineChannelDriverSelection(params: {
channel?: string | null;
channelDriver?: string | null;
}): Promise<QaCrablineChannelDriverSelection | null> {
const channelDriver = normalizeQaChannelDriverId(params.channelDriver);
if (!channelDriver) {
if (params.channel?.trim()) {
throw new Error("--channel requires --channel-driver crabline.");
}
return null;
}
const channel = await normalizeQaCrablineChannel(params.channel);
return {
channel,
channelDriver,
capabilityMatrixPath: QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH,
smokeArtifactPath: QA_CRABLINE_CHANNEL_SMOKE_PATH,
};
}
type CrablineCommandResult = {
command: string[];
stderr: string;
stdout: string;
};
export type QaCrablineChannelDriverSmokeResult = {
capabilityReport: unknown;
manifestPath: string;
smoke: unknown;
};
function resolveCrablineBinPath() {
const indexPath = fileURLToPath(import.meta.resolve("crabline"));
return path.join(path.dirname(indexPath), "bin", "crabline.js");
}
function createCrablineCatalogManifest() {
return {
configVersion: 1,
fixtures: [],
providers: {},
userName: "openclaw-qa",
};
}
function createCrablineManifest(selection: QaCrablineChannelDriverSelection) {
return {
configVersion: 1,
fixtures: [],
providers: {
[selection.channel]: {
adapter: selection.channel,
},
},
userName: "openclaw-qa",
};
}
async function runCrablineJsonCommand(params: {
args: readonly string[];
cwd: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ json: unknown; result: CrablineCommandResult }> {
const command = [resolveCrablineBinPath(), "--json", ...params.args];
const displayCommand = ["node", "crabline", "--json", ...params.args];
try {
const result = await execFileAsync(process.execPath, command, {
cwd: params.cwd,
encoding: "utf8",
env: params.env ?? process.env,
maxBuffer: 1024 * 1024,
});
const stdout = result.stdout.toString();
return {
json: JSON.parse(stdout),
result: {
command: displayCommand,
stderr: result.stderr.toString(),
stdout,
},
};
} catch (error) {
const childError = error as Error & {
code?: number | string;
stderr?: string | Buffer;
stdout?: string | Buffer;
};
const stdout = childError.stdout?.toString() ?? "";
const stderr = childError.stderr?.toString() ?? "";
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
throw new Error(
`Crabline command failed (${displayCommand.join(" ")}): ${details || childError.message}`,
{ cause: error },
);
}
}
function readCrablineSupportedChannels(payload: unknown): QaCrablineChannelId[] {
const support = (payload as { support?: unknown }).support;
if (!Array.isArray(support)) {
throw new Error("Crabline providers output did not include a support catalog.");
}
const channels = support
.flatMap((entry) => {
if (!entry || typeof entry !== "object") {
return [];
}
const candidate = entry as { platform?: unknown; status?: unknown };
return candidate.status === "ready" &&
typeof candidate.platform === "string" &&
candidate.platform !== "loopback"
? [candidate.platform]
: [];
})
.toSorted((left, right) => left.localeCompare(right));
return [...new Set(channels)];
}
async function readSupportedCrablineChannels(): Promise<QaCrablineChannelId[]> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-catalog-"));
try {
const manifestPath = path.join(tempDir, "crabline-catalog.json");
await fs.writeFile(
manifestPath,
`${JSON.stringify(createCrablineCatalogManifest(), null, 2)}\n`,
"utf8",
);
const providers = await runCrablineJsonCommand({
args: ["--config", manifestPath, "providers"],
cwd: tempDir,
});
const supportedChannels = readCrablineSupportedChannels(providers.json);
if (supportedChannels.length === 0) {
throw new Error("Crabline did not report any ready channel providers.");
}
return supportedChannels;
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function listSupportedCrablineChannels(): Promise<QaCrablineChannelId[]> {
supportedCrablineChannelsPromise ??= readSupportedCrablineChannels();
return await supportedCrablineChannelsPromise;
}
export async function runQaCrablineChannelDriverSmoke(
selection: QaCrablineChannelDriverSelection,
params: {
env?: NodeJS.ProcessEnv;
outputDir: string;
},
): Promise<QaCrablineChannelDriverSmokeResult> {
const manifestPath = path.join(params.outputDir, QA_CRABLINE_MANIFEST_PATH);
await fs.writeFile(
manifestPath,
`${JSON.stringify(createCrablineManifest(selection), null, 2)}\n`,
"utf8",
);
const providers = await runCrablineJsonCommand({
args: ["--config", manifestPath, "providers"],
cwd: params.outputDir,
env: params.env,
});
const doctor = await runCrablineJsonCommand({
args: ["--config", manifestPath, "doctor"],
cwd: params.outputDir,
env: params.env,
});
return {
capabilityReport: {
command: providers.result.command,
manifestPath: path.basename(manifestPath),
result: providers.json,
},
manifestPath: path.basename(manifestPath),
smoke: {
command: doctor.result.command,
manifestPath: path.basename(manifestPath),
result: doctor.json,
},
};
}
export function createQaCrablineChannelReportNotes(
selection: QaCrablineChannelDriverSelection | null | undefined,
): string[] {
if (!selection) {
return [];
}
return [
`Channel driver: ${selection.channelDriver} for ${selection.channel}.`,
`Channel capability matrix: ${selection.capabilityMatrixPath}.`,
`Channel driver smoke: ${selection.smokeArtifactPath}.`,
"This is the openclaw/crabline Chat SDK messaging-provider path; it is independent of the Canonical Multipass VM runner.",
];
}

View File

@@ -51,18 +51,8 @@ describe("qa scenario catalog", () => {
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.map((scenario) => scenario.id)
.toSorted(),
).toStrictEqual(
[
"channel-message-flows",
"control-ui-chat-flow-playwright",
"gateway-smoke",
"package-openclaw-for-docker",
"plugin-lifecycle-probe",
"qa-otel-smoke",
].toSorted(),
);
.map((scenario) => scenario.id),
).toStrictEqual(["control-ui-chat-flow-playwright"]);
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")

View File

@@ -58,14 +58,23 @@ const qaScenarioRepoRefSchema = z
message: "repo refs must not be absolute or contain parent-directory segments",
});
const qaScenarioChannelSchema = z
.string()
.trim()
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
message: "scenario execution channel ids must use lowercase dotted or dashed tokens",
});
const qaFlowScenarioExecutionSchema = z.object({
kind: z.literal("flow").default("flow"),
summary: z.string().trim().min(1).optional(),
channel: qaScenarioChannelSchema.optional(),
config: qaScenarioConfigSchema.optional(),
});
const qaTestFileScenarioExecutionBaseSchema = z.object({
summary: z.string().trim().min(1).optional(),
channel: qaScenarioChannelSchema.optional(),
path: qaScenarioRepoRefSchema,
config: qaScenarioConfigSchema.optional(),
});

View File

@@ -20,12 +20,14 @@ function isRepoRootRelativeRef(value: string) {
const qaCoverageEvidenceRoleSchema = z.enum(["primary", "secondary"]);
export const qaScorecardEvidenceModeSchema = z.enum(["full", "slim"]);
export const qaScorecardChannelDriverSchema = z.enum(["qa-channel", "crabline", "live"]);
const qaScorecardProfileSchema = z.object({
id: qaScorecardIdSchema,
description: z.string().trim().min(1),
evidenceMode: qaScorecardEvidenceModeSchema.optional(),
includeAllCategories: z.boolean().default(false),
channelDriver: qaScorecardChannelDriverSchema.default("qa-channel"),
categoryIds: z.array(qaScorecardIdSchema).default([]),
});
@@ -75,6 +77,20 @@ const qaMaturityTaxonomySchema = z
message: `profile ${profile.id} cannot set categoryIds when includeAllCategories is true`,
});
}
if (profile.channelDriver === "crabline" && profile.includeAllCategories) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "includeAllCategories"],
message: `profile ${profile.id} cannot set includeAllCategories when channelDriver is crabline`,
});
}
if (profile.channelDriver === "crabline" && !profile.categoryIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "categoryIds"],
message: `profile ${profile.id} requires categoryIds when channelDriver is crabline`,
});
}
const seenProfileCategoryIds = new Set<string>();
for (const [categoryIndex, categoryId] of profile.categoryIds.entries()) {
@@ -93,6 +109,7 @@ const qaMaturityTaxonomySchema = z
export type QaNativeCoverageEvidenceKind = "vitest" | "playwright";
export type QaScorecardEvidenceKind = QaNativeCoverageEvidenceKind | "qa-scenario";
export type QaScorecardEvidenceMode = z.infer<typeof qaScorecardEvidenceModeSchema>;
export type QaScorecardChannelDriver = z.infer<typeof qaScorecardChannelDriverSchema>;
type QaCoverageEvidenceRole = z.infer<typeof qaCoverageEvidenceRoleSchema>;
type QaMaturityTaxonomy = z.infer<typeof qaMaturityTaxonomySchema>;
@@ -138,6 +155,7 @@ export type QaScorecardCategoryCoverageReport = {
export type QaScorecardProfileReport = {
id: string;
evidenceMode: QaScorecardEvidenceMode;
channelDriver: QaScorecardChannelDriver;
categoryIds: string[];
};
@@ -348,12 +366,14 @@ export function readQaScorecardFeatureCoverageByCategory(repoRoot?: string) {
export function readQaScorecardProfileOptions(profileId: string | undefined, repoRoot?: string) {
const profile = profileId?.trim();
if (!profile) {
return { evidenceMode: "full" as const };
return { evidenceMode: "full" as const, channelDriver: "qa-channel" as const };
}
const profileOptions = readQaMaturityTaxonomy(repoRoot)?.profiles.find(
(entry) => entry.id === profile,
);
return {
evidenceMode:
readQaMaturityTaxonomy(repoRoot)?.profiles.find((entry) => entry.id === profile)
?.evidenceMode ?? "full",
evidenceMode: profileOptions?.evidenceMode ?? "full",
channelDriver: profileOptions?.channelDriver ?? "qa-channel",
};
}
@@ -496,6 +516,7 @@ export function buildQaScorecardTaxonomyReport(params: {
return {
id: profile.id,
evidenceMode: profile.evidenceMode ?? "full",
channelDriver: profile.channelDriver,
categoryIds: validCategoryIds,
};
}) ?? [];

View File

@@ -10,6 +10,7 @@ import {
collectQaSuitePluginIds,
mapQaSuiteWithConcurrency,
normalizeQaSuiteConcurrency,
resolveQaSuiteScenarioChannel,
resolveQaSuiteWorkerStartStaggerMs,
resolveQaSuiteOutputDir,
scenarioRequiresControlUi,
@@ -241,6 +242,47 @@ describe("qa suite planning helpers", () => {
).toEqual(["third", "first"]);
});
it("resolves driver channels from scenario execution with explicit and default fallbacks", () => {
expect(
resolveQaSuiteScenarioChannel({
defaultChannel: "telegram",
scenarios: [makeQaSuiteTestScenario("plain")],
}),
).toBe("telegram");
expect(
resolveQaSuiteScenarioChannel({
defaultChannel: "telegram",
scenarios: [
makeQaSuiteTestScenario("plain"),
makeQaSuiteTestScenario("slack-flow", { channel: "slack" }),
],
}),
).toBe("slack");
expect(
resolveQaSuiteScenarioChannel({
defaultChannel: "telegram",
explicitChannel: "slack",
scenarios: [makeQaSuiteTestScenario("slack-flow", { channel: "slack" })],
}),
).toBe("slack");
expect(() =>
resolveQaSuiteScenarioChannel({
defaultChannel: "telegram",
explicitChannel: "telegram",
scenarios: [makeQaSuiteTestScenario("slack-flow", { channel: "slack" })],
}),
).toThrow("--channel telegram conflicts with selected scenario execution.channel slack.");
expect(() =>
resolveQaSuiteScenarioChannel({
defaultChannel: "telegram",
scenarios: [
makeQaSuiteTestScenario("slack-flow", { channel: "slack" }),
makeQaSuiteTestScenario("telegram-flow", { channel: "telegram" }),
],
}),
).toThrow("Selected QA scenarios require multiple channels");
});
it("collects unique scenario-declared bundled plugins in encounter order", () => {
const scenarios = [
makeQaSuiteTestScenario("generic", { plugins: ["active-memory", "memory-wiki"] }),

View File

@@ -108,6 +108,45 @@ function selectQaFlowSuiteScenarios(params: {
);
}
function listQaSuiteScenarioChannels(
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
) {
return [
...new Set(
scenarios
.map((scenario) => scenario.execution.channel?.trim().toLowerCase())
.filter((channel): channel is string => Boolean(channel)),
),
];
}
function resolveQaSuiteScenarioChannel(params: {
defaultChannel: string;
explicitChannel?: string | null;
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
}) {
const scenarioChannels = listQaSuiteScenarioChannels(params.scenarios);
const explicitChannel = params.explicitChannel?.trim().toLowerCase();
if (explicitChannel) {
const conflictingChannels = scenarioChannels.filter((channel) => channel !== explicitChannel);
if (conflictingChannels.length > 0) {
throw new Error(
`--channel ${explicitChannel} conflicts with selected scenario execution.channel ${conflictingChannels.join(", ")}.`,
);
}
return explicitChannel;
}
if (scenarioChannels.length === 0) {
return params.defaultChannel;
}
if (scenarioChannels.length === 1) {
return scenarioChannels[0];
}
throw new Error(
`Selected QA scenarios require multiple channels (${scenarioChannels.join(", ")}); split the run by channel.`,
);
}
function collectQaSuitePluginIds(
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
) {
@@ -279,6 +318,7 @@ export {
collectQaSuitePluginIds,
mapQaSuiteWithConcurrency,
normalizeQaSuiteConcurrency,
resolveQaSuiteScenarioChannel,
resolveQaSuiteWorkerStartStaggerMs,
resolveQaSuiteOutputDir,
scenarioRequiresControlUi,

View File

@@ -5,6 +5,7 @@ import { QaSuiteArtifactError } from "./errors.js";
import type { QaEvidenceSummaryJson } from "./evidence-summary.js";
import type { QaProviderMode } from "./model-selection.js";
import type { RuntimeId, RuntimeParityResult } from "./runtime-parity.js";
import type { QaScorecardChannelDriver } from "./scorecard-taxonomy.js";
type QaSuiteSummaryScenario = {
name: string;
@@ -55,6 +56,10 @@ export type QaSuiteSummaryJson = {
alternateModelName: string | null;
fastMode: boolean;
concurrency: number;
channelDriver: QaScorecardChannelDriver | null;
channel: string | null;
channelCapabilityMatrixPath: string | null;
channelDriverSmokePath: string | null;
scenarioIds: string[] | null;
runtimePair?: [RuntimeId, RuntimeId] | null;
};

View File

@@ -6,6 +6,7 @@ type QaSuiteTestScenario = ReturnType<typeof readQaBootstrapScenarioCatalog>["sc
export function makeQaSuiteTestScenario(
id: string,
params: {
channel?: string;
config?: Record<string, unknown>;
plugins?: string[];
gatewayConfigPatch?: Record<string, unknown>;
@@ -27,6 +28,7 @@ export function makeQaSuiteTestScenario(
sourcePath: `qa/scenarios/${id}.yaml`,
execution: {
kind: "flow",
...(params.channel ? { channel: params.channel } : {}),
...(params.config ? { config: params.config } : {}),
flow: { steps: [{ name: "noop", actions: [{ assert: "true" }] }] },
},

View File

@@ -34,9 +34,42 @@ describe("buildQaSuiteSummaryJson", () => {
expect(json.run.alternateModelName).toBe("gpt-5.5-alt");
expect(json.run.fastMode).toBe(true);
expect(json.run.concurrency).toBe(2);
expect(json.run.channelDriver).toBeNull();
expect(json.run.channel).toBeNull();
expect(json.run.channelCapabilityMatrixPath).toBeNull();
expect(json.run.channelDriverSmokePath).toBeNull();
expect(json.run.scenarioIds).toBeNull();
});
it("records Crabline channel-driver metadata when selected", () => {
const json = buildQaSuiteSummaryJson({
...baseParams,
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
});
expect(json.run.channelDriver).toBe("crabline");
expect(json.run.channel).toBe("telegram");
expect(json.run.channelCapabilityMatrixPath).toBe("crabline-channel-capability-matrix.json");
expect(json.run.channelDriverSmokePath).toBe("crabline-channel-smoke.json");
});
it("records declarative non-Crabline channel-driver metadata", () => {
const json = buildQaSuiteSummaryJson({
...baseParams,
channelDriver: "live",
});
expect(json.run.channelDriver).toBe("live");
expect(json.run.channel).toBeNull();
expect(json.run.channelCapabilityMatrixPath).toBeNull();
expect(json.run.channelDriverSmokePath).toBeNull();
});
it("includes scenarioIds in run metadata when provided", () => {
const scenarioIds = ["approval-turn-tool-followthrough", "subagent-handoff", "memory-recall"];
const json = buildQaSuiteSummaryJson({

View File

@@ -272,6 +272,72 @@ describe("qa suite", () => {
}
});
it("writes Crabline channel-driver smoke artifacts when selected", async () => {
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-suite-crabline-"));
const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "telegram-token";
try {
const artifacts = await qaSuiteProgressTesting.writeQaSuiteArtifacts({
outputDir,
startedAt: new Date("2026-04-11T00:00:00.000Z"),
finishedAt: new Date("2026-04-11T00:01:00.000Z"),
scenarios: [{ name: "Telegram DM", status: "pass", steps: [] }],
scenarioDefinitions: [
{
...makeQaSuiteTestScenario("telegram-dm", {
surface: "channel",
}),
coverage: {
primary: ["channels.dm"],
},
},
],
transport: {
id: "qa-channel",
createReportNotes: () => [],
} as unknown as QaTransportAdapter,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
fastMode: true,
concurrency: 1,
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
});
const matrix = JSON.parse(
await fs.readFile(path.join(outputDir, "crabline-channel-capability-matrix.json"), "utf8"),
) as {
report?: { result?: { configured?: Array<{ adapter?: string; platform?: string }> } };
};
expect(matrix.report?.result?.configured).toEqual([
expect.objectContaining({ adapter: "telegram", platform: "telegram" }),
]);
const smoke = JSON.parse(
await fs.readFile(path.join(outputDir, "crabline-channel-smoke.json"), "utf8"),
) as { smoke?: { result?: { findings?: string[]; ok?: boolean } } };
expect(smoke.smoke?.result).toMatchObject({ findings: [], ok: true });
const evidence = JSON.parse(await fs.readFile(artifacts.evidencePath, "utf8")) as {
entries?: Array<{ execution?: { channel?: { driver?: string; id?: string } } }>;
};
expect(evidence.entries?.[0]?.execution?.channel).toMatchObject({
driver: "crabline",
id: "telegram",
});
} finally {
if (originalTelegramBotToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramBotToken;
}
await fs.rm(outputDir, { recursive: true, force: true });
}
});
it("arms gateway heap checkpoint env only when requested", () => {
expect(
qaSuiteProgressTesting.buildQaGatewayHeapCheckpointRuntimeEnvPatch({

View File

@@ -12,6 +12,11 @@ import {
type QaReportScenario,
} from "openclaw/plugin-sdk/qa-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createQaCrablineChannelReportNotes,
runQaCrablineChannelDriverSmoke,
type QaCrablineChannelDriverSelection,
} from "./crabline-channel-driver.js";
import { QaSuiteArtifactError } from "./errors.js";
import { buildQaSuiteEvidenceSummary, QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js";
@@ -51,7 +56,7 @@ import {
type QaSeedScenarioWithSource,
} from "./scenario-catalog.js";
import { runScenarioFlow } from "./scenario-flow-runner.js";
import type { QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import type { QaScorecardChannelDriver, QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import {
applyQaMergePatch,
collectQaSuiteGatewayConfigPatch,
@@ -107,6 +112,8 @@ export type QaSuiteRunParams = {
outputDir?: string;
providerMode?: QaProviderMode;
transportId?: QaTransportId;
channelDriver?: QaScorecardChannelDriver;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
@@ -418,6 +425,7 @@ function buildRuntimeParityScenarioResult(params: {
function createQaSuiteReportNotes(params: {
transport: QaTransportAdapter;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
@@ -425,7 +433,10 @@ function createQaSuiteReportNotes(params: {
concurrency: number;
isolatedWorkers?: boolean;
}) {
return params.transport.createReportNotes(params);
return [
...params.transport.createReportNotes(params),
...createQaCrablineChannelReportNotes(params.channelDriverSelection),
];
}
function buildQaIsolatedScenarioWorkerParams(params: {
@@ -433,6 +444,8 @@ function buildQaIsolatedScenarioWorkerParams(params: {
outputDir: string;
providerMode: QaProviderMode;
transportId: QaTransportId;
channelDriver?: QaScorecardChannelDriver;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
@@ -445,6 +458,8 @@ function buildQaIsolatedScenarioWorkerParams(params: {
outputDir: params.outputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriver: params.channelDriver,
channelDriverSelection: params.channelDriverSelection,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
@@ -550,6 +565,8 @@ export type QaSuiteSummaryJsonParams = {
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
};
@@ -609,6 +626,10 @@ export function buildQaSuiteSummaryJson(params: QaSuiteSummaryJsonParams): QaSui
alternateModelName: alternateSplit?.model ?? null,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriver: params.channelDriver ?? params.channelDriverSelection?.channelDriver ?? null,
channel: params.channelDriverSelection?.channel ?? null,
channelCapabilityMatrixPath: params.channelDriverSelection?.capabilityMatrixPath ?? null,
channelDriverSmokePath: params.channelDriverSelection?.smokeArtifactPath ?? null,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0 ? [...params.scenarioIds] : null,
runtimePair: params.runtimePair ?? null,
@@ -629,6 +650,8 @@ async function runQaRuntimeParitySuite(params: {
thinkingDefault?: QaThinkingLevel;
claudeCliAuthMode?: QaCliBackendAuthMode;
enabledPluginIds?: string[];
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
concurrency: number;
selectedScenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
startLab?: QaSuiteStartLabFn;
@@ -701,6 +724,8 @@ async function runQaRuntimeParitySuite(params: {
outputDir: cellOutputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriver: params.channelDriver ?? undefined,
channelDriverSelection: params.channelDriverSelection,
primaryModel: remapModelRefForForcedRuntime({
modelRef: params.primaryModel,
providerMode: params.providerMode,
@@ -802,6 +827,8 @@ async function runQaRuntimeParitySuite(params: {
alternateModel: params.alternateModel,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriver: params.channelDriver,
channelDriverSelection: params.channelDriverSelection,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0
? params.selectedScenarios.map((scenario) => scenario.id)
@@ -854,6 +881,8 @@ async function writeQaSuiteArtifacts(params: {
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
isolatedWorkers?: boolean;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
@@ -861,6 +890,23 @@ async function writeQaSuiteArtifacts(params: {
const reportPath = path.join(params.outputDir, "qa-suite-report.md");
const summaryPath = path.join(params.outputDir, "qa-suite-summary.json");
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
const channelDriverSmoke = params.channelDriverSelection
? await runQaCrablineChannelDriverSmoke(params.channelDriverSelection, {
outputDir: params.outputDir,
})
: undefined;
const channelDriverArtifactPaths = params.channelDriverSelection
? [
{
kind: "channel-capability-matrix",
path: params.channelDriverSelection.capabilityMatrixPath,
},
{
kind: "channel-driver-smoke",
path: params.channelDriverSelection.smokeArtifactPath,
},
]
: [];
const report = renderQaMarkdownReport({
title: "OpenClaw QA Scenario Suite",
startedAt: params.startedAt,
@@ -880,9 +926,11 @@ async function writeQaSuiteArtifacts(params: {
artifactPaths: [
{ kind: "summary", path: path.basename(summaryPath) },
{ kind: "report", path: path.basename(reportPath) },
...channelDriverArtifactPaths,
],
evidenceMode: params.evidenceMode,
channelId: params.transport.id,
channelId: params.channelDriverSelection?.channel ?? params.transport.id,
channelDriver: params.channelDriver ?? params.channelDriverSelection?.channelDriver,
env: process.env,
generatedAt: params.finishedAt.toISOString(),
primaryModel: params.primaryModel,
@@ -891,6 +939,40 @@ async function writeQaSuiteArtifacts(params: {
scenarioResults: params.scenarios,
})
: undefined;
if (params.channelDriverSelection && channelDriverSmoke) {
await fs.writeFile(
path.join(params.outputDir, params.channelDriverSelection.capabilityMatrixPath),
`${JSON.stringify(
{
version: 1,
source: "openclaw/crabline",
channelDriver: params.channelDriverSelection.channelDriver,
selectedChannel: params.channelDriverSelection.channel,
manifestPath: channelDriverSmoke.manifestPath,
report: channelDriverSmoke.capabilityReport,
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(params.outputDir, params.channelDriverSelection.smokeArtifactPath),
`${JSON.stringify(
{
version: 1,
source: "openclaw/crabline",
channelDriver: params.channelDriverSelection.channelDriver,
selectedChannel: params.channelDriverSelection.channel,
manifestPath: channelDriverSmoke.manifestPath,
smoke: channelDriverSmoke.smoke,
},
null,
2,
)}\n`,
"utf8",
);
}
await fs.writeFile(reportPath, report, "utf8");
if (evidence) {
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
@@ -1117,6 +1199,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
startedAt,
providerMode,
transportId,
channelDriverSelection: params?.channelDriverSelection,
channelDriver: params?.channelDriver,
primaryModel,
alternateModel,
fastMode,
@@ -1190,6 +1274,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
scenarioIds:
params?.scenarioIds && params.scenarioIds.length > 0
@@ -1238,6 +1324,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
outputDir: scenarioOutputDir,
providerMode,
transportId,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
primaryModel,
alternateModel,
fastMode,
@@ -1335,6 +1423,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
// When the caller supplied an explicit non-empty --scenario filter,
// record the executed (post-selectQaFlowSuiteScenarios-normalized) ids
@@ -1597,6 +1687,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: false,
// Same "filtered → executed list, unfiltered → null" convention as
// the concurrent-path writeQaSuiteArtifacts call above.

1642
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,7 @@ allowBuilds:
"@tloncorp/tlon-skill": true
baileys: true
authenticate-pam: true
crabline: true
"@discordjs/opus": false
esbuild: true
koffi: false

View File

@@ -22,6 +22,7 @@ scenario:
- extensions/qa-lab/src/bus-state.ts
execution:
kind: flow
channel: telegram
summary: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
config:
expectedMarker: QA-CHANNEL-BASELINE-OK

View File

@@ -1,27 +0,0 @@
title: Channel message flows QA evidence
scenario:
id: channel-message-flows
surface: channel-framework
coverage:
primary:
- outbound-direct-text-media-sends
secondary:
- channels.streaming
- channels.direct-visible-replies
objective: Exercise Telegram-shaped streamed previews and durable final text delivery through QA Lab evidence.
successCriteria:
- Telegram flow flags parse channel, target, account, thread, timing, and flow mode.
- Thinking preview updates flush, clear, and then send a durable final answer.
- Failed preview streaming clears the preview and does not send the final answer.
- Working preview updates render rich tool/status text before the durable final answer.
docsRefs:
- docs/channels/telegram.md
- docs/channels/qa-channel.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
summary: Vitest coverage for channel preview clearing and durable final text sends.

View File

@@ -22,6 +22,7 @@ scenario:
- extensions/qa-lab/src/lab-server.ts
execution:
kind: flow
channel: telegram
summary: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
config:
expectedMarker: QA-DM-BASELINE-OK

View File

@@ -33,6 +33,7 @@ scenario:
- extensions/qa-channel/src/inbound.ts
execution:
kind: flow
channel: telegram
summary: Verify message_tool visible replies degrade to automatic delivery when the active group policy removes message.
config:
conversationId: qa-fallback-room

View File

@@ -27,6 +27,7 @@ scenario:
- src/auto-reply/reply/dispatch-from-config.ts
execution:
kind: flow
channel: telegram
summary: Send a mentioned group message and verify visible output uses the message tool in the source group.
config:
conversationId: qa-visible-tool-room

View File

@@ -25,6 +25,7 @@ scenario:
- src/auto-reply/reply/dispatch-from-config.ts
execution:
kind: flow
channel: telegram
summary: Send a direct message_tool_only turn whose model reply omits the message tool, and verify a substantive private final warns without outbound delivery.
config:
conversationId: qa-stranded-dm

View File

@@ -23,6 +23,7 @@ scenario:
- extensions/qa-lab/src/suite-runtime-gateway.ts
execution:
kind: flow
channel: telegram
summary: Verify qa-channel readiness recovery does not duplicate old outbound delivery.
config:
firstPrompt: "@openclaw Reconnect dedupe setup marker. Reply exactly: RECONNECT-FIRST-OK"

View File

@@ -20,6 +20,7 @@ scenario:
- extensions/qa-lab/src/self-check-scenario.ts
execution:
kind: flow
channel: telegram
summary: Verify the agent can use channel-owned message actions and that the QA transcript reflects them.
config:
target: "channel:qa-room"

View File

@@ -22,6 +22,7 @@ scenario:
- extensions/qa-lab/src/bus-state.ts
execution:
kind: flow
channel: telegram
summary: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel.
config:
prompt: "@openclaw reply in one short sentence inside this thread only. Do not use ACP or any external runtime. Confirm you stayed in-thread."

View File

@@ -22,6 +22,7 @@ scenario:
- extensions/qa-lab/src/gateway-log-sentinel.ts
execution:
kind: flow
channel: telegram
summary: Run a direct current-chat reply and inspect the actual transcript for self-message routing.
config:
expectedMarker: WEBCHAT-DIRECT-REPLY-OK

View File

@@ -1,26 +0,0 @@
title: Plugin lifecycle probe evidence
scenario:
id: plugin-lifecycle-probe
surface: plugins
coverage:
primary:
- plugins.lifecycle
secondary:
- plugin-validation-and-repair
- plugin-setup
objective: Exercise strict plugin load/uninstall proof parsing through QA Lab evidence.
successCriteria:
- Enabled loaded plugin inspect JSON is accepted as proof.
- Pending or missing inspect JSON is rejected instead of treated as loaded.
- Malformed config during uninstall proof fails with a bounded diagnostic.
docsRefs:
- docs/plugins/manifest.md
- docs/cli/plugins.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
summary: Vitest coverage for plugin lifecycle proof parsing.

View File

@@ -1,28 +0,0 @@
title: Gateway smoke QA evidence
scenario:
id: gateway-smoke
surface: gateway-runtime
coverage:
primary:
- health-apis
secondary:
- websocket-transport
- connect-request
- hello-ok-snapshot
objective: Exercise loopback Gateway WebSocket connect and health checks through QA Lab evidence.
successCriteria:
- Gateway smoke passes against a loopback Gateway WebSocket using the real client.
- The smoke sends the expected operator connect request before health.
- Failed connect and health responses close the WebSocket client and report bounded errors.
- Unpaired iOS-shaped smoke clients do not call scoped chat history.
docsRefs:
- docs/gateway/protocol.md
- docs/gateway/index.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
summary: Vitest coverage for Gateway smoke connect and health behavior.

View File

@@ -1,28 +0,0 @@
title: Docker package artifact QA evidence
scenario:
id: package-openclaw-for-docker
surface: docker-podman-hosting
coverage:
primary:
- docker-e2e-package-artifact-generation
secondary:
- package-manager-installs
- runtime.package-update
objective: Exercise bounded OpenClaw package artifact generation through QA Lab evidence.
successCriteria:
- Package artifact output flags are parsed strictly.
- The Docker package path uses the single bounded build-all step before npm pack.
- Changelog trimming is restored after successful and failed ignore-scripts packaging.
- Timed-out and externally terminated child process groups are cleaned up without leaked descendants.
- Captured command output is bounded.
docsRefs:
- docs/install/updating.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
summary: Vitest coverage for Docker package artifact creation and cleanup behavior.

View File

@@ -1,28 +0,0 @@
title: QA OTEL smoke evidence
scenario:
id: qa-otel-smoke
surface: telemetry
coverage:
primary:
- telemetry.otel
secondary:
- harness.qa-lab
- plugin-sdk-diagnostic-runtime-exports
objective: Exercise bounded local OTLP capture and OpenTelemetry smoke assertions through QA Lab evidence.
successCriteria:
- Package-manager forwarded QA OTEL smoke arguments parse correctly.
- Body-size limits are strict positive integers.
- Local OTLP receiver rejects malformed, oversized, or truncated protobuf payloads with bounded diagnostics.
- Captured OTLP body text is bounded and leak needles remain detectable.
- Active local receiver sockets close during cleanup.
- Smoke assertions fail on non-2xx OTLP requests and missing release-critical signals.
docsRefs:
- docs/gateway/opentelemetry.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
summary: Vitest coverage for QA OTEL smoke receiver bounds and signal assertions.

View File

@@ -29,11 +29,6 @@ const legacyWriterNames = new Set([
"updateSessionStore",
"updateSessionStoreEntry",
]);
const legacyTranscriptWriterNames = new Set([
"appendSessionTranscriptMessage",
"emitSessionTranscriptUpdate",
"rewriteTranscriptEntriesInSessionFile",
]);
export const migratedSessionAccessorFiles = new Set([
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
@@ -93,15 +88,6 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-usage.ts",
]);
export const migratedTranscriptWriterFiles = new Set([
"src/agents/command/attempt-execution.ts",
"src/agents/embedded-agent-runner/context-engine-maintenance.ts",
"src/config/sessions/transcript.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/server-methods/chat-transcript-inject.ts",
"src/sessions/user-turn-transcript.ts",
]);
function normalizeRelativePath(filePath) {
return filePath.replaceAll(path.sep, "/");
}
@@ -141,7 +127,7 @@ function bindingName(node) {
return null;
}
function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
function findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
@@ -154,7 +140,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
if (legacyNames.has(importedName)) {
violations.push({
line: toLine(sourceFile, specifier),
reason: `imports ${subject} "${importedName}"`,
reason: `imports legacy session store ${legacyKind} "${importedName}"`,
});
}
}
@@ -166,7 +152,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
if (name && legacyNames.has(name)) {
violations.push({
line: toLine(sourceFile, node),
reason: `aliases ${subject} "${name}"`,
reason: `aliases legacy session store ${legacyKind} "${name}"`,
});
}
}
@@ -174,7 +160,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references ${subject} "${node.name.text}"`,
reason: `references legacy session store ${legacyKind} "${node.name.text}"`,
});
}
@@ -185,7 +171,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references ${subject} "${node.argumentExpression.text}"`,
reason: `references legacy session store ${legacyKind} "${node.argumentExpression.text}"`,
});
}
@@ -198,7 +184,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
) {
violations.push({
line: toLine(sourceFile, node.expression),
reason: `calls ${subject} "${calleeName}"`,
reason: `calls legacy session store ${legacyKind} "${calleeName}"`,
});
}
}
@@ -210,15 +196,6 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
return violations;
}
function findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind) {
return findNamedBoundaryViolations(
content,
fileName,
legacyNames,
`legacy session store ${legacyKind}`,
);
}
export function findSessionAccessorBoundaryViolations(content, fileName = "source.ts") {
const legacyNames = legacyNamesForFile(fileName);
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
@@ -229,15 +206,6 @@ export function findSessionAccessorWriteBoundaryViolations(content, fileName = "
return findNamedSessionStoreViolations(content, fileName, legacyWriterNames, "writer");
}
export function findTranscriptWriterBoundaryViolations(content, fileName = "source.ts") {
return findNamedBoundaryViolations(
content,
fileName,
legacyTranscriptWriterNames,
"legacy transcript writer",
);
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
const readSourceRoots = resolveSourceRoots(repoRoot, [
@@ -252,13 +220,6 @@ export async function main() {
"src/infra",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, ["src/agents", "src/auto-reply"]);
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
"src/agents/command",
"src/agents/embedded-agent-runner",
"src/config/sessions",
"src/gateway/server-methods",
"src/sessions",
]);
const readViolations = await collectFileViolations({
repoRoot,
sourceRoots: readSourceRoots,
@@ -280,14 +241,7 @@ export async function main() {
),
findViolations: findSessionAccessorWriteBoundaryViolations,
});
const transcriptWriterViolations = await collectFileViolations({
repoRoot,
sourceRoots: transcriptWriterSourceRoots,
skipFile: (filePath) =>
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
findViolations: findTranscriptWriterBoundaryViolations,
});
const violations = [...readViolations, ...writeViolations, ...transcriptWriterViolations];
const violations = [...readViolations, ...writeViolations];
if (violations.length === 0) {
console.log("session accessor boundary guard passed.");
@@ -299,7 +253,7 @@ export async function main() {
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
}
console.error(
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand this ratchet only after a slice migrates more files.",
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write paths. Expand this ratchet only after a slice migrates more files.",
);
process.exit(1);
}

View File

@@ -0,0 +1,159 @@
// Probe script for plugin lifecycle matrix E2E scenarios.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const home = os.homedir();
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
}
function readRequiredJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
}
}
function records() {
return readPluginInstallRecords();
}
function recordFor(pluginId) {
return records()[pluginId];
}
function config() {
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function requiredConfig() {
return readRequiredJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertVersion(pluginId, version) {
const record = recordFor(pluginId);
assert(record, `install record missing for ${pluginId}`);
assert(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
assert(
record.resolvedVersion === version || record.version === version,
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
);
assert(record.installPath, `install path missing for ${pluginId}`);
const packageJson = readJson(path.join(record.installPath, "package.json"));
assert(
packageJson.version === version,
`expected installed package version ${version}, got ${packageJson.version}`,
);
}
function assertNpmProjectRoot(pluginId, packageName) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
const relative = path.relative(openclawPath("npm", "projects"), record.installPath);
assert(
!relative.startsWith("..") && !path.isAbsolute(relative),
`install path outside npm projects: ${record.installPath}`,
);
const segments = relative.split(path.sep);
const packageSegments = packageName.split("/");
assert(
segments.length === 2 + packageSegments.length,
`unexpected npm project install path: ${record.installPath}`,
);
assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`);
assert(
segments[1] === "node_modules",
`missing project node_modules segment: ${record.installPath}`,
);
for (let index = 0; index < packageSegments.length; index++) {
assert(
segments[index + 2] === packageSegments[index],
`package path mismatch: ${record.installPath}`,
);
}
assert(
!fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)),
`legacy flat npm install path exists for ${packageName}`,
);
}
function assertInspectLoaded(pluginId, inspectPath) {
assert(inspectPath, "inspect JSON path is required");
const inspect = readRequiredJson(inspectPath);
const plugin = inspect.plugin;
assert(plugin?.id === pluginId, `expected inspected plugin id ${pluginId}, got ${plugin?.id}`);
assert(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
assert(
plugin.status === "loaded",
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
);
}
function assertEnabled(pluginId, expectedRaw) {
const expected = expectedRaw === "true";
const entry = config().plugins?.entries?.[pluginId];
assert(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
}
function printInstallPath(pluginId) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
process.stdout.write(record.installPath);
}
function assertUninstalled(pluginId) {
const cfg = requiredConfig();
const record = recordFor(pluginId);
assert(!record, `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
const loadPaths = cfg.plugins?.load?.paths ?? [];
assert(
!loadPaths.some((entry) => String(entry).includes(pluginId)),
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
);
}
const [command, pluginId, arg] = process.argv.slice(2);
switch (command) {
case "assert-version":
assertVersion(pluginId, arg);
break;
case "assert-npm-project-root":
assertNpmProjectRoot(pluginId, arg);
break;
case "assert-inspect-loaded":
assertInspectLoaded(pluginId, arg);
break;
case "assert-enabled":
assertEnabled(pluginId, arg);
break;
case "install-path":
printInstallPath(pluginId);
break;
case "assert-uninstalled":
assertUninstalled(pluginId);
break;
default:
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
}

View File

@@ -17,7 +17,7 @@ source scripts/e2e/lib/plugins/fixtures.sh
plugin_id="lifecycle-claw"
package_name="@openclaw/lifecycle-claw"
probe="test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
pack_root=""
@@ -43,10 +43,6 @@ run_measured() {
node "$measure" "$summary_tsv" "$phase" -- "$@"
}
run_probe() {
tsx "$probe" --probe "$@"
}
pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
@@ -55,27 +51,27 @@ start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root"
trap cleanup EXIT
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
run_probe assert-version "$plugin_id" 1.0.0
run_probe assert-npm-project-root "$plugin_id" "$package_name"
node "$probe" assert-version "$plugin_id" 1.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
run_probe assert-inspect-loaded "$plugin_id" "$inspect_v1"
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
run_measured disable node "$entry" plugins disable "$plugin_id"
run_probe assert-enabled "$plugin_id" false
node "$probe" assert-enabled "$plugin_id" false
run_measured enable node "$entry" plugins enable "$plugin_id"
run_probe assert-enabled "$plugin_id" true
node "$probe" assert-enabled "$plugin_id" true
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
run_probe assert-version "$plugin_id" 2.0.0
run_probe assert-npm-project-root "$plugin_id" "$package_name"
node "$probe" assert-version "$plugin_id" 2.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
run_probe assert-version "$plugin_id" 1.0.0
run_probe assert-npm-project-root "$plugin_id" "$package_name"
node "$probe" assert-version "$plugin_id" 1.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
install_path="$(run_probe install-path "$plugin_id")"
install_path="$(node "$probe" install-path "$plugin_id")"
rm -rf "$install_path"
if [[ -e "$install_path" ]]; then
echo "Failed to remove plugin code before missing-code uninstall: $install_path" >&2
@@ -83,7 +79,7 @@ if [[ -e "$install_path" ]]; then
fi
run_measured missing-code-uninstall node "$entry" plugins uninstall "$plugin_id" --force
run_probe assert-uninstalled "$plugin_id"
node "$probe" assert-uninstalled "$plugin_id"
echo "Plugin lifecycle resource summary:"
cat "$summary_tsv"

View File

@@ -251,7 +251,6 @@ docker_e2e_harness_mount_args() {
DOCKER_E2E_HARNESS_ARGS=(
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro"
-v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro"
-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"
)
}

View File

@@ -576,10 +576,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
[
"scripts/package-openclaw-for-docker.mjs",
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
],
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
[
@@ -634,9 +631,13 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
["test/scripts/plugin-lifecycle-measure.test.ts"],
],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
["test/scripts/plugin-lifecycle-probe.test.ts"],
],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh",
["test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts"],
["test/scripts/plugin-lifecycle-probe.test.ts"],
],
[
"scripts/e2e/release-media-memory-docker.sh",
@@ -651,12 +652,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
[
"scripts/dev/channel-message-flows.ts",
["test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts"],
],
["scripts/dev/gateway-smoke.ts", ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
["scripts/qa-otel-smoke.ts", ["test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts"]],
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]],

View File

@@ -17,7 +17,7 @@ import type { InputProvenance } from "../../sessions/input-provenance.js";
import type {
PersistedUserTurnMessage,
UserTurnTranscriptRecorder,
} from "../../sessions/user-turn-transcript.types.js";
} from "../../sessions/user-turn-transcript.js";
import type { SkillSnapshot } from "../../skills/types.js";
import type { BootstrapContextMode } from "../bootstrap-files.js";
import type { ResolvedCliBackend } from "../cli-backends.js";

View File

@@ -801,7 +801,7 @@ describe("CLI attempt execution", () => {
await fs.realpath(sessionFile),
);
expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(sessionEntry.updatedAt);
expect(persisted[sessionKey]?.updatedAt).toBeLessThanOrEqual(nowCalls.at(-1) ?? 0);
expect(persisted[sessionKey]?.updatedAt).toBeLessThan(nowCalls.at(-1) ?? 0);
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
});
@@ -1034,13 +1034,11 @@ describe("CLI attempt execution", () => {
throw new Error("Expected CLI transcript session file.");
}
expect(path.isAbsolute(sessionFile)).toBe(true);
const persistedFirst = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
SessionEntry
>;
expect(await fs.realpath(persistedFirst[sessionKey]?.sessionFile ?? "")).toBe(
await fs.realpath(sessionFile),
);
expect(
sessionFile.endsWith(
path.join(".openclaw", "agents", "main", "sessions", `${sessionEntry.sessionId}.jsonl`),
),
).toBe(true);
await appendSessionTranscriptMessage({
transcriptPath: sessionFile,

View File

@@ -6,8 +6,11 @@ import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
import { formatAcpErrorChain } from "../../acp/runtime/errors.js";
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
import { readTailAssistantTextFromSessionTranscript } from "../../config/sessions/transcript.js";
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
import {
readTailAssistantTextFromSessionTranscript,
resolveSessionTranscriptFile,
} from "../../config/sessions/transcript.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
@@ -21,8 +24,9 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import {
preparePersistedUserTurnMessageForTranscriptWrite,
appendUserTurnTranscriptMessage,
type PersistedUserTurnMessage,
} from "../../sessions/user-turn-transcript.js";
import { buildWorkspaceSkillSnapshot } from "../../skills/loading/workspace.js";
@@ -43,12 +47,14 @@ import { resolveOpenAIRuntimeProvider } from "../openai-routing.js";
import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
import type { AgentMessage } from "../runtime/index.js";
import { acquireSessionWriteLock, resolveSessionWriteLockOptions } from "../session-write-lock.js";
import { buildUsageWithNoCost } from "../stream-message-shared.js";
import {
buildClaudeCliFallbackContextPrelude,
claudeCliSessionTranscriptHasContent,
resolveFallbackRetryPrompt,
} from "./attempt-execution.helpers.js";
import { persistSessionEntry } from "./attempt-execution.shared.js";
import { resolveAgentRunContext } from "./run-context.js";
import { clearCliSessionInStore } from "./session-store.js";
import type { AgentCommandOpts } from "./types.js";
@@ -231,73 +237,110 @@ async function persistTextTurnTranscript(
return params.sessionEntry;
}
const messages = [];
const userMessage =
params.userMessage ??
(promptText
? ({
role: "user",
content: promptText,
timestamp: Date.now(),
} as PersistedUserTurnMessage)
: undefined);
if (userMessage) {
messages.push({
message: userMessage,
idempotencyLookup: "scan" as const,
prepareMessageAfterIdempotencyCheck: (message: unknown) =>
preparePersistedUserTurnMessageForTranscriptWrite(message as PersistedUserTurnMessage, {
agentId: params.sessionAgentId,
sessionKey: params.sessionKey,
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
}),
});
}
const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
agentId: params.sessionAgentId,
threadId: params.threadId,
});
const lock = await acquireSessionWriteLock({
sessionFile,
...resolveSessionWriteLockOptions(params.config),
allowReentrant: true,
});
let transcriptMarkerUpdatedAt: number | undefined;
try {
let wroteTranscript = false;
const userMessage = params.userMessage;
if (userMessage || promptText) {
await appendUserTurnTranscriptMessage({
transcriptPath: sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cwd: params.sessionCwd,
config: params.config,
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
...(userMessage
? { message: userMessage }
: {
input: {
text: promptText,
timestamp: Date.now(),
},
}),
updateMode: "none",
});
wroteTranscript = true;
}
if (replyText) {
messages.push({
message: {
role: "assistant",
content: [{ type: "text", text: replyText }],
api: params.assistant.api,
provider: params.assistant.provider,
model: params.assistant.model,
usage: resolveTranscriptUsage(params.assistant.usage),
stopReason: "stop",
timestamp: Date.now(),
},
shouldAppend: async ({ sessionFile }: { sessionFile: string }) => {
if (!params.embeddedAssistantGapFill) {
return true;
}
if (replyText) {
let appendAssistant = true;
if (params.embeddedAssistantGapFill) {
const latest = await readTailAssistantTextFromSessionTranscript(sessionFile);
const normalizedReply = normalizeTranscriptMirrorText(replyText);
const normalizedLatest = latest?.text ? normalizeTranscriptMirrorText(latest.text) : "";
return !normalizedLatest || normalizedLatest !== normalizedReply;
},
});
if (normalizedLatest && normalizedLatest === normalizedReply) {
appendAssistant = false;
}
}
if (appendAssistant) {
await appendSessionTranscriptMessage({
transcriptPath: sessionFile,
sessionId: params.sessionId,
cwd: params.sessionCwd,
config: params.config,
message: {
role: "assistant",
content: [{ type: "text", text: replyText }],
api: params.assistant.api,
provider: params.assistant.provider,
model: params.assistant.model,
usage: resolveTranscriptUsage(params.assistant.usage),
stopReason: "stop",
timestamp: Date.now(),
},
});
wroteTranscript = true;
}
}
if (wroteTranscript) {
transcriptMarkerUpdatedAt = Date.now();
}
} finally {
await lock.release();
}
const turn = await persistSessionTranscriptTurn(
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
agentId: params.sessionAgentId,
threadId: params.threadId,
},
{
config: params.config,
cwd: params.sessionCwd,
messages,
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
},
);
return turn.sessionEntry;
let updatedSessionEntry = sessionEntry;
if (params.sessionStore && params.storePath && transcriptMarkerUpdatedAt !== undefined) {
const currentEntry = params.sessionStore[params.sessionKey] ?? sessionEntry;
if (currentEntry?.sessionId === params.sessionId) {
// Keep updatedAt as the registry marker for transcript writes we own.
// Session reuse checks compare transcript mtime against this marker, not endedAt.
updatedSessionEntry =
(await persistSessionEntry({
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
entry: {
sessionId: params.sessionId,
sessionFile,
updatedAt: transcriptMarkerUpdatedAt,
},
preserveTranscriptMarkerUpdatedAt: true,
shouldPersist: (current) => current?.sessionId === params.sessionId,
})) ?? updatedSessionEntry;
}
}
emitSessionTranscriptUpdate({
sessionFile,
sessionKey: params.sessionKey,
agentId: params.sessionAgentId,
});
return updatedSessionEntry;
}
function resolveCliTranscriptReplyText(result: EmbeddedAgentRunResult): string {

View File

@@ -27,7 +27,7 @@ const rewriteTranscriptEntriesInSessionManagerMock = vi.fn((_params?: unknown) =
bytesFreed: 77,
rewrittenEntries: 1,
}));
const rewriteTranscriptEntriesInRuntimeTranscriptMock = vi.fn(async (_params?: unknown) => ({
const rewriteTranscriptEntriesInSessionFileMock = vi.fn(async (_params?: unknown) => ({
changed: true,
bytesFreed: 123,
rewrittenEntries: 2,
@@ -107,8 +107,8 @@ vi.mock("./context-engine-capabilities.js", () => ({
vi.mock("./transcript-rewrite.js", () => ({
rewriteTranscriptEntriesInSessionManager: (params: unknown) =>
rewriteTranscriptEntriesInSessionManagerMock(params),
rewriteTranscriptEntriesInRuntimeTranscript: (params: unknown) =>
rewriteTranscriptEntriesInRuntimeTranscriptMock(params),
rewriteTranscriptEntriesInSessionFile: (params: unknown) =>
rewriteTranscriptEntriesInSessionFileMock(params),
}));
async function loadFreshContextEngineMaintenanceModuleForTest() {
@@ -127,13 +127,13 @@ async function loadFreshContextEngineMaintenanceModuleForTest() {
describe("buildContextEngineMaintenanceRuntimeContext", () => {
beforeEach(async () => {
rewriteTranscriptEntriesInSessionManagerMock.mockClear();
rewriteTranscriptEntriesInRuntimeTranscriptMock.mockClear();
rewriteTranscriptEntriesInSessionFileMock.mockClear();
resetSystemEventsForTest();
resetTaskRegistryDeliveryRuntimeForTests();
await loadFreshContextEngineMaintenanceModuleForTest();
});
it("adds a transcript rewrite helper that targets the current runtime session", async () => {
it("adds a transcript rewrite helper that targets the current session file", async () => {
const runtimeContext = buildContextEngineMaintenanceRuntimeContext({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
@@ -157,18 +157,16 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
bytesFreed: 123,
rewrittenEntries: 2,
});
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).toHaveBeenCalledWith({
scope: {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
},
expect(rewriteTranscriptEntriesInSessionFileMock).toHaveBeenCalledWith({
sessionFile: "/tmp/session.jsonl",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
config: undefined,
request: {
replacements: [
{ entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
],
},
config: undefined,
});
});
@@ -200,7 +198,7 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
{ entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
],
});
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).not.toHaveBeenCalled();
expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled();
});
it("wraps active session manager rewrites in the supplied lock", async () => {
@@ -244,7 +242,7 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
{ entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
],
});
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).not.toHaveBeenCalled();
expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled();
});
it("lets background file rewrites run without the session lane", async () => {
@@ -264,7 +262,7 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
});
await Promise.resolve();
rewriteTranscriptEntriesInRuntimeTranscriptMock.mockImplementationOnce(
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
async (_params?: unknown) => {
events.push("rewrite");
return {
@@ -361,7 +359,7 @@ describe("runContextEngineMaintenance", () => {
beforeEach(async () => {
vi.useRealTimers();
rewriteTranscriptEntriesInSessionManagerMock.mockClear();
rewriteTranscriptEntriesInRuntimeTranscriptMock.mockClear();
rewriteTranscriptEntriesInSessionFileMock.mockClear();
await loadFreshContextEngineMaintenanceModuleForTest();
});
@@ -463,12 +461,11 @@ describe("runContextEngineMaintenance", () => {
});
expect(rewriteTranscriptEntriesInSessionManagerMock).not.toHaveBeenCalled();
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).toHaveBeenCalledWith({
scope: {
sessionId: "session-background-file-rewrite",
sessionKey: "agent:main:session-background-file-rewrite",
sessionFile: "/tmp/session-background-file-rewrite.jsonl",
},
expect(rewriteTranscriptEntriesInSessionFileMock).toHaveBeenCalledWith({
sessionFile: "/tmp/session-background-file-rewrite.jsonl",
sessionId: "session-background-file-rewrite",
sessionKey: "agent:main:session-background-file-rewrite",
config: { session: { writeLock: { acquireTimeoutMs: 75_000 } } },
request: {
replacements: [
{
@@ -481,7 +478,6 @@ describe("runContextEngineMaintenance", () => {
},
],
},
config: { session: { writeLock: { acquireTimeoutMs: 75_000 } } },
});
});
@@ -545,7 +541,7 @@ describe("runContextEngineMaintenance", () => {
{ entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
],
});
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).not.toHaveBeenCalled();
expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled();
});
it("defers turn maintenance to a hidden background task when enabled", async () => {
@@ -620,12 +616,11 @@ describe("runContextEngineMaintenance", () => {
expect(result).toBeUndefined();
await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(1));
await waitForAssertion(() =>
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).toHaveBeenCalledWith({
scope: {
sessionId: "session-1",
sessionKey,
sessionFile: "/tmp/session.jsonl",
},
expect(rewriteTranscriptEntriesInSessionFileMock).toHaveBeenCalledWith({
sessionFile: "/tmp/session.jsonl",
sessionId: "session-1",
sessionKey,
config: { session: { writeLock: { acquireTimeoutMs: 91_000 } } },
request: {
replacements: [
{
@@ -638,7 +633,6 @@ describe("runContextEngineMaintenance", () => {
},
],
},
config: { session: { writeLock: { acquireTimeoutMs: 91_000 } } },
}),
);
@@ -1384,7 +1378,7 @@ describe("runContextEngineMaintenance", () => {
};
});
rewriteTranscriptEntriesInRuntimeTranscriptMock.mockImplementationOnce(
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
async (_params?: unknown) => {
events.push("rewrite");
return {

View File

@@ -33,7 +33,7 @@ import { findActiveSessionTask } from "../session-async-task-status.js";
import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js";
import { log } from "./logger.js";
import {
rewriteTranscriptEntriesInRuntimeTranscript,
rewriteTranscriptEntriesInSessionFile,
rewriteTranscriptEntriesInSessionManager,
} from "./transcript-rewrite.js";
@@ -336,18 +336,16 @@ export function buildContextEngineMaintenanceRuntimeContext(params: {
? await params.withSessionManagerRewriteLock(rewriteSessionManagerEntries)
: rewriteSessionManagerEntries();
}
const rewriteRuntimeTranscriptEntries = async () =>
await rewriteTranscriptEntriesInRuntimeTranscript({
scope: {
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? params.sessionId,
sessionFile: params.sessionFile,
...(params.agentId ? { agentId: params.agentId } : {}),
},
request,
const rewriteTranscriptEntriesInFile = async () =>
await rewriteTranscriptEntriesInSessionFile({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
request,
});
return await rewriteRuntimeTranscriptEntries();
return await rewriteTranscriptEntriesInFile();
},
};
}

View File

@@ -15,7 +15,7 @@ import type { ImageContent } from "../../../llm/types.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import type { CommandQueueEnqueueFn } from "../../../process/command-queue.types.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js";
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js";
import type { SkillSnapshot } from "../../../skills/types.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.exec-types.js";
import type { AgentStreamParams, ClientToolDefinition } from "../../command/shared-types.js";

View File

@@ -329,95 +329,6 @@ describe("rewriteTranscriptEntriesInSessionFile", () => {
expect(await fs.readFile(storePath, "utf8")).toBe("{}\n");
});
it("rewrites runtime transcripts through scoped session identity", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-runtime-"));
const storePath = path.join(dir, "sessions.json");
const sessionManager = SessionManager.create(dir, dir);
const entryIds = appendSessionMessages(sessionManager, [
asAppendMessage({
role: "user",
content: "run tool",
timestamp: 1,
}),
asAppendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "exec",
content: createTextContent("before rewrite"),
isError: false,
timestamp: 2,
}),
asAppendMessage({
role: "assistant",
content: createTextContent("summarized"),
timestamp: 3,
}),
]);
const sessionFile = requireString(sessionManager.getSessionFile(), "persisted session file");
const resolvedSessionFile = await fs.realpath(sessionFile);
const sessionId = path.basename(sessionFile, ".jsonl");
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:test": {
sessionFile,
sessionId,
updatedAt: 10,
},
}),
"utf8",
);
const toolResultEntryId = entryIds[1];
const listener = vi.fn();
const cleanup = onSessionTranscriptUpdate(listener);
try {
const result = await rewriteTranscriptEntriesInRuntimeTranscript({
scope: {
agentId: "main",
sessionId,
sessionKey: "agent:main:test",
storePath,
},
request: {
replacements: [
{
entryId: toolResultEntryId,
message: createToolResultReplacement("exec", "[runtime rewrite]", 2),
},
],
},
});
expect(result.changed).toBe(true);
expect(acquireSessionWriteLockMock).toHaveBeenCalledWith({
sessionFile: resolvedSessionFile,
staleMs: 1_800_000,
timeoutMs: 60_000,
maxHoldMs: 300_000,
});
expect(acquireSessionWriteLockReleaseMock).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
agentId: "main",
sessionFile: resolvedSessionFile,
sessionKey: "agent:main:test",
});
const rewrittenSession = SessionManager.open(sessionFile);
const branchMessages = getBranchMessages(rewrittenSession);
expect(branchMessages.map((message) => message.role)).toEqual([
"user",
"toolResult",
"assistant",
]);
expect((branchMessages[1] as Extract<AgentMessage, { role: "toolResult" }>).content).toEqual([
{ type: "text", text: "[runtime rewrite]" },
]);
} finally {
cleanup();
}
});
it("aborts under the write lock when the active suffix contains an unexpected entry", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-guard-"));
const sessionManager = SessionManager.create(dir, dir);

View File

@@ -2113,14 +2113,6 @@ export class SessionManager {
this.sessionFileSnapshot = rememberedAppend.snapshot;
if (rememberedAppend.ownedAppendVerified && publishSnapshot) {
publishRememberedSessionFileSnapshot(this.sessionFile, rememberedAppend.snapshot);
} else if (cacheOwnedAppend) {
this.setLoadedSessionFile(
this.sessionFile,
revalidateLoadedSessionFile(this.sessionFile, {
entries: this.fileEntries,
snapshot: beforeAppendSnapshot,
}),
);
}
}
}

View File

@@ -40,7 +40,7 @@ function isToolDocBlockStart(line: string): boolean {
return true;
}
return (
normalized.endsWith(":") && line.trim() === line.trim().toUpperCase() && normalized.length > 12
normalized.endsWith(":") && normalized === normalized.toUpperCase() && normalized.length > 12
);
}

View File

@@ -1,7 +1,7 @@
/** Public option types for reply generation callbacks, streaming, and delivery policy. */
import type { ImageContent } from "../llm/types.js";
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.types.js";
import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.js";
import type { ReplyPayload } from "./reply-payload.js";
import type { TypingController } from "./reply/typing.js";

View File

@@ -10,7 +10,7 @@ import type { ReplyToMode } from "../../../config/types.base.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js";
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js";
import type { SkillSnapshot } from "../../../skills/types.js";
import type {
QueuedReplyDeliveryCorrelation,

View File

@@ -12,7 +12,6 @@ import {
loadSessionEntry,
loadTranscriptEvents,
patchSessionEntry,
persistSessionTranscriptTurn,
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
@@ -21,8 +20,7 @@ import {
updateSessionEntry,
upsertSessionEntry,
} from "./session-accessor.js";
import { loadSessionStore, updateSessionStoreEntry } from "./store.js";
import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js";
import { loadSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
describe("session accessor file-backed seam", () => {
@@ -535,281 +533,6 @@ describe("session accessor file-backed seam", () => {
]);
});
it("persists a transcript turn, touches metadata, and publishes after the write", async () => {
const scope = {
agentId: "main",
sessionId: "session-lock-order",
sessionKey: "agent:main:lock-order",
storePath,
};
await upsertSessionEntry(scope, {
sessionId: scope.sessionId,
updatedAt: 10,
});
const updates: Array<{
lineCount: number;
sessionFile: string | undefined;
updatedAt: number | undefined;
}> = [];
const unsubscribe = onSessionTranscriptUpdate((update) => {
const lines = fs.readFileSync(update.sessionFile, "utf8").trim().split("\n");
updates.push({
lineCount: lines.length,
sessionFile: loadSessionEntry(scope)?.sessionFile,
updatedAt: loadSessionEntry(scope)?.updatedAt,
});
});
const result = await persistSessionTranscriptTurn(scope, {
cwd: tempDir,
messages: [
{
message: {
role: "user",
content: "hello",
timestamp: 100,
},
},
{
message: {
role: "assistant",
content: "hi there",
timestamp: 200,
},
},
],
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
});
unsubscribe();
expect(result.appendedCount).toBe(2);
expect(loadSessionEntry(scope)).toMatchObject({
sessionFile: result.sessionFile,
sessionId: scope.sessionId,
updatedAt: expect.any(Number),
});
expect(loadSessionEntry(scope)?.updatedAt).toBeGreaterThanOrEqual(10);
const events = await loadTranscriptEvents({ ...scope, sessionFile: result.sessionFile });
expect(events).toEqual([
expect.objectContaining({ type: "session" }),
expect.objectContaining({
id: result.messages[0]?.messageId,
message: expect.objectContaining({ role: "user", content: "hello" }),
parentId: null,
type: "message",
}),
expect.objectContaining({
id: result.messages[1]?.messageId,
message: expect.objectContaining({ role: "assistant", content: "hi there" }),
parentId: result.messages[0]?.messageId,
type: "message",
}),
]);
expect(updates).toEqual([
{
lineCount: 3,
sessionFile: result.sessionFile,
updatedAt: expect.any(Number),
},
]);
});
it("queues transcript turn appends before taking the file write lock", async () => {
const scope = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath,
};
await upsertSessionEntry(scope, {
sessionId: scope.sessionId,
updatedAt: 10,
});
let markShouldAppendEntered!: () => void;
const shouldAppendEntered = new Promise<void>((resolve) => {
markShouldAppendEntered = resolve;
});
let resumeShouldAppend!: () => void;
const shouldAppendReleased = new Promise<boolean>((resolve) => {
resumeShouldAppend = () => resolve(true);
});
const turnPromise = persistSessionTranscriptTurn(scope, {
cwd: tempDir,
messages: [
{
message: {
role: "assistant",
content: "batch reply",
timestamp: 100,
},
shouldAppend: async () => {
markShouldAppendEntered();
return await shouldAppendReleased;
},
},
],
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
});
await shouldAppendEntered;
const queuedAppendPromise = appendTranscriptMessage(scope, {
cwd: tempDir,
message: {
role: "user",
content: "queued prompt",
timestamp: 200,
},
});
resumeShouldAppend();
const results = Promise.all([turnPromise, queuedAppendPromise]);
const completed = await Promise.race([
results.then(() => true),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 1_000);
}),
]);
expect(completed).toBe(true);
const [turnResult] = await results;
const events = await loadTranscriptEvents({ ...scope, sessionFile: turnResult.sessionFile });
expect(
events
.filter(
(event): event is { message?: { content?: unknown }; type?: unknown } =>
typeof event === "object" &&
event !== null &&
(event as { type?: unknown }).type === "message",
)
.map((event) => event.message?.content),
).toEqual(["batch reply", "queued prompt"]);
});
it("rejects expected-session transcript turns after a queued session rebind", async () => {
const scope = {
agentId: "main",
sessionId: "session-original",
sessionKey: "agent:main:main",
storePath,
};
await upsertSessionEntry(scope, {
sessionId: scope.sessionId,
updatedAt: 10,
});
let releaseReset = () => {};
const resetGate = new Promise<void>((resolve) => {
releaseReset = resolve;
});
let markResetStarted = () => {};
const resetStarted = new Promise<void>((resolve) => {
markResetStarted = resolve;
});
const replacementSessionFile = path.join(tempDir, "session-replacement.jsonl");
const reset = updateSessionStoreEntry({
storePath,
sessionKey: scope.sessionKey,
update: async () => {
markResetStarted();
await resetGate;
return {
sessionFile: replacementSessionFile,
sessionId: "session-replacement",
};
},
});
await resetStarted;
const turn = persistSessionTranscriptTurn(scope, {
expectedSessionId: scope.sessionId,
messages: [
{
message: {
role: "assistant",
content: "late reply",
timestamp: 100,
},
},
],
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
releaseReset();
await reset;
const result = await turn;
expect(result).toMatchObject({
appendedCount: 0,
rejectedReason: "session-rebound",
});
expect(fs.existsSync(path.join(tempDir, "session-original.jsonl"))).toBe(false);
expect(fs.existsSync(replacementSessionFile)).toBe(false);
});
it("publishes transcript turn appends through an active owned write lock", async () => {
const scope = {
agentId: "main",
sessionFile: transcriptPath,
sessionId: "session-owned-publish",
sessionKey: "agent:main:owned-publish",
storePath,
};
const publishOptions: Array<boolean | undefined> = [];
const publishedEntryBatches: unknown[][] = [];
await withOwnedSessionTranscriptWrites(
{
sessionFile: transcriptPath,
sessionKey: scope.sessionKey,
withSessionWriteLock: async (run, options) => {
publishOptions.push(options?.publishOwnedWrite);
const result = await run();
publishedEntryBatches.push([...(options?.resolvePublishedEntries?.(result) ?? [])]);
return result;
},
},
async () =>
await persistSessionTranscriptTurn(scope, {
cwd: tempDir,
messages: [
{
message: {
role: "assistant",
content: "owned batch",
timestamp: 100,
},
},
],
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
}),
);
expect(publishOptions).toEqual([true]);
expect(publishedEntryBatches).toHaveLength(1);
expect(publishedEntryBatches[0]).toEqual([
expect.objectContaining({ kind: "header" }),
expect.objectContaining({ kind: "id" }),
]);
await expect(loadTranscriptEvents(scope)).resolves.toEqual([
expect.objectContaining({ type: "session" }),
expect.objectContaining({
message: expect.objectContaining({ content: "owned batch" }),
type: "message",
}),
]);
});
it("honors thread fallback paths when resolving transcript scope from the store", async () => {
const scope = {
agentId: "main",
@@ -862,29 +585,6 @@ describe("session accessor file-backed seam", () => {
expect(loadSessionEntry(scope)?.sessionFile).toBe(target.sessionFile);
});
it("preserves an explicitly resolved runtime transcript file target", async () => {
const explicitSessionFile = path.join(tempDir, "explicit-session.jsonl");
const scope = {
agentId: "main",
sessionFile: explicitSessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath,
};
await upsertSessionEntry(scope, {
sessionId: scope.sessionId,
updatedAt: 10,
});
const readTarget = await resolveSessionTranscriptRuntimeReadTarget(scope);
const writeTarget = await resolveSessionTranscriptRuntimeTarget(scope);
expect(readTarget.sessionFile).toBe(explicitSessionFile);
expect(writeTarget.sessionFile).toBe(explicitSessionFile);
expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined();
});
it("keeps read and write runtime targets aligned for new topic sessions", async () => {
const scope = {
agentId: "main",

View File

@@ -1,9 +1,5 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
} from "../../agents/session-write-lock.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
@@ -11,7 +7,6 @@ import { getRuntimeConfig } from "../io.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
resolveStorePath,
@@ -32,20 +27,11 @@ import {
} from "./store.js";
import { parseSessionThreadInfo } from "./thread-info.js";
import {
type AppendSessionTranscriptMessageParams,
type AppendSessionTranscriptMessageResult,
appendSessionTranscriptEvent,
appendSessionTranscriptMessage,
appendSessionTranscriptMessageWithOwnedWriteLock,
withSessionTranscriptAppendQueue,
} from "./transcript-append.js";
import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
import { streamSessionTranscriptLines } from "./transcript-stream.js";
import {
type OwnedSessionTranscriptPublishedEntry,
resolveOwnedSessionTranscriptWriteLockRunner,
withOwnedSessionTranscriptWrites,
} from "./transcript-write-context.js";
import { resolveSessionTranscriptFile } from "./transcript.js";
import type { SessionEntry } from "./types.js";
/**
@@ -101,8 +87,6 @@ export type SessionTranscriptAccessScope = SessionTranscriptReadScope & {
};
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
/** Resolved file-backed artifact for the current runtime target. */
sessionFile?: string;
sessionId: string;
threadId?: string | number;
};
@@ -157,62 +141,6 @@ export type TranscriptMessageAppendResult<TMessage> = {
/** Transcript update fields supplied by callers; sessionFile is resolved here. */
export type TranscriptUpdatePayload = Omit<SessionTranscriptUpdate, "sessionFile">;
export type SessionTranscriptTurnUpdateMode = "inline" | "file-only" | "none";
export type SessionTranscriptTurnMessageAppend = TranscriptMessageAppendOptions<unknown> & {
/**
* Runs inside the file-backed write lock before this message is appended.
* SQLite implementation note: duplicate/skip decisions should be evaluated
* inside the same write transaction as the transcript row append.
*/
shouldAppend?: (context: SessionTranscriptTurnWriteContext) => Promise<boolean> | boolean;
};
export type SessionTranscriptTurnWriteContext = {
agentId?: string;
sessionFile: string;
sessionId?: string;
sessionKey?: string;
};
export type SessionTranscriptTurnPersistOptions = {
/** Runtime config used for lock settings, redaction, and header metadata. */
config?: OpenClawConfig;
/** Working directory recorded in a newly created transcript header. */
cwd?: string;
/**
* Rejects the turn when the persisted session key no longer points at this
* runtime session id. SQLite implementations must evaluate this guard inside
* the same write transaction as the transcript append and metadata touch.
*/
expectedSessionId?: string;
/** Message rows to append under one transcript write lock. */
messages: readonly SessionTranscriptTurnMessageAppend[];
/** Controls whether the update event includes the last appended message. */
updateMode?: SessionTranscriptTurnUpdateMode;
/** Emit file-only updates even when every candidate message was skipped. */
publishWhen?: "always" | "when-appended";
/**
* Touch updatedAt/sessionFile metadata after appending.
* SQLite implementation note: transcript row append(s) plus this session
* metadata touch should be one SQLite write transaction; publish happens
* after that transaction commits.
*/
touchSessionEntry?: boolean;
};
export type SessionTranscriptTurnPersistResult = {
appendedCount: number;
messages: TranscriptMessageAppendResult<unknown>[];
rejectedReason?: "session-rebound";
sessionEntry: SessionEntry | undefined;
sessionFile: string;
};
type SessionTranscriptTurnAppendRunner = <TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage>,
) => Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined>;
export type SessionTranscriptRuntimeTarget = {
agentId: string;
sessionFile: string;
@@ -463,241 +391,6 @@ export async function publishTranscriptUpdate(
});
}
/**
* Persists one logical transcript turn through the current file-backed writer.
* The file implementation resolves/rebinds the transcript file, holds one
* session write lock across all message appends, optionally touches session
* metadata, then publishes after the write has completed.
*
* SQLite implementation note: the transcript row append(s), sessionFile marker,
* and requested updatedAt touch become one SQLite write transaction; transcript
* update delivery must run only after commit.
*/
export async function persistSessionTranscriptTurn(
scope: SessionTranscriptWriteScope & {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
},
options: SessionTranscriptTurnPersistOptions,
): Promise<SessionTranscriptTurnPersistResult> {
const expectedSessionId = options.expectedSessionId;
if (expectedSessionId) {
return await persistExpectedSessionTranscriptTurn(scope, { ...options, expectedSessionId });
}
const target = await resolveTranscriptTurnTarget(scope);
const appendedMessages = await appendTranscriptTurnMessages(target, options);
const appendedCount = countAppendedTranscriptMessages(appendedMessages);
const sessionEntry = await touchTranscriptTurnSessionEntry({
scope,
target,
shouldTouch: options.touchSessionEntry === true && appendedCount > 0,
});
await publishTranscriptTurnUpdate({
target,
updateMode: options.updateMode ?? "inline",
publishWhen: options.publishWhen ?? "when-appended",
appendedMessages,
});
return {
appendedCount,
messages: appendedMessages,
sessionEntry,
sessionFile: target.sessionFile,
};
}
async function appendTranscriptTurnMessages(
target: SessionTranscriptTurnWriteContext,
options: SessionTranscriptTurnPersistOptions,
): Promise<TranscriptMessageAppendResult<unknown>[]> {
const appendedMessages: TranscriptMessageAppendResult<unknown>[] = [];
const publishedEntries: OwnedSessionTranscriptPublishedEntry[] = [];
const appendMessages = async (appendMessage: SessionTranscriptTurnAppendRunner) => {
for (const append of options.messages) {
const shouldAppend = append.shouldAppend
? await append.shouldAppend({
...(target.agentId ? { agentId: target.agentId } : {}),
sessionFile: target.sessionFile,
...(target.sessionId ? { sessionId: target.sessionId } : {}),
...(target.sessionKey ? { sessionKey: target.sessionKey } : {}),
})
: true;
if (!shouldAppend) {
continue;
}
const result = await appendMessage({
transcriptPath: target.sessionFile,
message: append.message,
...(target.sessionId ? { sessionId: target.sessionId } : {}),
...((append.cwd ?? options.cwd) ? { cwd: append.cwd ?? options.cwd } : {}),
...((append.config ?? options.config) ? { config: append.config ?? options.config } : {}),
...(append.idempotencyLookup ? { idempotencyLookup: append.idempotencyLookup } : {}),
...(append.now !== undefined ? { now: append.now } : {}),
...(append.prepareMessageAfterIdempotencyCheck
? { prepareMessageAfterIdempotencyCheck: append.prepareMessageAfterIdempotencyCheck }
: {}),
onHeaderCreated: (header) => {
publishedEntries.push({ kind: "header", serialized: header });
},
...(append.useRawWhenLinear !== undefined
? { useRawWhenLinear: append.useRawWhenLinear }
: {}),
});
if (result) {
appendedMessages.push(result);
if (result.appended) {
publishedEntries.push({ kind: "id", id: result.messageId });
}
}
}
};
const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({
sessionFile: target.sessionFile,
sessionKey: target.sessionKey,
});
const runBatchWithOwnedLock = async () =>
await withOwnedSessionTranscriptWrites(
{
sessionFile: target.sessionFile,
sessionKey: target.sessionKey,
withSessionWriteLock: async (run) => await run(),
},
async () => await appendMessages(appendSessionTranscriptMessageWithOwnedWriteLock),
);
if (activeLockRunner) {
await activeLockRunner(
() => withSessionTranscriptAppendQueue(target.sessionFile, runBatchWithOwnedLock),
{
publishOwnedWrite: true,
resolvePublishedEntries: () => publishedEntries,
resolvePublishedEntriesAfterFailure: () => publishedEntries,
},
);
} else {
await withSessionTranscriptAppendQueue(target.sessionFile, async () => {
const lock = await acquireSessionWriteLock({
sessionFile: target.sessionFile,
...resolveSessionWriteLockOptions(options.config),
allowReentrant: true,
});
try {
await runBatchWithOwnedLock();
} finally {
await lock.release();
}
});
}
return appendedMessages;
}
function countAppendedTranscriptMessages(
messages: readonly TranscriptMessageAppendResult<unknown>[],
): number {
return messages.filter((message) => message.appended).length;
}
async function persistExpectedSessionTranscriptTurn(
scope: SessionTranscriptWriteScope & {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
},
options: SessionTranscriptTurnPersistOptions & { expectedSessionId: string },
): Promise<SessionTranscriptTurnPersistResult> {
const sessionKey = scope.sessionKey?.trim();
if (!scope.storePath || !sessionKey) {
throw new Error("Cannot guard a transcript turn without a session store and key");
}
const expectedSessionId = options.expectedSessionId;
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(sessionKey);
if (!agentId) {
throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`);
}
const store =
scope.sessionStore ?? loadSessionStore(scope.storePath, { skipCache: true, clone: false });
const resolved = resolveSessionStoreEntry({ store, sessionKey });
let appendedMessages: TranscriptMessageAppendResult<unknown>[] = [];
let target: SessionTranscriptTurnWriteContext = {
agentId,
sessionFile:
scope.sessionFile ??
resolveSessionTranscriptPathInDir(expectedSessionId, path.dirname(scope.storePath)),
sessionId: expectedSessionId,
sessionKey: resolved.normalizedKey,
};
let rejectedEntry: SessionEntry | undefined;
let touchUpdatedAt: number | undefined;
const updated = await updateSessionEntry(
{
sessionKey: resolved.normalizedKey,
storePath: scope.storePath,
},
async (currentEntry) => {
if (currentEntry.sessionId !== expectedSessionId) {
rejectedEntry = currentEntry;
return null;
}
const sessionFile =
scope.sessionFile ??
resolveSessionFilePath(
currentEntry.sessionId,
currentEntry,
resolveSessionFilePathOptions({
agentId,
storePath: scope.storePath,
}),
);
target = {
agentId,
sessionFile,
sessionId: currentEntry.sessionId,
sessionKey: resolved.normalizedKey,
};
appendedMessages = await appendTranscriptTurnMessages(target, options);
const appendedCount = countAppendedTranscriptMessages(appendedMessages);
if (options.touchSessionEntry === true && appendedCount > 0) {
touchUpdatedAt = Date.now();
}
const patch = {
...(currentEntry.sessionFile === sessionFile ? {} : { sessionFile }),
...(touchUpdatedAt !== undefined
? { updatedAt: Math.max(currentEntry.updatedAt ?? 0, touchUpdatedAt) }
: {}),
};
return Object.keys(patch).length > 0 ? patch : null;
},
{ skipMaintenance: true },
);
if (rejectedEntry || updated?.sessionId !== expectedSessionId) {
return {
appendedCount: 0,
messages: [],
rejectedReason: "session-rebound",
sessionEntry: rejectedEntry ?? updated ?? undefined,
sessionFile: target.sessionFile,
};
}
await publishTranscriptTurnUpdate({
target,
updateMode: options.updateMode ?? "inline",
publishWhen: options.publishWhen ?? "when-appended",
appendedMessages,
});
if (updated && scope.sessionStore) {
scope.sessionStore[resolved.normalizedKey] = updated;
}
return {
appendedCount: countAppendedTranscriptMessages(appendedMessages),
messages: appendedMessages,
sessionEntry: updated ?? scope.sessionEntry,
sessionFile: target.sessionFile,
};
}
/**
* Resolves the current file-backed target for a storage-neutral runtime
* transcript scope. Callers use the scope as identity; sessionFile is returned
@@ -718,14 +411,6 @@ export async function resolveSessionTranscriptRuntimeTarget(
: undefined;
const sessionEntry = resolvedStoreEntry?.existing ?? loadSessionEntry(scope);
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
if (scope.sessionFile?.trim()) {
return {
agentId,
sessionFile: path.resolve(scope.sessionFile),
sessionId: scope.sessionId,
sessionKey,
};
}
if (sessionStore && scope.storePath) {
const sessionsDir = path.dirname(path.resolve(scope.storePath));
const threadId = scope.threadId ?? parseSessionThreadInfo(scope.sessionKey).threadId;
@@ -788,14 +473,6 @@ export async function resolveSessionTranscriptRuntimeReadTarget(
: undefined;
const sessionEntry = resolvedStoreEntry?.existing ?? loadSessionEntry(scope);
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
if (scope.sessionFile?.trim()) {
return {
agentId,
sessionFile: path.resolve(scope.sessionFile),
sessionId: scope.sessionId,
sessionKey,
};
}
const matchingSessionEntry =
sessionEntry?.sessionId === scope.sessionId ? sessionEntry : undefined;
if (scope.storePath) {
@@ -892,121 +569,3 @@ async function resolveTranscriptAccess(scope: SessionTranscriptWriteScope): Prom
sessionKey: scopeSessionKey,
});
}
async function resolveTranscriptTurnTarget(
scope: SessionTranscriptWriteScope & {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
},
): Promise<
SessionTranscriptTurnWriteContext & {
sessionEntry: SessionEntry | undefined;
}
> {
if (scope.sessionFile?.trim()) {
return {
...(scope.agentId ? { agentId: scope.agentId } : {}),
sessionFile: scope.sessionFile,
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}),
sessionEntry: scope.sessionEntry,
};
}
const sessionKey = scope.sessionKey?.trim();
if (!sessionKey || !scope.sessionId) {
throw new Error(
"Cannot persist a transcript turn without a session key and session id or explicit session file",
);
}
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(sessionKey);
if (!agentId) {
throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`);
}
const store =
scope.sessionStore ??
(scope.storePath ? loadSessionStore(scope.storePath, { skipCache: true }) : undefined);
const resolved = store ? resolveSessionStoreEntry({ store, sessionKey }) : undefined;
const sessionEntry =
resolved?.existing ?? scope.sessionEntry ?? loadSessionEntry({ ...scope, sessionKey });
const resolvedFile = await resolveSessionTranscriptFile({
agentId,
sessionEntry,
sessionId: scope.sessionId,
sessionKey,
...(store ? { sessionStore: store } : {}),
...(scope.storePath ? { storePath: scope.storePath } : {}),
...(scope.threadId !== undefined ? { threadId: scope.threadId } : {}),
});
return {
agentId,
sessionFile: resolvedFile.sessionFile,
sessionId: scope.sessionId,
sessionKey: resolved?.normalizedKey ?? sessionKey,
sessionEntry: resolvedFile.sessionEntry,
};
}
async function touchTranscriptTurnSessionEntry(params: {
scope: SessionTranscriptWriteScope & {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
};
target: SessionTranscriptTurnWriteContext & {
sessionEntry: SessionEntry | undefined;
};
shouldTouch: boolean;
}): Promise<SessionEntry | undefined> {
if (
!params.shouldTouch ||
!params.scope.storePath ||
!params.target.sessionKey ||
!params.target.sessionId
) {
return params.target.sessionEntry;
}
const markerUpdatedAt = Date.now();
const updated = await updateSessionEntry(
{
sessionKey: params.target.sessionKey,
storePath: params.scope.storePath,
},
(current) =>
current.sessionId === params.target.sessionId
? {
sessionFile: params.target.sessionFile,
updatedAt: Math.max(current.updatedAt ?? 0, markerUpdatedAt),
}
: null,
{ skipMaintenance: true },
);
if (updated && params.scope.sessionStore) {
params.scope.sessionStore[params.target.sessionKey] = updated;
}
return updated ?? params.target.sessionEntry;
}
async function publishTranscriptTurnUpdate(params: {
target: SessionTranscriptTurnWriteContext;
updateMode: SessionTranscriptTurnUpdateMode;
publishWhen: "always" | "when-appended";
appendedMessages: TranscriptMessageAppendResult<unknown>[];
}): Promise<void> {
if (params.updateMode === "none") {
return;
}
const lastAppended = params.appendedMessages.findLast((message) => message.appended);
if (params.publishWhen === "when-appended" && !lastAppended) {
return;
}
emitSessionTranscriptUpdate({
...(params.target.sessionKey ? { sessionKey: params.target.sessionKey } : {}),
...(params.target.agentId ? { agentId: params.target.agentId } : {}),
...(params.updateMode === "inline" && lastAppended
? {
message: lastAppended.message,
messageId: lastAppended.messageId,
}
: {}),
sessionFile: params.target.sessionFile,
});
}

View File

@@ -338,7 +338,7 @@ async function resolveTranscriptAppendQueueKey(transcriptPath: string): Promise<
}
}
export async function withSessionTranscriptAppendQueue<T>(
async function withTranscriptAppendQueue<T>(
transcriptPath: string,
fn: () => Promise<T>,
): Promise<T> {
@@ -363,7 +363,7 @@ export async function withSessionTranscriptAppendQueue<T>(
}
}
export type AppendSessionTranscriptMessageParams<TMessage = unknown> = {
type AppendSessionTranscriptMessageParams<TMessage = unknown> = {
transcriptPath: string;
message: TMessage;
now?: number;
@@ -375,11 +375,9 @@ export type AppendSessionTranscriptMessageParams<TMessage = unknown> = {
/** Runs under the transcript write lock after idempotency replay checks and before append. */
prepareMessageAfterIdempotencyCheck?: (message: TMessage) => TMessage | undefined;
config?: OpenClawConfig;
/** Internal owned-batch hook for publishing a newly created transcript header. */
onHeaderCreated?: (serializedHeader: string) => void;
};
export type AppendSessionTranscriptMessageResult<TMessage> = {
type AppendSessionTranscriptMessageResult<TMessage> = {
messageId: string;
message: TMessage;
appended: boolean;
@@ -415,7 +413,7 @@ export async function appendSessionTranscriptMessage<TMessage>(
let publishedHeader: string | undefined;
return await activeLockRunner(
() =>
withSessionTranscriptAppendQueue(params.transcriptPath, () =>
withTranscriptAppendQueue(params.transcriptPath, () =>
appendSessionTranscriptMessageLocked({
...params,
onHeaderCreated: (header) => {
@@ -434,36 +432,11 @@ export async function appendSessionTranscriptMessage<TMessage>(
},
);
}
return await withSessionTranscriptAppendQueue(params.transcriptPath, () =>
return await withTranscriptAppendQueue(params.transcriptPath, () =>
withSessionTranscriptWriteLock(params, () => appendSessionTranscriptMessageLocked(params)),
);
}
/**
* Appends a message while the caller already owns the transcript write lock and
* append FIFO. Batch writers use this to keep queue-before-lock ordering while
* reusing the same file lock for multiple transcript rows.
*/
export async function appendSessionTranscriptMessageWithOwnedWriteLock<TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage> & {
prepareMessageAfterIdempotencyCheck: (message: TMessage) => TMessage | undefined;
},
): Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined>;
export async function appendSessionTranscriptMessageWithOwnedWriteLock<TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage>,
): Promise<AppendSessionTranscriptMessageResult<TMessage>>;
export async function appendSessionTranscriptMessageWithOwnedWriteLock<TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage>,
): Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined> {
const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({
sessionFile: params.transcriptPath,
});
if (!activeLockRunner) {
throw new Error("Owned transcript write lock is required for batch transcript append");
}
return await activeLockRunner(() => appendSessionTranscriptMessageLocked(params));
}
export type AppendSessionTranscriptEventParams = {
config?: OpenClawConfig;
event: unknown;
@@ -480,7 +453,7 @@ export async function appendSessionTranscriptEvent(
if (activeLockRunner) {
await activeLockRunner(
() =>
withSessionTranscriptAppendQueue(params.transcriptPath, () =>
withTranscriptAppendQueue(params.transcriptPath, () =>
appendSessionTranscriptEventLocked(params),
),
{
@@ -492,7 +465,7 @@ export async function appendSessionTranscriptEvent(
);
return;
}
await withSessionTranscriptAppendQueue(params.transcriptPath, () =>
await withTranscriptAppendQueue(params.transcriptPath, () =>
withSessionTranscriptWriteLock(params, () => appendSessionTranscriptEventLocked(params)),
);
}
@@ -523,7 +496,9 @@ async function appendSessionTranscriptEventLocked(
}
async function appendSessionTranscriptMessageLocked<TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage>,
params: AppendSessionTranscriptMessageParams<TMessage> & {
onHeaderCreated?: (serializedHeader: string) => void;
},
): Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined> {
const now = params.now ?? Date.now();
const serializedHeader = await ensureTranscriptHeader(params.transcriptPath, {

View File

@@ -1,59 +0,0 @@
// Resolves transcript file targets without depending on transcript read/write facades.
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
} from "./paths.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { parseSessionThreadInfo } from "./thread-info.js";
import type { SessionEntry } from "./types.js";
/**
* Resolves the transcript file for a session and persists the resolved target
* when the caller supplies the owning session store.
*/
export async function resolveSessionTranscriptFile(params: {
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
agentId: string;
threadId?: string | number;
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> {
const sessionPathOpts = resolveSessionFilePathOptions({
agentId: params.agentId,
storePath: params.storePath,
});
let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts);
let sessionEntry = params.sessionEntry;
if (params.sessionStore && params.storePath) {
// Persisting the resolved transcript path keeps later tail reads and exports on the same file.
const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId;
const fallbackSessionFile = !sessionEntry?.sessionFile
? resolveSessionTranscriptPath(
params.sessionId,
params.agentId,
params.threadId ?? threadIdFromSessionKey,
)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
sessionEntry,
agentId: sessionPathOpts?.agentId,
sessionsDir: sessionPathOpts?.sessionsDir,
fallbackSessionFile,
});
sessionFile = resolvedSessionFile.sessionFile;
sessionEntry = resolvedSessionFile.sessionEntry;
}
return {
sessionFile,
sessionEntry,
};
}

View File

@@ -286,7 +286,7 @@ describe("appendAssistantMessageToSessionTranscript", () => {
);
expect(result.ok).toBe(true);
expect(events).toEqual(["lock"]);
expect(events).toEqual(["lock", "lock"]);
});
it("keeps matching owned transcript appends locked from bound callbacks", async () => {

View File

@@ -4,15 +4,24 @@ import type { AgentMessage } from "../../agents/runtime/index.js";
import type { SessionManager } from "../../agents/sessions/session-manager.js";
import { redactTranscriptMessage } from "../../agents/transcript-redact.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { extractAssistantVisibleText } from "../../shared/chat-message-content.js";
import { isTranscriptOnlyOpenClawAssistantModel } from "../../shared/transcript-only-openclaw-assistant.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { resolveDefaultSessionStorePath } from "./paths.js";
import { persistSessionTranscriptTurn } from "./session-accessor.js";
import {
resolveDefaultSessionStorePath,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
} from "./paths.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { loadSessionStore, resolveSessionStoreEntry } from "./store.js";
import { loadSessionStore, resolveSessionStoreEntry, updateSessionStoreEntry } from "./store.js";
import { parseSessionThreadInfo } from "./thread-info.js";
import { appendSessionTranscriptMessage } from "./transcript-append.js";
import { resolveMirroredTranscriptText } from "./transcript-mirror.js";
import { streamSessionTranscriptLinesReverse } from "./transcript-stream.js";
import { runWithOwnedSessionTranscriptWriteLock } from "./transcript-write-context.js";
import type { SessionEntry } from "./types.js";
export type SessionTranscriptAppendResult =
| { ok: true; sessionFile: string; messageId: string }
@@ -71,8 +80,6 @@ type AssistantTranscriptText = {
export type LatestAssistantTranscriptText = AssistantTranscriptText;
export type TailAssistantTranscriptText = AssistantTranscriptText;
export { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
function parseAssistantTranscriptText(
line: string,
options?: { excludeTranscriptOnlyOpenClawAssistant?: boolean },
@@ -113,6 +120,52 @@ function isTranscriptOnlyOpenClawAssistantMessage(message: {
return isTranscriptOnlyOpenClawAssistantModel(message.provider, message.model);
}
export async function resolveSessionTranscriptFile(params: {
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
agentId: string;
threadId?: string | number;
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> {
const sessionPathOpts = resolveSessionFilePathOptions({
agentId: params.agentId,
storePath: params.storePath,
});
let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts);
let sessionEntry = params.sessionEntry;
if (params.sessionStore && params.storePath) {
// Persisting the resolved transcript path keeps later tail reads and exports on the same file.
const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId;
const fallbackSessionFile = !sessionEntry?.sessionFile
? resolveSessionTranscriptPath(
params.sessionId,
params.agentId,
params.threadId ?? threadIdFromSessionKey,
)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
sessionEntry,
agentId: sessionPathOpts?.agentId,
sessionsDir: sessionPathOpts?.sessionsDir,
fallbackSessionFile,
});
sessionFile = resolvedSessionFile.sessionFile;
sessionEntry = resolvedSessionFile.sessionEntry;
}
return {
sessionFile,
sessionEntry,
};
}
export async function readLatestAssistantTextFromSessionTranscript(
sessionFile: string | undefined,
): Promise<LatestAssistantTranscriptText | undefined> {
@@ -259,108 +312,140 @@ export async function appendExactAssistantMessageToSessionTranscript(params: {
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
}
let transcriptMarkerUpdatedAt: number | undefined;
let appendedSessionId = entry.sessionId;
const appendToSessionFile = async (
currentEntry: NonNullable<typeof entry>,
sessionFile?: string,
): Promise<SessionTranscriptAppendResult> => {
const explicitIdempotencyKey =
params.idempotencyKey ??
((params.message as { idempotencyKey?: unknown }).idempotencyKey as string | undefined);
const message = {
...params.message,
...(explicitIdempotencyKey ? { idempotencyKey: explicitIdempotencyKey } : {}),
} as Parameters<SessionManager["appendMessage"]>[0];
const preparedUnkeyedMessage =
!explicitIdempotencyKey && params.beforeMessageWrite
? applyBeforeMessageWriteToAssistant({
message,
beforeMessageWrite: params.beforeMessageWrite,
agentId: params.agentId,
sessionKey: resolved.normalizedKey,
})
: message;
if (!preparedUnkeyedMessage) {
return {
ok: false,
code: "blocked",
reason: "blocked by before_message_write",
};
}
const identifiedChannelFinal =
Boolean(explicitIdempotencyKey) && isChannelFinalDeliveryMirror(params.message);
let latestEquivalentAssistantId: string | undefined;
// Unidentified delivery mirrors dedupe by latest text. Identified channel finals use their
// idempotency key so repeated replies on separate user turns remain distinct.
const turn = await persistSessionTranscriptTurn(
{
sessionId: currentEntry.sessionId,
sessionKey: resolved.normalizedKey,
storePath,
...(sessionFile ? { sessionFile } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
},
{
cwd: currentEntry.spawnedCwd,
...(params.expectedSessionId ? { expectedSessionId: params.expectedSessionId } : {}),
...(params.config ? { config: params.config } : {}),
updateMode: params.updateMode ?? "inline",
touchSessionEntry: true,
messages: [
{
message: preparedUnkeyedMessage,
...(explicitIdempotencyKey ? { idempotencyLookup: "scan" } : {}),
...(explicitIdempotencyKey && params.beforeMessageWrite
? {
prepareMessageAfterIdempotencyCheck: (candidate: unknown) =>
applyBeforeMessageWriteToAssistant({
message: candidate as Parameters<SessionManager["appendMessage"]>[0],
beforeMessageWrite: params.beforeMessageWrite,
explicitIdempotencyKey,
agentId: params.agentId,
sessionKey: resolved.normalizedKey,
}),
}
: {}),
shouldAppend: async (target) => {
latestEquivalentAssistantId =
isRedundantDeliveryMirror(params.message) && !identifiedChannelFinal
? await findLatestEquivalentAssistantMessageId(
target.sessionFile,
preparedUnkeyedMessage as SessionTranscriptAssistantMessage,
params.config,
)
: undefined;
return !latestEquivalentAssistantId;
},
},
],
currentEntry: SessionEntry,
sessionFile: string,
): Promise<SessionTranscriptAppendResult> =>
await runWithOwnedSessionTranscriptWriteLock<SessionTranscriptAppendResult>(
{ sessionFile, sessionKey: resolved.normalizedKey },
async (): Promise<SessionTranscriptAppendResult> => {
const explicitIdempotencyKey =
params.idempotencyKey ??
((params.message as { idempotencyKey?: unknown }).idempotencyKey as string | undefined);
const message = {
...params.message,
...(explicitIdempotencyKey ? { idempotencyKey: explicitIdempotencyKey } : {}),
} as Parameters<SessionManager["appendMessage"]>[0];
const preparedUnkeyedMessage =
!explicitIdempotencyKey && params.beforeMessageWrite
? applyBeforeMessageWriteToAssistant({
message,
beforeMessageWrite: params.beforeMessageWrite,
agentId: params.agentId,
sessionKey: resolved.normalizedKey,
})
: message;
if (!preparedUnkeyedMessage) {
return {
ok: false,
code: "blocked",
reason: "blocked by before_message_write",
};
}
const identifiedChannelFinal =
Boolean(explicitIdempotencyKey) && isChannelFinalDeliveryMirror(params.message);
const latestEquivalentAssistantId =
isRedundantDeliveryMirror(params.message) && !identifiedChannelFinal
? await findLatestEquivalentAssistantMessageId(
sessionFile,
preparedUnkeyedMessage as SessionTranscriptAssistantMessage,
params.config,
)
: undefined;
// Unidentified delivery mirrors dedupe by latest text. Identified channel finals use their
// idempotency key so repeated replies on separate user turns remain distinct.
if (latestEquivalentAssistantId) {
return { ok: true, sessionFile, messageId: latestEquivalentAssistantId };
}
const appendedResult = await appendSessionTranscriptMessage({
transcriptPath: sessionFile,
sessionId: currentEntry.sessionId,
cwd: currentEntry.spawnedCwd,
message: preparedUnkeyedMessage,
...(explicitIdempotencyKey ? { idempotencyLookup: "scan" } : {}),
...(explicitIdempotencyKey && params.beforeMessageWrite
? {
prepareMessageAfterIdempotencyCheck: (
candidate: Parameters<SessionManager["appendMessage"]>[0],
) =>
applyBeforeMessageWriteToAssistant({
message: candidate,
beforeMessageWrite: params.beforeMessageWrite,
explicitIdempotencyKey,
agentId: params.agentId,
sessionKey: resolved.normalizedKey,
}),
}
: {}),
config: params.config,
});
if (!appendedResult) {
return {
ok: false,
code: "blocked",
reason: "blocked by before_message_write",
};
}
const { messageId, message: appendedMessage, appended } = appendedResult;
if (!appended) {
return { ok: true, sessionFile, messageId };
}
transcriptMarkerUpdatedAt = Date.now();
switch (params.updateMode ?? "inline") {
case "inline":
emitSessionTranscriptUpdate({
sessionFile,
sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
message: appendedMessage,
messageId,
});
break;
case "file-only":
emitSessionTranscriptUpdate({
sessionFile,
sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
});
break;
case "none":
break;
}
return { ok: true, sessionFile, messageId };
},
);
if (turn.rejectedReason === "session-rebound") {
return {
ok: false,
code: "session-rebound",
reason: `session rebound for sessionKey: ${sessionKey}`,
};
}
if (latestEquivalentAssistantId) {
return { ok: true, sessionFile: turn.sessionFile, messageId: latestEquivalentAssistantId };
}
const appendedResult = turn.messages[0];
if (!appendedResult) {
return {
ok: false,
code: "blocked",
reason: "blocked by before_message_write",
};
}
const { messageId } = appendedResult;
return { ok: true, sessionFile: turn.sessionFile, messageId };
};
let result: SessionTranscriptAppendResult;
if (params.expectedSessionId) {
result = await appendToSessionFile(entry);
result = {
ok: false,
code: "session-rebound",
reason: `session rebound for sessionKey: ${sessionKey}`,
};
await updateSessionStoreEntry({
storePath,
sessionKey: resolved.normalizedKey,
update: async (currentEntry) => {
if (currentEntry.sessionId !== params.expectedSessionId) {
return null;
}
const sessionFile = resolveSessionFilePath(
currentEntry.sessionId,
currentEntry,
resolveSessionFilePathOptions({
agentId: params.agentId,
storePath,
}),
);
appendedSessionId = currentEntry.sessionId;
result = await appendToSessionFile(currentEntry, sessionFile);
return currentEntry.sessionFile === sessionFile ? null : { sessionFile };
},
skipMaintenance: true,
});
} else {
let sessionFile: string;
try {
@@ -382,6 +467,14 @@ export async function appendExactAssistantMessageToSessionTranscript(params: {
}
result = await appendToSessionFile(entry, sessionFile);
}
if (result.ok && transcriptMarkerUpdatedAt !== undefined) {
await updateSessionStoreEntry({
storePath,
sessionKey: resolved.normalizedKey,
update: (current) =>
current.sessionId === appendedSessionId ? { updatedAt: transcriptMarkerUpdatedAt } : null,
});
}
return result;
}

View File

@@ -1,9 +1,10 @@
// Chat transcript injection appends gateway-authored assistant rows while
// preserving agent-session parent links and transcript update notifications.
import type { SessionManager } from "../../agents/sessions/session-manager.js";
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
type AppendMessageArg = Parameters<SessionManager["appendMessage"]>[0];
@@ -118,33 +119,21 @@ export async function appendInjectedAssistantMessageToTranscript(params: {
};
try {
const turn = await persistSessionTranscriptTurn(
{
sessionFile: params.transcriptPath,
sessionKey: params.sessionKey ?? "",
...(params.agentId ? { agentId: params.agentId } : {}),
},
{
updateMode: "inline",
...(params.config ? { config: params.config } : {}),
messages: [
{
message: messageBody,
now,
useRawWhenLinear: true,
},
],
},
);
const appended = turn.messages[0];
if (!appended) {
return { ok: false, error: "gateway-injected assistant message was not appended" };
}
return {
ok: true,
messageId: appended.messageId,
message: appended.message as Record<string, unknown>,
};
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
transcriptPath: params.transcriptPath,
message: messageBody,
now,
useRawWhenLinear: true,
config: params.config,
});
emitSessionTranscriptUpdate({
sessionFile: params.transcriptPath,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: appendedMessage,
messageId,
});
return { ok: true, messageId, message: appendedMessage as unknown as Record<string, unknown> };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
}

View File

@@ -38,7 +38,7 @@ import {
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { rewriteTranscriptEntriesInRuntimeTranscript } from "../../agents/embedded-agent-runner/transcript-rewrite.js";
import { rewriteTranscriptEntriesInSessionFile } from "../../agents/embedded-agent-runner/transcript-rewrite.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
import { modelCatalogBrowseRequiresFullDiscovery } from "../../agents/model-catalog-browse.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
@@ -3700,11 +3700,10 @@ export const chatHandlers: GatewayRequestHandlers = {
input: baseUserTurnInput,
resolveInput: () => userTurnInputPromise,
target: () => {
const {
storePath: latestStorePath,
store: latestStore,
entry: latestEntry,
} = loadSessionEntry(sessionKey, sessionLoadOptions);
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
sessionKey,
sessionLoadOptions,
);
const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId;
if (!resolvedSessionId) {
return undefined;
@@ -3713,7 +3712,6 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionId: resolvedSessionId,
sessionKey,
sessionEntry: latestEntry ?? entry,
sessionStore: latestStore,
storePath: latestStorePath,
agentId,
config: cfg,
@@ -4763,14 +4761,11 @@ export const chatHandlers: GatewayRequestHandlers = {
allowedSourceReplyMirrorIds.has(entryLocal.id),
) === true;
if (canRewriteSourceReplyMirrors) {
const result = await rewriteTranscriptEntriesInRuntimeTranscript({
scope: {
sessionId,
sessionKey,
sessionFile: resolvedTranscriptPath,
agentId,
...(latestStorePath ? { storePath: latestStorePath } : {}),
},
const result = await rewriteTranscriptEntriesInSessionFile({
sessionFile: resolvedTranscriptPath,
sessionKey,
agentId,
config: cfg,
request: {
allowedRewriteSuffixEntryIds: [...allowedSourceReplyMirrorIds],
replacements: rewriteTargets.map((target) => ({
@@ -4782,7 +4777,6 @@ export const chatHandlers: GatewayRequestHandlers = {
} as unknown as AgentMessage,
})),
},
config: cfg,
});
if (result.changed) {
await advanceSessionTranscriptMarker({

View File

@@ -2337,7 +2337,7 @@ describe("gateway chat transcript writes (guardrail)", () => {
expect(chatSrc.includes("fs.appendFileSync(transcriptPath")).toBe(false);
expect(chatSrc).toContain("appendInjectedAssistantMessageToTranscript(");
expect(helperSrc).toContain("persistSessionTranscriptTurn(");
expect(helperSrc).toContain("appendSessionTranscriptMessage({");
expect(helperSrc).toContain("useRawWhenLinear: true");
expect(helperSrc).not.toContain("SessionManager.open(params.transcriptPath)");
});

View File

@@ -97,26 +97,14 @@ async function withGatewayChatHarness(
}
}
function testSessionFilePath(sessionDir: string, sessionId: string): string {
return path.join(sessionDir, `${sessionId}.jsonl`);
}
async function writeMainSessionStore(sessionDir?: string, sessionId = "sess-main") {
async function writeMainSessionStore() {
await writeSessionStore({
entries: {
main: {
sessionId,
updatedAt: futureFixtureUpdatedAt(),
...(sessionDir ? { sessionFile: testSessionFilePath(sessionDir, sessionId) } : {}),
},
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
}
function futureFixtureUpdatedAt(): number {
return Date.now() + 60_000;
}
async function writeGatewayConfig(config: Record<string, unknown>) {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
@@ -127,12 +115,8 @@ async function writeGatewayConfig(config: Record<string, unknown>) {
clearConfigCache();
}
async function writeMainSessionTranscript(
sessionDir: string,
lines: string[],
sessionId = "sess-main",
) {
await fs.writeFile(testSessionFilePath(sessionDir, sessionId), `${lines.join("\n")}\n`, "utf-8");
async function writeMainSessionTranscript(sessionDir: string, lines: string[]) {
await fs.writeFile(path.join(sessionDir, "sess-main.jsonl"), `${lines.join("\n")}\n`, "utf-8");
}
async function removeTempDir(dir: string): Promise<void> {
@@ -213,14 +197,13 @@ async function prepareMainHistoryHarness(params: {
ws: GatewaySocket;
createSessionDir: () => Promise<string>;
historyMaxBytes?: number;
sessionId?: string;
}) {
if (params.historyMaxBytes !== undefined) {
setMaxChatHistoryMessagesBytesForTest(params.historyMaxBytes);
}
await connectOk(params.ws);
const sessionDir = await params.createSessionDir();
await writeMainSessionStore(sessionDir, params.sessionId);
await writeMainSessionStore();
return sessionDir;
}
@@ -2002,7 +1985,6 @@ describe("gateway server chat", () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
const sessionId = "sess-claude-cli-backfill";
const originalHome = process.env.HOME;
const homeDir = path.join(sessionDir, "home");
const cliSessionId = "5b8b202c-f6bb-4046-9475-d2f15fd07530";
@@ -2046,9 +2028,8 @@ describe("gateway server chat", () => {
await writeSessionStore({
entries: {
main: {
sessionId,
sessionFile: testSessionFilePath(sessionDir, sessionId),
updatedAt: futureFixtureUpdatedAt(),
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "claude-cli",
model: "claude-sonnet-4-6",
cliSessionBindings: {
@@ -2756,12 +2737,11 @@ describe("gateway server chat", () => {
test("chat.message.get returns archive-backed rows surfaced by history", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const sessionId = "sess-archive-backed";
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir, sessionId });
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
await fs.writeFile(
`${testSessionFilePath(sessionDir, sessionId)}.reset.2026-02-16T22-26-34.000Z`,
path.join(sessionDir, "sess-main.jsonl.reset.2026-02-16T22-26-34.000Z"),
[
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
JSON.stringify({
id: "msg-archive-full-assistant",
message: {

View File

@@ -99,19 +99,14 @@ describe("gateway server chat", () => {
});
};
const withMainSessionStore = async <T>(
run: (dir: string) => Promise<T>,
options?: { sessionId?: string },
): Promise<T> => {
const withMainSessionStore = async <T>(run: (dir: string) => Promise<T>): Promise<T> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
const sessionId = options?.sessionId ?? "sess-main";
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId,
sessionFile: path.join(dir, `${sessionId}.jsonl`),
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
@@ -1434,100 +1429,93 @@ describe("gateway server chat", () => {
});
test("chat.history persists assistant image data URLs as managed image blocks", async () => {
await withMainSessionStore(
async (dir) => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = dir;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
const [params] = args as [
{
dispatcher: {
sendFinalReply: (payload: { text?: string; mediaUrls?: string[] }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendFinalReply({
text: "Image reply",
mediaUrls: [`data:image/png;base64,${pngB64}`],
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
await withMainSessionStore(async (dir) => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = dir;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
const [params] = args as [
{
dispatcher: {
sendFinalReply: (payload: { text?: string; mediaUrls?: string[] }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendFinalReply({
mediaUrls: [`data:image/png;base64,${pngB64}`],
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
});
try {
const finalPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-managed-image-history",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "show me an image",
idempotencyKey: "idem-managed-image-history",
});
try {
const finalPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-managed-image-history",
8000,
);
const res = await rpcReq(ws, "chat.send", {
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-managed-image-history");
await finalPromise;
let assistantMessage: Record<string, unknown> | undefined;
await vi.waitFor(async () => {
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
message: "show me an image",
idempotencyKey: "idem-managed-image-history",
});
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-managed-image-history");
await finalPromise;
let assistantMessage: Record<string, unknown> | undefined;
await vi.waitFor(
async () => {
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
const messages = historyRes.payload?.messages ?? [];
assistantMessage = messages.find(
(message): message is Record<string, unknown> =>
typeof message === "object" &&
message !== null &&
(message as { role?: unknown }).role === "assistant",
);
if (!assistantMessage) {
throw new Error("Expected assistant history message");
}
},
{ timeout: CHAT_RESPONSE_TIMEOUT_MS },
expect(historyRes.ok).toBe(true);
const messages = historyRes.payload?.messages ?? [];
assistantMessage = messages.find(
(message): message is Record<string, unknown> =>
typeof message === "object" &&
message !== null &&
(message as { role?: unknown }).role === "assistant",
);
const assistantContent = (assistantMessage as { content?: unknown[] }).content ?? [];
expect(assistantContent).toHaveLength(2);
expect(assistantContent[0]).toEqual({ type: "text", text: "Image reply" });
const imageBlock = expectRecordFields(assistantContent[1], {
type: "image",
alt: "Generated image 1",
mimeType: "image/png",
width: 1,
height: 1,
});
expect(String(imageBlock.url)).toContain("/api/chat/media/outgoing/");
expect(String(imageBlock.openUrl)).toContain("/full");
const serializedAssistant = JSON.stringify(assistantMessage);
expect(serializedAssistant).not.toContain("data:image/png;base64");
expect(serializedAssistant).not.toContain(pngB64);
} finally {
if (previousStateDir == null) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
if (!assistantMessage) {
throw new Error("Expected assistant history message");
}
});
const assistantContent = (assistantMessage as { content?: unknown[] }).content ?? [];
expect(assistantContent).toHaveLength(2);
expect(assistantContent[0]).toEqual({ type: "text", text: "Image reply" });
const imageBlock = expectRecordFields(assistantContent[1], {
type: "image",
alt: "Generated image 1",
mimeType: "image/png",
width: 1,
height: 1,
});
expect(String(imageBlock.url)).toContain("/api/chat/media/outgoing/");
expect(String(imageBlock.openUrl)).toContain("/full");
const serializedAssistant = JSON.stringify(assistantMessage);
expect(serializedAssistant).not.toContain("data:image/png;base64");
expect(serializedAssistant).not.toContain(pngB64);
} finally {
if (previousStateDir == null) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
},
{ sessionId: "sess-managed-image-history" },
);
}
});
});
test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => {

View File

@@ -166,7 +166,7 @@ async function persistTestSessionConfig(): Promise<void> {
}
const nextStoreValue =
typeof testState.sessionStorePath === "string"
? testState.sessionStorePath
? preservedTemplateStore || testState.sessionStorePath
: preservedTemplateStore;
for (const configPath of configPaths) {
const config = { ...parsedConfigs.get(configPath) };
@@ -223,9 +223,8 @@ export async function writeSessionStore(params: {
// file directly; clear the in-process cache so handlers reload the seeded state.
clearSessionStoreCacheForTest();
await persistTestSessionConfig();
const serializedStore = JSON.stringify(store, null, 2);
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, serializedStore, "utf-8");
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
clearSessionStoreCacheForTest();
}

View File

@@ -1,6 +1,6 @@
// Input provenance helpers normalize source metadata for session messages.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { AgentMessage } from "../../packages/agent-core/src/types.js";
import type { AgentMessage } from "../agents/runtime/index.js";
// Input provenance marks whether a user-role message actually came from an
// external user, another session, or an internal system/tool handoff.

View File

@@ -1,29 +1,32 @@
// User turn transcript helpers extract user-turn text from session transcripts.
import path from "node:path";
import { mimeTypeFromFilePath } from "@openclaw/media-core/mime";
import type { AgentMessage } from "../../packages/agent-core/src/types.js";
import { persistSessionTranscriptTurn } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { applyInputProvenanceToUserMessage, normalizeInputProvenance } from "./input-provenance.js";
import type {
PersistedUserTurnMediaInput,
PersistedUserTurnMessage,
UserTurnBeforeMessageWrite,
UserTurnInput,
UserTurnSessionEntry,
UserTurnTranscriptFileTarget,
UserTurnTranscriptPersistResult,
UserTurnTranscriptRecorder,
UserTurnTranscriptTarget,
UserTurnTranscriptTargetResolver,
UserTurnTranscriptUpdateMode,
} from "./user-turn-transcript.types.js";
import type { AgentMessage } from "../agents/runtime/index.js";
import { appendSessionTranscriptMessage } from "../config/sessions/transcript-append.js";
import {
applyInputProvenanceToUserMessage,
type InputProvenance,
normalizeInputProvenance,
} from "./input-provenance.js";
import { emitSessionTranscriptUpdate } from "./transcript-events.js";
export type {
PersistedUserTurnMessage,
UserTurnInput,
UserTurnTranscriptRecorder,
} from "./user-turn-transcript.types.js";
// User-turn transcript helpers persist the selected prompt/media as a user
// message before or during runtime execution, preserving provenance/idempotency.
type TranscriptAppendConfig = Parameters<typeof appendSessionTranscriptMessage>[0]["config"];
type UserTurnSessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
threadId?: string | number;
} & Record<string, unknown>;
type PersistedUserTurnMediaInput = {
path?: string | null;
url?: string | null;
contentType?: string | null;
kind?: string | null;
};
type PersistedUserTurnMediaFields = {
MediaPath?: string;
@@ -32,6 +35,25 @@ type PersistedUserTurnMediaFields = {
MediaTypes?: string[];
};
export type PersistedUserTurnMessage = Extract<AgentMessage, { role: "user" }>;
export type UserTurnInput = {
text?: string | null;
media?: readonly PersistedUserTurnMediaInput[] | null;
timestamp?: number;
idempotencyKey?: string;
provenance?: InputProvenance;
mediaOnlyText?: string;
};
type UserTurnTranscriptUpdateMode = "inline" | "none";
export type UserTurnBeforeMessageWrite = (params: {
message: PersistedUserTurnMessage;
agentId?: string;
sessionKey?: string;
}) => AgentMessage | null;
type AppendUserTurnTranscriptMessageParams = {
transcriptPath: string;
input?: UserTurnInput;
@@ -40,7 +62,7 @@ type AppendUserTurnTranscriptMessageParams = {
agentId?: string;
sessionKey?: string;
cwd?: string;
config?: OpenClawConfig;
config?: TranscriptAppendConfig;
updateMode?: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
};
@@ -56,13 +78,60 @@ type PersistUserTurnTranscriptParams = {
agentId: string;
threadId?: string | number;
cwd?: string;
config?: unknown;
config?: TranscriptAppendConfig;
updateMode?: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
};
type UserTurnTranscriptPersistenceTarget = Omit<
PersistUserTurnTranscriptParams,
"input" | "message" | "updateMode"
>;
type UserTurnTranscriptFileTarget = {
transcriptPath: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
cwd?: string;
config?: TranscriptAppendConfig;
};
type UserTurnTranscriptTarget = UserTurnTranscriptPersistenceTarget | UserTurnTranscriptFileTarget;
type UserTurnTranscriptPersistResult = {
sessionFile: string;
sessionEntry: UserTurnSessionEntry | undefined;
messageId: string;
message: PersistedUserTurnMessage;
};
type UserTurnTranscriptTargetResolver =
| UserTurnTranscriptTarget
| (() => UserTurnTranscriptTarget | undefined | Promise<UserTurnTranscriptTarget | undefined>);
type UserTurnInputResolver = () => UserTurnInput | undefined | Promise<UserTurnInput | undefined>;
export type UserTurnTranscriptRecorder = {
readonly message: PersistedUserTurnMessage | undefined;
resolveMessage: () => Promise<PersistedUserTurnMessage | undefined>;
markRuntimePersistencePending: (pending: Promise<void>) => void;
markRuntimePersisted: (message?: PersistedUserTurnMessage) => void;
markBlocked: () => void;
hasPersisted: () => boolean;
isBlocked: () => boolean;
hasRuntimePersistencePending: () => boolean;
waitForRuntimePersistence: () => Promise<void>;
persistApproved: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
persistFallback: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
};
type CreateUserTurnTranscriptRecorderParams = {
input?: UserTurnInput;
message?: PersistedUserTurnMessage;
@@ -287,8 +356,7 @@ export function mergePreparedUserTurnMessageForRuntime(params: {
} as unknown as AgentMessage;
}
/** Applies before-message hooks while preserving user-turn transcript metadata. */
export function preparePersistedUserTurnMessageForTranscriptWrite(
function applyBeforeMessageWriteToUserTurn(
message: PersistedUserTurnMessage,
params: Pick<
AppendUserTurnTranscriptMessageParams,
@@ -338,40 +406,36 @@ export async function appendUserTurnTranscriptMessage(
return undefined;
}
const turn = await persistSessionTranscriptTurn(
{
sessionFile: params.transcriptPath,
sessionKey: params.sessionKey ?? "",
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
},
{
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config } : {}),
updateMode: params.updateMode ?? "inline",
messages: [
{
message: resolvedMessage,
idempotencyLookup: "scan",
prepareMessageAfterIdempotencyCheck: (message) =>
preparePersistedUserTurnMessageForTranscriptWrite(
message as PersistedUserTurnMessage,
params,
),
},
],
},
);
const appended = turn.messages[0] as
| {
messageId: string;
message: PersistedUserTurnMessage;
}
| undefined;
const appended = await appendSessionTranscriptMessage({
transcriptPath: params.transcriptPath,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config } : {}),
message: resolvedMessage,
idempotencyLookup: "scan",
prepareMessageAfterIdempotencyCheck: (message) =>
applyBeforeMessageWriteToUserTurn(message, params),
});
if (!appended) {
return undefined;
}
switch (params.updateMode ?? "inline") {
case "inline":
if (appended.appended) {
emitSessionTranscriptUpdate({
sessionFile: params.transcriptPath,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: appended.message,
messageId: appended.messageId,
});
}
break;
case "none":
break;
}
return {
sessionFile: params.transcriptPath,
messageId: appended.messageId,
@@ -389,72 +453,38 @@ export async function persistUserTurnTranscript(
return undefined;
}
const turn = await persistSessionTranscriptTurn(
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
...(params.sessionStore ? { sessionStore: params.sessionStore } : {}),
...(params.storePath ? { storePath: params.storePath } : {}),
agentId: params.agentId,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
},
{
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config as OpenClawConfig } : {}),
updateMode: params.updateMode ?? "inline",
messages: [
{
message,
idempotencyLookup: "scan",
prepareMessageAfterIdempotencyCheck: (candidate) =>
preparePersistedUserTurnMessageForTranscriptWrite(
candidate as PersistedUserTurnMessage,
params,
),
},
],
},
);
const appended = turn.messages[0] as
| {
messageId: string;
message: PersistedUserTurnMessage;
}
| undefined;
const { resolveSessionTranscriptFile } = await import("../config/sessions/transcript.js");
const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
...(params.sessionStore ? { sessionStore: params.sessionStore } : {}),
...(params.storePath ? { storePath: params.storePath } : {}),
agentId: params.agentId,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
});
const appended = await appendUserTurnTranscriptMessage({
transcriptPath: sessionFile,
message,
sessionId: params.sessionId,
agentId: params.agentId,
sessionKey: params.sessionKey,
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config } : {}),
...(params.updateMode ? { updateMode: params.updateMode } : {}),
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
});
if (!appended) {
return undefined;
}
return {
...appended,
sessionEntry: turn.sessionEntry,
sessionFile: turn.sessionFile,
sessionEntry,
};
}
async function appendFileTargetUserTurnTranscript(params: {
target: UserTurnTranscriptFileTarget;
message: PersistedUserTurnMessage;
updateMode: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
}): Promise<UserTurnTranscriptPersistResult | undefined> {
const { config, ...target } = params.target;
const appended = await appendUserTurnTranscriptMessage({
...target,
message: params.message,
updateMode: params.updateMode,
...(config ? { config: config as OpenClawConfig } : {}),
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
});
return appended
? {
...appended,
sessionEntry: undefined,
}
: undefined;
}
async function resolveUserTurnTranscriptTarget(
target: UserTurnTranscriptTargetResolver,
): Promise<UserTurnTranscriptTarget | undefined> {
@@ -566,12 +596,19 @@ export function createUserTurnTranscriptRecorder(
}
const updateMode = options.updateMode ?? params.updateMode ?? "inline";
const result = isUserTurnTranscriptFileTarget(target)
? await appendFileTargetUserTurnTranscript({
target,
? await appendUserTurnTranscriptMessage({
...target,
message: resolvedMessage,
updateMode,
beforeMessageWrite: params.beforeMessageWrite,
})
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
}).then((appended) =>
appended
? {
...appended,
sessionEntry: undefined,
}
: undefined,
)
: await persistUserTurnTranscript({
...target,
message: resolvedMessage,

View File

@@ -1,93 +0,0 @@
// User-turn transcript type contracts shared by runtime and queue option types.
import type { AgentMessage } from "../../packages/agent-core/src/types.js";
import type { InputProvenance } from "./input-provenance.js";
export type UserTurnSessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
threadId?: string | number;
} & Record<string, unknown>;
export type PersistedUserTurnMediaInput = {
path?: string | null;
url?: string | null;
contentType?: string | null;
kind?: string | null;
};
export type PersistedUserTurnMessage = Extract<AgentMessage, { role: "user" }>;
export type UserTurnInput = {
text?: string | null;
media?: readonly PersistedUserTurnMediaInput[] | null;
timestamp?: number;
idempotencyKey?: string;
provenance?: InputProvenance;
mediaOnlyText?: string;
};
export type UserTurnTranscriptUpdateMode = "inline" | "none";
export type UserTurnBeforeMessageWrite = (params: {
message: PersistedUserTurnMessage;
agentId?: string;
sessionKey?: string;
}) => AgentMessage | null;
export type UserTurnTranscriptPersistenceTarget = {
sessionId: string;
sessionKey: string;
sessionEntry: UserTurnSessionEntry | undefined;
sessionStore?: Record<string, UserTurnSessionEntry>;
storePath?: string;
agentId: string;
threadId?: string | number;
cwd?: string;
config?: unknown;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
};
export type UserTurnTranscriptFileTarget = {
transcriptPath: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
cwd?: string;
config?: unknown;
};
export type UserTurnTranscriptTarget =
| UserTurnTranscriptPersistenceTarget
| UserTurnTranscriptFileTarget;
export type UserTurnTranscriptPersistResult = {
sessionFile: string;
sessionEntry: UserTurnSessionEntry | undefined;
messageId: string;
message: PersistedUserTurnMessage;
};
export type UserTurnTranscriptTargetResolver =
| UserTurnTranscriptTarget
| (() => UserTurnTranscriptTarget | undefined | Promise<UserTurnTranscriptTarget | undefined>);
export type UserTurnTranscriptRecorder = {
readonly message: PersistedUserTurnMessage | undefined;
resolveMessage: () => Promise<PersistedUserTurnMessage | undefined>;
markRuntimePersistencePending: (pending: Promise<void>) => void;
markRuntimePersisted: (message?: PersistedUserTurnMessage) => void;
markBlocked: () => void;
hasPersisted: () => boolean;
isBlocked: () => boolean;
hasRuntimePersistencePending: () => boolean;
waitForRuntimePersistence: () => Promise<void>;
persistApproved: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
persistFallback: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
};

View File

@@ -10,6 +10,7 @@ profiles:
description: Deterministic PR and merge proof with mock model providers, synthetic qa-channel,
and local SDK-backed channel upstreams; no live external service required.
evidenceMode: slim
channelDriver: crabline
categoryIds:
- agent-runtime-and-provider-execution.agent-turn-execution
- session-memory-and-context-engine.token-management
@@ -31,6 +32,7 @@ profiles:
description: Stable/LTS proof selector for live providers, live channels, package artifacts,
upgrade paths, and platform proof where the claim depends on real upstreams or release
artifacts.
channelDriver: live
includeAllCategories: true
levels:
- id: planned

View File

@@ -1,280 +0,0 @@
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os, { tmpdir } from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../../../../scripts/e2e/lib/plugin-index-sqlite.mjs";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-lifecycle-probe-"));
tempDirs.push(dir);
return dir;
}
type ProbeEnv = Pick<NodeJS.ProcessEnv, "HOME" | "OPENCLAW_CONFIG_PATH" | "OPENCLAW_STATE_DIR">;
function stateDir(env: ProbeEnv = process.env) {
return env.OPENCLAW_STATE_DIR || path.join(env.HOME ?? os.homedir(), ".openclaw");
}
function configPath(env: ProbeEnv = process.env) {
return env.OPENCLAW_CONFIG_PATH || path.join(stateDir(env), "openclaw.json");
}
function readJson(file: string) {
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
} catch {
return {};
}
}
function readRequiredJson(file: string) {
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
}
}
function records(env: ProbeEnv = process.env) {
return readPluginInstallRecords({
configPath: configPath(env),
stateDir: stateDir(env),
}) as Record<string, Record<string, unknown>>;
}
function recordFor(pluginId: string, env: ProbeEnv = process.env) {
return records(env)[pluginId];
}
function config(env: ProbeEnv = process.env) {
return readJson(configPath(env));
}
function requiredConfig(env: ProbeEnv = process.env) {
return readRequiredJson(configPath(env));
}
function assertProbe(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function assertVersion(pluginId: string, version: string, env: ProbeEnv = process.env) {
const record = recordFor(pluginId, env);
assertProbe(record, `install record missing for ${pluginId}`);
assertProbe(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
assertProbe(
record.resolvedVersion === version || record.version === version,
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
);
assertProbe(record.installPath, `install path missing for ${pluginId}`);
const packageJson = readJson(path.join(String(record.installPath), "package.json"));
assertProbe(
packageJson.version === version,
`expected installed package version ${version}, got ${packageJson.version}`,
);
}
function assertNpmProjectRoot(pluginId: string, packageName: string, env: ProbeEnv = process.env) {
const record = recordFor(pluginId, env);
assertProbe(record?.installPath, `install path missing for ${pluginId}`);
const installPath = String(record.installPath);
const relative = path.relative(path.join(stateDir(env), "npm", "projects"), installPath);
assertProbe(
!relative.startsWith("..") && !path.isAbsolute(relative),
`install path outside npm projects: ${installPath}`,
);
const segments = relative.split(path.sep);
const packageSegments = packageName.split("/");
assertProbe(
segments.length === 2 + packageSegments.length,
`unexpected npm project install path: ${installPath}`,
);
assertProbe(Boolean(segments[0]), `missing npm project directory: ${installPath}`);
assertProbe(
segments[1] === "node_modules",
`missing project node_modules segment: ${installPath}`,
);
for (let index = 0; index < packageSegments.length; index++) {
assertProbe(
segments[index + 2] === packageSegments[index],
`package path mismatch: ${installPath}`,
);
}
assertProbe(
!fs.existsSync(path.join(stateDir(env), "npm", "node_modules", ...packageSegments)),
`legacy flat npm install path exists for ${packageName}`,
);
}
function assertInspectLoaded(pluginId: string, inspectPath: string | undefined) {
assertProbe(inspectPath, "inspect JSON path is required");
const inspect = readRequiredJson(inspectPath);
const plugin = inspect.plugin as
| { enabled?: boolean; id?: string; status?: string }
| null
| undefined;
assertProbe(
plugin?.id === pluginId,
`expected inspected plugin id ${pluginId}, got ${plugin?.id}`,
);
assertProbe(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
assertProbe(
plugin.status === "loaded",
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
);
}
function assertEnabled(pluginId: string, expectedRaw: string, env: ProbeEnv = process.env) {
const expected = expectedRaw === "true";
const cfg = config(env) as {
plugins?: { entries?: Record<string, { enabled?: boolean }> };
};
const entry = cfg.plugins?.entries?.[pluginId];
assertProbe(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
}
function installPath(pluginId: string, env: ProbeEnv = process.env) {
const record = recordFor(pluginId, env);
assertProbe(record?.installPath, `install path missing for ${pluginId}`);
return String(record.installPath);
}
function assertUninstalled(pluginId: string, env: ProbeEnv = process.env) {
const cfg = requiredConfig(env) as {
plugins?: {
allow?: string[];
deny?: string[];
entries?: Record<string, unknown>;
load?: { paths?: unknown[] };
};
};
const record = recordFor(pluginId, env);
assertProbe(!record, `install record still present for ${pluginId}`);
assertProbe(
!cfg.plugins?.entries?.[pluginId],
`plugin config entry still present for ${pluginId}`,
);
assertProbe(
!(cfg.plugins?.allow ?? []).includes(pluginId),
`allowlist still contains ${pluginId}`,
);
assertProbe(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
const loadPaths = cfg.plugins?.load?.paths ?? [];
assertProbe(
!loadPaths.some((entry) => String(entry).includes(pluginId)),
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
);
}
export async function runPluginLifecycleProbeCommand(
args: readonly string[],
env: ProbeEnv = process.env,
) {
const [command, pluginId, arg] = args;
assertProbe(pluginId, "plugin id is required");
switch (command) {
case "assert-version":
assertProbe(arg, "expected version is required");
assertVersion(pluginId, arg, env);
return "";
case "assert-npm-project-root":
assertProbe(arg, "package name is required");
assertNpmProjectRoot(pluginId, arg, env);
return "";
case "assert-inspect-loaded":
assertInspectLoaded(pluginId, arg);
return "";
case "assert-enabled":
assertProbe(arg, "expected enabled value is required");
assertEnabled(pluginId, arg, env);
return "";
case "install-path":
return installPath(pluginId, env);
case "assert-uninstalled":
assertUninstalled(pluginId, env);
return "";
default:
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
}
}
const isProbeCli = process.argv[2] === "--probe";
if (isProbeCli) {
try {
const output = await runPluginLifecycleProbeCommand(process.argv.slice(3));
if (output) {
process.stdout.write(output);
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
} else {
const { afterEach, describe, expect, it } = await import("vitest");
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin lifecycle matrix probe", () => {
it("accepts inspect JSON for an enabled loaded plugin", async () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "inspect.json");
writeFileSync(
inspectPath,
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "loaded" } })}\n`,
"utf8",
);
await expect(
runPluginLifecycleProbeCommand(["assert-inspect-loaded", "lifecycle-claw", inspectPath]),
).resolves.toBe("");
});
it("rejects inspect JSON that does not prove the runtime loaded", async () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "inspect.json");
writeFileSync(
inspectPath,
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "pending" } })}\n`,
"utf8",
);
await expect(
runPluginLifecycleProbeCommand(["assert-inspect-loaded", "lifecycle-claw", inspectPath]),
).rejects.toThrow("expected lifecycle-claw inspect status loaded, got pending");
});
it("rejects missing inspect JSON instead of treating it as an empty object", async () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "missing.json");
await expect(
runPluginLifecycleProbeCommand(["assert-inspect-loaded", "lifecycle-claw", inspectPath]),
).rejects.toThrow(`failed to read JSON from ${inspectPath}`);
});
it("rejects unreadable config during uninstall proof", async () => {
const dir = makeTempDir();
const configFile = path.join(dir, ".openclaw", "openclaw.json");
mkdirSync(path.dirname(configFile), { recursive: true });
writeFileSync(configFile, "{ malformed\n", "utf8");
await expect(
runPluginLifecycleProbeCommand(["assert-uninstalled", "lifecycle-claw"], {
HOME: dir,
OPENCLAW_CONFIG_PATH: configFile,
}),
).rejects.toThrow(`failed to read JSON from ${configFile}`);
});
});
}

View File

@@ -1,12 +1,12 @@
// Channel Message Flows tests cover QA Lab channel preview evidence.
// Channel Message Flows tests cover channel message flows script behavior.
import { describe, expect, it, vi } from "vitest";
import {
parseChannelMessageFlowArgs,
resolveTelegramFlowThreadSpec,
runTelegramThinkingFinalFlow,
runTelegramWorkingFinalFlow,
} from "../../../../scripts/dev/channel-message-flows.ts";
import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js";
} from "../../scripts/dev/channel-message-flows.ts";
import type { OpenClawConfig } from "../../src/config/types.openclaw.js";
describe("channel message flows dev runner", () => {
function createTestDraftStream(params?: {

View File

@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import {
findSessionAccessorBoundaryViolations,
findSessionAccessorWriteBoundaryViolations,
findTranscriptWriterBoundaryViolations,
migratedBundledPluginSessionAccessorFiles,
findSessionAccessorWriteBoundaryViolations,
migratedSessionAccessorFiles,
migratedSessionAccessorWriteFiles,
migratedTranscriptWriterFiles,
} from "../../scripts/check-session-accessor-boundary.mjs";
describe("session accessor boundary guard", () => {
@@ -80,19 +78,6 @@ describe("session accessor boundary guard", () => {
);
});
it("ratchets only the files migrated by the transcript writer slice", () => {
expect(migratedTranscriptWriterFiles).toEqual(
new Set([
"src/agents/command/attempt-execution.ts",
"src/agents/embedded-agent-runner/context-engine-maintenance.ts",
"src/config/sessions/transcript.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/server-methods/chat-transcript-inject.ts",
"src/sessions/user-turn-transcript.ts",
]),
);
});
it("flags legacy reader imports", () => {
expect(
findSessionAccessorBoundaryViolations(`
@@ -193,52 +178,6 @@ describe("session accessor boundary guard", () => {
).toEqual([]);
});
it("flags legacy transcript writer imports", () => {
expect(
findTranscriptWriterBoundaryViolations(`
import { appendSessionTranscriptMessage } from "../config/sessions/transcript-append.js";
import { emitSessionTranscriptUpdate as emitUpdate } from "../sessions/transcript-events.js";
import { rewriteTranscriptEntriesInSessionFile } from "../agents/embedded-agent-runner/transcript-rewrite.js";
`),
).toEqual([
{ line: 2, reason: 'imports legacy transcript writer "appendSessionTranscriptMessage"' },
{ line: 3, reason: 'imports legacy transcript writer "emitSessionTranscriptUpdate"' },
{
line: 4,
reason: 'imports legacy transcript writer "rewriteTranscriptEntriesInSessionFile"',
},
]);
});
it("flags direct and namespace legacy transcript writer calls", () => {
expect(
findTranscriptWriterBoundaryViolations(`
appendSessionTranscriptMessage({ transcriptPath, message });
transcriptEvents.emitSessionTranscriptUpdate({ sessionFile });
transcriptAppend["appendSessionTranscriptMessage"]({ transcriptPath, message });
transcriptRewrite.rewriteTranscriptEntriesInSessionFile({ sessionFile, request });
`),
).toEqual([
{ line: 2, reason: 'calls legacy transcript writer "appendSessionTranscriptMessage"' },
{ line: 3, reason: 'references legacy transcript writer "emitSessionTranscriptUpdate"' },
{ line: 4, reason: 'references legacy transcript writer "appendSessionTranscriptMessage"' },
{
line: 5,
reason: 'references legacy transcript writer "rewriteTranscriptEntriesInSessionFile"',
},
]);
});
it("allows migrated transcript writer helpers", () => {
expect(
findTranscriptWriterBoundaryViolations(`
import { appendTranscriptMessage, publishTranscriptUpdate } from "../config/sessions/session-accessor.js";
appendTranscriptMessage(scope, { message });
publishTranscriptUpdate(scope, { messageId });
`),
).toEqual([]);
});
it("ignores comments and strings that describe legacy readers", () => {
expect(
findSessionAccessorBoundaryViolations(`

View File

@@ -2648,7 +2648,6 @@ output="$(cat "$sampler_log")"
expect(helper).toContain(
'-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
);
expect(helper).toContain('-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"');
});
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {

View File

@@ -1,8 +1,8 @@
// Gateway Smoke tests cover QA Lab gateway smoke evidence.
// Gateway Smoke tests cover gateway smoke script behavior.
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { runGatewaySmoke } from "../../../../scripts/dev/gateway-smoke.js";
import { runGatewaySmoke } from "../../scripts/dev/gateway-smoke.js";
let server: Server | undefined;
let wss: WebSocketServer | undefined;

View File

@@ -1,4 +1,4 @@
// Package OpenClaw For Docker tests cover QA Lab package artifact evidence.
// Package Openclaw For Docker tests cover package openclaw for docker script behavior.
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
@@ -10,7 +10,7 @@ import {
packOpenClawPackageForDocker,
parseArgs,
runCommandForTest,
} from "../../../../scripts/package-openclaw-for-docker.mjs";
} from "../../scripts/package-openclaw-for-docker.mjs";
function isProcessAlive(pid: number): boolean {
try {

View File

@@ -0,0 +1,87 @@
// Plugin Lifecycle Probe tests cover plugin lifecycle probe script behavior.
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const probePath = "scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-lifecycle-probe-"));
tempDirs.push(dir);
return dir;
}
function runProbe(args: string[], home = makeTempDir()) {
return spawnSync(process.execPath, [probePath, ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
HOME: home,
OPENCLAW_CONFIG_PATH: path.join(home, ".openclaw", "openclaw.json"),
USERPROFILE: home,
},
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin lifecycle matrix probe", () => {
it("accepts inspect JSON for an enabled loaded plugin", () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "inspect.json");
writeFileSync(
inspectPath,
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "loaded" } })}\n`,
"utf8",
);
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
expect(result.status, result.stderr).toBe(0);
});
it("rejects inspect JSON that does not prove the runtime loaded", () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "inspect.json");
writeFileSync(
inspectPath,
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "pending" } })}\n`,
"utf8",
);
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("expected lifecycle-claw inspect status loaded, got pending");
});
it("rejects missing inspect JSON instead of treating it as an empty object", () => {
const dir = makeTempDir();
const inspectPath = path.join(dir, "missing.json");
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(`failed to read JSON from ${inspectPath}`);
});
it("rejects unreadable config during uninstall proof", () => {
const dir = makeTempDir();
const configPath = path.join(dir, ".openclaw", "openclaw.json");
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, "{ malformed\n", "utf8");
const result = runProbe(["assert-uninstalled", "lifecycle-claw"], dir);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(`failed to read JSON from ${configPath}`);
});
});

View File

@@ -1,4 +1,4 @@
// QA OTEL Smoke tests cover QA Lab telemetry evidence.
// Qa Otel Smoke tests cover qa otel smoke script behavior.
import { spawn, spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
@@ -8,7 +8,7 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { gzipSync } from "node:zlib";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { testing } from "../../../../scripts/qa-otel-smoke.ts";
import { testing } from "../../scripts/qa-otel-smoke.ts";
describe("qa-otel-smoke receiver bounds", () => {
let configuredBodyLimitLoad: ReturnType<typeof spawnSync>;

View File

@@ -593,7 +593,7 @@ describe("scripts/test-projects changed-target routing", () => {
["scripts/generate-npm-shrinkwrap.mjs", ["test/scripts/generate-npm-shrinkwrap.test.ts"]],
[
"scripts/package-openclaw-for-docker.mjs",
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
["test/scripts/package-openclaw-for-docker.test.ts"],
],
["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]],
["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]],
@@ -625,28 +625,6 @@ describe("scripts/test-projects changed-target routing", () => {
}
});
it("keeps QA Lab script-evidence edits on QA e2e tests", () => {
const expectedTargets = new Map([
[
"scripts/dev/channel-message-flows.ts",
["test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts"],
],
["scripts/dev/gateway-smoke.ts", ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
["scripts/qa-otel-smoke.ts", ["test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts"]],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh",
["test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts"],
],
]);
for (const [source, targets] of expectedTargets) {
expect(resolveChangedTestTargetPlan([source]), source).toEqual({
mode: "targets",
targets,
});
}
});
it("keeps shared script library edits on owner tests", () => {
const expectedTargets = new Map([
[