diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f1033b6a3..85c6f127b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ Docs: https://docs.openclaw.ai - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. - Slack/directory: make `openclaw directory peers/groups list --channel slack` prefer token-backed live readers and return the connected Slack account from `directory self`, so valid Slack tokens no longer produce empty directory CLI results. Fixes #50776. Thanks @pjaillon. -- Slack: keep the assistant typing status and temporary typing reaction active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne. +- Slack: keep assistant typing status, temporary typing reactions, and status reactions active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne. - Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter. - Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua. - WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber. diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 2db383289ee..06e7b7171ef 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -695,6 +695,39 @@ describe("monitorSlackProvider tool results", () => { }); }); + it("keeps status reactions for mentioned message-tool-only channel turns", async () => { + replyMock.mockResolvedValue({ text: "quiet default reply" }); + slackTestState.config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { visibleReplies: "message_tool" }, + statusReactions: { + enabled: true, + timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 }, + }, + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + groupPolicy: "open", + }, + }, + }; + mockGeneralChannelInfo(); + + await runMentionGatedChannelMessageAndFlush(); + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(sendMock).not.toHaveBeenCalled(); + expect(reactMock).toHaveBeenCalledWith({ + channel: "C1", + timestamp: "456", + name: "eyes", + }); + }); + it("keeps the error reaction when dispatch fails before any reply is delivered", async () => { replyMock.mockRejectedValue(new Error("boom")); setMentionGatedAckConfig(true); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 3cae2065998..521e6a372dc 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -32,6 +32,16 @@ class TestSlackStreamNotDeliveredError extends Error { let mockedNativeStreaming = false; let mockedBlockStreamingEnabled: boolean | undefined = false; let capturedReplyOptions: { disableBlockStreaming?: boolean } | undefined; +let capturedStatusReactionOptions: { enabled?: boolean; initialEmoji?: string } | undefined; +const statusReactionControllerMock = { + setQueued: vi.fn(async () => {}), + setThinking: vi.fn(async () => {}), + setTool: vi.fn(async () => {}), + setError: vi.fn(async () => {}), + setDone: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + restoreInitial: vi.fn(async () => {}), +}; let mockedReplyThreadTs: string | undefined = THREAD_TS; let mockedReplyThreadTsSequence: Array | undefined; let capturedTyping: @@ -87,6 +97,8 @@ function createPreparedSlackMessage(params?: { status: string; }) => Promise; typingReaction?: string; + ackReactionMessageTs?: string; + ackReactionPromise?: Promise | null; }) { return { ctx: { @@ -136,7 +148,8 @@ function createPreparedSlackMessage(params?: { historyKey: "history-key", preview: "", ackReactionValue: "eyes", - ackReactionPromise: null, + ackReactionMessageTs: params?.ackReactionMessageTs, + ackReactionPromise: params?.ackReactionPromise ?? null, } as never; } @@ -149,15 +162,10 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({ doneHoldMs: 0, errorHoldMs: 0, }, - createStatusReactionController: () => ({ - setQueued: async () => {}, - setThinking: async () => {}, - setTool: async () => {}, - setError: async () => {}, - setDone: async () => {}, - clear: async () => {}, - restoreInitial: async () => {}, - }), + createStatusReactionController: (params: { enabled?: boolean; initialEmoji?: string }) => { + capturedStatusReactionOptions = params; + return statusReactionControllerMock; + }, logAckFailure: () => {}, logTypingFailure: () => {}, removeAckReactionAfterReply: () => {}, @@ -408,9 +416,13 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { stopSlackStreamMock.mockReset(); reactSlackMessageMock.mockReset(); removeSlackReactionMock.mockReset(); + for (const value of Object.values(statusReactionControllerMock)) { + value.mockClear(); + } mockedNativeStreaming = false; mockedBlockStreamingEnabled = false; capturedReplyOptions = undefined; + capturedStatusReactionOptions = undefined; capturedTyping = undefined; mockedReplyThreadTs = THREAD_TS; mockedReplyThreadTsSequence = undefined; @@ -520,6 +532,32 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { ); }); + it("keeps Slack status reactions when channel replies are message-tool-only", async () => { + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ + cfg: { + messages: { + groupChat: { visibleReplies: "message_tool" }, + statusReactions: { enabled: true }, + }, + }, + ctxPayload: { ChatType: "channel" }, + ackReactionMessageTs: "171234.111", + ackReactionPromise: Promise.resolve(true), + }), + ); + + expect(capturedReplyOptions?.disableBlockStreaming).toBe(true); + expect(capturedStatusReactionOptions).toEqual( + expect.objectContaining({ + enabled: true, + initialEmoji: "eyes", + }), + ); + expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1); + expect(statusReactionControllerMock.setDone).toHaveBeenCalledTimes(1); + }); + it("escapes Slack mrkdwn in tool progress preview labels", async () => { const draftStream = createDraftStreamStub(); createSlackDraftStreamMock.mockReturnValueOnce(draftStream); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index afd9305b26c..5fae667c29b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -306,7 +306,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const incomingThreadTs = message.thread_ts; let didSetStatus = false; const statusReactionsEnabled = - !sourceRepliesAreToolOnly && Boolean(prepared.ackReactionPromise) && Boolean(reactionMessageTs) && cfg.messages?.statusReactions?.enabled !== false; diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index f11f24a7162..c9aed796ee8 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -399,6 +399,42 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared?.ackReactionPromise).toBeNull(); }); + it("primes Slack status reactions when channel replies are message-tool-only", async () => { + const slackCtx = createInboundSlackCtx({ + cfg: { + messages: { + ackReaction: "eyes", + groupChat: { visibleReplies: "message_tool" }, + statusReactions: { enabled: true }, + }, + channels: { + slack: { + enabled: true, + groupPolicy: "open", + replyToMode: "all", + }, + }, + } as OpenClawConfig, + replyToMode: "all", + }); + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); + + const prepared = await prepareMessageWith(slackCtx, defaultAccount, { + channel: "C123", + channel_type: "channel", + user: "U1", + text: "<@B1> hi", + ts: "1.000", + } as SlackMessageEvent); + + expect(prepared).toBeTruthy(); + expect(prepared?.ackReactionMessageTs).toBe("1.000"); + expect(prepared?.ackReactionValue).toBe("eyes"); + expect(prepared?.ackReactionPromise).toBeTruthy(); + expect(await prepared!.ackReactionPromise).toBe(true); + }); + it("includes forwarded shared attachment text in raw body", async () => { const prepared = await prepareWithDefaultCtx( createSlackMessage({ diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 4cc23e1af26..62a0f80aea9 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -563,7 +563,7 @@ export async function prepareSlackMessage(params: { const sourceRepliesAreToolOnly = resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) === "message_tool_only"; - + const statusReactionsExplicitlyEnabled = cfg.messages?.statusReactions?.enabled === true; const shouldAckReaction = () => Boolean( ackReaction && @@ -580,7 +580,11 @@ export async function prepareSlackMessage(params: { ); const ackReactionMessageTs = message.ts; - const shouldSendAckReaction = !sourceRepliesAreToolOnly && shouldAckReaction(); + const allowToolOnlyStatusReaction = + statusReactionsExplicitlyEnabled && + (effectiveWasMentioned || mentionDecision.shouldBypassMention === true); + const shouldSendAckReaction = + shouldAckReaction() && (!sourceRepliesAreToolOnly || allowToolOnlyStatusReaction); const statusReactionsWillHandle = Boolean(ackReactionMessageTs) && cfg.messages?.statusReactions?.enabled !== false &&