From cb408bb06b1a670aca23cdcf7e6d60de04431096 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 18 May 2026 19:08:14 +0800 Subject: [PATCH] fix(release): repair broad gate regressions --- CHANGELOG.md | 1 + .../monitor/message-handler.process.test.ts | 21 ++++++-- .../src/providers/mock-openai/server.test.ts | 3 +- .../src/providers/mock-openai/server.ts | 5 +- extensions/slack/src/monitor.test-helpers.ts | 13 +++++ .../slack/src/monitor.tool-result.test.ts | 3 +- src/agents/subagent-announce-delivery.ts | 20 +++++-- src/plugins/uninstall.test.ts | 2 +- test/openclaw-launcher.e2e.test.ts | 54 ++++++++++++++++--- 9 files changed, 100 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338345e7d20..cdbaa95c65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 0aea3a104fd..549bb9df639 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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, diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index a49fe99817e..9864de152d7 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -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.", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 38dfc1e39fb..fd574c530e3 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -916,7 +916,6 @@ function buildExplicitSessionsSpawnArgs(text: string): Record | } function extractToolErrorForNamedCall(params: { - allInputText: string; input: ResponsesInputItem[]; name: string; toolJson: Record | 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, diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index d8bba88dd1c..a3d7587c52d 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -1,4 +1,5 @@ import { Mock, vi } from "vitest"; +import { clearSlackInboundDeliveryStateForTest } from "./monitor/inbound-delivery-state.js"; type SlackHandler = (args: unknown) => Promise; type SlackMiddleware = (args: { next: () => Promise } & Record) => unknown; @@ -191,6 +192,7 @@ export const defaultSlackTestConfig = () => ({ }); export function resetSlackTestState(config: Record = defaultSlackTestConfig()) { + clearSlackInboundDeliveryStateForTest(); slackTestState.config = config; slackTestState.sendMock.mockReset().mockResolvedValue(undefined); slackTestState.replyMock.mockReset(); @@ -208,6 +210,17 @@ export function resetSlackTestState(config: Record = 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(); } diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 451b504eeac..fe3d96a91c5 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -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 }, diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index e83b76ed4e6..2351ddd8171 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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, diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index f036d401d32..cfb98659982 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -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( diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index f47b418df2e..96fc12db0ef 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -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 >=.."); + } + 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 () => {