From 7e42e2c087f91bedb8384b5f85fbd77cb9984756 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 12:50:49 +0100 Subject: [PATCH] fix(release): stabilize beta validation --- ...nclaw-cross-os-release-checks-reusable.yml | 3 + extensions/qa-channel/src/channel.test.ts | 97 +++++++++++++++++++ extensions/qa-channel/src/inbound.ts | 48 ++++++++- .../qa-lab/src/qa-channel-transport.test.ts | 1 + extensions/qa-lab/src/qa-channel-transport.ts | 1 + .../qa-lab/src/qa-gateway-config.test.ts | 2 + src/commands/doctor-state-integrity.ts | 8 +- src/flows/doctor-health-contributions.ts | 3 +- src/flows/doctor-health.ts | 2 + .../net/proxy/external-proxy.e2e.test.ts | 2 +- 10 files changed, 161 insertions(+), 6 deletions(-) diff --git a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml index aee56480b68..e4b57d2650f 100644 --- a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml +++ b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml @@ -333,6 +333,9 @@ jobs: cache: pnpm cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }} + - name: Ensure pnpm cache path exists + run: mkdir -p "$(pnpm store path --silent)" + - name: Build candidate artifact once if: inputs.candidate_artifact_name == '' env: diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 3d09d42192a..1717be7f15d 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -1,3 +1,4 @@ +import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { @@ -56,6 +57,30 @@ function createMockQaRuntime(params?: { sessionUpdatedAt.set(sessionKey, Date.now()); }, }, + text: { + hasControlCommand() { + return false; + }, + }, + mentions: { + buildMentionRegexes() { + return [/\b@?openclaw\b/i]; + }, + matchesMentionPatterns(text: string, regexes: RegExp[]) { + return regexes.some((regex) => regex.test(text)); + }, + resolveInboundMentionDecision, + }, + groups: { + resolveRequireMention() { + return true; + }, + }, + commands: { + shouldHandleTextCommands() { + return true; + }, + }, reply: { resolveEnvelopeFormatOptions() { return {}; @@ -197,6 +222,78 @@ describe("qa-channel plugin", () => { } }); + it("marks mentioned threaded group traffic before dispatch", { timeout: 20_000 }, async () => { + let dispatchedCtx: Record | null = null; + const harness = await startQaChannelTestHarness({ + allowFrom: ["*"], + runtime: createMockQaRuntime({ + onDispatch: (ctx) => { + dispatchedCtx = ctx; + }, + }), + }); + + try { + harness.state.addInboundMessage({ + conversation: { id: "qa-room", kind: "channel", title: "QA Room" }, + senderId: "alice", + senderName: "Alice", + text: "@openclaw thread memory check", + threadId: "thread-1", + threadTitle: "Thread 1", + }); + + const outbound = await harness.state.waitFor({ + kind: "message-text", + textIncludes: "qa-echo: @openclaw thread memory check", + direction: "outbound", + timeoutMs: 15_000, + }); + expect("threadId" in outbound && outbound.threadId).toBe("thread-1"); + expect(dispatchedCtx).toMatchObject({ + ChatType: "group", + WasMentioned: true, + MessageThreadId: "thread-1", + }); + } finally { + await harness.stop(); + } + }); + + it("drops unmentioned group traffic when mention is required", { timeout: 20_000 }, async () => { + let didDispatch = false; + const harness = await startQaChannelTestHarness({ + allowFrom: ["*"], + runtime: createMockQaRuntime({ + onDispatch: () => { + didDispatch = true; + }, + }), + }); + + try { + harness.state.addInboundMessage({ + conversation: { id: "qa-room", kind: "channel", title: "QA Room" }, + senderId: "alice", + senderName: "Alice", + text: "thread memory check", + threadId: "thread-1", + }); + + await expect( + harness.state.waitFor({ + kind: "message-text", + textIncludes: "qa-echo:", + direction: "outbound", + timeoutMs: 750, + }), + ).rejects.toThrow(/wait timeout/i); + expect(didDispatch).toBe(false); + } finally { + await harness.stop(); + } + }); + it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => { let dispatchedCtx: Record | null = null; const harness = await startQaChannelTestHarness({ diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index fcc1103740d..005e281cf46 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -81,6 +81,49 @@ export async function handleQaInbound(params: { id: target, }, }); + const isGroup = inbound.conversation.kind !== "direct"; + const mentionRegexes = isGroup + ? runtime.channel.mentions.buildMentionRegexes(params.config as OpenClawConfig, route.agentId) + : []; + const wasMentioned = + isGroup && mentionRegexes.length > 0 + ? runtime.channel.mentions.matchesMentionPatterns(inbound.text, mentionRegexes) + : false; + const allowTextCommands = runtime.channel.commands.shouldHandleTextCommands({ + cfg: params.config as OpenClawConfig, + surface: params.channelId, + }); + const hasControlCommand = runtime.channel.text.hasControlCommand( + inbound.text, + params.config as OpenClawConfig, + ); + const commandAuthorized = true; + const requireMention = isGroup + ? runtime.channel.groups.resolveRequireMention({ + cfg: params.config as OpenClawConfig, + channel: params.channelId, + groupId: inbound.conversation.id, + groupChannel: inbound.conversation.id, + accountId: params.account.accountId, + }) + : false; + const mentionDecision = runtime.channel.mentions.resolveInboundMentionDecision({ + facts: { + canDetectMention: mentionRegexes.length > 0, + wasMentioned, + hasAnyMention: wasMentioned, + }, + policy: { + isGroup, + requireMention, + allowTextCommands, + hasControlCommand, + commandAuthorized, + }, + }); + if (isGroup && mentionDecision.shouldSkip) { + return; + } const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, { agentId: route.agentId, }); @@ -110,7 +153,7 @@ export async function handleQaInbound(params: { To: target, SessionKey: route.sessionKey, AccountId: route.accountId ?? params.account.accountId, - ChatType: inbound.conversation.kind === "direct" ? "direct" : "group", + ChatType: isGroup ? "group" : "direct", ConversationLabel: inbound.threadTitle || inbound.conversation.title || @@ -135,7 +178,8 @@ export async function handleQaInbound(params: { Timestamp: inbound.timestamp, OriginatingChannel: params.channelId, OriginatingTo: target, - CommandAuthorized: true, + WasMentioned: isGroup ? mentionDecision.effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, ...mediaPayload, }); diff --git a/extensions/qa-lab/src/qa-channel-transport.test.ts b/extensions/qa-lab/src/qa-channel-transport.test.ts index 15d6fcb5d66..ee1f0a9fb3a 100644 --- a/extensions/qa-lab/src/qa-channel-transport.test.ts +++ b/extensions/qa-lab/src/qa-channel-transport.test.ts @@ -24,6 +24,7 @@ describe("qa channel transport", () => { messages: { groupChat: { mentionPatterns: ["\\b@?openclaw\\b"], + visibleReplies: "automatic", }, }, }); diff --git a/extensions/qa-lab/src/qa-channel-transport.ts b/extensions/qa-lab/src/qa-channel-transport.ts index 455f8130d3f..96cb6e645e6 100644 --- a/extensions/qa-lab/src/qa-channel-transport.ts +++ b/extensions/qa-lab/src/qa-channel-transport.ts @@ -90,6 +90,7 @@ export function createQaChannelGatewayConfig(params: { messages: { groupChat: { mentionPatterns: ["\\b@?openclaw\\b"], + visibleReplies: "automatic", }, }, }; diff --git a/extensions/qa-lab/src/qa-gateway-config.test.ts b/extensions/qa-lab/src/qa-gateway-config.test.ts index ff0b2b96872..dfdcd59a955 100644 --- a/extensions/qa-lab/src/qa-gateway-config.test.ts +++ b/extensions/qa-lab/src/qa-gateway-config.test.ts @@ -23,6 +23,7 @@ function createQaChannelTransportParams(baseUrl = "http://127.0.0.1:43124") { messages: { groupChat: { mentionPatterns: ["\\b@?openclaw\\b"], + visibleReplies: "automatic", }, }, } satisfies QaTransportGatewayConfig, @@ -78,6 +79,7 @@ describe("buildQaGatewayConfig", () => { pollTimeoutMs: 250, }); expect(cfg.messages?.groupChat?.mentionPatterns).toEqual(["\\b@?openclaw\\b"]); + expect(cfg.messages?.groupChat?.visibleReplies).toBe("automatic"); }); it("maps provider-qualified openai and anthropic refs through the mock provider lane", () => { diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 291e7881e46..1b568b1f436 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -595,12 +595,16 @@ export async function noteStateIntegrity( cfg: OpenClawConfig, prompter: DoctorPrompterLike, configPath?: string, + params: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + } = {}, ) { const warnings: string[] = []; const changes: string[] = []; const noteFn = prompter.note ?? note; - const env = process.env; - const homedir = () => resolveRequiredHomeDir(env, os.homedir); + const env = params.env ?? process.env; + const homedir = () => resolveRequiredHomeDir(env, params.homedir ?? os.homedir); const stateDir = resolveStateDir(env, homedir); const defaultStateDir = path.join(homedir(), ".openclaw"); const oauthDir = resolveOAuthDir(env, stateDir); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 433da1a322a..ed7eb9a1a01 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -24,6 +24,7 @@ export type DoctorHealthFlowContext = { cfgForPersistence: OpenClawConfig; sourceConfigValid: boolean; configPath: string; + env: NodeJS.ProcessEnv; gatewayDetails?: ReturnType; healthOk?: boolean; gatewayMemoryProbe?: Awaited>; @@ -251,7 +252,7 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise { const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js"); - await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath); + await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath, { env: ctx.env }); } async function runSessionLocksHealth(ctx: DoctorHealthFlowContext): Promise { diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index a10ac64a0d5..80619257dbf 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -8,6 +8,7 @@ const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? messa export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) { const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime; + const envSnapshot = { ...process.env }; const { createDoctorPrompter } = await import("../commands/doctor-prompter.js"); const { printWizardHeader } = await import("../commands/onboard-helpers.js"); const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options }); @@ -57,6 +58,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions cfgForPersistence: structuredClone(configResult.cfg), sourceConfigValid: configResult.sourceConfigValid ?? true, configPath: configResult.path ?? CONFIG_PATH, + env: envSnapshot, }; const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js"); await runDoctorHealthContributions(ctx); diff --git a/src/infra/net/proxy/external-proxy.e2e.test.ts b/src/infra/net/proxy/external-proxy.e2e.test.ts index 601f3041558..a91cabbd4ae 100644 --- a/src/infra/net/proxy/external-proxy.e2e.test.ts +++ b/src/infra/net/proxy/external-proxy.e2e.test.ts @@ -136,7 +136,7 @@ async function runNodeModule( const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`child process timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`)); - }, 10_000); + }, 45_000); child.on("error", (err) => { clearTimeout(timeout);