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 7b94be57b411..f57b0f192c58 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 @@ -47,6 +47,8 @@ let mockedPinnedMainDmOwner: string | undefined; let capturedReplyOptions: | { disableBlockStreaming?: boolean; + sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + suppressTyping?: boolean; suppressDefaultToolProgressMessages?: boolean; commentaryProgressEnabled?: boolean; onVerboseProgressVisibility?: (isActive: () => boolean) => void; @@ -516,12 +518,15 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => { }, resolveChannelMessageSourceReplyDeliveryMode: (params: { cfg?: { messages?: { groupChat?: { visibleReplies?: string } } }; - ctx?: { ChatType?: string }; + ctx?: { ChatType?: string; InboundEventKind?: string }; requested?: "automatic" | "message_tool_only"; }) => { if (params.requested) { return params.requested; } + if (params.ctx?.InboundEventKind === "room_event") { + return "message_tool_only"; + } const chatType = params.ctx?.ChatType; if (chatType === "group" || chatType === "channel") { return params.cfg?.messages?.groupChat?.visibleReplies === "automatic" @@ -978,6 +983,8 @@ vi.mock("../reply.runtime.js", () => ({ }; replyOptions?: { disableBlockStreaming?: boolean; + sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + suppressTyping?: boolean; suppressDefaultToolProgressMessages?: boolean; onItemEvent?: (payload: { kind?: string; @@ -1130,6 +1137,8 @@ vi.mock("../reply.runtime.js", () => ({ dispatchInboundMessage: async (params: { replyOptions?: { disableBlockStreaming?: boolean; + sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + suppressTyping?: boolean; suppressDefaultToolProgressMessages?: boolean; onAssistantMessageStart?: () => Promise | void; onReasoningEnd?: () => Promise | void; @@ -2087,6 +2096,30 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled(); }); + it("suppresses Slack typing for ambient room events", async () => { + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ + cfg: { messages: { groupChat: { visibleReplies: "automatic" } } }, + ctxPayload: { ChatType: "channel", InboundEventKind: "room_event" }, + }), + ); + + expect(capturedReplyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(capturedReplyOptions?.suppressTyping).toBe(true); + }); + + it("leaves Slack typing unsuppressed for normal channel turns", async () => { + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ + cfg: { messages: { groupChat: { visibleReplies: "automatic" } } }, + ctxPayload: { ChatType: "channel" }, + }), + ); + + expect(capturedReplyOptions?.sourceReplyDeliveryMode).toBe("automatic"); + expect(capturedReplyOptions?.suppressTyping).toBeUndefined(); + }); + 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 cce570481b0c..e89a9c504dbe 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -503,6 +503,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ctx: prepared.ctxPayload, }); const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only"; + const suppressRoomEventTyping = prepared.ctxPayload.InboundEventKind === "room_event"; // Shared context for the `message_sent` plugin hook emitted on each delivered // reply (both the `deliverReplies` paths and the native-streaming finalizer). @@ -1904,6 +1905,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag replyOptions: { skillFilter: prepared.channelConfig?.skills, sourceReplyDeliveryMode, + // Room events are observe-style turns; Slack status indicators imply an + // automatic visible reply and can auto-open assistant threads. + suppressTyping: suppressRoomEventTyping ? true : undefined, hasRepliedRef, disableBlockStreaming, onModelSelected,