From 741315e6579140bf86bc12fe07f410b976801319 Mon Sep 17 00:00:00 2001 From: Zeroth Date: Fri, 8 May 2026 05:30:10 +0200 Subject: [PATCH] fix(slack): seed thread routing for implicit-conversation channels (#78522) When a Slack channel has `requireMention: false` and a non-`off` reply mode, every top-level bot reply creates a Slack thread (because `replyToMode` does). Without seeding the inbound root, the root turn landed on the channel session while later thread replies landed on a fresh `:thread:` session, breaking conversational continuity. Extend `seedTopLevelRoomThreadBySource` to also fire for those channels, mirroring how `app_mention` / `explicitlyMentioned` roots already get seeded. The thread session key is now consistent on both sides of the turn, so follow-up thread messages route back to the originating session. Fixes #78505 --- .../monitor/message-handler/prepare.test.ts | 74 +++++++++++++++++++ .../src/monitor/message-handler/prepare.ts | 21 +++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 36cd9396124d..2745fa357163 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -1388,6 +1388,80 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); }); + it("keeps an implicit-conversation root and its Slack thread follow-up on one parent session in `requireMention: false` channels (#78505)", async () => { + const { storePath } = storeFixture.makeTmpStorePath(); + const rootTs = "1778073105.769279"; + const expectedSessionKey = `agent:main:slack:channel:c0agg76cp1s:thread:${rootTs}`; + const replies = vi.fn().mockResolvedValue({ + messages: [ + { + text: "What day is it?", + user: "U_TRAJCHE", + ts: rootTs, + }, + ], + response_metadata: { next_cursor: "" }, + }); + const slackCtx = createInboundSlackCtx({ + cfg: { + session: { store: storePath }, + channels: { + slack: { + enabled: true, + replyToMode: "first", + groupPolicy: "open", + channels: { C0AGG76CP1S: { enabled: true, requireMention: false } }, + }, + }, + } as OpenClawConfig, + appClient: { conversations: { replies } } as unknown as App["client"], + defaultRequireMention: true, + replyToMode: "first", + channelsConfig: { C0AGG76CP1S: { enabled: true, requireMention: false } }, + }); + slackCtx.resolveChannelName = async () => ({ name: "genai", type: "channel" }); + slackCtx.resolveUserName = async () => ({ name: "Trajche" }); + + const root = await prepareSlackMessage({ + ctx: slackCtx, + account: createSlackAccount({ replyToMode: "first" }), + message: { + type: "message", + channel: "C0AGG76CP1S", + channel_type: "channel", + user: "U_TRAJCHE", + text: "What day is it?", + ts: rootTs, + } as SlackMessageEvent, + opts: { source: "message" }, + }); + recordSlackThreadParticipation("default", "C0AGG76CP1S", rootTs); + + const followUp = await prepareSlackMessage({ + ctx: slackCtx, + account: createSlackAccount({ replyToMode: "first" }), + message: { + type: "message", + channel: "C0AGG76CP1S", + channel_type: "channel", + user: "U_TRAJCHE", + text: "and the time?", + ts: "1778073128.229409", + thread_ts: rootTs, + } as SlackMessageEvent, + opts: { source: "message" }, + }); + + expect(root).toBeTruthy(); + expect(followUp).toBeTruthy(); + // Without the seeding fix, root would land on `agent:main:slack:channel:c0agg76cp1s` + // while followUp would land on `:thread:`, splitting the conversation + // across two sessions. Both must share one session key. + expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); + }); + it("treats Slack user-group mentions as explicit mentions when the bot is a member", async () => { const usergroupsUsersList = vi.fn().mockResolvedValue({ ok: true, diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 59ea569c5f22..0a58e1b7efce 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -30,6 +30,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; +import { resolveSlackReplyToMode } from "../../account-reply-mode.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { formatSlackFileReference } from "../../file-reference.js"; @@ -303,8 +304,26 @@ export async function prepareSlackMessage(params: { log: logVerbose, })))), ); + // Channels with `requireMention: false` and a non-`off` reply mode produce + // a Slack-side thread on every top-level bot reply (because `replyToMode` + // creates one). Seed thread routing for the root turn too, so the inbound + // root and its later thread replies share one parent session — same way + // app_mention / explicitly mentioned roots already do. Without this gate, + // the root lands on the channel session while later thread replies land on + // a fresh `:thread:` session, breaking continuity. + const channelRequireMention = channelConfig?.requireMention ?? ctx.defaultRequireMention ?? true; + const channelChatType: "direct" | "group" | "channel" = isDirectMessage + ? "direct" + : isGroupDm + ? "group" + : "channel"; + const willImplicitlyThreadReply = + isRoom && !channelRequireMention && resolveSlackReplyToMode(account, channelChatType) !== "off"; const seedTopLevelRoomThreadBySource = - opts.source === "app_mention" || opts.wasMentioned === true || explicitlyMentioned; + opts.source === "app_mention" || + opts.wasMentioned === true || + explicitlyMentioned || + willImplicitlyThreadReply; let routing = resolveSlackRoutingContext({ ctx, account,