mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
13 Commits
script-to-
...
codex/mult
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38112cf433 | ||
|
|
ca55a88111 | ||
|
|
cd47086457 | ||
|
|
ad8164a790 | ||
|
|
0df7a057d6 | ||
|
|
21f002b55a | ||
|
|
44c2db1681 | ||
|
|
be23bf66d4 | ||
|
|
ef058dcbbc | ||
|
|
4afdb60bc6 | ||
|
|
ce173cb7d6 | ||
|
|
5af338204c | ||
|
|
beb97c163d |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
123
extensions/qa-lab/src/crabline-channel-driver.test.ts
Normal file
123
extensions/qa-lab/src/crabline-channel-driver.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
251
extensions/qa-lab/src/crabline-channel-driver.ts
Normal file
251
extensions/qa-lab/src/crabline-channel-driver.ts
Normal 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.",
|
||||
];
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" }] }] },
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
1642
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ allowBuilds:
|
||||
"@tloncorp/tlon-skill": true
|
||||
baileys: true
|
||||
authenticate-pam: true
|
||||
crabline: true
|
||||
"@discordjs/opus": false
|
||||
esbuild: true
|
||||
koffi: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
159
scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs
Normal file
159
scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs
Normal 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>"}`);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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?: {
|
||||
@@ -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(`
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
87
test/scripts/plugin-lifecycle-probe.test.ts
Normal file
87
test/scripts/plugin-lifecycle-probe.test.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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([
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user