diff --git a/extensions/qa-lab/src/providers/image-generation.test.ts b/extensions/qa-lab/src/providers/image-generation.test.ts index d5f685886a2..c25718ada21 100644 --- a/extensions/qa-lab/src/providers/image-generation.test.ts +++ b/extensions/qa-lab/src/providers/image-generation.test.ts @@ -9,7 +9,7 @@ describe("QA provider image generation config", () => { requiredPluginIds: ["qa-channel"], }); - expect(patch.plugins.allow).toEqual(["memory-core", "qa-channel"]); + expect(patch.plugins.allow).toEqual(["acpx", "memory-core", "qa-channel"]); expect(patch.agents.defaults.imageGenerationModel.primary).toBe("mock-openai/gpt-image-1"); expect(patch.models?.providers["mock-openai"]?.baseUrl).toBe("http://127.0.0.1:44080/v1"); }); @@ -33,7 +33,7 @@ describe("QA provider image generation config", () => { }); expect(patch.plugins).toEqual({ - allow: ["memory-core", "openai", "qa-channel"], + allow: ["acpx", "memory-core", "openai", "qa-channel"], entries: { openai: { enabled: true, diff --git a/extensions/qa-lab/src/providers/image-generation.ts b/extensions/qa-lab/src/providers/image-generation.ts index cc1a70ea242..1b3728fe4a6 100644 --- a/extensions/qa-lab/src/providers/image-generation.ts +++ b/extensions/qa-lab/src/providers/image-generation.ts @@ -1,3 +1,4 @@ +import { QA_BASE_RUNTIME_PLUGIN_IDS } from "../qa-gateway-config.js"; import type { QaProviderMode } from "./index.js"; import { getQaProvider } from "./index.js"; @@ -45,7 +46,11 @@ export function buildQaImageGenerationConfigPatch(input: QaImageGenerationPatchI return { plugins: { - allow: uniqueNonEmpty(["memory-core", ...enabledPluginIds, ...input.requiredPluginIds]), + allow: uniqueNonEmpty([ + ...QA_BASE_RUNTIME_PLUGIN_IDS, + ...enabledPluginIds, + ...input.requiredPluginIds, + ]), ...(enabledPluginIds.length > 0 ? { entries: Object.fromEntries( diff --git a/extensions/qa-lab/src/qa-gateway-config.test.ts b/extensions/qa-lab/src/qa-gateway-config.test.ts index 8fd928fdf0c..c70a4d2d1e9 100644 --- a/extensions/qa-lab/src/qa-gateway-config.test.ts +++ b/extensions/qa-lab/src/qa-gateway-config.test.ts @@ -58,12 +58,14 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true }); expect(cfg.models?.providers?.anthropic?.baseUrl).toBe("http://127.0.0.1:44080"); expect(cfg.models?.providers?.anthropic?.request).toEqual({ allowPrivateNetwork: true }); - expect(cfg.plugins?.allow).toEqual(["memory-core", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-channel"]); + expect(cfg.plugins?.entries?.acpx).toEqual({ enabled: false }); expect(cfg.plugins?.entries?.["memory-core"]).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.["qa-channel"]).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.openai).toBeUndefined(); expect(cfg.gateway?.reload?.deferralTimeoutMs).toBe(1_000); expect(cfg.tools?.profile).toBe("coding"); + expect(cfg.agents?.list?.[0]?.tools?.profile).toBe("coding"); expect(cfg.channels?.["qa-channel"]).toMatchObject({ enabled: true, baseUrl: "http://127.0.0.1:43124", @@ -94,7 +96,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.providers?.anthropic?.models.map((model) => model.id)).toContain( "claude-opus-4-6", ); - expect(cfg.plugins?.allow).toEqual(["memory-core"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]); }); it("can wire AIMock as a separate mock provider lane", () => { @@ -131,7 +133,7 @@ describe("buildQaGatewayConfig", () => { transportConfig: {}, }); - expect(cfg.plugins?.allow).toEqual(["memory-core"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]); expect(cfg.plugins?.entries?.["qa-channel"]).toBeUndefined(); expect(cfg.channels?.["qa-channel"]).toBeUndefined(); }); @@ -147,7 +149,7 @@ describe("buildQaGatewayConfig", () => { ...createQaChannelTransportParams(), }); - expect(cfg.plugins?.allow).toEqual(["memory-core", "active-memory", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "active-memory", "qa-channel"]); expect(cfg.plugins?.entries?.["active-memory"]).toEqual({ enabled: true }); }); @@ -167,7 +169,7 @@ describe("buildQaGatewayConfig", () => { expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("openai/gpt-5.4"); expect(getPrimaryModel(cfg.agents?.list?.[0]?.model)).toBe("openai/gpt-5.4"); expect(cfg.models).toBeUndefined(); - expect(cfg.plugins?.allow).toEqual(["memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({ params: { transport: "sse", openaiWsWarmup: false, fastMode: true }, @@ -187,7 +189,13 @@ describe("buildQaGatewayConfig", () => { ...createQaChannelTransportParams(), }); - expect(cfg.plugins?.allow).toEqual(["memory-core", "anthropic", "google", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual([ + "acpx", + "memory-core", + "anthropic", + "google", + "qa-channel", + ]); expect(cfg.plugins?.entries?.anthropic).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.google).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.openai).toBeUndefined(); @@ -209,7 +217,7 @@ describe("buildQaGatewayConfig", () => { }); expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("codex-cli/test-model"); - expect(cfg.plugins?.allow).toEqual(["memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.["codex-cli"]).toBeUndefined(); }); @@ -249,7 +257,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.mode).toBe("merge"); expect(cfg.models?.providers?.["custom-openai"]?.api).toBe("openai-responses"); - expect(cfg.plugins?.allow).toEqual(["memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); }); it("can set a QA default thinking level for judge turns", () => { diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index f1cf799ddb8..a206071872a 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -20,6 +20,8 @@ export const DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS = Object.freeze([ "http://localhost:43124", ]); +export const QA_BASE_RUNTIME_PLUGIN_IDS = Object.freeze(["acpx", "memory-core"]); + export function mergeQaControlUiAllowedOrigins(extraOrigins?: string[]) { const normalizedExtra = (extraOrigins ?? []) .map((origin) => origin.trim()) @@ -96,7 +98,9 @@ export function buildQaGatewayConfig(params: { const transportPluginEntries = Object.fromEntries( transportPluginIds.map((pluginId) => [pluginId, { enabled: true }]), ); - const allowedPlugins = [...new Set(["memory-core", ...selectedPluginIds, ...transportPluginIds])]; + const allowedPlugins = [ + ...new Set([...QA_BASE_RUNTIME_PLUGIN_IDS, ...selectedPluginIds, ...transportPluginIds]), + ]; const resolveModelParams = (modelRef: string) => provider.resolveModelParams({ modelRef, @@ -114,6 +118,9 @@ export function buildQaGatewayConfig(params: { allow: allowedPlugins, entries: { acpx: { + // The parity gateway stages a clean bundled-plugin tree. Keep the + // runtime backend plugin in the allowlist so this disabled entry is + // not mistaken for stale config when optional bundles are pruned. enabled: false, }, "memory-core": { @@ -174,6 +181,9 @@ export function buildQaGatewayConfig(params: { subagents: { allowAgents: ["*"], }, + tools: { + profile: "coding", + }, }, ], }, diff --git a/extensions/qa-lab/src/reply-failure.ts b/extensions/qa-lab/src/reply-failure.ts index 2804251717a..a8e42d26b56 100644 --- a/extensions/qa-lab/src/reply-failure.ts +++ b/extensions/qa-lab/src/reply-failure.ts @@ -23,6 +23,11 @@ const VISIBLE_REPLY_LEAK_PATTERNS = [ /\bnot inventing status\b/i, ]; +const TOOL_BACKED_FAILURE_PATTERNS = [ + /\btool\s+[a-z0-9_.-]+\s+not found\b/i, + /^status:\s*blocked\b/im, +]; + export function extractQaVisibleReplyLeakText(text: string): string | undefined { const trimmed = text.trim(); if (!trimmed) { @@ -47,5 +52,8 @@ export function extractQaFailureReplyText(text: string): string | undefined { if (visibleReplyLeak) { return visibleReplyLeak; } + if (TOOL_BACKED_FAILURE_PATTERNS.some((pattern) => pattern.test(trimmed))) { + return trimmed; + } return undefined; } diff --git a/extensions/qa-lab/src/suite-runtime-transport.test.ts b/extensions/qa-lab/src/suite-runtime-transport.test.ts index 5d7bcf4250c..433a9697827 100644 --- a/extensions/qa-lab/src/suite-runtime-transport.test.ts +++ b/extensions/qa-lab/src/suite-runtime-transport.test.ts @@ -79,6 +79,24 @@ describe("qa suite transport helpers", () => { await expect(pending).rejects.toThrow("checking thread context"); }); + it("fails success-only waitForOutboundMessage calls when a tool-backed scenario reports missing tools", async () => { + const state = createQaBusState(); + const pending = waitForOutboundMessage( + state, + (candidate) => candidate.text.includes("Status: complete"), + 5_000, + ); + + state.addOutboundMessage({ + to: "dm:qa-operator", + text: "Read: AGENT.md\nEvidence snippet: Tool read not found\nStatus: blocked", + senderId: "openclaw", + senderName: "OpenClaw QA", + }); + + await expect(pending).rejects.toThrow("Tool read not found"); + }); + it("fails raw scenario waitForCondition calls when a classified failure reply arrives", async () => { const state = createQaBusState(); const waitForCondition = createScenarioWaitForCondition(state);