Skip to content

Commit 7ed9ed5

Browse files
committed
fix(slack): seed thread routing for implicit-conversation channels
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:<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
1 parent 4721ca8 commit 7ed9ed5

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

extensions/slack/src/monitor/message-handler/prepare.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,80 @@ describe("slack prepareSlackMessage inbound contract", () => {
13881388
expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1);
13891389
});
13901390

1391+
it("keeps an implicit-conversation root and its Slack thread follow-up on one parent session in `requireMention: false` channels (#78505)", async () => {
1392+
const { storePath } = storeFixture.makeTmpStorePath();
1393+
const rootTs = "1778073105.769279";
1394+
const expectedSessionKey = `agent:main:slack:channel:c0agg76cp1s:thread:${rootTs}`;
1395+
const replies = vi.fn().mockResolvedValue({
1396+
messages: [
1397+
{
1398+
text: "What day is it?",
1399+
user: "U_TRAJCHE",
1400+
ts: rootTs,
1401+
},
1402+
],
1403+
response_metadata: { next_cursor: "" },
1404+
});
1405+
const slackCtx = createInboundSlackCtx({
1406+
cfg: {
1407+
session: { store: storePath },
1408+
channels: {
1409+
slack: {
1410+
enabled: true,
1411+
replyToMode: "first",
1412+
groupPolicy: "open",
1413+
channels: { C0AGG76CP1S: { enabled: true, requireMention: false } },
1414+
},
1415+
},
1416+
} as OpenClawConfig,
1417+
appClient: { conversations: { replies } } as unknown as App["client"],
1418+
defaultRequireMention: true,
1419+
replyToMode: "first",
1420+
channelsConfig: { C0AGG76CP1S: { enabled: true, requireMention: false } },
1421+
});
1422+
slackCtx.resolveChannelName = async () => ({ name: "genai", type: "channel" });
1423+
slackCtx.resolveUserName = async () => ({ name: "Trajche" });
1424+
1425+
const root = await prepareSlackMessage({
1426+
ctx: slackCtx,
1427+
account: createSlackAccount({ replyToMode: "first" }),
1428+
message: {
1429+
type: "message",
1430+
channel: "C0AGG76CP1S",
1431+
channel_type: "channel",
1432+
user: "U_TRAJCHE",
1433+
text: "What day is it?",
1434+
ts: rootTs,
1435+
} as SlackMessageEvent,
1436+
opts: { source: "message" },
1437+
});
1438+
recordSlackThreadParticipation("default", "C0AGG76CP1S", rootTs);
1439+
1440+
const followUp = await prepareSlackMessage({
1441+
ctx: slackCtx,
1442+
account: createSlackAccount({ replyToMode: "first" }),
1443+
message: {
1444+
type: "message",
1445+
channel: "C0AGG76CP1S",
1446+
channel_type: "channel",
1447+
user: "U_TRAJCHE",
1448+
text: "and the time?",
1449+
ts: "1778073128.229409",
1450+
thread_ts: rootTs,
1451+
} as SlackMessageEvent,
1452+
opts: { source: "message" },
1453+
});
1454+
1455+
expect(root).toBeTruthy();
1456+
expect(followUp).toBeTruthy();
1457+
// Without the seeding fix, root would land on `agent:main:slack:channel:c0agg76cp1s`
1458+
// while followUp would land on `:thread:<rootTs>`, splitting the conversation
1459+
// across two sessions. Both must share one session key.
1460+
expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey);
1461+
expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey);
1462+
expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1);
1463+
});
1464+
13911465
it("treats Slack user-group mentions as explicit mentions when the bot is a member", async () => {
13921466
const usergroupsUsersList = vi.fn().mockResolvedValue({
13931467
ok: true,

extensions/slack/src/monitor/message-handler/prepare.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
normalizeLowercaseStringOrEmpty,
3131
normalizeOptionalString,
3232
} from "openclaw/plugin-sdk/text-runtime";
33+
import { resolveSlackReplyToMode } from "../../account-reply-mode.js";
3334
import type { ResolvedSlackAccount } from "../../accounts.js";
3435
import { reactSlackMessage } from "../../actions.js";
3536
import { formatSlackFileReference } from "../../file-reference.js";
@@ -303,8 +304,26 @@ export async function prepareSlackMessage(params: {
303304
log: logVerbose,
304305
})))),
305306
);
307+
// Channels with `requireMention: false` and a non-`off` reply mode produce
308+
// a Slack-side thread on every top-level bot reply (because `replyToMode`
309+
// creates one). Seed thread routing for the root turn too, so the inbound
310+
// root and its later thread replies share one parent session — same way
311+
// app_mention / explicitly mentioned roots already do. Without this gate,
312+
// the root lands on the channel session while later thread replies land on
313+
// a fresh `:thread:<root_ts>` session, breaking continuity.
314+
const channelRequireMention = channelConfig?.requireMention ?? ctx.defaultRequireMention ?? true;
315+
const channelChatType: "direct" | "group" | "channel" = isDirectMessage
316+
? "direct"
317+
: isGroupDm
318+
? "group"
319+
: "channel";
320+
const willImplicitlyThreadReply =
321+
isRoom && !channelRequireMention && resolveSlackReplyToMode(account, channelChatType) !== "off";
306322
const seedTopLevelRoomThreadBySource =
307-
opts.source === "app_mention" || opts.wasMentioned === true || explicitlyMentioned;
323+
opts.source === "app_mention" ||
324+
opts.wasMentioned === true ||
325+
explicitlyMentioned ||
326+
willImplicitlyThreadReply;
308327
let routing = resolveSlackRoutingContext({
309328
ctx,
310329
account,

0 commit comments

Comments
 (0)