Skip to content

Commit f082ac1

Browse files
committed
fix(slack): use normalized DM state for assistant thread roots
1 parent 1a58f17 commit f082ac1

6 files changed

Lines changed: 110 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,7 @@ Docs: https://docs.openclaw.ai
17501750
- Browser/chrome-mcp: read Chrome DevTools MCP screenshot output from the extension-suffixed path, fixing ENOENT on screenshot capture. Fixes #77222. (#74685) Thanks @barbarhan.
17511751

17521752
- macOS/launchd: set generated Gateway LaunchAgent plists to `ProcessType=Interactive` so the gateway keeps timely execution during idle periods. Fixes #58061; refs #62294 and closed duplicate #66992. (#62308) Thanks @bryanpearson and @zssggle-rgb.
1753+
- Slack: preserve Agents & Assistants DM root thread context for tool and subagent replies even when Slack omits or misreports `channel_type`, while leaving non-DM self-thread roots top-level. Fixes #63659. Thanks @zozo123.
17531754
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
17541755
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
17551756
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function resolveSlackRoutingContext(params: {
179179

180180
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
181181
const replyToMode = resolveSlackReplyToMode(account, chatType);
182-
const threadContext = resolveSlackThreadContext({ message, replyToMode });
182+
const threadContext = resolveSlackThreadContext({ message, replyToMode, isDirectMessage });
183183
const threadTs = threadContext.incomingThreadTs;
184184
const isThreadReply = threadContext.isThreadReply;
185185
// Keep true thread replies thread-scoped, while top-level DMs keep their

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,39 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
13291329
expectMainScopedDmClassification(prepared);
13301330
});
13311331

1332+
it("preserves MessageThreadId for normalized DM assistant thread roots", async () => {
1333+
const cases: Array<{
1334+
name: string;
1335+
message: SlackMessageEvent;
1336+
}> = [
1337+
{
1338+
name: "raw im",
1339+
message: createMainScopedDmMessage({ channel_type: "im", thread_ts: "1.000" }),
1340+
},
1341+
{
1342+
name: "wrong channel_type",
1343+
message: createMainScopedDmMessage({ channel_type: "channel", thread_ts: "1.000" }),
1344+
},
1345+
{
1346+
name: "missing channel_type",
1347+
message: createMainScopedDmMessage({ thread_ts: "1.000" }),
1348+
},
1349+
];
1350+
delete cases[2].message.channel_type;
1351+
1352+
for (const testCase of cases) {
1353+
const prepared = await prepareMessageWith(
1354+
createDmScopeMainSlackCtx(),
1355+
createSlackAccount(),
1356+
testCase.message,
1357+
);
1358+
1359+
expectMainScopedDmClassification(prepared, { includeFromCheck: testCase.name !== "raw im" });
1360+
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
1361+
expect(prepared!.ctxPayload.ReplyToId).toBe("1.000");
1362+
}
1363+
});
1364+
13321365
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
13331366
const prepared = await prepareMessageWith(
13341367
createReplyToAllSlackCtx(),

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ describe("thread-level session keys", () => {
347347
});
348348

349349
expect(routing.sessionKey).toBe("agent:main:slack:channel:c123");
350+
expect(routing.threadContext.messageThreadId).toBeUndefined();
350351
});
351352

352353
it("does not seed top-level group DM mentions into thread sessions", () => {
@@ -634,4 +635,46 @@ describe("thread-level session keys", () => {
634635
unregisterSessionBindingAdapter({ channel: "slack", accountId: "default", adapter });
635636
}
636637
});
638+
639+
it("preserves distinct MessageThreadIds for concurrent assistant DM roots", () => {
640+
const ctx = buildCtx({ replyToMode: "off", dmScope: "per-channel-peer" });
641+
const account = buildAccount("off");
642+
643+
const first = resolveSlackRoutingContext({
644+
ctx,
645+
account,
646+
message: {
647+
channel: "D456",
648+
channel_type: "channel",
649+
user: "U3",
650+
text: "first assistant root",
651+
ts: "1770408530.000000",
652+
thread_ts: "1770408530.000000",
653+
} as SlackMessageEvent,
654+
isDirectMessage: true,
655+
isGroupDm: false,
656+
isRoom: false,
657+
isRoomish: false,
658+
});
659+
const second = resolveSlackRoutingContext({
660+
ctx,
661+
account,
662+
message: {
663+
channel: "D456",
664+
user: "U3",
665+
text: "second assistant root",
666+
ts: "1770408531.000000",
667+
thread_ts: "1770408531.000000",
668+
} as SlackMessageEvent,
669+
isDirectMessage: true,
670+
isGroupDm: false,
671+
isRoom: false,
672+
isRoomish: false,
673+
});
674+
675+
expect(first.sessionKey).toBe("agent:main:slack:direct:u3");
676+
expect(second.sessionKey).toBe(first.sessionKey);
677+
expect(first.threadContext.messageThreadId).toBe("1770408530.000000");
678+
expect(second.threadContext.messageThreadId).toBe("1770408531.000000");
679+
});
637680
});

extensions/slack/src/threading.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe("resolveSlackThreadTargets", () => {
9292
for (const replyToMode of ["off", "first", "batched"] as const) {
9393
const context = resolveSlackThreadContext({
9494
replyToMode,
95+
isDirectMessage: true,
9596
message: {
9697
type: "message",
9798
channel: "D1",
@@ -109,10 +110,33 @@ describe("resolveSlackThreadTargets", () => {
109110
}
110111
});
111112

113+
it("uses normalized direct-message state for DM assistant thread-root messages", () => {
114+
for (const channelType of ["channel", undefined] as const) {
115+
const message = {
116+
type: "message",
117+
channel: "D1",
118+
ts: "123",
119+
thread_ts: "123",
120+
...(channelType ? { channel_type: channelType } : {}),
121+
} as const;
122+
123+
const context = resolveSlackThreadContext({
124+
replyToMode: "off",
125+
isDirectMessage: true,
126+
message,
127+
});
128+
129+
expect(context.isThreadReply).toBe(false);
130+
expect(context.messageThreadId).toBe("123");
131+
expect(context.replyToId).toBe("123");
132+
}
133+
});
134+
112135
it("does not set messageThreadId for channel thread-root messages with non-all replyToMode", () => {
113136
for (const replyToMode of ["off", "first", "batched"] as const) {
114137
const context = resolveSlackThreadContext({
115138
replyToMode,
139+
isDirectMessage: false,
116140
message: {
117141
type: "message",
118142
channel: "C1",

extensions/slack/src/threading.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type SlackThreadContext = {
1212
export function resolveSlackThreadContext(params: {
1313
message: SlackMessageEvent | SlackAppMentionEvent;
1414
replyToMode: ReplyToMode;
15+
isDirectMessage?: boolean;
1516
}): SlackThreadContext {
1617
const incomingThreadTs = params.message.thread_ts;
1718
const eventTs = params.message.event_ts;
@@ -20,21 +21,13 @@ export function resolveSlackThreadContext(params: {
2021
const isThreadReply =
2122
hasThreadTs && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
2223
const replyToId = incomingThreadTs ?? messageTs;
23-
// Preserve thread context for DM assistant thread-root messages
24-
// (Slack Agents & Assistants DMs where thread_ts == ts on the initial
25-
// message). Without this, tool calls that run during the same turn (subagent
26-
// results) lose the thread identifier after the first reply because
27-
// hasRepliedRef gets marked true, causing subsequent sendMessage calls to fall
28-
// through to the top-level channel.
29-
// Only apply for DM (im) channels — in channels/groups, an auto-created
30-
// thread_ts == ts must not force threaded mode, since downstream
31-
// buildSlackThreadingToolContext treats any MessageThreadId as an explicit
32-
// thread target and overrides replyToMode to "all".
33-
const isDmAssistantThread =
34-
hasThreadTs && !isThreadReply && params.message.channel_type === "im";
35-
const messageThreadId = isThreadReply
36-
? incomingThreadTs
37-
: isDmAssistantThread
24+
// Preserve thread context for Slack Agents & Assistants DM root messages
25+
// where thread_ts == ts. Non-DM self-thread roots must stay unset because
26+
// downstream tool threading treats MessageThreadId as an explicit thread
27+
// target and overrides replyToMode to "all".
28+
const isAssistantDmThreadRoot = hasThreadTs && !isThreadReply && params.isDirectMessage === true;
29+
const messageThreadId =
30+
isThreadReply || isAssistantDmThreadRoot
3831
? incomingThreadTs
3932
: params.replyToMode === "all"
4033
? messageTs

0 commit comments

Comments
 (0)