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 `🧵<root_ts>` 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
This commit is contained in:
Zeroth
2026-05-08 05:30:10 +02:00
committed by GitHub
parent 4e983aa57b
commit 741315e657
2 changed files with 94 additions and 1 deletions
@@ -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:<rootTs>`, 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,
@@ -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:<root_ts>` 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,