fix(release): repair broad gate regressions

This commit is contained in:
Vincent Koc
2026-05-18 19:08:14 +08:00
parent fa814eb9ed
commit cb408bb06b
9 changed files with 100 additions and 22 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.

View File

@@ -1257,11 +1257,17 @@ describe("processDiscordMessage session routing", () => {
});
});
it("marks always-on guild replies as message-tool-only and disables source streaming", async () => {
it("marks explicit message-tool guild replies as message-tool-only and disables source streaming", async () => {
const ctx = await createBaseContext({
shouldRequireMention: false,
effectiveWasMentioned: false,
discordConfig: { streaming: "partial", blockStreaming: true },
cfg: {
messages: {
groupChat: { visibleReplies: "message_tool" },
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
});
@@ -1283,6 +1289,7 @@ describe("processDiscordMessage session routing", () => {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
groupChat: { visibleReplies: "message_tool" },
statusReactions: {
timing: { debounceMs: 0 },
},
@@ -1314,6 +1321,7 @@ describe("processDiscordMessage session routing", () => {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
groupChat: { visibleReplies: "message_tool" },
statusReactions: {
enabled: true,
timing: { debounceMs: 0 },
@@ -1500,7 +1508,7 @@ describe("processDiscordMessage session routing", () => {
});
});
it("defaults guild replies to message-tool-only source delivery", async () => {
it("resolves guild source delivery from default, explicit, and room-event modes", async () => {
await runProcessDiscordMessage(
await createBaseContext({
shouldRequireMention: true,
@@ -1508,7 +1516,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
}),
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
dispatchInboundMessage.mockClear();
await runProcessDiscordMessage(
@@ -1518,7 +1526,7 @@ describe("processDiscordMessage session routing", () => {
cfg: {
messages: {
groupChat: {
visibleReplies: "automatic",
visibleReplies: "message_tool",
},
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
@@ -1526,7 +1534,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
}),
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
dispatchInboundMessage.mockClear();
await runProcessDiscordMessage(
@@ -1754,6 +1762,9 @@ describe("processDiscordMessage draft streaming", () => {
const ctx = await createBaseContext({
cfg: {
tools: { profile: "coding" },
messages: {
groupChat: { visibleReplies: "message_tool" },
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,

View File

@@ -1604,7 +1604,8 @@ describe("qa mock openai server", () => {
input: [
{
role: "system",
content: "## /workspace/MEMORY.md\nThread-hidden codename: ORBIT-22.",
content:
"Available tools include sessions_spawn.\n## /workspace/MEMORY.md\nThread-hidden codename: ORBIT-22.",
},
makeUserInput(
"@openclaw Thread memory check: what is the hidden thread codename stored only in memory? Use memory tools first and reply only in this thread.",

View File

@@ -916,7 +916,6 @@ function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> |
}
function extractToolErrorForNamedCall(params: {
allInputText: string;
input: ResponsesInputItem[];
name: string;
toolJson: Record<string, unknown> | null;
@@ -928,8 +927,7 @@ function extractToolErrorForNamedCall(params: {
const namedFunctionCall = params.input.some(
(item) => item.type === "function_call" && item.name === params.name,
);
const namedPromptReference = new RegExp(`\\b${params.name}\\b`, "i").test(params.allInputText);
if (namedFunctionCall || namedPromptReference) {
if (namedFunctionCall) {
return error;
}
return undefined;
@@ -1015,7 +1013,6 @@ function buildAssistantText(
const activeMemorySummary = extractActiveMemorySummary(allInputText);
const snackPreference = extractSnackPreference(activeMemorySummary ?? memorySnippet);
const sessionsSpawnError = extractToolErrorForNamedCall({
allInputText,
input,
name: "sessions_spawn",
toolJson,

View File

@@ -1,4 +1,5 @@
import { Mock, vi } from "vitest";
import { clearSlackInboundDeliveryStateForTest } from "./monitor/inbound-delivery-state.js";
type SlackHandler = (args: unknown) => Promise<void>;
type SlackMiddleware = (args: { next: () => Promise<void> } & Record<string, unknown>) => unknown;
@@ -191,6 +192,7 @@ export const defaultSlackTestConfig = () => ({
});
export function resetSlackTestState(config: Record<string, unknown> = defaultSlackTestConfig()) {
clearSlackInboundDeliveryStateForTest();
slackTestState.config = config;
slackTestState.sendMock.mockReset().mockResolvedValue(undefined);
slackTestState.replyMock.mockReset();
@@ -208,6 +210,17 @@ export function resetSlackTestState(config: Record<string, unknown> = defaultSla
.mockImplementation(async ({ entries }) =>
entries.map((input) => ({ input, resolved: false })),
);
const client = getSlackClient();
client.auth.test.mockReset().mockResolvedValue({ user_id: "bot-user" });
client.conversations.info.mockReset().mockResolvedValue({
channel: { name: "dm", is_im: true },
});
client.conversations.replies.mockReset().mockResolvedValue({ messages: [] });
client.conversations.history.mockReset().mockResolvedValue({ messages: [] });
client.users.info.mockReset().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
});
client.assistant.threads.setStatus.mockReset().mockResolvedValue({ ok: true });
getSlackHandlers()?.clear();
}

View File

@@ -518,11 +518,12 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
it("keeps always-on channel messages private by default", async () => {
it("keeps always-on channel messages private when group visible replies use message_tool", async () => {
slackTestState.config = {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
groupChat: { visibleReplies: "message_tool" },
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },

View File

@@ -1,4 +1,7 @@
import { completionRequiresMessageToolDelivery } from "../auto-reply/reply/completion-delivery-policy.js";
import {
completionRequiresMessageToolDelivery,
resolveCompletionChatType,
} from "../auto-reply/reply/completion-delivery-policy.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
@@ -351,10 +354,10 @@ export async function resolveSubagentCompletionOrigin(params: {
const accountId = normalizeAccountId(requesterOrigin?.accountId);
const threadId =
requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
? stringifyRouteThreadId(requesterOrigin.threadId)
? requesterOrigin.threadId
: undefined;
const conversationId =
threadId ||
stringifyRouteThreadId(threadId) ||
resolveConversationIdFromTargets({
targets: [to],
}) ||
@@ -660,9 +663,18 @@ async function sendSubagentAnnounceDirectly(params: {
sourceTool: params.sourceTool,
});
const expectedMediaUrls = collectExpectedMediaFromInternalEvents(params.internalEvents);
const completionChatType = resolveCompletionChatType({
requesterSessionKey: params.requesterSessionKey,
targetRequesterSessionKey: canonicalRequesterSessionKey,
requesterEntry,
directOrigin: effectiveDirectOrigin,
requesterSessionOrigin,
});
const requiresMessageToolDelivery =
agentMediatedCompletion &&
(expectedMediaUrls.length > 0 ||
(completionChatType === "channel" ||
completionChatType === "group" ||
expectedMediaUrls.length > 0 ||
completionRequiresMessageToolDelivery({
cfg,
requesterSessionKey: params.requesterSessionKey,

View File

@@ -1209,7 +1209,7 @@ describe("uninstallPlugin", () => {
const pluginDir = path.join(npmRoot, "node_modules", "missing-plugin");
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
await fs.mkdir(peerLink, { recursive: true });
await fs.mkdir(path.dirname(peerLink), { recursive: true });
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(

View File

@@ -120,13 +120,55 @@ describe("openclaw launcher", () => {
);
const engineMatch = packageJson.engines?.node?.match(/^>=(\d+)\.(\d+)\.(\d+)$/u);
expect(launcherMatch).not.toBeNull();
expect(runtimeMatch).not.toBeNull();
expect(engineMatch).not.toBeNull();
expect(`${launcherMatch?.[1]}.${launcherMatch?.[2]}.0`).toBe(
`${engineMatch?.[1]}.${engineMatch?.[2]}.${engineMatch?.[3]}`,
if (!launcherMatch) {
throw new Error("openclaw.mjs MIN_NODE_* constants were not found");
}
if (!runtimeMatch) {
throw new Error("src/infra/runtime-guard.ts MIN_NODE constant was not found");
}
if (!engineMatch) {
throw new Error("package.json engines.node must use >=<major>.<minor>.<patch>");
}
const [engineMajor, engineMinor, enginePatch] = engineMatch.slice(1, 4).map(Number);
const launcherMinimumLabel = `${engineMajor}.${engineMinor}`;
expect(
[Number(launcherMatch[1]), Number(launcherMatch[2]), 0],
"openclaw.mjs MIN_NODE_* must match package.json engines.node",
).toEqual([engineMajor, engineMinor, enginePatch]);
expect(
runtimeMatch.slice(1, 4).map(Number),
"src/infra/runtime-guard.ts MIN_NODE must match package.json engines.node",
).toEqual([engineMajor, engineMinor, enginePatch]);
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
const mockedNodeVersion =
engineMinor > 0 ? `${engineMajor}.${engineMinor - 1}.0` : `${engineMajor - 1}.999.0`;
const mockNodeVersionPath = path.join(fixtureRoot, "mock-node-version.mjs");
await fs.writeFile(
mockNodeVersionPath,
[
"Object.defineProperty(process.versions, 'node', {",
` value: ${JSON.stringify(mockedNodeVersion)},`,
"});",
].join("\n"),
"utf8",
);
const result = spawnSync(
process.execPath,
["--import", mockNodeVersionPath, path.join(fixtureRoot, "openclaw.mjs"), "--help"],
{
cwd: fixtureRoot,
env: launcherEnv(),
encoding: "utf8",
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
`openclaw: Node.js v${launcherMinimumLabel}+ is required (current: v${mockedNodeVersion}).`,
);
expect(runtimeMatch?.slice(1, 4)).toEqual(engineMatch?.slice(1, 4));
});
it("surfaces transitive entry import failures instead of masking them as missing dist", async () => {