fix(release): stabilize beta validation

This commit is contained in:
Peter Steinberger
2026-04-28 12:50:49 +01:00
parent 99e69a232b
commit 7e42e2c087
10 changed files with 161 additions and 6 deletions

View File

@@ -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:

View File

@@ -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<string, unknown> | 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<string, unknown> | null = null;
const harness = await startQaChannelTestHarness({

View File

@@ -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,
});

View File

@@ -24,6 +24,7 @@ describe("qa channel transport", () => {
messages: {
groupChat: {
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
},
},
});

View File

@@ -90,6 +90,7 @@ export function createQaChannelGatewayConfig(params: {
messages: {
groupChat: {
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
},
},
};

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -24,6 +24,7 @@ export type DoctorHealthFlowContext = {
cfgForPersistence: OpenClawConfig;
sourceConfigValid: boolean;
configPath: string;
env: NodeJS.ProcessEnv;
gatewayDetails?: ReturnType<typeof buildGatewayConnectionDetails>;
healthOk?: boolean;
gatewayMemoryProbe?: Awaited<ReturnType<typeof probeGatewayMemoryStatus>>;
@@ -251,7 +252,7 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext):
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void> {

View File

@@ -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);

View File

@@ -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);