diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index 653873fc61a6..02e2b9a0a768 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -1,2 +1,2 @@
-9ce72d763de6c95566e0167f99f5454b07c7c67940675533cb24c07058619a63 plugin-sdk-api-baseline.json
-e4dfccb85b985fe865145e24978255b729cdcbca0e26650a363a11bfcfc2e27b plugin-sdk-api-baseline.jsonl
+58e91672fcf00ebb6947081a5a5ba2d379c2bdbde8f8e5322a045e840dce2f7a plugin-sdk-api-baseline.json
+15b24cc1d41fde8206a82f319669d43410c633b92a37600a99f84c9ae6af7490 plugin-sdk-api-baseline.jsonl
diff --git a/docs/concepts/personal-agent-benchmark-pack.md b/docs/concepts/personal-agent-benchmark-pack.md
index ee78313eff2c..337860fb4805 100644
--- a/docs/concepts/personal-agent-benchmark-pack.md
+++ b/docs/concepts/personal-agent-benchmark-pack.md
@@ -34,7 +34,7 @@ The machine-readable pack metadata lives in
`--pack personal-agent`:
```bash
-OPENCLAW_ENABLE_PRIVATE_QA_CLI=1 pnpm openclaw qa suite \
+OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI=1 pnpm openclaw qa suite \
--provider-mode mock-openai \
--pack personal-agent \
--concurrency 1
diff --git a/docs/concepts/qa-matrix.md b/docs/concepts/qa-matrix.md
index 606b672b9ec9..1307dc1925ff 100644
--- a/docs/concepts/qa-matrix.md
+++ b/docs/concepts/qa-matrix.md
@@ -9,7 +9,7 @@ title: "Matrix QA"
The Matrix QA lane runs the bundled `@openclaw/matrix` plugin against a disposable Tuwunel homeserver in Docker, with temporary driver, SUT, and observer accounts plus seeded rooms. It is the live transport-real coverage for Matrix.
-This is maintainer-only tooling. Packaged OpenClaw releases intentionally omit `qa-lab`, so `openclaw qa` is only available from a source checkout. Source checkouts load the bundled runner directly - no plugin install step is needed.
+This is maintainer-only tooling. The Matrix live lane still requires a source checkout because `qa-matrix` and its Docker-backed fixtures are not packaged with OpenClaw. Source checkouts load the bundled runner directly - no plugin install step is needed.
For broader QA framework context, see [QA overview](/concepts/qa-e2e-automation).
diff --git a/docs/help/testing.md b/docs/help/testing.md
index 60d600a291f0..6d6c0bf9b4b2 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -333,7 +333,7 @@ gh workflow run package-acceptance.yml --ref main \
- Starts only the local AIMock provider server for direct protocol smoke
testing.
- `pnpm openclaw qa matrix`
- - Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel homeserver. Source-checkout only - packaged installs do not ship `qa-lab`.
+ - Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel homeserver. Source-checkout only - packaged installs do not ship `qa-matrix` and its Docker-backed fixtures.
- Full CLI, profile/scenario catalog, env vars, and artifact layout: [Matrix QA](/concepts/qa-matrix).
- `pnpm openclaw qa telegram`
- Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env.
diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md
index 26f9790a5620..b4d70080d3fa 100644
--- a/docs/plugins/plugin-inventory.md
+++ b/docs/plugins/plugin-inventory.md
@@ -180,10 +180,10 @@ commands.
| [zalo](/plugins/reference/zalo) | OpenClaw Zalo channel plugin for bot and webhook chats. | `@openclaw/zalo`
npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | OpenClaw Zalo Personal Account plugin via native zca-js integration. | `@openclaw/zalouser`
npm; ClawHub | channels: zalouser; contracts: tools |
-## Source checkout only
+## QA Tooling
-| Plugin | Description | Distribution | Surface |
-| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ | -------------------- |
-| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel |
-| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin |
-| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin |
+| Plugin | Description | Distribution | Surface |
+| ------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | -------------------- |
+| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | bundled in OpenClaw | channels: qa-channel |
+| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | bundled in OpenClaw; experimental CLI gate | plugin |
+| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin |
diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md
index 6d93b1f44ce5..3f9f86f2e925 100644
--- a/docs/plugins/reference.md
+++ b/docs/plugins/reference.md
@@ -103,8 +103,8 @@ pnpm plugins:inventory:gen
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`
included in OpenClaw | contracts: webSearchProviders |
| [pixverse](/plugins/reference/pixverse) | OpenClaw PixVerse video generation provider plugin. | `@openclaw/pixverse-provider`
npm; ClawHub: `clawhub:@openclaw/pixverse-provider` | contracts: videoGenerationProviders |
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`
included in OpenClaw | plugin |
-| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel |
-| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin |
+| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | bundled in OpenClaw | channels: qa-channel |
+| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | bundled in OpenClaw; experimental CLI gate | plugin |
| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin |
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`
included in OpenClaw | providers: qianfan |
| [qqbot](/plugins/reference/qqbot) | OpenClaw QQ Bot channel plugin for group and direct-message workflows. | `@openclaw/qqbot`
npm; ClawHub | channels: qqbot; contracts: tools; skills |
diff --git a/docs/plugins/reference/qa-channel.md b/docs/plugins/reference/qa-channel.md
index 8d9de64e3d56..a2bf897e7431 100644
--- a/docs/plugins/reference/qa-channel.md
+++ b/docs/plugins/reference/qa-channel.md
@@ -11,8 +11,8 @@ Adds the QA Channel surface for sending and receiving OpenClaw messages.
## Distribution
-- Package: `@openclaw/qa-channel`
-- Install route: source checkout only
+- Package: bundled in OpenClaw as `@openclaw/qa-channel`
+- Install route: included with OpenClaw for QA scenarios and experimental user-flow execution
## Surface
diff --git a/docs/plugins/reference/qa-lab.md b/docs/plugins/reference/qa-lab.md
index 6e59bbbf8f46..c8c39b994343 100644
--- a/docs/plugins/reference/qa-lab.md
+++ b/docs/plugins/reference/qa-lab.md
@@ -11,8 +11,8 @@ OpenClaw QA lab plugin with private debugger UI and scenario runner.
## Distribution
-- Package: `@openclaw/qa-lab`
-- Install route: source checkout only
+- Package: bundled in OpenClaw as `@openclaw/qa-lab`
+- Install route: included with OpenClaw; CLI access requires `OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI=1`
## Surface
diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md
index 8ac213462a51..30caac13e164 100644
--- a/docs/plugins/sdk-subpaths.md
+++ b/docs/plugins/sdk-subpaths.md
@@ -229,7 +229,12 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/lazy-runtime` | Lazy runtime import/binding helpers such as `createLazyRuntimeModule`, `createLazyRuntimeMethod`, and `createLazyRuntimeSurface` |
| `plugin-sdk/process-runtime` | Process exec helpers |
| `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers |
+ | `plugin-sdk/qa-channel` | QA Channel facade helpers for synthetic user-flow and suite execution |
+ | `plugin-sdk/qa-channel-protocol` | QA Channel bus protocol helpers shared by the synthetic QA channel runtime |
+ | `plugin-sdk/qa-lab` | Gated QA Lab CLI facade used by experimental QA command registration |
+ | `plugin-sdk/qa-runtime` | QA Lab runtime facade helpers for scenario and user-flow execution |
| `plugin-sdk/qa-live-transport-scenarios` | Shared live transport QA scenario ids, baseline coverage helpers, and scenario-selection helper |
+ | `plugin-sdk/qa-user-flows` | Shared user-flow surfaces, capability mappings, standard flow catalog, and planner helpers for QA runners |
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, and channel-status patch helpers |
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts
index d06bbb93bf8e..f4881216ff61 100644
--- a/extensions/qa-lab/src/cli.test.ts
+++ b/extensions/qa-lab/src/cli.test.ts
@@ -162,6 +162,76 @@ describe("qa cli registration", () => {
expect(commandNames).toContain("mantis");
expect(commandNames).toContain("credentials");
expect(commandNames).toContain("coverage");
+ expect(commandNames).toContain("user-flows");
+ });
+
+ it("runs selected user flows through existing QA suite scenarios", async () => {
+ const stdoutChunks: string[] = [];
+ const stdoutWrite = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation((chunk: string | Uint8Array) => {
+ stdoutChunks.push(String(chunk));
+ return true;
+ });
+ try {
+ await program.parseAsync([
+ "node",
+ "openclaw",
+ "qa",
+ "user-flows",
+ "run",
+ "--flow",
+ "messaging.direct-reply",
+ "--capability",
+ "messaging.inbound-message",
+ "--capability",
+ "messaging.outbound-final-reply",
+ "--repo-root",
+ "/tmp/openclaw-repo",
+ "--output-dir",
+ ".artifacts/qa-user-flows",
+ "--transport",
+ "qa-channel",
+ "--provider-mode",
+ "mock-openai",
+ "--concurrency",
+ "1",
+ "--allow-failures",
+ "--json",
+ ]);
+ } finally {
+ stdoutWrite.mockRestore();
+ }
+
+ expect(runQaSuiteCommand).toHaveBeenCalledWith({
+ repoRoot: "/tmp/openclaw-repo",
+ outputDir: ".artifacts/qa-user-flows",
+ transportId: "qa-channel",
+ providerMode: "mock-openai",
+ primaryModel: undefined,
+ alternateModel: undefined,
+ fastMode: false,
+ allowFailures: true,
+ concurrency: 1,
+ scenarioIds: ["channel-chat-baseline", "dm-chat-baseline"],
+ });
+ const output = JSON.parse(stdoutChunks.join("")) as {
+ execution: { runner: string; scenarioIds: string[] };
+ plan: { selected: Array<{ id: string }>; skipped: Array<{ id: string; reason: string }> };
+ status: string;
+ };
+ expect(output).toMatchObject({
+ status: "pass",
+ execution: {
+ runner: "qa-suite",
+ scenarioIds: ["channel-chat-baseline", "dm-chat-baseline"],
+ },
+ });
+ expect(output.plan.selected.map((flow) => flow.id)).toEqual(["messaging.direct-reply"]);
+ expect(output.plan.skipped.find((flow) => flow.id === "memory.scoped-recall")).toMatchObject({
+ id: "memory.scoped-recall",
+ reason: "not-requested",
+ });
});
it("does not expose a control-ui token flag on qa ui", () => {
diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts
index 75018683fa87..b79b40711f87 100644
--- a/extensions/qa-lab/src/cli.ts
+++ b/extensions/qa-lab/src/cli.ts
@@ -15,6 +15,7 @@ import {
} from "./providers/live-frontier/parity.js";
import type { QaProviderMode, QaProviderModeInput } from "./run-config.js";
import { hasQaScenarioPack } from "./scenario-catalog.js";
+import { registerQaUserFlowCli } from "./user-flow-cli.js";
type QaLabCliRuntime = typeof import("./cli.runtime.js");
@@ -285,6 +286,7 @@ export function registerQaLabCli(program: Command) {
.command("qa")
.description("Run private QA automation flows and launch the QA debugger");
registerMantisCli(qa);
+ registerQaUserFlowCli(qa, { runSuite: runQaSuite });
qa.command("run")
.description("Run the bundled QA self-check and write a Markdown report")
diff --git a/extensions/qa-lab/src/user-flow-cli.ts b/extensions/qa-lab/src/user-flow-cli.ts
new file mode 100644
index 000000000000..69219cd1edbd
--- /dev/null
+++ b/extensions/qa-lab/src/user-flow-cli.ts
@@ -0,0 +1,243 @@
+// Qa Lab plugin module exposes user-flow planning commands.
+import type { Command } from "commander";
+import {
+ QA_USER_FLOW_STANDARD_FLOWS,
+ planQaUserFlows,
+ type QaStandardUserFlowCapabilityId,
+ type QaStandardUserFlowId,
+} from "openclaw/plugin-sdk/qa-user-flows";
+
+type QaUserFlowSelectionOptions = {
+ surface?: string[];
+};
+
+type QaUserFlowsPlanOptions = QaUserFlowSelectionOptions & {
+ capability?: string[];
+ driverFlow?: string[];
+ flow?: string[];
+};
+
+type QaUserFlowsRunOptions = QaUserFlowsPlanOptions & {
+ allowFailures?: boolean;
+ altModel?: string;
+ concurrency?: number;
+ fast?: boolean;
+ json?: boolean;
+ model?: string;
+ outputDir?: string;
+ providerMode?: string;
+ repoRoot?: string;
+ transport?: string;
+};
+
+type QaUserFlowSuiteRunOptions = {
+ allowFailures?: boolean;
+ alternateModel?: string;
+ concurrency?: number;
+ fastMode?: boolean;
+ outputDir?: string;
+ primaryModel?: string;
+ providerMode?: string;
+ repoRoot?: string;
+ scenarioIds?: string[];
+ transportId?: string;
+};
+
+type QaUserFlowCliRuntime = {
+ runSuite(opts: QaUserFlowSuiteRunOptions): Promise;
+};
+
+function writeJson(value: unknown) {
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
+}
+
+function normalizeRepeatedStringValues(values: readonly string[] | undefined) {
+ const result: string[] = [];
+ const seen = new Set();
+ for (const value of values ?? []) {
+ for (const entry of value.split(",")) {
+ const trimmed = entry.trim();
+ if (!trimmed || seen.has(trimmed)) {
+ continue;
+ }
+ seen.add(trimmed);
+ result.push(trimmed);
+ }
+ }
+ return result;
+}
+
+function collectQaUserFlowCliString(value: string, previous: string[]) {
+ const trimmed = value.trim();
+ return trimmed ? [...previous, trimmed] : previous;
+}
+
+function filterStandardFlowsBySurface(surfaces: readonly string[]) {
+ if (surfaces.length === 0) {
+ return [...QA_USER_FLOW_STANDARD_FLOWS];
+ }
+ const requested = new Set(surfaces);
+ return QA_USER_FLOW_STANDARD_FLOWS.filter((flow) => requested.has(flow.surface));
+}
+
+function buildQaUserFlowsPlanOutput(opts: QaUserFlowsPlanOptions) {
+ const surfaces = normalizeRepeatedStringValues(opts.surface);
+ const flows = filterStandardFlowsBySurface(surfaces);
+ const availableCapabilities = normalizeRepeatedStringValues(
+ opts.capability,
+ ) as QaStandardUserFlowCapabilityId[];
+ const driverSupportedFlowIds = normalizeRepeatedStringValues(
+ opts.driverFlow,
+ ) as QaStandardUserFlowId[];
+ const requestedFlowIds = normalizeRepeatedStringValues(opts.flow) as QaStandardUserFlowId[];
+ const plan = planQaUserFlows({
+ flows,
+ availableCapabilities,
+ ...(driverSupportedFlowIds.length > 0 ? { driverSupportedFlowIds } : {}),
+ ...(requestedFlowIds.length > 0 ? { requestedFlowIds } : {}),
+ });
+ return {
+ version: 1,
+ filters: {
+ surfaces,
+ requestedFlowIds,
+ driverSupportedFlowIds,
+ },
+ availableCapabilities,
+ ...plan,
+ };
+}
+
+function resolveQaUserFlowRunScenarioIds(plan: ReturnType) {
+ const unsupportedRunnerFlowIds = plan.selected
+ .filter((flow) => flow.execution.runner !== "qa-lab-flow" || !flow.qaScenarioIds?.length)
+ .map((flow) => `${flow.id} (${flow.execution.runner})`);
+ if (unsupportedRunnerFlowIds.length > 0) {
+ throw new Error(
+ `selected user flow(s) do not have a QA suite execution mapping yet: ${unsupportedRunnerFlowIds.join(", ")}`,
+ );
+ }
+ return normalizeRepeatedStringValues(plan.selected.flatMap((flow) => flow.qaScenarioIds ?? []));
+}
+
+function buildQaUserFlowRunOutput(params: {
+ error?: string;
+ plan: ReturnType;
+ scenarioIds: readonly string[];
+ status: "fail" | "pass";
+}) {
+ return {
+ version: 1,
+ status: params.status,
+ plan: {
+ selected: params.plan.selected,
+ skipped: params.plan.skipped,
+ },
+ execution: {
+ runner: "qa-suite",
+ scenarioIds: [...params.scenarioIds],
+ },
+ ...(params.error ? { error: params.error } : {}),
+ };
+}
+
+export function registerQaUserFlowCli(qa: Command, runtime?: QaUserFlowCliRuntime) {
+ const userFlows = qa.command("user-flows").description("Run OpenClaw-owned QA user flows");
+
+ // Future: list and plan commands could expose the standard catalog and the
+ // selected/skipped run plan for Kova/debugging without executing scenarios.
+
+ userFlows
+ .command("run")
+ .description("Run selected QA user flows through the existing QA suite runner")
+ .option("--repo-root ", "Repository root to target when running from a neutral cwd")
+ .option("--output-dir ", "Suite artifact directory")
+ .option("--transport ", "QA transport id", "qa-channel")
+ .option("--provider-mode ", "QA provider mode", "mock-openai")
+ .option("--model [", "Primary provider/model ref")
+ .option("--alt-model ][", "Alternate provider/model ref")
+ .option("--fast", "Enable provider fast mode where supported", false)
+ .option(
+ "--allow-failures",
+ "Write artifacts without setting a failing exit code when scenarios fail",
+ false,
+ )
+ .option("--concurrency ", "Scenario worker concurrency", (value: string) => {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== value.trim()) {
+ throw new Error("--concurrency must be a positive integer.");
+ }
+ return parsed;
+ })
+ .option(
+ "--surface ",
+ "Filter by user-flow surface (repeatable or comma-separated)",
+ collectQaUserFlowCliString,
+ [],
+ )
+ .option(
+ "--flow ",
+ "Request one user-flow id (repeatable or comma-separated)",
+ collectQaUserFlowCliString,
+ [],
+ )
+ .option(
+ "--capability ",
+ "Declare one available capability id (repeatable or comma-separated)",
+ collectQaUserFlowCliString,
+ [],
+ )
+ .option(
+ "--driver-flow ",
+ "Declare one concretely driver-supported flow id (repeatable or comma-separated)",
+ collectQaUserFlowCliString,
+ [],
+ )
+ .option("--json", "Emit selected/skipped user-flow plan and execution summary as JSON", false)
+ .action(async (opts: QaUserFlowsRunOptions) => {
+ if (!runtime) {
+ throw new Error("QA user-flow run command is missing its suite runner.");
+ }
+ const plan = buildQaUserFlowsPlanOutput(opts);
+ const scenarioIds = resolveQaUserFlowRunScenarioIds(plan);
+ if (scenarioIds.length === 0) {
+ throw new Error("No runnable QA suite scenarios selected for the requested user flows.");
+ }
+ try {
+ await runtime.runSuite({
+ repoRoot: opts.repoRoot,
+ outputDir: opts.outputDir,
+ transportId: opts.transport,
+ providerMode: opts.providerMode,
+ primaryModel: opts.model,
+ alternateModel: opts.altModel,
+ fastMode: opts.fast,
+ allowFailures: opts.allowFailures,
+ concurrency: opts.concurrency,
+ scenarioIds,
+ });
+ if (opts.json) {
+ writeJson(buildQaUserFlowRunOutput({ plan, scenarioIds, status: "pass" }));
+ }
+ } catch (error) {
+ if (opts.json) {
+ writeJson(
+ buildQaUserFlowRunOutput({
+ plan,
+ scenarioIds,
+ status: "fail",
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }
+ throw error;
+ }
+ });
+}
+
+export const qaUserFlowCliTesting = {
+ buildQaUserFlowsPlanOutput,
+ buildQaUserFlowRunOutput,
+ normalizeRepeatedStringValues,
+ resolveQaUserFlowRunScenarioIds,
+};
diff --git a/package.json b/package.json
index 12aa527fd637..6d0f732e6f45 100644
--- a/package.json
+++ b/package.json
@@ -99,8 +99,6 @@
"!dist/extensions/nostr/**",
"!dist/extensions/qqbot/**",
"!dist/extensions/pixverse/**",
- "!dist/extensions/qa-channel/**",
- "!dist/extensions/qa-lab/**",
"!dist/extensions/qa-matrix/**",
"!dist/extensions/openshell/**",
"!dist/extensions/slack/**",
@@ -112,25 +110,15 @@
"!dist/extensions/whatsapp/**",
"!dist/extensions/zalo/**",
"!dist/extensions/zalouser/**",
- "!dist/plugin-sdk/extensions/qa-channel/**",
- "!dist/plugin-sdk/extensions/qa-lab/**",
- "!dist/plugin-sdk/qa-channel.*",
- "!dist/plugin-sdk/qa-channel-protocol.*",
- "!dist/plugin-sdk/qa-lab.*",
- "!dist/plugin-sdk/qa-runtime.*",
"!dist/plugin-sdk/src/**",
- "!dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts",
- "!dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts",
- "!dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts",
- "!dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts",
- "!dist/qa-runtime-*.js",
"docs/",
"!docs/.generated/**",
- "!docs/channels/qa-channel.md",
"!docs/assets/**",
"!docs/images/**",
"!docs/**/*.jpg",
"!docs/**/*.png",
+ "qa/scenarios.md",
+ "qa/scenarios/",
"src/agents/templates/",
"scripts/crabbox-wrapper.mjs",
"patches/",
@@ -1073,6 +1061,22 @@
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
"default": "./dist/plugin-sdk/keyed-async-queue.js"
},
+ "./plugin-sdk/qa-channel": {
+ "types": "./dist/plugin-sdk/qa-channel.d.ts",
+ "default": "./dist/plugin-sdk/qa-channel.js"
+ },
+ "./plugin-sdk/qa-channel-protocol": {
+ "types": "./dist/plugin-sdk/qa-channel-protocol.d.ts",
+ "default": "./dist/plugin-sdk/qa-channel-protocol.js"
+ },
+ "./plugin-sdk/qa-lab": {
+ "types": "./dist/plugin-sdk/qa-lab.d.ts",
+ "default": "./dist/plugin-sdk/qa-lab.js"
+ },
+ "./plugin-sdk/qa-runtime": {
+ "types": "./dist/plugin-sdk/qa-runtime.d.ts",
+ "default": "./dist/plugin-sdk/qa-runtime.js"
+ },
"./plugin-sdk/qa-runner-runtime": {
"types": "./dist/plugin-sdk/qa-runner-runtime.d.ts",
"default": "./dist/plugin-sdk/qa-runner-runtime.js"
@@ -1081,6 +1085,10 @@
"types": "./dist/plugin-sdk/qa-live-transport-scenarios.d.ts",
"default": "./dist/plugin-sdk/qa-live-transport-scenarios.js"
},
+ "./plugin-sdk/qa-user-flows": {
+ "types": "./dist/plugin-sdk/qa-user-flows.d.ts",
+ "default": "./dist/plugin-sdk/qa-user-flows.js"
+ },
"./plugin-sdk/memory-core": {
"types": "./dist/plugin-sdk/memory-core.d.ts",
"default": "./dist/plugin-sdk/memory-core.js"
diff --git a/scripts/check-gateway-cpu-scenarios.mjs b/scripts/check-gateway-cpu-scenarios.mjs
index 862b761b319e..e5bc88f36796 100644
--- a/scripts/check-gateway-cpu-scenarios.mjs
+++ b/scripts/check-gateway-cpu-scenarios.mjs
@@ -195,7 +195,7 @@ function buildPrivateQaEnv(env, qaState) {
}
: {}),
OPENCLAW_BUILD_PRIVATE_QA: "1",
- OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
+ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD ?? "1",
OPENCLAW_TEST_DISABLE_UPDATE_CHECK: env.OPENCLAW_TEST_DISABLE_UPDATE_CHECK ?? "1",
};
diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs
index 7ff38cb25aad..459275e54cdf 100644
--- a/scripts/check-openclaw-package-tarball.mjs
+++ b/scripts/check-openclaw-package-tarball.mjs
@@ -86,32 +86,11 @@ const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 }
const LEGACY_SHRINKWRAP_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
-const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
- "dist/extensions/qa-channel/",
- "dist/extensions/qa-lab/",
- "dist/extensions/qa-matrix/",
- "dist/plugin-sdk/extensions/qa-channel/",
- "dist/plugin-sdk/extensions/qa-lab/",
-];
-const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_FILES = new Set([
- "dist/plugin-sdk/qa-channel.d.ts",
- "dist/plugin-sdk/qa-channel.js",
- "dist/plugin-sdk/qa-channel-protocol.d.ts",
- "dist/plugin-sdk/qa-channel-protocol.js",
- "dist/plugin-sdk/qa-lab.d.ts",
- "dist/plugin-sdk/qa-lab.js",
- "dist/plugin-sdk/qa-runtime.d.ts",
- "dist/plugin-sdk/qa-runtime.js",
- "dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts",
-]);
+const LEGACY_OMITTED_QA_MATRIX_INVENTORY_PREFIXES = ["dist/extensions/qa-matrix/"];
-function isLegacyOmittedPrivateQaInventoryEntry(relativePath) {
- return (
- LEGACY_OMITTED_PRIVATE_QA_INVENTORY_FILES.has(relativePath) ||
- LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))
+function isLegacyOmittedQaMatrixInventoryEntry(relativePath) {
+ return LEGACY_OMITTED_QA_MATRIX_INVENTORY_PREFIXES.some((prefix) =>
+ relativePath.startsWith(prefix),
);
}
@@ -256,7 +235,7 @@ if (!entrySet.has("dist/postinstall-inventory.json")) {
let packageDistImports = null;
if (entrySet.has("dist/postinstall-inventory.json")) {
try {
- const allowLegacyPrivateQaInventoryOmissions =
+ const allowLegacyQaMatrixInventoryOmissions =
isLegacyPackageAcceptanceCompatVersion(packageVersion);
const inventory = JSON.parse(readTarEntry("dist/postinstall-inventory.json"));
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
@@ -274,11 +253,11 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
const normalizedEntry = inventoryEntry.replace(/\\/gu, "/");
if (!entrySet.has(normalizedEntry)) {
if (
- allowLegacyPrivateQaInventoryOmissions &&
- isLegacyOmittedPrivateQaInventoryEntry(normalizedEntry)
+ allowLegacyQaMatrixInventoryOmissions &&
+ isLegacyOmittedQaMatrixInventoryEntry(normalizedEntry)
) {
warnings.push(
- `legacy inventory references omitted private QA tar entry ${normalizedEntry}`,
+ `legacy inventory references omitted QA Matrix tar entry ${normalizedEntry}`,
);
continue;
}
diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts
index b3f438a11320..75d2224b518c 100644
--- a/scripts/e2e/telegram-user-crabbox-proof.ts
+++ b/scripts/e2e/telegram-user-crabbox-proof.ts
@@ -450,7 +450,7 @@ function childProcessBaseEnv() {
"LC_ALL",
"NODE_OPTIONS",
"OPENCLAW_BUILD_PRIVATE_QA",
- "OPENCLAW_ENABLE_PRIVATE_QA_CLI",
+ "OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI",
"PATH",
"PNPM_HOME",
"SHELL",
diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs
index 69cb54510195..fdf04759200a 100644
--- a/scripts/lib/bundled-plugin-build-entries.mjs
+++ b/scripts/lib/bundled-plugin-build-entries.mjs
@@ -10,8 +10,8 @@ import {
import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs";
const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
-/** Bundled plugin directories built with core but not packaged as standalone npm plugins. */
-export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]);
+/** Bundled plugin directories built with core but not packaged in the root npm package. */
+export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-matrix"]);
const EXCLUDED_CORE_BUNDLED_PLUGIN_DIRS = new Set(["qqbot", "whatsapp"]);
const BUNDLED_PLUGIN_BUILD_IDS_ENV = "OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS";
const TOP_LEVEL_PRIVATE_TEST_SURFACE_RE =
diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json
index 893b7400b818..b2e900ee0380 100644
--- a/scripts/lib/bundled-runtime-sidecar-paths.json
+++ b/scripts/lib/bundled-runtime-sidecar-paths.json
@@ -11,6 +11,8 @@
"dist/extensions/memory-core/runtime-api.js",
"dist/extensions/ollama/runtime-api.js",
"dist/extensions/open-prose/runtime-api.js",
+ "dist/extensions/qa-channel/runtime-api.js",
+ "dist/extensions/qa-lab/runtime-api.js",
"dist/extensions/signal/runtime-api.js",
"dist/extensions/telegram/runtime-api.js",
"dist/extensions/telegram/runtime-setter-api.js",
diff --git a/scripts/lib/plugin-gateway-gauntlet.mjs b/scripts/lib/plugin-gateway-gauntlet.mjs
index 2bf6cb415d69..5abe73a4f6f1 100644
--- a/scripts/lib/plugin-gateway-gauntlet.mjs
+++ b/scripts/lib/plugin-gateway-gauntlet.mjs
@@ -386,7 +386,7 @@ function buildGauntletPrebuildEnv(env, options = {}) {
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN ?? "false",
...runtimeOnlyPrebuildEnv,
OPENCLAW_BUILD_PRIVATE_QA: "1",
- OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
+ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1",
...(buildIds.size > 0
? {
OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: [...buildIds]
diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts
index e42302d98bb7..5231602dbb8a 100644
--- a/scripts/lib/plugin-sdk-doc-metadata.ts
+++ b/scripts/lib/plugin-sdk-doc-metadata.ts
@@ -108,9 +108,24 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
+ "qa-channel": {
+ category: "utilities",
+ },
+ "qa-channel-protocol": {
+ category: "utilities",
+ },
+ "qa-lab": {
+ category: "utilities",
+ },
+ "qa-runtime": {
+ category: "utilities",
+ },
"qa-live-transport-scenarios": {
category: "utilities",
},
+ "qa-user-flows": {
+ category: "utilities",
+ },
"agent-runtime": {
category: "runtime",
},
diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json
index abf98e35139a..df6ee77fbf52 100644
--- a/scripts/lib/plugin-sdk-entrypoints.json
+++ b/scripts/lib/plugin-sdk-entrypoints.json
@@ -247,8 +247,13 @@
"json-store",
"persistent-dedupe",
"keyed-async-queue",
+ "qa-channel",
+ "qa-channel-protocol",
+ "qa-lab",
+ "qa-runtime",
"qa-runner-runtime",
"qa-live-transport-scenarios",
+ "qa-user-flows",
"memory-core",
"memory-core-engine-runtime",
"memory-core-host-embedding-registry",
diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json
index 0c3f60f5f3d4..93b384a79a5e 100644
--- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json
+++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json
@@ -10,10 +10,6 @@
"plugin-test-runtime",
"provider-http-test-mocks",
"provider-test-contracts",
- "qa-channel",
- "qa-channel-protocol",
- "qa-lab",
- "qa-runtime",
"reply-payload-testing",
"ssrf-runtime-internal",
"test-env",
diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts
index 68d995c5d9a2..c0e0580a5484 100644
--- a/scripts/openclaw-cross-os-release-checks.ts
+++ b/scripts/openclaw-cross-os-release-checks.ts
@@ -150,11 +150,7 @@ function buildReleaseProviderConfigOverride(providerMeta) {
const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu;
-const OMITTED_QA_EXTENSION_PREFIXES = [
- "dist/extensions/qa-channel/",
- "dist/extensions/qa-lab/",
- "dist/extensions/qa-matrix/",
-];
+const OMITTED_QA_EXTENSION_PREFIXES = ["dist/extensions/qa-matrix/"];
export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000;
export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000;
export const CROSS_OS_DISCORD_FETCH_TIMEOUT_MS = parsePositiveIntegerEnv(
diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts
index 8851627b2048..4bde93cbbb1e 100644
--- a/scripts/openclaw-npm-release-check.ts
+++ b/scripts/openclaw-npm-release-check.ts
@@ -3,7 +3,6 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
-import { join } from "node:path";
import { pathToFileURL } from "node:url";
import {
LOCAL_BUILD_METADATA_DIST_PATHS,
@@ -85,61 +84,7 @@ const FORBIDDEN_PACKED_PATH_RULES = [
describe: (packedPath: string) =>
`npm package must not include generated docs artifact "${packedPath}".`,
},
- {
- prefix: "docs/channels/qa-channel.md",
- describe: (packedPath: string) =>
- `npm package must not include private QA channel docs "${packedPath}".`,
- },
- {
- prefix: "dist/extensions/qa-channel/",
- describe: (packedPath: string) =>
- `npm package must not include private QA channel artifact "${packedPath}".`,
- },
- {
- prefix: "dist/extensions/qa-lab/",
- describe: (packedPath: string) =>
- `npm package must not include private QA lab artifact "${packedPath}".`,
- },
- {
- prefix: "dist/plugin-sdk/extensions/qa-channel/",
- describe: (packedPath: string) =>
- `npm package must not include private QA channel type artifact "${packedPath}".`,
- },
- {
- prefix: "dist/plugin-sdk/extensions/qa-lab/",
- describe: (packedPath: string) =>
- `npm package must not include private QA lab type artifact "${packedPath}".`,
- },
- {
- prefix: "dist/plugin-sdk/qa-channel.",
- describe: (packedPath: string) =>
- `npm package must not include private QA channel SDK artifact "${packedPath}".`,
- },
- {
- prefix: "dist/plugin-sdk/qa-channel-protocol.",
- describe: (packedPath: string) =>
- `npm package must not include private QA channel SDK artifact "${packedPath}".`,
- },
- {
- prefix: "dist/qa-runtime-",
- describe: (packedPath: string) =>
- `npm package must not include private QA runtime chunk "${packedPath}".`,
- },
- {
- prefix: "qa/",
- describe: (packedPath: string) =>
- `npm package must not include private QA suite artifact "${packedPath}".`,
- },
] as const;
-const FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS = [
- "//#region extensions/qa-lab/",
- "qa-channel/runtime-api.js",
- "qa-channel.js",
- "qa-channel-protocol.js",
- "qa-lab/cli.js",
- "qa-lab/runtime-api.js",
-] as const;
-const FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES = ["dist/"] as const;
const PACKED_TEST_CARGO_DIRECTORY_SEGMENTS = new Set([
"__snapshots__",
"__tests__",
@@ -688,7 +633,6 @@ function collectPackedTarballErrors(): string[] {
return [
...collectControlUiPackErrors(packedPaths),
...collectForbiddenPackedPathErrors(packedPaths),
- ...collectForbiddenPackedContentErrors(packedPaths),
...collectPackedTestCargoErrors(packedPaths),
];
}
@@ -723,40 +667,6 @@ export function collectForbiddenPackedPathErrors(paths: Iterable): strin
return errors.toSorted((left, right) => left.localeCompare(right));
}
-export function collectForbiddenPackedContentErrors(
- paths: Iterable,
- rootDir = process.cwd(),
-): string[] {
- const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u;
- const errors: string[] = [];
- for (const packedPath of paths) {
- if (
- !FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES.some((prefix) => packedPath.startsWith(prefix))
- ) {
- continue;
- }
- if (!textPathPattern.test(packedPath)) {
- continue;
- }
- let content: string;
- try {
- content = readFileSync(pathToFileURL(join(rootDir, packedPath)), "utf8");
- } catch {
- continue;
- }
- const matchedMarker = FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS.find((marker) =>
- content.includes(marker),
- );
- if (!matchedMarker) {
- continue;
- }
- errors.push(
- `npm package must not include private QA lab marker "${matchedMarker}" in "${packedPath}".`,
- );
- }
- return errors.toSorted((left, right) => left.localeCompare(right));
-}
-
export function collectPackedTestCargoErrors(paths: Iterable): string[] {
const errors: string[] = [];
for (const packedPath of paths) {
diff --git a/scripts/qa-e2e.ts b/scripts/qa-e2e.ts
index 58c5413a0524..e8bcb871e5af 100644
--- a/scripts/qa-e2e.ts
+++ b/scripts/qa-e2e.ts
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
export function enablePrivateQaScriptEnv(env: NodeJS.ProcessEnv = process.env) {
env.OPENCLAW_BUILD_PRIVATE_QA = "1";
- env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
+ env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "0";
}
diff --git a/scripts/release-check.ts b/scripts/release-check.ts
index facd540e60e2..707f2f63d9a2 100755
--- a/scripts/release-check.ts
+++ b/scripts/release-check.ts
@@ -104,41 +104,18 @@ const forbiddenPrefixes = [
...LOCAL_BUILD_METADATA_DIST_PATHS,
"dist-runtime/",
"dist/OpenClaw.app/",
- "dist/extensions/qa-channel/",
- "dist/extensions/qa-lab/",
- "dist/plugin-sdk/extensions/qa-channel/",
- "dist/plugin-sdk/extensions/qa-lab/",
- "dist/plugin-sdk/qa-channel.",
- "dist/plugin-sdk/qa-channel-protocol.",
- "dist/plugin-sdk/qa-lab.",
- "dist/plugin-sdk/qa-runtime.",
"dist/plugin-sdk/src/",
- "dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts",
- "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts",
...listPrivateLocalOnlyPluginSdkDistArtifacts(),
- "dist/qa-runtime-",
"dist/plugin-sdk/.tsbuildinfo",
"docs/.generated/",
- "docs/channels/qa-channel.md",
- "qa/",
];
-const forbiddenPrivateQaContentMarkers = [
- "//#region extensions/qa-lab/",
- "qa-channel/runtime-api.js",
- "qa-channel.js",
- "qa-channel-protocol.js",
- "qa-lab/cli.js",
- "qa-lab/runtime-api.js",
-] as const;
const forbiddenPrivatePluginSdkDeclarationMarkers = [
"//#region src/agents/test-helpers/",
"//#region src/plugin-sdk/test-helpers/",
"//#region src/test-helpers/",
"//#region src/test-utils/",
] as const;
-const forbiddenPrivateQaContentScanPrefixes = ["dist/"] as const;
+const forbiddenPackContentScanPrefixes = ["dist/"] as const;
const forbiddenPluginSdkRootAliasMinifiedExportPattern = /\bmod\.[A-Za-z_$]\b/u;
const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
@@ -924,7 +901,7 @@ export function collectForbiddenPackContentPaths(
const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u;
return [...paths]
.filter((packedPath) => {
- if (!forbiddenPrivateQaContentScanPrefixes.some((prefix) => packedPath.startsWith(prefix))) {
+ if (!forbiddenPackContentScanPrefixes.some((prefix) => packedPath.startsWith(prefix))) {
return false;
}
if (!textPathPattern.test(packedPath)) {
@@ -936,10 +913,7 @@ export function collectForbiddenPackContentPaths(
} catch {
return false;
}
- return (
- forbiddenPrivateQaContentMarkers.some((marker) => content.includes(marker)) ||
- forbiddenPrivatePluginSdkDeclarationMarkers.some((marker) => content.includes(marker))
- );
+ return forbiddenPrivatePluginSdkDeclarationMarkers.some((marker) => content.includes(marker));
})
.toSorted((left, right) => left.localeCompare(right));
}
@@ -1202,7 +1176,7 @@ async function main() {
}
}
if (forbiddenContent.length > 0) {
- console.error("release-check: forbidden private QA markers in npm pack:");
+ console.error("release-check: forbidden package content markers in npm pack:");
for (const path of forbiddenContent) {
console.error(` - ${path}`);
}
diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs
index d70f401a0b80..88d6a031245f 100644
--- a/scripts/run-node.mjs
+++ b/scripts/run-node.mjs
@@ -1412,7 +1412,7 @@ export async function runNodeMain(params = {}) {
deps.privateQaRequiredDistEntries = resolvePrivateQaRequiredDistEntries(deps.distRoot);
if (deps.args[0] === "qa") {
deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1";
- deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
+ deps.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
deps.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ??= "0";
}
deps.outputTee = createRunNodeOutputTee(deps);
diff --git a/src/cli/program/private-qa-cli.test.ts b/src/cli/program/private-qa-cli.test.ts
index d2e06b7c0fcc..f9a96bde3848 100644
--- a/src/cli/program/private-qa-cli.test.ts
+++ b/src/cli/program/private-qa-cli.test.ts
@@ -1,34 +1,30 @@
-// Private QA CLI tests cover private QA command registration and filesystem behavior.
+// Experimental QA CLI tests cover QA command registration and filesystem behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { loadPrivateQaCliModule } from "./private-qa-cli.js";
+import { loadExperimentalQaCliModule } from "./private-qa-cli.js";
-describe("private-qa-cli", () => {
+describe("experimental QA CLI loader", () => {
const tempDirs: string[] = [];
- const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ const originalExperimentalQaCli = process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
- if (originalPrivateQaCli === undefined) {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ if (originalExperimentalQaCli === undefined) {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
} else {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli;
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = originalExperimentalQaCli;
}
});
- it("loads the private QA CLI from a source checkout path", async () => {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
- const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-source-"));
+ it("loads the bundled QA CLI module when experimental QA is enabled", async () => {
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-bundle-"));
tempDirs.push(repoRoot);
- const expectedPaths = new Set([
- path.join(repoRoot, ".git"),
- path.join(repoRoot, "src"),
- path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js"),
- ]);
+ const expectedPaths = new Set([path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js")]);
let importedSpecifier: string | undefined;
const isQaLabCliAvailable = vi.fn();
const registerQaLabCli = vi.fn();
@@ -40,7 +36,7 @@ describe("private-qa-cli", () => {
};
});
- const module = await loadPrivateQaCliModule({
+ const module = await loadExperimentalQaCliModule({
importModule,
resolvePackageRootSync: () => repoRoot,
existsSync: (filePath) => typeof filePath === "string" && expectedPaths.has(filePath),
@@ -52,55 +48,31 @@ describe("private-qa-cli", () => {
expect(module.registerQaLabCli).toBe(registerQaLabCli);
});
- it("loads the private QA CLI from a raw synced source checkout path", async () => {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
- const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-raw-source-"));
+ it("rejects when the bundled QA CLI module is missing", () => {
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-missing-bundle-"));
tempDirs.push(repoRoot);
- const expectedPaths = new Set([
- path.join(repoRoot, "pnpm-workspace.yaml"),
- path.join(repoRoot, "src"),
- path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js"),
- ]);
const importModule = vi.fn(async () => ({
isQaLabCliAvailable: vi.fn(),
registerQaLabCli: vi.fn(),
}));
- await expect(
- loadPrivateQaCliModule({
+ expect(() =>
+ loadExperimentalQaCliModule({
importModule,
resolvePackageRootSync: () => repoRoot,
- existsSync: (filePath) => typeof filePath === "string" && expectedPaths.has(filePath),
+ existsSync: () => false,
}),
- ).resolves.toMatchObject({
- isQaLabCliAvailable: expect.any(Function),
- registerQaLabCli: expect.any(Function),
- });
- expect(importModule).toHaveBeenCalledTimes(1);
- });
-
- it("rejects non-source package roots even when private QA is enabled", () => {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-"));
- tempDirs.push(root);
- fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf8");
- const importModule = vi.fn(async () => ({}));
-
- expect(() =>
- loadPrivateQaCliModule({
- resolvePackageRootSync: () => root,
- importModule,
- }),
- ).toThrow("Private QA CLI is only available from an OpenClaw source checkout.");
+ ).toThrow("bundled QA Lab CLI module");
expect(importModule).not.toHaveBeenCalled();
});
- it("rejects when the private QA env flag is disabled", () => {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ it("rejects when the experimental QA env flag is disabled", () => {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
const importModule = vi.fn(async () => ({}));
- expect(() => loadPrivateQaCliModule({ importModule })).toThrow(
- "Private QA CLI is only available from an OpenClaw source checkout.",
+ expect(() => loadExperimentalQaCliModule({ importModule })).toThrow(
+ "OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI=1",
);
expect(importModule).not.toHaveBeenCalled();
});
diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts
index dca2966f5ba3..dd4d736e136b 100644
--- a/src/cli/program/private-qa-cli.ts
+++ b/src/cli/program/private-qa-cli.ts
@@ -1,18 +1,17 @@
-// Private QA CLI loader, enabled only from source checkouts and explicit env opt-in.
+// Experimental QA CLI loader, enabled by explicit env opt-in.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
-const PRIVATE_QA_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js");
-const SOURCE_CHECKOUT_MARKER_RELATIVE_PATHS = [".git", "pnpm-workspace.yaml"] as const;
+const QA_LAB_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js");
-/** Return true when private QA CLI routes should be exposed. */
-export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
- return env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1";
+/** Return true when experimental QA CLI routes should be exposed. */
+export function isExperimentalQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI === "1";
}
-function resolvePrivateQaSourceModuleSpecifier(params?: {
+function resolveQaLabModuleSpecifier(params?: {
env?: NodeJS.ProcessEnv;
cwd?: string;
argv1?: string;
@@ -21,7 +20,7 @@ function resolvePrivateQaSourceModuleSpecifier(params?: {
existsSync?: typeof fs.existsSync;
}): string | null {
const env = params?.env ?? process.env;
- if (!isPrivateQaCliEnabled(env)) {
+ if (!isExperimentalQaCliEnabled(env)) {
return null;
}
const resolvePackageRootSync = params?.resolvePackageRootSync ?? resolveOpenClawPackageRootSync;
@@ -34,28 +33,21 @@ function resolvePrivateQaSourceModuleSpecifier(params?: {
return null;
}
const existsSync = params?.existsSync ?? fs.existsSync;
- const sourceModulePath = path.join(packageRoot, PRIVATE_QA_DIST_RELATIVE_PATH);
- const hasSourceCheckoutMarker = SOURCE_CHECKOUT_MARKER_RELATIVE_PATHS.some((relativePath) =>
- existsSync(path.join(packageRoot, relativePath)),
- );
- if (
- !hasSourceCheckoutMarker ||
- !existsSync(path.join(packageRoot, "src")) ||
- !existsSync(sourceModulePath)
- ) {
+ const modulePath = path.join(packageRoot, QA_LAB_DIST_RELATIVE_PATH);
+ if (!existsSync(modulePath)) {
return null;
}
- return pathToFileURL(sourceModulePath).href;
+ return pathToFileURL(modulePath).href;
}
-async function dynamicImportPrivateQaCliModule(
+async function dynamicImportExperimentalQaCliModule(
specifier: string,
): Promise> {
return (await import(specifier)) as Record;
}
-/** Load the private QA module from a source checkout or throw a user-facing availability error. */
-export function loadPrivateQaCliModule(params?: {
+/** Load the experimental QA module or throw a user-facing availability error. */
+export function loadExperimentalQaCliModule(params?: {
env?: NodeJS.ProcessEnv;
cwd?: string;
argv1?: string;
@@ -64,9 +56,11 @@ export function loadPrivateQaCliModule(params?: {
existsSync?: typeof fs.existsSync;
importModule?: (specifier: string) => Promise>;
}): Promise> {
- const specifier = resolvePrivateQaSourceModuleSpecifier(params);
+ const specifier = resolveQaLabModuleSpecifier(params);
if (!specifier) {
- throw new Error("Private QA CLI is only available from an OpenClaw source checkout.");
+ throw new Error(
+ "Experimental QA CLI requires OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI=1 and a bundled QA Lab CLI module.",
+ );
}
- return (params?.importModule ?? dynamicImportPrivateQaCliModule)(specifier);
+ return (params?.importModule ?? dynamicImportExperimentalQaCliModule)(specifier);
}
diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts
index 90d323b17fca..cbdaad51a89e 100644
--- a/src/cli/program/register.subclis-core.ts
+++ b/src/cli/program/register.subclis-core.ts
@@ -13,7 +13,7 @@ import {
type CommandGroupDescriptorSpec,
} from "./command-group-descriptors.js";
import { removeCommandByName } from "./command-tree.js";
-import { loadPrivateQaCliModule } from "./private-qa-cli.js";
+import { loadExperimentalQaCliModule } from "./private-qa-cli.js";
import {
registerCommandGroupByName,
registerCommandGroups,
@@ -182,7 +182,7 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [
},
{
commandNames: ["qa"],
- loadModule: loadPrivateQaCliModule,
+ loadModule: loadExperimentalQaCliModule,
exportName: "registerQaLabCli",
},
{
diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts
index 5b179afec89b..0409a72df332 100644
--- a/src/cli/program/register.subclis.test.ts
+++ b/src/cli/program/register.subclis.test.ts
@@ -26,8 +26,8 @@ const { registerQaLabCli } = vi.hoisted(() => ({
qa.command("run").action(() => undefined);
}),
}));
-const { loadPrivateQaCliModule } = vi.hoisted(() => ({
- loadPrivateQaCliModule: vi.fn(async () => ({ registerQaLabCli })),
+const { loadExperimentalQaCliModule } = vi.hoisted(() => ({
+ loadExperimentalQaCliModule: vi.fn(async () => ({ registerQaLabCli })),
}));
const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
@@ -79,14 +79,14 @@ vi.mock("./private-qa-cli.js", async () => {
const actual = await vi.importActual("./private-qa-cli.js");
return {
...actual,
- loadPrivateQaCliModule,
+ loadExperimentalQaCliModule,
};
});
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
const originalDisableLazySubcommands = process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS;
- const originalEnablePrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ const originalEnableExperimentalQaCli = process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
const createRegisteredProgram = (argv: string[], name?: string) => {
process.argv = argv;
@@ -104,13 +104,13 @@ describe("registerSubCliCommands", () => {
} else {
process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS = originalDisableLazySubcommands;
}
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
registerAcpCli.mockClear();
acpAction.mockClear();
registerNodesCli.mockClear();
nodesAction.mockClear();
registerQaLabCli.mockClear();
- loadPrivateQaCliModule.mockClear();
+ loadExperimentalQaCliModule.mockClear();
registerCapabilityCli.mockClear();
inferAction.mockClear();
registerPluginsCli.mockClear();
@@ -128,10 +128,10 @@ describe("registerSubCliCommands", () => {
} else {
process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS = originalDisableLazySubcommands;
}
- if (originalEnablePrivateQaCli === undefined) {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ if (originalEnableExperimentalQaCli === undefined) {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
} else {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalEnablePrivateQaCli;
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = originalEnableExperimentalQaCli;
}
});
@@ -157,8 +157,8 @@ describe("registerSubCliCommands", () => {
expect(registerAcpCli).not.toHaveBeenCalled();
});
- it("omits the qa placeholder when the private qa cli is disabled", () => {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ it("omits the qa placeholder when the experimental QA CLI is disabled", () => {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
const program = createRegisteredProgram(["node", "openclaw"]);
diff --git a/src/cli/program/subcli-descriptors.test.ts b/src/cli/program/subcli-descriptors.test.ts
index d82db228ceaa..ef0298ef4dcc 100644
--- a/src/cli/program/subcli-descriptors.test.ts
+++ b/src/cli/program/subcli-descriptors.test.ts
@@ -11,19 +11,19 @@ function descriptorNames(descriptors: ReadonlyArray<{ name: string }>): string[]
}
describe("sub-cli descriptors", () => {
- const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ const originalExperimentalQaCli = process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
afterEach(() => {
- if (originalPrivateQaCli === undefined) {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ if (originalExperimentalQaCli === undefined) {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
} else {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli;
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = originalExperimentalQaCli;
}
vi.resetModules();
});
- it("keeps the exported descriptor list aligned with private QA visibility when disabled (#83927)", async () => {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ it("keeps the exported descriptor list aligned with experimental QA visibility when disabled", async () => {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
const { SUB_CLI_DESCRIPTORS, getSubCliEntries } = await importSubCliDescriptors();
const exportedNames = descriptorNames(SUB_CLI_DESCRIPTORS);
@@ -32,8 +32,8 @@ describe("sub-cli descriptors", () => {
expect(exportedNames).not.toContain("qa");
});
- it("keeps all sub-cli filter surfaces aligned when private QA is disabled (#83926)", async () => {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ it("keeps all sub-cli filter surfaces aligned when experimental QA is disabled", async () => {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
const {
SUB_CLI_DESCRIPTORS,
@@ -47,8 +47,8 @@ describe("sub-cli descriptors", () => {
expect(getSubCliParentDefaultHelpCommands()).not.toContain("qa");
});
- it("includes qa in the exported descriptor list when private QA is enabled", async () => {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
+ it("includes qa in the exported descriptor list when experimental QA is enabled", async () => {
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
const {
SUB_CLI_DESCRIPTORS,
diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts
index 1de2bbd0fb02..bc6afaae5475 100644
--- a/src/cli/program/subcli-descriptors.ts
+++ b/src/cli/program/subcli-descriptors.ts
@@ -1,7 +1,7 @@
// Sub-CLI descriptor catalog used for root help placeholders and lazy registration.
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
-import { isPrivateQaCliEnabled } from "./private-qa-cli.js";
+import { isExperimentalQaCliEnabled } from "./private-qa-cli.js";
/** Descriptor shape for root-level sub-CLI commands. */
export type SubCliDescriptor = NamedCommandDescriptor;
@@ -104,7 +104,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
},
{
name: "qa",
- description: "Run QA scenarios and launch the private QA debugger UI",
+ description: "Run experimental QA scenarios and user flows",
hasSubcommands: true,
},
{
@@ -181,25 +181,25 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
},
] as const satisfies ReadonlyArray);
-function filterPrivateQaItems(
+function filterExperimentalQaItems(
items: ReadonlyArray,
getName: (item: T) => string,
): ReadonlyArray {
- if (isPrivateQaCliEnabled()) {
+ if (isExperimentalQaCliEnabled()) {
return items;
}
return items.filter((item) => getName(item) !== "qa");
}
-/** Visible sub-CLI descriptors after private QA gating. */
-export const SUB_CLI_DESCRIPTORS = filterPrivateQaItems(
+/** Visible sub-CLI descriptors after experimental QA gating. */
+export const SUB_CLI_DESCRIPTORS = filterExperimentalQaItems(
subCliCommandCatalog.descriptors,
(descriptor) => descriptor.name,
);
/** Return visible sub-CLI descriptors in help/registration order. */
export function getSubCliEntries(): ReadonlyArray {
- return filterPrivateQaItems(
+ return filterExperimentalQaItems(
subCliCommandCatalog.getDescriptors(),
(descriptor) => descriptor.name,
);
@@ -208,7 +208,7 @@ export function getSubCliEntries(): ReadonlyArray {
/** Return visible sub-CLI names that own child subcommands. */
export function getSubCliCommandsWithSubcommands(): string[] {
return [
- ...filterPrivateQaItems(
+ ...filterExperimentalQaItems(
subCliCommandCatalog.getCommandsWithSubcommands(),
(command) => command,
),
@@ -218,7 +218,7 @@ export function getSubCliCommandsWithSubcommands(): string[] {
/** Return visible sub-CLI names whose parent command should show help by default. */
export function getSubCliParentDefaultHelpCommands(): string[] {
return [
- ...filterPrivateQaItems(
+ ...filterExperimentalQaItems(
subCliCommandCatalog.getParentDefaultHelpCommands(),
(command) => command,
),
diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts
index 03126bc4986f..c18ab6111994 100644
--- a/src/infra/package-dist-inventory.test.ts
+++ b/src/infra/package-dist-inventory.test.ts
@@ -40,7 +40,7 @@ describe("package dist inventory", () => {
});
});
- it("keeps npm-omitted dist artifacts out of the inventory", async () => {
+ it("keeps omitted dist artifacts out of the inventory while tracking packaged QA facades", async () => {
await withTempDir({ prefix: "openclaw-dist-inventory-pack-" }, async (packageRoot) => {
const packagedQaChannelRuntime = path.join(
packageRoot,
@@ -56,8 +56,8 @@ describe("package dist inventory", () => {
"qa-lab",
"runtime-api.js",
);
- const omittedQaChunk = path.join(packageRoot, "dist", "extensions", "qa-channel", "cli.js");
- const omittedQaLabChunk = path.join(packageRoot, "dist", "extensions", "qa-lab", "cli.js");
+ const packagedQaChunk = path.join(packageRoot, "dist", "extensions", "qa-channel", "cli.js");
+ const packagedQaLabChunk = path.join(packageRoot, "dist", "extensions", "qa-lab", "cli.js");
const omittedQaMatrixChunk = path.join(
packageRoot,
"dist",
@@ -65,20 +65,20 @@ describe("package dist inventory", () => {
"qa-matrix",
"index.js",
);
- const omittedQaLabPluginSdk = path.join(packageRoot, "dist", "plugin-sdk", "qa-lab.js");
- const omittedQaChannelPluginSdk = path.join(
+ const packagedQaLabPluginSdk = path.join(packageRoot, "dist", "plugin-sdk", "qa-lab.js");
+ const packagedQaChannelPluginSdk = path.join(
packageRoot,
"dist",
"plugin-sdk",
"qa-channel.js",
);
- const omittedQaChannelProtocolPluginSdk = path.join(
+ const packagedQaChannelProtocolPluginSdk = path.join(
packageRoot,
"dist",
"plugin-sdk",
"qa-channel-protocol.js",
);
- const omittedQaLabTypes = path.join(
+ const packagedQaLabTypes = path.join(
packageRoot,
"dist",
"plugin-sdk",
@@ -100,7 +100,7 @@ describe("package dist inventory", () => {
"plugin-sdk",
"provider-entry.d.ts",
);
- const omittedQaRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-B9LDtssJ.js");
+ const packagedQaRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-B9LDtssJ.js");
const [omittedBuildStamp, omittedRuntimePostBuildStamp] = LOCAL_BUILD_METADATA_DIST_PATHS.map(
(relativePath) => path.join(packageRoot, relativePath),
);
@@ -108,27 +108,36 @@ describe("package dist inventory", () => {
await fs.mkdir(path.dirname(packagedQaChannelRuntime), { recursive: true });
await fs.mkdir(path.dirname(packagedQaLabRuntime), { recursive: true });
await fs.mkdir(path.dirname(omittedQaMatrixChunk), { recursive: true });
- await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true });
+ await fs.mkdir(path.dirname(packagedQaLabTypes), { recursive: true });
await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true });
await fs.mkdir(path.dirname(omittedDeepPluginSdkDeclaration), { recursive: true });
await fs.writeFile(packagedQaChannelRuntime, "export {};\n", "utf8");
await fs.writeFile(packagedQaLabRuntime, "export {};\n", "utf8");
- await fs.writeFile(omittedQaChunk, "export {};\n", "utf8");
- await fs.writeFile(omittedQaLabChunk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaChunk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaLabChunk, "export {};\n", "utf8");
await fs.writeFile(omittedQaMatrixChunk, "export {};\n", "utf8");
- await fs.writeFile(omittedQaLabPluginSdk, "export {};\n", "utf8");
- await fs.writeFile(omittedQaChannelPluginSdk, "export {};\n", "utf8");
- await fs.writeFile(omittedQaChannelProtocolPluginSdk, "export {};\n", "utf8");
- await fs.writeFile(omittedQaLabTypes, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaLabPluginSdk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaChannelPluginSdk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaChannelProtocolPluginSdk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaLabTypes, "export {};\n", "utf8");
await fs.writeFile(omittedDeepPluginSdkDeclaration, "export {};\n", "utf8");
await fs.writeFile(flatPluginSdkDeclaration, "export {};\n", "utf8");
- await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8");
+ await fs.writeFile(packagedQaRuntimeChunk, "export {};\n", "utf8");
await fs.writeFile(omittedBuildStamp, "{}\n", "utf8");
await fs.writeFile(omittedRuntimePostBuildStamp, "{}\n", "utf8");
await fs.writeFile(omittedMap, "{}", "utf8");
await expect(writePackageDistInventory(packageRoot)).resolves.toStrictEqual([
+ "dist/extensions/qa-channel/cli.js",
+ "dist/extensions/qa-channel/runtime-api.js",
+ "dist/extensions/qa-lab/cli.js",
+ "dist/extensions/qa-lab/runtime-api.js",
+ "dist/plugin-sdk/extensions/qa-lab/cli.d.ts",
"dist/plugin-sdk/provider-entry.d.ts",
+ "dist/plugin-sdk/qa-channel-protocol.js",
+ "dist/plugin-sdk/qa-channel.js",
+ "dist/plugin-sdk/qa-lab.js",
+ "dist/qa-runtime-B9LDtssJ.js",
]);
});
});
diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts
index 61ce26f64423..15da296ea5e0 100644
--- a/src/infra/package-dist-inventory.ts
+++ b/src/infra/package-dist-inventory.ts
@@ -9,35 +9,10 @@ export { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-m
export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const PACKAGE_DIST_INVENTORY_SCAN_CONCURRENCY = 32;
-const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
-const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
-const OMITTED_QA_EXTENSION_PREFIXES = [
- `dist/extensions/${LEGACY_QA_CHANNEL_DIR}/`,
- `dist/extensions/${LEGACY_QA_LAB_DIR}/`,
- "dist/extensions/qa-matrix/",
-];
-const OMITTED_PRIVATE_QA_PLUGIN_SDK_PREFIXES = [
- `dist/plugin-sdk/extensions/${LEGACY_QA_CHANNEL_DIR}/`,
- `dist/plugin-sdk/extensions/${LEGACY_QA_LAB_DIR}/`,
-];
-const OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES = new Set([
- `dist/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}.d.ts`,
- `dist/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}.js`,
- `dist/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}-protocol.d.ts`,
- `dist/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}-protocol.js`,
- `dist/plugin-sdk/${LEGACY_QA_LAB_DIR}.d.ts`,
- `dist/plugin-sdk/${LEGACY_QA_LAB_DIR}.js`,
- "dist/plugin-sdk/qa-runtime.d.ts",
- "dist/plugin-sdk/qa-runtime.js",
- `dist/plugin-sdk/src/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}.d.ts`,
- `dist/plugin-sdk/src/plugin-sdk/${LEGACY_QA_CHANNEL_DIR}-protocol.d.ts`,
- `dist/plugin-sdk/src/plugin-sdk/${LEGACY_QA_LAB_DIR}.d.ts`,
- "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts",
-]);
+const OMITTED_QA_EXTENSION_PREFIXES = ["dist/extensions/qa-matrix/"];
// The build keeps source-shaped SDK declarations for local boundary projects,
// but the npm package ships flat declarations and must not inventory the old tree.
const OMITTED_DEEP_PLUGIN_SDK_DECLARATION_PREFIX = "dist/plugin-sdk/src/";
-const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"];
const OMITTED_PLUGIN_SDK_TEST_FILES = new Set([
"dist/plugin-sdk/agent-runtime-test-contracts.d.ts",
"dist/plugin-sdk/agent-runtime-test-contracts.js",
@@ -77,8 +52,6 @@ const OMITTED_DIST_SUBTREE_PATTERNS = [
/^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u,
/^dist\/extensions\/qa-matrix(?:\/|$)/u,
/^dist\/plugin-sdk\/src(?:\/|$)/u,
- new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_CHANNEL_DIR}(?:/|$)`, "u"),
- new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_LAB_DIR}(?:/|$)`, "u"),
] as const;
const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu;
type ExternalizedBundledExtensionIds = ReadonlySet;
@@ -314,13 +287,6 @@ function isPackagedDistPath(relativePath: string, rules: PackageDistInventoryRul
if (relativePath.startsWith(OMITTED_DEEP_PLUGIN_SDK_DECLARATION_PREFIX)) {
return false;
}
- if (
- OMITTED_PRIVATE_QA_PLUGIN_SDK_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) ||
- OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES.has(relativePath) ||
- OMITTED_PRIVATE_QA_DIST_PREFIXES.some((prefix) => relativePath.startsWith(prefix))
- ) {
- return false;
- }
if (OMITTED_QA_EXTENSION_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) {
return false;
}
diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts
index 93b1be6771e3..0ad5af0ce76a 100644
--- a/src/infra/run-node.test.ts
+++ b/src/infra/run-node.test.ts
@@ -1060,7 +1060,7 @@ describe("run-node script", () => {
| undefined;
expect(postBuildParams?.cwd).toBe(tmp);
expect(postBuildParams?.env?.OPENCLAW_BUILD_PRIVATE_QA).toBe("1");
- expect(postBuildParams?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1");
+ expect(postBuildParams?.env?.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI).toBe("1");
expect(postBuildParams?.env?.OPENCLAW_DISABLE_BUNDLED_PLUGINS).toBe("0");
});
});
diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts
index 4e66368cb08b..a4d801b6a629 100644
--- a/src/plugin-sdk/entrypoints.ts
+++ b/src/plugin-sdk/entrypoints.ts
@@ -59,7 +59,10 @@ export const supportedBundledFacadeSdkEntrypoints = [
"mattermost",
"memory-core-engine-runtime",
"provider-zai-endpoint",
+ "qa-channel",
+ "qa-lab",
"qa-runner-runtime",
+ "qa-runtime",
"telegram-account",
"tts-runtime",
"zalouser",
@@ -89,6 +92,7 @@ export const publicPluginOwnedSdkEntrypoints = [
"memory-host-markdown",
"memory-host-search",
"memory-host-status",
+ "qa-channel-protocol",
"speech-core",
"telegram-command-config",
"video-generation-core",
diff --git a/src/plugin-sdk/private-qa-bundled-env.ts b/src/plugin-sdk/private-qa-bundled-env.ts
index 9029a7ede7c9..70c804aa6e60 100644
--- a/src/plugin-sdk/private-qa-bundled-env.ts
+++ b/src/plugin-sdk/private-qa-bundled-env.ts
@@ -9,7 +9,7 @@ import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
export function resolvePrivateQaBundledPluginsEnv(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv | undefined {
- if (env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") {
+ if (env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI !== "1") {
return undefined;
}
const packageRoot = resolveOpenClawPackageRootSync({
diff --git a/src/plugin-sdk/qa-live-transport-scenarios.ts b/src/plugin-sdk/qa-live-transport-scenarios.ts
index 28c7ad1415bc..ac12f2ce79b3 100644
--- a/src/plugin-sdk/qa-live-transport-scenarios.ts
+++ b/src/plugin-sdk/qa-live-transport-scenarios.ts
@@ -1,3 +1,11 @@
+import {
+ planQaUserFlows,
+ type QaStandardUserFlowId,
+ type QaUserFlowDefinition,
+ type QaUserFlowPlan,
+ type QaUserFlowSkipReason,
+} from "./qa-user-flows.js";
+
/** Standard live-transport behavior buckets used to compare channel QA suites. */
export type LiveTransportStandardScenarioId =
| "canary"
@@ -10,6 +18,19 @@ export type LiveTransportStandardScenarioId =
| "reaction-observation"
| "help-command";
+/** Channel capability atoms used to plan standard live transport scenarios. */
+export type LiveTransportScenarioCapabilityId =
+ | "inbound-message"
+ | "inbound-reaction"
+ | "inbound-thread"
+ | "mention-gating"
+ | "native-help-command"
+ | "outbound-final-reply"
+ | "outbound-thread-reply"
+ | "sender-allowlist"
+ | "top-level-reply"
+ | "runtime-restart";
+
/** Transport-specific live QA scenario with optional mapping to a standard behavior bucket. */
export type LiveTransportScenarioDefinition = {
/** Transport-specific scenario id accepted by CLI scenario filters. */
@@ -22,59 +43,121 @@ export type LiveTransportScenarioDefinition = {
title: string;
};
-type LiveTransportStandardScenarioDefinition = {
+export type LiveTransportStandardScenarioDefinition = {
description: string;
id: LiveTransportStandardScenarioId;
+ requiredCapabilities: readonly LiveTransportScenarioCapabilityId[];
title: string;
+ userFlowId?: QaStandardUserFlowId;
+} & Pick;
+
+export type LiveTransportStandardScenarioPlanEntry = LiveTransportStandardScenarioDefinition & {
+ missingCapabilities: readonly LiveTransportScenarioCapabilityId[];
};
-const LIVE_TRANSPORT_STANDARD_SCENARIOS: readonly LiveTransportStandardScenarioDefinition[] = [
- {
- id: "canary",
- title: "Transport canary",
- description: "The lane can trigger one known-good reply on the real transport.",
- },
- {
- id: "mention-gating",
- title: "Mention gating",
- description: "Messages without the required mention do not trigger a reply.",
- },
- {
- id: "allowlist-block",
- title: "Sender allowlist block",
- description: "Non-allowlisted senders do not trigger a reply.",
- },
- {
- id: "top-level-reply-shape",
- title: "Top-level reply shape",
- description: "Top-level replies stay top-level when the lane is configured that way.",
- },
- {
- id: "restart-resume",
- title: "Restart resume",
- description: "The lane still responds after a gateway restart.",
- },
- {
- id: "thread-follow-up",
- title: "Thread follow-up",
- description: "Threaded prompts receive threaded replies with the expected relation metadata.",
- },
- {
- id: "thread-isolation",
- title: "Thread isolation",
- description: "Fresh top-level prompts stay out of prior threads.",
- },
- {
- id: "reaction-observation",
- title: "Reaction observation",
- description: "Reaction events are observed and normalized correctly.",
- },
- {
- id: "help-command",
- title: "Help command",
- description: "The transport-specific help command path replies successfully.",
- },
-] as const;
+export type LiveTransportStandardScenarioSkipReason = QaUserFlowSkipReason;
+
+export type LiveTransportStandardScenarioPlan =
+ QaUserFlowPlan;
+
+export const LIVE_TRANSPORT_STANDARD_SCENARIOS: readonly LiveTransportStandardScenarioDefinition[] =
+ [
+ {
+ id: "canary",
+ title: "Transport canary",
+ description: "The lane can trigger one known-good reply on the real transport.",
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "message" },
+ contracts: [{ family: "channel", name: "base channel plugin contract" }],
+ requiredCapabilities: ["inbound-message", "outbound-final-reply"],
+ userFlowId: "messaging.direct-reply",
+ },
+ {
+ id: "mention-gating",
+ title: "Mention gating",
+ description: "Messages without the required mention do not trigger a reply.",
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "unmentioned group message" },
+ contracts: [{ family: "channel", name: "mention gating contract" }],
+ requiredCapabilities: ["inbound-message", "mention-gating"],
+ userFlowId: "messaging.mention-gating",
+ },
+ {
+ id: "allowlist-block",
+ title: "Sender allowlist block",
+ description: "Non-allowlisted senders do not trigger a reply.",
+ surface: "security",
+ action: { actor: "user", verb: "send", object: "blocked sender message" },
+ contracts: [{ family: "channel", name: "sender allowlist contract" }],
+ requiredCapabilities: ["inbound-message", "sender-allowlist"],
+ userFlowId: "security.sender-allowlist-block",
+ },
+ {
+ id: "top-level-reply-shape",
+ title: "Top-level reply shape",
+ description: "Top-level replies stay top-level when the lane is configured that way.",
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "top-level message" },
+ contracts: [{ family: "channel", name: "reply shape contract" }],
+ requiredCapabilities: ["inbound-message", "outbound-final-reply", "top-level-reply"],
+ userFlowId: "messaging.direct-reply",
+ },
+ {
+ id: "restart-resume",
+ title: "Restart resume",
+ description: "The lane still responds after a gateway restart.",
+ surface: "recovery",
+ action: { actor: "system", verb: "restart", object: "gateway" },
+ contracts: [{ family: "gateway", name: "restart recovery contract" }],
+ requiredCapabilities: ["inbound-message", "outbound-final-reply", "runtime-restart"],
+ userFlowId: "recovery.restart-resume",
+ },
+ {
+ id: "thread-follow-up",
+ title: "Thread follow-up",
+ description: "Threaded prompts receive threaded replies with the expected relation metadata.",
+ surface: "messaging",
+ action: { actor: "user", verb: "reply", object: "thread" },
+ contracts: [{ family: "channel", name: "thread binding contract" }],
+ requiredCapabilities: ["inbound-message", "inbound-thread", "outbound-thread-reply"],
+ userFlowId: "messaging.thread-follow-up",
+ },
+ {
+ id: "thread-isolation",
+ title: "Thread isolation",
+ description: "Fresh top-level prompts stay out of prior threads.",
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "fresh top-level message" },
+ contracts: [{ family: "channel", name: "thread isolation contract" }],
+ requiredCapabilities: [
+ "inbound-message",
+ "inbound-thread",
+ "outbound-final-reply",
+ "top-level-reply",
+ ],
+ userFlowId: "messaging.thread-follow-up",
+ },
+ {
+ id: "reaction-observation",
+ title: "Reaction observation",
+ description: "Reaction events are observed and normalized correctly.",
+ surface: "messaging",
+ action: { actor: "user", verb: "react", object: "message" },
+ contracts: [{ family: "channel", name: "message actions contract" }],
+ requiredCapabilities: ["inbound-reaction"],
+ userFlowId: "messaging.reaction-edit-delete",
+ },
+ {
+ id: "help-command",
+ title: "Help command",
+ description: "The transport-specific help command path replies successfully.",
+ surface: "setup",
+ action: { actor: "user", verb: "request", object: "native help command" },
+ contracts: [{ family: "channel", name: "native command contract" }],
+ requiredCapabilities: ["inbound-message", "native-help-command", "outbound-final-reply"],
+ userFlowId: "setup.native-help-command",
+ },
+ ] as const;
/** Minimum standard scenarios expected from baseline live transport suites. */
export const LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS: readonly LiveTransportStandardScenarioId[] =
@@ -158,3 +241,22 @@ export function findMissingLiveTransportStandardScenarios(params: {
const covered = new Set(params.coveredStandardScenarioIds);
return params.expectedStandardScenarioIds.filter((id) => !covered.has(id));
}
+
+/** Plans standard live transport scenarios from channel capabilities and driver support. */
+export function planLiveTransportStandardScenarios(params: {
+ availableCapabilities: readonly LiveTransportScenarioCapabilityId[];
+ driverSupportedScenarioIds?: readonly LiveTransportStandardScenarioId[];
+ requestedScenarioIds?: readonly LiveTransportStandardScenarioId[];
+}): LiveTransportStandardScenarioPlan {
+ assertKnownStandardScenarioIds(params.driverSupportedScenarioIds ?? []);
+ assertKnownStandardScenarioIds(params.requestedScenarioIds ?? []);
+
+ return planQaUserFlows({
+ flows: LIVE_TRANSPORT_STANDARD_SCENARIOS,
+ availableCapabilities: params.availableCapabilities,
+ ...(params.driverSupportedScenarioIds
+ ? { driverSupportedFlowIds: params.driverSupportedScenarioIds }
+ : {}),
+ ...(params.requestedScenarioIds ? { requestedFlowIds: params.requestedScenarioIds } : {}),
+ });
+}
diff --git a/src/plugin-sdk/qa-runner-runtime.test.ts b/src/plugin-sdk/qa-runner-runtime.test.ts
index 9b2f2e10402d..ad29b925def0 100644
--- a/src/plugin-sdk/qa-runner-runtime.test.ts
+++ b/src/plugin-sdk/qa-runner-runtime.test.ts
@@ -6,10 +6,10 @@ import type { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupTempDirs,
- expectPrivateQaLabRuntimeSurfaceLoad,
+ expectExperimentalQaLabRuntimeSurfaceLoad,
expectQaLabRuntimeSurfaceLoad,
- makePrivateQaSourceRoot,
- restorePrivateQaCliEnv,
+ makeExperimentalQaSourceRoot,
+ restoreExperimentalQaCliEnv,
} from "./qa-runtime.test-helpers.js";
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
@@ -49,7 +49,7 @@ function firstPublicSurfaceCall(): PublicSurfaceCall | undefined {
describe("plugin-sdk qa-runner-runtime", () => {
const tempDirs: string[] = [];
- const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ const originalExperimentalQaCli = process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
beforeEach(() => {
loadPluginManifestRegistry.mockReset().mockReturnValue({
@@ -59,12 +59,12 @@ describe("plugin-sdk qa-runner-runtime", () => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
tryLoadActivatedBundledPluginPublicSurfaceModuleSync.mockReset();
resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null);
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
});
afterEach(() => {
cleanupTempDirs(tempDirs);
- restorePrivateQaCliEnv(originalPrivateQaCli);
+ restoreExperimentalQaCliEnv(originalExperimentalQaCli);
});
it("stays cold until runner discovery is requested", async () => {
@@ -82,8 +82,8 @@ describe("plugin-sdk qa-runner-runtime", () => {
});
});
- it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => {
- await expectPrivateQaLabRuntimeSurfaceLoad({
+ it("uses the source bundled tree for qa-lab runtime loading with the experimental QA source override", async () => {
+ await expectExperimentalQaLabRuntimeSurfaceLoad({
tempDirs,
importRuntime: () => import("./qa-runner-runtime.js"),
loadBundledPluginPublicSurfaceModuleSync,
@@ -91,8 +91,8 @@ describe("plugin-sdk qa-runner-runtime", () => {
});
});
- it("loads bundled plugin test APIs with the private QA source tree override", async () => {
- const sourceRoot = makePrivateQaSourceRoot(tempDirs, "openclaw-qa-test-api-root-");
+ it("loads bundled plugin test APIs with the experimental QA source tree override", async () => {
+ const sourceRoot = makeExperimentalQaSourceRoot(tempDirs, "openclaw-qa-test-api-root-");
resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const testApi = { marker: "matrix-test-api" };
@@ -104,7 +104,7 @@ describe("plugin-sdk qa-runner-runtime", () => {
const testApiCall = firstPublicSurfaceCall();
expect(testApiCall?.dirName).toBe("matrix");
expect(testApiCall?.artifactBasename).toBe("test-api.js");
- expect(testApiCall?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1");
+ expect(testApiCall?.env?.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI).toBe("1");
expect(testApiCall?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR).toBe(
path.join(sourceRoot, "extensions"),
);
@@ -188,7 +188,7 @@ describe("plugin-sdk qa-runner-runtime", () => {
});
it("prefers the source bundled tree for private qa discovery in repo checkouts", async () => {
- const sourceRoot = makePrivateQaSourceRoot(tempDirs, "openclaw-qa-runner-root-");
+ const sourceRoot = makeExperimentalQaSourceRoot(tempDirs, "openclaw-qa-runner-root-");
resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const register = vi.fn((qa: Command) => qa);
@@ -221,7 +221,7 @@ describe("plugin-sdk qa-runner-runtime", () => {
},
]);
const manifestCall = firstManifestRegistryCall();
- expect(manifestCall?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1");
+ expect(manifestCall?.env?.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI).toBe("1");
expect(manifestCall?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR).toBe(
path.join(sourceRoot, "extensions"),
);
@@ -229,7 +229,7 @@ describe("plugin-sdk qa-runner-runtime", () => {
const publicSurfaceCall = firstPublicSurfaceCall();
expect(publicSurfaceCall?.dirName).toBe("qa-matrix");
expect(publicSurfaceCall?.artifactBasename).toBe("runtime-api.js");
- expect(publicSurfaceCall?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1");
+ expect(publicSurfaceCall?.env?.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI).toBe("1");
expect(publicSurfaceCall?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR).toBe(
path.join(sourceRoot, "extensions"),
);
diff --git a/src/plugin-sdk/qa-runtime.test-helpers.ts b/src/plugin-sdk/qa-runtime.test-helpers.ts
index 957d5acd83fa..6de436f040be 100644
--- a/src/plugin-sdk/qa-runtime.test-helpers.ts
+++ b/src/plugin-sdk/qa-runtime.test-helpers.ts
@@ -19,23 +19,23 @@ export function cleanupTempDirs(tempDirs: string[]): void {
}
}
-/** Restores the private QA CLI env flag after a test mutates it. */
-export function restorePrivateQaCliEnv(originalPrivateQaCli: string | undefined): void {
- if (originalPrivateQaCli === undefined) {
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+/** Restores the experimental QA CLI env flag after a test mutates it. */
+export function restoreExperimentalQaCliEnv(originalExperimentalQaCli: string | undefined): void {
+ if (originalExperimentalQaCli === undefined) {
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
} else {
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli;
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = originalExperimentalQaCli;
}
}
-/** Creates a minimal source checkout shape that enables private QA runtime loading. */
-export function makePrivateQaSourceRoot(tempDirs: string[], prefix: string): string {
+/** Creates a minimal source checkout shape that enables experimental QA source runtime loading. */
+export function makeExperimentalQaSourceRoot(tempDirs: string[], prefix: string): string {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(sourceRoot);
fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
- process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
+ process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI = "1";
return sourceRoot;
}
@@ -63,14 +63,14 @@ export async function expectQaLabRuntimeSurfaceLoad(params: {
});
}
-/** Asserts private QA loading rewrites bundled plugin lookup to the source extensions root. */
-export async function expectPrivateQaLabRuntimeSurfaceLoad(params: {
+/** Asserts experimental QA loading rewrites bundled plugin lookup to the source extensions root. */
+export async function expectExperimentalQaLabRuntimeSurfaceLoad(params: {
tempDirs: string[];
importRuntime: () => Promise;
loadBundledPluginPublicSurfaceModuleSync: SurfaceLoaderMock;
resolveOpenClawPackageRootSync: SurfaceLoaderMock;
}) {
- const sourceRoot = makePrivateQaSourceRoot(params.tempDirs, "openclaw-qa-runtime-root-");
+ const sourceRoot = makeExperimentalQaSourceRoot(params.tempDirs, "openclaw-qa-runtime-root-");
params.resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const runtimeSurface = makeQaRuntimeSurface();
@@ -83,7 +83,7 @@ export async function expectPrivateQaLabRuntimeSurfaceLoad(params: {
dirName: "qa-lab",
artifactBasename: "runtime-api.js",
env: expect.objectContaining({
- OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
+ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"),
}),
});
diff --git a/src/plugin-sdk/qa-runtime.test.ts b/src/plugin-sdk/qa-runtime.test.ts
index f926e137cd45..c049cd8a8b3f 100644
--- a/src/plugin-sdk/qa-runtime.test.ts
+++ b/src/plugin-sdk/qa-runtime.test.ts
@@ -5,9 +5,9 @@ import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupTempDirs,
- expectPrivateQaLabRuntimeSurfaceLoad,
+ expectExperimentalQaLabRuntimeSurfaceLoad,
expectQaLabRuntimeSurfaceLoad,
- restorePrivateQaCliEnv,
+ restoreExperimentalQaCliEnv,
} from "./qa-runtime.test-helpers.js";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
@@ -23,17 +23,17 @@ vi.mock("../infra/openclaw-root.js", () => ({
describe("plugin-sdk qa-runtime", () => {
const tempDirs: string[] = [];
- const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ const originalExperimentalQaCli = process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
beforeEach(() => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null);
- delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+ delete process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI;
});
afterEach(() => {
cleanupTempDirs(tempDirs);
- restorePrivateQaCliEnv(originalPrivateQaCli);
+ restoreExperimentalQaCliEnv(originalExperimentalQaCli);
});
it("stays cold until the runtime seam is used", async () => {
@@ -51,8 +51,8 @@ describe("plugin-sdk qa-runtime", () => {
});
});
- it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => {
- await expectPrivateQaLabRuntimeSurfaceLoad({
+ it("uses the source bundled tree for qa-lab runtime loading with the experimental QA source override", async () => {
+ await expectExperimentalQaLabRuntimeSurfaceLoad({
tempDirs,
importRuntime: () => import("./qa-runtime.js"),
loadBundledPluginPublicSurfaceModuleSync,
@@ -149,6 +149,107 @@ describe("plugin-sdk qa-runtime", () => {
expectedStandardScenarioIds: module.LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
}),
).toEqual(["allowlist-block", "top-level-reply-shape"]);
+
+ expect(
+ module.LIVE_TRANSPORT_STANDARD_SCENARIOS.find((scenario) => scenario.id === "canary"),
+ ).toMatchObject({
+ requiredCapabilities: ["inbound-message", "outbound-final-reply"],
+ userFlowId: "messaging.direct-reply",
+ });
+ });
+
+ it("re-exports the shared QA user-flow planner surface for QA Lab callers", async () => {
+ const module = await import("./qa-runtime.js");
+
+ expect(module.QA_USER_FLOW_SURFACES).toContain("messaging");
+ expect(
+ module.QA_USER_FLOW_STANDARD_FLOWS.find((flow) => flow.id === "tool.call-followthrough"),
+ ).toMatchObject({
+ requiredCapabilities: ["tool.call", "model.final-response"],
+ });
+ expect(
+ module
+ .planQaStandardUserFlows({
+ availableCapabilities: ["tool.call", "model.final-response"],
+ requestedFlowIds: ["tool.call-followthrough"],
+ })
+ .selected.map((flow) => flow.id),
+ ).toEqual(["tool.call-followthrough"]);
+ });
+
+ it("plans standard live transport scenarios from capabilities and driver support", async () => {
+ const module = await import("./qa-runtime.js");
+
+ const plan = module.planLiveTransportStandardScenarios({
+ availableCapabilities: [
+ "inbound-message",
+ "mention-gating",
+ "outbound-final-reply",
+ "sender-allowlist",
+ "top-level-reply",
+ ],
+ driverSupportedScenarioIds: [
+ "canary",
+ "mention-gating",
+ "allowlist-block",
+ "top-level-reply-shape",
+ "thread-follow-up",
+ ],
+ requestedScenarioIds: [
+ "canary",
+ "allowlist-block",
+ "top-level-reply-shape",
+ "thread-follow-up",
+ ],
+ });
+
+ expect(plan.selected.map((scenario) => scenario.id)).toEqual([
+ "canary",
+ "allowlist-block",
+ "top-level-reply-shape",
+ ]);
+ expect(
+ plan.skipped.map((scenario) => ({
+ id: scenario.id,
+ reason: scenario.reason,
+ missingCapabilities: scenario.missingCapabilities,
+ })),
+ ).toEqual([
+ { id: "mention-gating", reason: "not-requested", missingCapabilities: [] },
+ { id: "restart-resume", reason: "not-requested", missingCapabilities: ["runtime-restart"] },
+ {
+ id: "thread-follow-up",
+ reason: "missing-capability",
+ missingCapabilities: ["inbound-thread", "outbound-thread-reply"],
+ },
+ {
+ id: "thread-isolation",
+ reason: "not-requested",
+ missingCapabilities: ["inbound-thread"],
+ },
+ {
+ id: "reaction-observation",
+ reason: "not-requested",
+ missingCapabilities: ["inbound-reaction"],
+ },
+ { id: "help-command", reason: "not-requested", missingCapabilities: ["native-help-command"] },
+ ]);
+ });
+
+ it("reports driver gaps before capability gaps in live transport plans", async () => {
+ const module = await import("./qa-runtime.js");
+
+ const plan = module.planLiveTransportStandardScenarios({
+ availableCapabilities: [],
+ driverSupportedScenarioIds: [],
+ requestedScenarioIds: ["thread-follow-up"],
+ });
+
+ expect(plan.selected).toEqual([]);
+ expect(plan.skipped.find((scenario) => scenario.id === "thread-follow-up")).toMatchObject({
+ reason: "driver-not-implemented",
+ missingCapabilities: ["inbound-message", "inbound-thread", "outbound-thread-reply"],
+ });
});
it("registers shared live transport QA CLI options", async () => {
diff --git a/src/plugin-sdk/qa-runtime.ts b/src/plugin-sdk/qa-runtime.ts
index 8dab6c79525b..46c4543ed903 100644
--- a/src/plugin-sdk/qa-runtime.ts
+++ b/src/plugin-sdk/qa-runtime.ts
@@ -234,13 +234,45 @@ export type QaReportScenario = {
export {
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
+ LIVE_TRANSPORT_STANDARD_SCENARIOS,
collectLiveTransportStandardScenarioCoverage,
findMissingLiveTransportStandardScenarios,
+ planLiveTransportStandardScenarios,
selectLiveTransportScenarios,
+ type LiveTransportScenarioCapabilityId,
type LiveTransportScenarioDefinition,
+ type LiveTransportStandardScenarioDefinition,
type LiveTransportStandardScenarioId,
+ type LiveTransportStandardScenarioPlan,
+ type LiveTransportStandardScenarioPlanEntry,
+ type LiveTransportStandardScenarioSkipReason,
} from "./qa-live-transport-scenarios.js";
+export {
+ QA_USER_FLOW_STANDARD_CAPABILITIES,
+ QA_USER_FLOW_STANDARD_FLOWS,
+ QA_USER_FLOW_SURFACES,
+ assertKnownQaStandardUserFlowIds,
+ collectQaUserFlowCapabilities,
+ collectQaUserFlowSupportedFlowIds,
+ planQaStandardUserFlows,
+ planQaUserFlows,
+ type QaStandardUserFlowCapabilityId,
+ type QaStandardUserFlowId,
+ type QaUserFlowActionDescriptor,
+ type QaUserFlowCapabilityDefinition,
+ type QaUserFlowCapabilityMapping,
+ type QaUserFlowContractFamily,
+ type QaUserFlowContractRef,
+ type QaUserFlowDefinition,
+ type QaUserFlowExecutionDescriptor,
+ type QaUserFlowExecutionTarget,
+ type QaUserFlowPlan,
+ type QaUserFlowPlanEntry,
+ type QaUserFlowSkipReason,
+ type QaUserFlowSurfaceId,
+} from "./qa-user-flows.js";
+
/** Docker command runner abstraction used by QA Docker helpers and tests. */
export type QaDockerRunCommand = (
command: string,
diff --git a/src/plugin-sdk/qa-user-flows.test.ts b/src/plugin-sdk/qa-user-flows.test.ts
new file mode 100644
index 000000000000..87e8b2562016
--- /dev/null
+++ b/src/plugin-sdk/qa-user-flows.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import {
+ collectQaUserFlowCapabilities,
+ collectQaUserFlowSupportedFlowIds,
+ planQaStandardUserFlows,
+ planQaUserFlows,
+} from "./qa-user-flows.js";
+
+describe("plugin-sdk qa-user-flows", () => {
+ it("plans flows from optional owner mappings without requiring every plugin to map everything", () => {
+ const mappings = [
+ {
+ ownerId: "qa-channel",
+ provides: ["messaging.inbound-message", "messaging.outbound-final-reply"],
+ supportedFlowIds: ["messaging.direct-reply"],
+ },
+ {
+ ownerId: "openai",
+ provides: ["model.final-response"],
+ },
+ ] as const;
+
+ expect(collectQaUserFlowCapabilities(mappings)).toEqual([
+ "messaging.inbound-message",
+ "messaging.outbound-final-reply",
+ "model.final-response",
+ ]);
+ expect(collectQaUserFlowSupportedFlowIds(mappings)).toEqual(["messaging.direct-reply"]);
+
+ const plan = planQaStandardUserFlows({
+ mappings,
+ requestedFlowIds: ["messaging.direct-reply", "tool.call-followthrough"],
+ });
+
+ expect(plan.selected.map((flow) => flow.id)).toEqual(["messaging.direct-reply"]);
+ expect(
+ plan.skipped.map((flow) => ({
+ id: flow.id,
+ reason: flow.reason,
+ missingCapabilities: flow.missingCapabilities,
+ })),
+ ).toContainEqual({
+ id: "tool.call-followthrough",
+ reason: "driver-not-implemented",
+ missingCapabilities: ["tool.call"],
+ });
+ expect(plan.selected[0]).toMatchObject({
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ requiredCapabilities: ["messaging.inbound-message", "messaging.outbound-final-reply"],
+ });
+ });
+
+ it("uses live-transport mappings for channel-native user flows that do not have QA Lab markdown yet", () => {
+ const plan = planQaStandardUserFlows({
+ availableCapabilities: [
+ "messaging.inbound-message",
+ "messaging.mention-gating",
+ "messaging.outbound-final-reply",
+ "setup.native-help-command",
+ ],
+ requestedFlowIds: ["messaging.mention-gating", "setup.native-help-command"],
+ });
+
+ expect(
+ plan.selected.map((flow) => ({
+ execution: flow.execution,
+ id: flow.id,
+ })),
+ ).toEqual([
+ {
+ id: "messaging.mention-gating",
+ execution: { runner: "live-transport", target: "running-gateway" },
+ },
+ {
+ id: "setup.native-help-command",
+ execution: { runner: "live-transport", target: "running-gateway" },
+ },
+ ]);
+ });
+
+ it("keeps generic planner id validation and skip ordering shared", () => {
+ const flows = [
+ {
+ id: "alpha",
+ title: "Alpha",
+ description: "Alpha flow",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "message" },
+ requiredCapabilities: ["cap.alpha"],
+ },
+ {
+ id: "beta",
+ title: "Beta",
+ description: "Beta flow",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "tool",
+ action: { actor: "user", verb: "request", object: "tool" },
+ requiredCapabilities: ["cap.beta"],
+ },
+ ] as const;
+
+ expect(() =>
+ planQaUserFlows({
+ flows,
+ availableCapabilities: [],
+ requestedFlowIds: ["missing"],
+ }),
+ ).toThrow("unknown QA user flow id: missing");
+
+ const plan = planQaUserFlows({
+ flows,
+ availableCapabilities: ["cap.beta"],
+ driverSupportedFlowIds: ["beta"],
+ requestedFlowIds: ["beta"],
+ });
+
+ expect(plan.selected.map((flow) => flow.id)).toEqual(["beta"]);
+ expect(plan.skipped.map((flow) => [flow.id, flow.reason, flow.missingCapabilities])).toEqual([
+ ["alpha", "not-requested", ["cap.alpha"]],
+ ]);
+ });
+});
diff --git a/src/plugin-sdk/qa-user-flows.ts b/src/plugin-sdk/qa-user-flows.ts
new file mode 100644
index 000000000000..294639ccccb9
--- /dev/null
+++ b/src/plugin-sdk/qa-user-flows.ts
@@ -0,0 +1,586 @@
+/** Generic user-flow planning contracts used by QA Lab and plugin-owned QA drivers. */
+
+export const QA_USER_FLOW_SURFACES = [
+ "messaging",
+ "setup",
+ "model",
+ "tool",
+ "approval",
+ "memory",
+ "media",
+ "plugin",
+ "recovery",
+ "scheduling",
+ "security",
+ "workspace",
+] as const;
+
+export type QaUserFlowSurfaceId = (typeof QA_USER_FLOW_SURFACES)[number] | (string & {});
+
+export type QaUserFlowContractFamily =
+ | "agent-runtime"
+ | "approval"
+ | "channel"
+ | "gateway"
+ | "media"
+ | "memory"
+ | "plugin"
+ | "provider"
+ | "scheduler"
+ | "security"
+ | "setup"
+ | "tool"
+ | "workspace"
+ | (string & {});
+
+export type QaUserFlowActionDescriptor = {
+ actor: "user" | "operator" | "system";
+ object?: string;
+ verb: string;
+};
+
+export type QaUserFlowContractRef = {
+ family: QaUserFlowContractFamily;
+ name: string;
+};
+
+export type QaUserFlowExecutionTarget =
+ | "running-gateway"
+ | "gateway-lab"
+ | "contract-fixture"
+ | "static-catalog"
+ | (string & {});
+
+export type QaUserFlowExecutionDescriptor = {
+ runner: "qa-lab-flow" | "live-transport" | "contract-test" | "manual" | (string & {});
+ target: QaUserFlowExecutionTarget;
+};
+
+export type QaUserFlowCapabilityDefinition = {
+ contracts?: readonly QaUserFlowContractRef[];
+ description: string;
+ id: TCapabilityId;
+ surface: QaUserFlowSurfaceId;
+ title: string;
+};
+
+export type QaUserFlowDefinition<
+ TId extends string = string,
+ TCapabilityId extends string = string,
+> = {
+ action: QaUserFlowActionDescriptor;
+ contracts?: readonly QaUserFlowContractRef[];
+ description: string;
+ execution: QaUserFlowExecutionDescriptor;
+ id: TId;
+ qaScenarioIds?: readonly string[];
+ requiredCapabilities: readonly TCapabilityId[];
+ surface: QaUserFlowSurfaceId;
+ title: string;
+};
+
+export type QaUserFlowCapabilityMapping = {
+ /** Plugin, runtime, or driver id that owns this optional flow mapping. */
+ ownerId: string;
+ /** Capability atoms this owner can prove for user-flow planning. */
+ provides: readonly TCapabilityId[];
+ /** Flow ids this owner has concrete driver coverage for. Omit when inferred by capabilities. */
+ supportedFlowIds?: readonly string[];
+};
+
+export type QaUserFlowPlanEntry = TDefinition & {
+ missingCapabilities: readonly TDefinition["requiredCapabilities"][number][];
+};
+
+export type QaUserFlowSkipReason =
+ | "driver-not-implemented"
+ | "missing-capability"
+ | "not-requested";
+
+export type QaUserFlowPlan = {
+ selected: readonly TDefinition[];
+ skipped: readonly (QaUserFlowPlanEntry & {
+ reason: QaUserFlowSkipReason;
+ })[];
+};
+
+export const QA_USER_FLOW_STANDARD_CAPABILITIES = [
+ {
+ id: "messaging.inbound-message",
+ title: "Receive message",
+ description: "A user can send text into OpenClaw through a message surface.",
+ surface: "messaging",
+ contracts: [{ family: "channel", name: "base message ingress contract" }],
+ },
+ {
+ id: "messaging.outbound-final-reply",
+ title: "Send final reply",
+ description: "OpenClaw can deliver the assistant's final answer back to the user.",
+ surface: "messaging",
+ contracts: [{ family: "channel", name: "outbound reply contract" }],
+ },
+ {
+ id: "messaging.thread-reply",
+ title: "Reply in thread",
+ description: "Threaded user prompts preserve their reply context.",
+ surface: "messaging",
+ contracts: [{ family: "channel", name: "thread binding contract" }],
+ },
+ {
+ id: "messaging.reaction-events",
+ title: "Observe reactions",
+ description: "Native reaction/edit/delete style events can be observed and normalized.",
+ surface: "messaging",
+ contracts: [{ family: "channel", name: "message actions contract" }],
+ },
+ {
+ id: "messaging.mention-gating",
+ title: "Gate unmentioned message",
+ description: "A group message that does not address OpenClaw does not trigger a reply.",
+ surface: "messaging",
+ contracts: [{ family: "channel", name: "mention gating contract" }],
+ },
+ {
+ id: "security.sender-allowlist",
+ title: "Block unauthorized sender",
+ description: "A sender outside the configured allowlist does not trigger a reply.",
+ surface: "security",
+ contracts: [{ family: "channel", name: "sender allowlist contract" }],
+ },
+ {
+ id: "setup.native-help-command",
+ title: "Handle native help command",
+ description: "A user can ask the transport-specific command surface for help.",
+ surface: "setup",
+ contracts: [{ family: "channel", name: "native command contract" }],
+ },
+ {
+ id: "setup.provider-auth",
+ title: "Authenticate provider",
+ description: "A user can connect provider credentials through setup or auth repair.",
+ surface: "setup",
+ contracts: [{ family: "provider", name: "provider auth contract" }],
+ },
+ {
+ id: "setup.config-apply",
+ title: "Apply configuration",
+ description: "A config/setup change becomes active without corrupting runtime state.",
+ surface: "setup",
+ contracts: [{ family: "setup", name: "config mutation contract" }],
+ },
+ {
+ id: "model.final-response",
+ title: "Return model response",
+ description: "A selected model can produce a final response for a user turn.",
+ surface: "model",
+ contracts: [{ family: "provider", name: "provider runtime contract" }],
+ },
+ {
+ id: "model.switch",
+ title: "Switch models",
+ description: "A user can switch models and continue the same task coherently.",
+ surface: "model",
+ contracts: [{ family: "provider", name: "provider selection contract" }],
+ },
+ {
+ id: "tool.call",
+ title: "Call tool",
+ description: "A user request can drive an OpenClaw tool call and continue to completion.",
+ surface: "tool",
+ contracts: [{ family: "tool", name: "tool runtime contract" }],
+ },
+ {
+ id: "approval.native-roundtrip",
+ title: "Approve action",
+ description: "A user can approve or deny a pending action through a supported surface.",
+ surface: "approval",
+ contracts: [{ family: "approval", name: "approval runtime contract" }],
+ },
+ {
+ id: "memory.store",
+ title: "Store memory",
+ description: "A user-visible fact can be committed to the configured memory surface.",
+ surface: "memory",
+ contracts: [{ family: "memory", name: "memory host contract" }],
+ },
+ {
+ id: "memory.recall",
+ title: "Recall memory",
+ description: "A later user turn can recall relevant scoped memory.",
+ surface: "memory",
+ contracts: [{ family: "memory", name: "memory query contract" }],
+ },
+ {
+ id: "media.image-input",
+ title: "Understand image",
+ description: "A user can attach an image and receive a grounded answer about it.",
+ surface: "media",
+ contracts: [{ family: "media", name: "media understanding contract" }],
+ },
+ {
+ id: "plugin.lifecycle",
+ title: "Load plugin",
+ description: "A plugin can be installed, discovered, and made available to a user task.",
+ surface: "plugin",
+ contracts: [{ family: "plugin", name: "plugin registration contract" }],
+ },
+ {
+ id: "recovery.restart-resume",
+ title: "Resume after restart",
+ description: "A user flow can continue after a runtime or gateway restart.",
+ surface: "recovery",
+ contracts: [{ family: "gateway", name: "restart recovery contract" }],
+ },
+ {
+ id: "scheduling.reminder",
+ title: "Deliver reminder",
+ description: "A scheduled user reminder can be created and delivered once.",
+ surface: "scheduling",
+ contracts: [{ family: "scheduler", name: "scheduled task contract" }],
+ },
+ {
+ id: "security.redaction",
+ title: "Protect secret",
+ description: "Secrets stay redacted in user-visible logs, traces, and tool output.",
+ surface: "security",
+ contracts: [{ family: "security", name: "secret redaction contract" }],
+ },
+ {
+ id: "workspace.edit-loop",
+ title: "Edit workspace",
+ description: "A user can ask for workspace edits and receive a coherent completion report.",
+ surface: "workspace",
+ contracts: [{ family: "workspace", name: "workspace tool contract" }],
+ },
+] as const satisfies readonly QaUserFlowCapabilityDefinition[];
+
+export type QaStandardUserFlowCapabilityId =
+ (typeof QA_USER_FLOW_STANDARD_CAPABILITIES)[number]["id"];
+
+export const QA_USER_FLOW_STANDARD_FLOWS = [
+ {
+ id: "messaging.direct-reply",
+ title: "Direct message reply",
+ description: "A user sends a message and receives a final answer on the same surface.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "message" },
+ requiredCapabilities: ["messaging.inbound-message", "messaging.outbound-final-reply"],
+ contracts: [{ family: "channel", name: "base channel plugin contract" }],
+ qaScenarioIds: ["channel-chat-baseline", "dm-chat-baseline"],
+ },
+ {
+ id: "messaging.thread-follow-up",
+ title: "Thread follow-up",
+ description: "A user follows up inside a thread and receives the answer in that context.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "messaging",
+ action: { actor: "user", verb: "reply", object: "thread" },
+ requiredCapabilities: ["messaging.inbound-message", "messaging.thread-reply"],
+ contracts: [{ family: "channel", name: "thread binding contract" }],
+ qaScenarioIds: ["thread-follow-up"],
+ },
+ {
+ id: "messaging.reaction-edit-delete",
+ title: "Message action observation",
+ description: "A user edits, deletes, or reacts to a message and the transport observes it.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "messaging",
+ action: { actor: "user", verb: "react", object: "message" },
+ requiredCapabilities: ["messaging.reaction-events"],
+ contracts: [{ family: "channel", name: "message actions contract" }],
+ qaScenarioIds: ["reaction-edit-delete"],
+ },
+ {
+ id: "messaging.mention-gating",
+ title: "Mention gating",
+ description: "A group user message that does not mention OpenClaw does not receive a reply.",
+ execution: { runner: "live-transport", target: "running-gateway" },
+ surface: "messaging",
+ action: { actor: "user", verb: "send", object: "unmentioned group message" },
+ requiredCapabilities: ["messaging.inbound-message", "messaging.mention-gating"],
+ contracts: [{ family: "channel", name: "mention gating contract" }],
+ },
+ {
+ id: "security.sender-allowlist-block",
+ title: "Sender allowlist block",
+ description: "A blocked user sends a message and receives no OpenClaw reply.",
+ execution: { runner: "live-transport", target: "running-gateway" },
+ surface: "security",
+ action: { actor: "user", verb: "send", object: "blocked sender message" },
+ requiredCapabilities: ["messaging.inbound-message", "security.sender-allowlist"],
+ contracts: [{ family: "channel", name: "sender allowlist contract" }],
+ },
+ {
+ id: "setup.native-help-command",
+ title: "Native help command",
+ description: "A user asks for help through the transport command surface and gets a reply.",
+ execution: { runner: "live-transport", target: "running-gateway" },
+ surface: "setup",
+ action: { actor: "user", verb: "request", object: "native help command" },
+ requiredCapabilities: [
+ "messaging.inbound-message",
+ "setup.native-help-command",
+ "messaging.outbound-final-reply",
+ ],
+ contracts: [{ family: "channel", name: "native command contract" }],
+ },
+ {
+ id: "setup.provider-auth",
+ title: "Provider auth setup",
+ description: "A user connects provider credentials and can use the provider afterward.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "setup",
+ action: { actor: "user", verb: "connect", object: "provider" },
+ requiredCapabilities: ["setup.provider-auth", "model.final-response"],
+ contracts: [{ family: "provider", name: "provider auth contract" }],
+ qaScenarioIds: ["anthropic-opus-api-key-smoke", "auth-profile-doctor-migration-safety"],
+ },
+ {
+ id: "setup.config-hot-apply",
+ title: "Config hot apply",
+ description: "A user changes configuration and the runtime observes the new behavior.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "setup",
+ action: { actor: "user", verb: "change", object: "configuration" },
+ requiredCapabilities: ["setup.config-apply"],
+ contracts: [{ family: "setup", name: "config mutation contract" }],
+ qaScenarioIds: ["config-patch-hot-apply", "config-apply-restart-wakeup"],
+ },
+ {
+ id: "model.switch-follow-up",
+ title: "Model switch follow-up",
+ description: "A user switches models and continues the task without losing context.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "model",
+ action: { actor: "user", verb: "switch", object: "model" },
+ requiredCapabilities: ["model.final-response", "model.switch"],
+ contracts: [{ family: "provider", name: "provider selection contract" }],
+ qaScenarioIds: ["model-switch-follow-up", "model-switch-tool-continuity"],
+ },
+ {
+ id: "tool.call-followthrough",
+ title: "Tool call follow-through",
+ description: "A user asks for a tool-backed task and receives a final answer after the tool.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "tool",
+ action: { actor: "user", verb: "request", object: "tool-backed task" },
+ requiredCapabilities: ["tool.call", "model.final-response"],
+ contracts: [{ family: "agent-runtime", name: "OpenClaw-owned tool runtime contract" }],
+ qaScenarioIds: ["approval-turn-tool-followthrough", "message-tool"],
+ },
+ {
+ id: "approval.native-roundtrip",
+ title: "Approval roundtrip",
+ description: "A user approves or denies a pending tool action and the run honors it.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "approval",
+ action: { actor: "user", verb: "decide", object: "approval request" },
+ requiredCapabilities: ["approval.native-roundtrip", "tool.call"],
+ contracts: [{ family: "approval", name: "approval runtime contract" }],
+ qaScenarioIds: ["approval-turn-tool-followthrough", "approval-denial-stop"],
+ },
+ {
+ id: "memory.scoped-recall",
+ title: "Scoped memory recall",
+ description: "A user stores a fact and receives a later answer that recalls the right fact.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "memory",
+ action: { actor: "user", verb: "ask", object: "remembered fact" },
+ requiredCapabilities: ["memory.store", "memory.recall"],
+ contracts: [{ family: "memory", name: "memory host/query contract" }],
+ qaScenarioIds: ["memory-recall", "thread-memory-isolation"],
+ },
+ {
+ id: "media.image-understanding",
+ title: "Image understanding",
+ description: "A user attaches an image and receives an answer grounded in the image.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "media",
+ action: { actor: "user", verb: "attach", object: "image" },
+ requiredCapabilities: ["media.image-input", "model.final-response"],
+ contracts: [{ family: "media", name: "media understanding contract" }],
+ qaScenarioIds: ["image-understanding-attachment"],
+ },
+ {
+ id: "plugin.lifecycle-hot-reload",
+ title: "Plugin lifecycle hot reload",
+ description: "A user installs or updates a plugin and can use its newly available surface.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "plugin",
+ action: { actor: "user", verb: "install", object: "plugin" },
+ requiredCapabilities: ["plugin.lifecycle"],
+ contracts: [{ family: "plugin", name: "plugin registration contract" }],
+ qaScenarioIds: ["plugin-lifecycle-hot-reload", "mcp-plugin-tools-call"],
+ },
+ {
+ id: "recovery.restart-resume",
+ title: "Restart resume",
+ description: "A user-visible run survives a gateway/runtime restart and completes.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "recovery",
+ action: { actor: "system", verb: "restart", object: "gateway" },
+ requiredCapabilities: ["recovery.restart-resume", "model.final-response"],
+ contracts: [{ family: "gateway", name: "restart recovery contract" }],
+ qaScenarioIds: ["gateway-restart-inflight-run"],
+ },
+ {
+ id: "scheduling.reminder-roundtrip",
+ title: "Reminder roundtrip",
+ description: "A user creates a reminder and receives it exactly once at the expected time.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "scheduling",
+ action: { actor: "user", verb: "schedule", object: "reminder" },
+ requiredCapabilities: ["scheduling.reminder", "messaging.outbound-final-reply"],
+ contracts: [{ family: "scheduler", name: "scheduled task contract" }],
+ qaScenarioIds: ["reminder-roundtrip", "cron-single-run-no-duplicate"],
+ },
+ {
+ id: "security.secret-redaction",
+ title: "Secret redaction",
+ description: "A user action that touches secrets does not leak them into visible evidence.",
+ execution: { runner: "qa-lab-flow", target: "running-gateway" },
+ surface: "security",
+ action: { actor: "user", verb: "inspect", object: "diagnostics" },
+ requiredCapabilities: ["security.redaction"],
+ contracts: [{ family: "security", name: "secret redaction contract" }],
+ qaScenarioIds: ["redaction-no-secret-leak", "secret-redaction-tool-logs"],
+ },
+ {
+ id: "workspace.edit-loop",
+ title: "Workspace edit loop",
+ description: "A user asks for workspace changes and receives a coherent completion report.",
+ execution: { runner: "jsonl-replay", target: "running-gateway" },
+ surface: "workspace",
+ action: { actor: "user", verb: "edit", object: "workspace" },
+ requiredCapabilities: ["workspace.edit-loop", "tool.call"],
+ contracts: [{ family: "workspace", name: "workspace tool contract" }],
+ },
+] as const satisfies readonly QaUserFlowDefinition[];
+
+export type QaStandardUserFlowId = (typeof QA_USER_FLOW_STANDARD_FLOWS)[number]["id"];
+
+const QA_USER_FLOW_STANDARD_ID_SET = new Set(QA_USER_FLOW_STANDARD_FLOWS.map((flow) => flow.id));
+
+function assertKnownQaUserFlowIds(
+ flows: readonly TDefinition[],
+ ids: readonly string[],
+) {
+ const knownIds = new Set(flows.map((flow) => flow.id));
+ for (const id of ids) {
+ if (!knownIds.has(id)) {
+ throw new Error(`unknown QA user flow id: ${id}`);
+ }
+ }
+}
+
+/** Collects unique capability atoms from optional plugin, runtime, or driver mappings. */
+export function collectQaUserFlowCapabilities(
+ mappings: readonly QaUserFlowCapabilityMapping[],
+) {
+ const capabilities: TCapabilityId[] = [];
+ const seen = new Set();
+ for (const mapping of mappings) {
+ for (const capability of mapping.provides) {
+ if (seen.has(capability)) {
+ continue;
+ }
+ seen.add(capability);
+ capabilities.push(capability);
+ }
+ }
+ return capabilities;
+}
+
+/** Collects concrete flow ids from mappings that declare driver-backed support. */
+export function collectQaUserFlowSupportedFlowIds(
+ mappings: readonly QaUserFlowCapabilityMapping[],
+) {
+ const flowIds: string[] = [];
+ const seen = new Set();
+ for (const mapping of mappings) {
+ for (const flowId of mapping.supportedFlowIds ?? []) {
+ if (seen.has(flowId)) {
+ continue;
+ }
+ seen.add(flowId);
+ flowIds.push(flowId);
+ }
+ }
+ return flowIds;
+}
+
+/** Plans user-facing flows from capability mappings and optional driver support. */
+export function planQaUserFlows(params: {
+ availableCapabilities: readonly string[];
+ driverSupportedFlowIds?: readonly string[];
+ flows: readonly TDefinition[];
+ requestedFlowIds?: readonly string[];
+}): QaUserFlowPlan {
+ assertKnownQaUserFlowIds(params.flows, params.driverSupportedFlowIds ?? []);
+ assertKnownQaUserFlowIds(params.flows, params.requestedFlowIds ?? []);
+
+ const availableCapabilities = new Set(params.availableCapabilities);
+ const driverSupportedFlowIds = params.driverSupportedFlowIds
+ ? new Set(params.driverSupportedFlowIds)
+ : null;
+ const requestedFlowIds = params.requestedFlowIds ? new Set(params.requestedFlowIds) : null;
+
+ const selected: TDefinition[] = [];
+ const skipped: QaUserFlowPlan["skipped"][number][] = [];
+
+ for (const flow of params.flows) {
+ const missingCapabilities = flow.requiredCapabilities.filter(
+ (capability) => !availableCapabilities.has(capability),
+ );
+ const baseEntry = {
+ ...flow,
+ missingCapabilities,
+ };
+
+ if (requestedFlowIds && !requestedFlowIds.has(flow.id)) {
+ skipped.push({ ...baseEntry, reason: "not-requested" });
+ continue;
+ }
+
+ if (driverSupportedFlowIds && !driverSupportedFlowIds.has(flow.id)) {
+ skipped.push({ ...baseEntry, reason: "driver-not-implemented" });
+ continue;
+ }
+
+ if (missingCapabilities.length > 0) {
+ skipped.push({ ...baseEntry, reason: "missing-capability" });
+ continue;
+ }
+
+ selected.push(flow);
+ }
+
+ return { selected, skipped };
+}
+
+/** Plans the built-in standard user-flow catalog from optional owner mappings. */
+export function planQaStandardUserFlows(params: {
+ availableCapabilities?: readonly QaStandardUserFlowCapabilityId[];
+ mappings?: readonly QaUserFlowCapabilityMapping[];
+ requestedFlowIds?: readonly QaStandardUserFlowId[];
+}) {
+ const mappedCapabilities = params.mappings ? collectQaUserFlowCapabilities(params.mappings) : [];
+ const mappedFlowIds = params.mappings ? collectQaUserFlowSupportedFlowIds(params.mappings) : [];
+ const driverSupportedFlowIds = mappedFlowIds.length > 0 ? mappedFlowIds : undefined;
+ return planQaUserFlows({
+ flows: QA_USER_FLOW_STANDARD_FLOWS,
+ availableCapabilities: [...mappedCapabilities, ...(params.availableCapabilities ?? [])],
+ ...(driverSupportedFlowIds ? { driverSupportedFlowIds } : {}),
+ ...(params.requestedFlowIds ? { requestedFlowIds: params.requestedFlowIds } : {}),
+ });
+}
+
+/** Fails fast when caller-provided standard flow ids drift from the shared catalog. */
+export function assertKnownQaStandardUserFlowIds(ids: readonly QaStandardUserFlowId[]) {
+ for (const id of ids) {
+ if (!QA_USER_FLOW_STANDARD_ID_SET.has(id)) {
+ throw new Error(`unknown QA standard user flow id: ${id}`);
+ }
+ }
+}
diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs
index 3a5059bd7f2f..af0da942b77c 100644
--- a/src/plugin-sdk/root-alias.cjs
+++ b/src/plugin-sdk/root-alias.cjs
@@ -265,9 +265,13 @@ function findDistChunkByPrefix(prefix) {
}
}
+function isExperimentalQaCliEnabled() {
+ return process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI === "1";
+}
+
function listPluginSdkExportedSubpaths() {
const packageRoot = getPackageRoot();
- const cacheKey = `${packageRoot}::privateQa=${process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1" ? "1" : "0"}`;
+ const cacheKey = `${packageRoot}::experimentalQa=${isExperimentalQaCliEnabled() ? "1" : "0"}`;
if (pluginSdkSubpathsCache.has(cacheKey)) {
return pluginSdkSubpathsCache.get(cacheKey);
}
@@ -290,7 +294,7 @@ function listPluginSdkExportedSubpaths() {
}
function listPrivateLocalOnlyPluginSdkSubpaths() {
- if (process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") {
+ if (!isExperimentalQaCliEnabled()) {
return [];
}
try {
diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts
index d979f8473263..dbe01e071ea8 100644
--- a/src/plugins/bundled-plugin-metadata.test.ts
+++ b/src/plugins/bundled-plugin-metadata.test.ts
@@ -372,11 +372,9 @@ describe("bundled plugin metadata", () => {
},
);
- it("excludes non-packaged QA sidecars from the packaged runtime sidecar baseline", () => {
- expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain(
- "dist/extensions/qa-channel/runtime-api.js",
- );
- expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain("dist/extensions/qa-lab/runtime-api.js");
+ it("includes packaged QA sidecars and excludes non-packaged QA Matrix sidecars", () => {
+ expect(BUNDLED_RUNTIME_SIDECAR_PATHS).toContain("dist/extensions/qa-channel/runtime-api.js");
+ expect(BUNDLED_RUNTIME_SIDECAR_PATHS).toContain("dist/extensions/qa-lab/runtime-api.js");
expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain("dist/extensions/qa-matrix/runtime-api.js");
});
diff --git a/src/plugins/bundled-plugin-naming.test.ts b/src/plugins/bundled-plugin-naming.test.ts
index f56ac52a78ed..8e94ed45df65 100644
--- a/src/plugins/bundled-plugin-naming.test.ts
+++ b/src/plugins/bundled-plugin-naming.test.ts
@@ -35,7 +35,7 @@ const DIR_ID_EXCEPTIONS = new Map([
// Historical directory name kept until a wider repo cleanup is worth the churn.
["kimi-coding", "kimi"],
]);
-const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]);
+const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-matrix"]);
const ALLOWED_PACKAGE_SUFFIXES = [
"",
"-provider",
diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts
index 8cf0f4558bc0..c0fa479b5ef1 100644
--- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts
+++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts
@@ -548,7 +548,7 @@ describe("plugin-sdk root alias", () => {
"ssrf-runtime-internal.ts",
);
const lazyModule = loadRootAliasWithStubs({
- env: { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" },
+ env: { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1" },
privateLocalOnlySubpaths: ["qa-lab", "../escape", "nested/path", "ssrf-runtime-internal"],
existingPaths: [qaLabPath, ssrfRuntimeInternalPath],
monolithicExports: {
diff --git a/src/plugins/runtime-sidecar-paths-baseline.ts b/src/plugins/runtime-sidecar-paths-baseline.ts
index e99c68e4d727..ecee82ca942b 100644
--- a/src/plugins/runtime-sidecar-paths-baseline.ts
+++ b/src/plugins/runtime-sidecar-paths-baseline.ts
@@ -4,7 +4,7 @@ import path from "node:path";
import { tryReadJsonSync } from "../infra/json-files.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
-const NON_PACKAGED_RUNTIME_SIDECAR_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]);
+const NON_PACKAGED_RUNTIME_SIDECAR_PLUGIN_DIRS = new Set(["qa-matrix"]);
function buildBundledDistArtifactPath(dirName: string, artifact: string): string {
return ["dist", "extensions", dirName, artifact].join("/");
diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts
index b829d7c2ea19..26bcdfa3a0f9 100644
--- a/src/plugins/sdk-alias.test.ts
+++ b/src/plugins/sdk-alias.test.ts
@@ -709,7 +709,7 @@ describe("plugin sdk alias helpers", () => {
"utf-8",
);
- const subpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () =>
+ const subpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1" }, () =>
listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
}),
@@ -770,18 +770,18 @@ describe("plugin sdk alias helpers", () => {
);
fs.writeFileSync(shadowCodexEntry, 'export const plugin = "shadow";\n', "utf-8");
- const codexSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ const codexSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: sourceCodexEntry,
}),
);
- const otherSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ const otherSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: sourceOtherEntry,
}),
);
const installedCodexSubpaths = withCwd(installedCodexRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: installedCodexEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
@@ -789,7 +789,7 @@ describe("plugin sdk alias helpers", () => {
),
);
const installedOtherSubpaths = withCwd(installedOtherRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: installedOtherEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
@@ -797,7 +797,7 @@ describe("plugin sdk alias helpers", () => {
),
);
const shadowCodexSubpaths = withCwd(shadowCodexRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: shadowCodexEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
@@ -849,7 +849,7 @@ describe("plugin sdk alias helpers", () => {
}),
).toEqual(["core"]);
- const privateSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () =>
+ const privateSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1" }, () =>
listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
}),
@@ -963,7 +963,7 @@ describe("plugin sdk alias helpers", () => {
bundledPluginFile("qa-matrix", "src/index.ts"),
);
- const aliases = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", NODE_ENV: undefined }, () =>
+ const aliases = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1", NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(sourcePluginEntry),
);
@@ -1107,15 +1107,15 @@ describe("plugin sdk alias helpers", () => {
fs.writeFileSync(shadowCodexEntry, 'export const plugin = "shadow";\n', "utf-8");
const aliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourcePluginEntry),
);
const otherAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourceOtherPluginEntry),
);
const devRootAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() =>
buildPluginLoaderAliasMap(
distCodexEntry,
@@ -1126,7 +1126,7 @@ describe("plugin sdk alias helpers", () => {
),
);
const installedAliases = withCwd(installedCodexRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(
installedCodexEntry,
path.join(fixture.root, "openclaw.mjs"),
@@ -1136,7 +1136,7 @@ describe("plugin sdk alias helpers", () => {
),
);
const shadowCodexAliases = withCwd(shadowCodexRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(
shadowCodexEntry,
path.join(fixture.root, "openclaw.mjs"),
@@ -1146,7 +1146,7 @@ describe("plugin sdk alias helpers", () => {
),
);
const installedOtherAliases = withCwd(installedOtherRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(
installedOtherEntry,
path.join(fixture.root, "openclaw.mjs"),
@@ -1260,55 +1260,55 @@ describe("plugin sdk alias helpers", () => {
packageName: "@openclaw/ollama",
});
- const sourceSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ const sourceSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: sourceOllamaEntry,
}),
);
- const sourceBrowserSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ const sourceBrowserSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
listPluginSdkExportedSubpaths({
modulePath: sourceBrowserEntry,
}),
);
- const privateQaOtherSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () =>
+ const privateQaOtherSubpaths = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1" }, () =>
listPluginSdkExportedSubpaths({
modulePath: sourceOtherPluginEntry,
}),
);
const sourceAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourceOllamaEntry),
);
const sourceBrowserAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourceBrowserEntry),
);
const distAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(distOllamaEntry, undefined, undefined, "dist"),
);
const distBrowserAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(distBrowserEntry, undefined, undefined, "dist"),
);
const distRuntimeAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(distRuntimeOllamaEntry, undefined, undefined, "dist"),
);
const distRuntimeBrowserAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(distRuntimeBrowserEntry, undefined, undefined, "dist"),
);
const otherAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourceOtherPluginEntry),
);
const privateQaOtherAliases = withEnv(
- { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", NODE_ENV: undefined },
+ { OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1", NODE_ENV: undefined },
() => buildPluginLoaderAliasMap(sourceOtherPluginEntry),
);
const installedAliases = withCwd(installedOllamaRoot, () =>
- withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () =>
+ withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined, NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(
installedOllamaEntry,
path.join(fixture.root, "openclaw.mjs"),
@@ -1805,7 +1805,7 @@ describe("plugin sdk alias helpers", () => {
bundledPluginFile("qa-lab", "src/live-transports/slack/slack-live.runtime.ts"),
);
- const aliases = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", NODE_ENV: undefined }, () =>
+ const aliases = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1", NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(sourcePluginEntry),
);
@@ -2551,10 +2551,10 @@ describe("buildPluginLoaderAliasMap memoization", () => {
fs.writeFileSync(sourceQaRuntimePath, "export const qaRuntime = true;\n", "utf-8");
const entry = writePluginEntry(fixture.root, bundledPluginFile("private-qa", "src/index.ts"));
- const publicAliases = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
+ const publicAliases = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: undefined }, () =>
buildPluginLoaderAliasMap(entry),
);
- const privateAliases = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () =>
+ const privateAliases = withEnv({ OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI: "1" }, () =>
buildPluginLoaderAliasMap(entry),
);
diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts
index 99d38eff269a..6f98cef477cf 100644
--- a/src/plugins/sdk-alias.ts
+++ b/src/plugins/sdk-alias.ts
@@ -1368,7 +1368,7 @@ function resolveWorkspacePackageAliasMap(params: {
}
function shouldIncludePrivateLocalOnlyPluginSdkSubpaths() {
- return process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1";
+ return process.env.OPENCLAW_ENABLE_EXPERIMENTAL_QA_CLI === "1";
}
function isBundledPluginModulePath(params: {
]