From a08f6ebdda811a79e06747e440225d66f909cbf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 02:55:51 +0100 Subject: [PATCH] fix(slack): keep typing indicators for message-tool replies --- CHANGELOG.md | 1 + .../dispatch.preview-fallback.test.ts | 103 +++++++++++++++-- .../src/monitor/message-handler/dispatch.ts | 104 +++++++++--------- 3 files changed, 144 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c01e86e5e..da3ba543dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - 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: 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: 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/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 1c0abd53c22..3cae2065998 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 @@ -17,6 +17,8 @@ const startSlackStreamMock = vi.fn(async () => ({ pendingText: "", })); const stopSlackStreamMock = vi.fn(async () => {}); +const reactSlackMessageMock = vi.fn(async () => {}); +const removeSlackReactionMock = vi.fn(async () => {}); class TestSlackStreamNotDeliveredError extends Error { readonly pendingText: string; readonly slackCode: string; @@ -32,6 +34,14 @@ let mockedBlockStreamingEnabled: boolean | undefined = false; let capturedReplyOptions: { disableBlockStreaming?: boolean } | undefined; let mockedReplyThreadTs: string | undefined = THREAD_TS; let mockedReplyThreadTsSequence: Array | undefined; +let capturedTyping: + | { + start: () => Promise; + stop?: () => Promise; + onStartError: (err: unknown) => void; + onStopError?: (err: unknown) => void; + } + | undefined; let mockedDispatchSequence: Array<{ kind: "tool" | "block" | "final"; payload: { @@ -62,6 +72,8 @@ function createDraftStreamStub() { } function createPreparedSlackMessage(params?: { + cfg?: Record; + ctxPayload?: Record; message?: Partial<{ channel: string; ts: string; @@ -69,21 +81,27 @@ function createPreparedSlackMessage(params?: { user: string; }>; replyToMode?: "off" | "first" | "all" | "batched"; + setSlackThreadStatus?: (params: { + channelId: string; + threadTs?: string; + status: string; + }) => Promise; + typingReaction?: string; }) { return { ctx: { - cfg: {}, + cfg: params?.cfg ?? {}, runtime: {}, botToken: "xoxb-test", app: { client: { chat: { postMessage: postMessageMock } } }, teamId: "T1", textLimit: 4000, - typingReaction: "", + typingReaction: params?.typingReaction ?? "", removeAckAfterReply: false, historyLimit: 0, channelHistories: new Map(), allowFrom: [], - setSlackThreadStatus: async () => undefined, + setSlackThreadStatus: params?.setSlackThreadStatus ?? (async () => undefined), }, account: { accountId: "default", @@ -106,6 +124,7 @@ function createPreparedSlackMessage(params?: { replyTarget: "channel:C123", ctxPayload: { MessageThreadId: THREAD_TS, + ...params?.ctxPayload, }, turn: { storePath: "/tmp/slack-sessions.json", @@ -149,12 +168,29 @@ vi.mock("../conversation.runtime.js", () => ({ })); vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({ - createChannelReplyPipeline: () => ({ - typingCallbacks: { - onIdle: vi.fn(), - }, - onModelSelected: undefined, - }), + createChannelReplyPipeline: (params: { + typing?: { + start: () => Promise; + stop?: () => Promise; + onStartError: (err: unknown) => void; + onStopError?: (err: unknown) => void; + }; + }) => { + capturedTyping = params.typing; + return { + ...(params.typing + ? { + typingCallbacks: { + onReplyStart: params.typing.start, + onIdle: () => { + void params.typing?.stop?.(); + }, + }, + } + : {}), + onModelSelected: undefined, + }; + }, resolveChannelSourceReplyDeliveryMode: (params: { cfg?: { messages?: { groupChat?: { visibleReplies?: string } } }; ctx?: { ChatType?: string }; @@ -220,8 +256,8 @@ vi.mock("openclaw/plugin-sdk/text-runtime", () => ({ })); vi.mock("../../actions.js", () => ({ - reactSlackMessage: async () => {}, - removeSlackReaction: async () => {}, + reactSlackMessage: reactSlackMessageMock, + removeSlackReaction: removeSlackReactionMock, })); vi.mock("../../draft-stream.js", () => ({ @@ -370,9 +406,12 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { appendSlackStreamMock.mockReset(); startSlackStreamMock.mockReset(); stopSlackStreamMock.mockReset(); + reactSlackMessageMock.mockReset(); + removeSlackReactionMock.mockReset(); mockedNativeStreaming = false; mockedBlockStreamingEnabled = false; capturedReplyOptions = undefined; + capturedTyping = undefined; mockedReplyThreadTs = THREAD_TS; mockedReplyThreadTsSequence = undefined; mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }]; @@ -439,6 +478,48 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(capturedReplyOptions?.disableBlockStreaming).toBe(true); }); + it("keeps Slack typing callbacks when channel replies are message-tool-only", async () => { + const setSlackThreadStatus = vi.fn(async () => undefined); + + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ + cfg: { messages: { groupChat: { visibleReplies: "message_tool" } } }, + ctxPayload: { ChatType: "channel" }, + setSlackThreadStatus, + typingReaction: "hourglass_flowing_sand", + }), + ); + + expect(capturedTyping).toBeDefined(); + expect(capturedReplyOptions?.disableBlockStreaming).toBe(true); + + await capturedTyping?.start(); + await capturedTyping?.stop?.(); + + expect(setSlackThreadStatus).toHaveBeenCalledWith({ + channelId: "C123", + threadTs: THREAD_TS, + status: "is typing...", + }); + expect(setSlackThreadStatus).toHaveBeenCalledWith({ + channelId: "C123", + threadTs: THREAD_TS, + status: "", + }); + expect(reactSlackMessageMock).toHaveBeenCalledWith( + "C123", + "171234.111", + "hourglass_flowing_sand", + expect.objectContaining({ token: "xoxb-test" }), + ); + expect(removeSlackReactionMock).toHaveBeenCalledWith( + "C123", + "171234.111", + "hourglass_flowing_sand", + expect.objectContaining({ token: "xoxb-test" }), + ); + }); + 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 c406c4e44c8..a81129dd0fe 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -380,59 +380,57 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId }) ? compileSlackInteractiveReplies(payload) : payload, - typing: sourceRepliesAreToolOnly - ? undefined - : { - start: async () => { - didSetStatus = true; - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "is typing...", - }); - if (typingReaction && message.ts) { - await reactSlackMessage(message.channel, message.ts, typingReaction, { - token: ctx.botToken, - client: ctx.app.client, - }).catch(() => {}); - } - }, - stop: async () => { - if (!didSetStatus) { - return; - } - didSetStatus = false; - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "", - }); - if (typingReaction && message.ts) { - await removeSlackReaction(message.channel, message.ts, typingReaction, { - token: ctx.botToken, - client: ctx.app.client, - }).catch(() => {}); - } - }, - onStartError: (err) => { - logTypingFailure({ - log: (message) => runtime.error?.(danger(message)), - channel: "slack", - action: "start", - target: typingTarget, - error: err, - }); - }, - onStopError: (err) => { - logTypingFailure({ - log: (message) => runtime.error?.(danger(message)), - channel: "slack", - action: "stop", - target: typingTarget, - error: err, - }); - }, - }, + typing: { + start: async () => { + didSetStatus = true; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "is typing...", + }); + if (typingReaction && message.ts) { + await reactSlackMessage(message.channel, message.ts, typingReaction, { + token: ctx.botToken, + client: ctx.app.client, + }).catch(() => {}); + } + }, + stop: async () => { + if (!didSetStatus) { + return; + } + didSetStatus = false; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); + if (typingReaction && message.ts) { + await removeSlackReaction(message.channel, message.ts, typingReaction, { + token: ctx.botToken, + client: ctx.app.client, + }).catch(() => {}); + } + }, + onStartError: (err) => { + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "start", + target: typingTarget, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "stop", + target: typingTarget, + error: err, + }); + }, + }, }); const slackStreaming = resolveSlackStreamingConfig({