From 86856b88e3c37ffd107d0db69546b9f8c3cf8bc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 00:23:09 +0100 Subject: [PATCH] fix(slack): suppress reasoning in native streams --- CHANGELOG.md | 1 + .../dispatch.preview-fallback.test.ts | 35 +++++++++++++++++-- .../src/monitor/message-handler/dispatch.ts | 3 ++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63aa28f42d..ec683bc8a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Providers/GitHub Copilot: keep the plugin stream wrapper from claiming transport selection before OpenClaw picks a boundary-aware stream path, avoiding Pi's stale fallback Copilot headers on normal model turns. Thanks @steipete. - Discord/subagents: pass runtime config into thread-bound native subagent binding and require it at the helper boundary so Discord channel resolution keeps account-aware config. Fixes #71054. (#70945) Thanks @jai. - Slack/Assistant: accept Slack Assistant DM `message_changed` events when their metadata identifies the human sender, while continuing to drop self-authored bot edits. Fixes #55445. Thanks @AlfredPros. +- Slack/native streaming: suppress reasoning-only payloads before `chat.startStream`/`appendStream`, so Claude extended-thinking blocks no longer appear as visible Slack messages. Fixes #59687. Thanks @vision-ifc. - Slack/block replies: keep multi-part block deliveries in the first Slack reply thread when `replyToMode` is `first`, matching text reply threading instead of leaking later blocks into the channel. Fixes #49341. Thanks @pholmstr and @xiwuqi. - Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu. - Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih. 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 5dd8163037a..5348cc9ccf7 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 @@ -34,7 +34,13 @@ let mockedReplyThreadTs: string | undefined = THREAD_TS; let mockedReplyThreadTsSequence: Array | undefined; let mockedDispatchSequence: Array<{ kind: "tool" | "block" | "final"; - payload: { text: string; isError?: boolean; mediaUrl?: string; mediaUrls?: string[] }; + payload: { + text: string; + isError?: boolean; + isReasoning?: boolean; + mediaUrl?: string; + mediaUrls?: string[]; + }; }> = []; const noop = () => {}; @@ -289,7 +295,13 @@ vi.mock("../reply.runtime.js", () => ({ replyOptions?: { disableBlockStreaming?: boolean }; dispatcher: { deliver: ( - payload: { text: string; isError?: boolean; mediaUrl?: string; mediaUrls?: string[] }, + payload: { + text: string; + isError?: boolean; + isReasoning?: boolean; + mediaUrl?: string; + mediaUrls?: string[]; + }, info: { kind: "tool" | "block" | "final" }, ) => Promise; }; @@ -389,6 +401,25 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(deliverRepliesMock).not.toHaveBeenCalled(); }); + it("suppresses reasoning payloads before Slack native streaming delivery", async () => { + mockedNativeStreaming = true; + mockedDispatchSequence = [ + { kind: "block", payload: { text: "Reasoning:\n_hidden_", isReasoning: true } }, + { kind: "final", payload: { text: FINAL_REPLY_TEXT } }, + ]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(startSlackStreamMock).toHaveBeenCalledTimes(1); + expect(startSlackStreamMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: FINAL_REPLY_TEXT, + }), + ); + expect(appendSlackStreamMock).not.toHaveBeenCalled(); + expect(deliverRepliesMock).not.toHaveBeenCalled(); + }); + it("keeps same-content tool and final payloads distinct after preview fallback", async () => { mockedDispatchSequence = [ { kind: "tool", payload: { text: SAME_TEXT } }, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 9509c7228d5..5306f495f1a 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -589,6 +589,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag payload: ReplyPayload; kind: ReplyDispatchKind; }): Promise => { + if (params.payload.isReasoning === true) { + return; + } const reply = resolveSendableOutboundReplyParts(params.payload); if ( streamFailed ||