Skip to content

Commit 4432554

Browse files
authored
fix(slack): preserve assistant DM root thread context (#63840)
Preserve Slack 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.
1 parent 7dde396 commit 4432554

5 files changed

Lines changed: 155 additions & 6 deletions

File tree

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
@@ -1376,6 +1376,39 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
13761376
expectMainScopedDmClassification(prepared);
13771377
});
13781378

1379+
it("preserves MessageThreadId for normalized DM assistant thread roots", async () => {
1380+
const cases: Array<{
1381+
name: string;
1382+
message: SlackMessageEvent;
1383+
}> = [
1384+
{
1385+
name: "raw im",
1386+
message: createMainScopedDmMessage({ channel_type: "im", thread_ts: "1.000" }),
1387+
},
1388+
{
1389+
name: "wrong channel_type",
1390+
message: createMainScopedDmMessage({ channel_type: "channel", thread_ts: "1.000" }),
1391+
},
1392+
{
1393+
name: "missing channel_type",
1394+
message: createMainScopedDmMessage({ thread_ts: "1.000" }),
1395+
},
1396+
];
1397+
delete cases[2].message.channel_type;
1398+
1399+
for (const testCase of cases) {
1400+
const prepared = await prepareMessageWith(
1401+
createDmScopeMainSlackCtx(),
1402+
createSlackAccount(),
1403+
testCase.message,
1404+
);
1405+
1406+
expectMainScopedDmClassification(prepared, { includeFromCheck: testCase.name !== "raw im" });
1407+
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
1408+
expect(prepared!.ctxPayload.ReplyToId).toBe("1.000");
1409+
}
1410+
});
1411+
13791412
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
13801413
const prepared = await prepareMessageWith(
13811414
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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,72 @@ describe("resolveSlackThreadTargets", () => {
8888
expect(context.replyToId).toBe("123");
8989
});
9090

91+
it("sets messageThreadId for DM assistant thread-root messages regardless of replyToMode", () => {
92+
for (const replyToMode of ["off", "first", "batched"] as const) {
93+
const context = resolveSlackThreadContext({
94+
replyToMode,
95+
isDirectMessage: true,
96+
message: {
97+
type: "message",
98+
channel: "D1",
99+
channel_type: "im",
100+
ts: "123",
101+
thread_ts: "123",
102+
},
103+
});
104+
105+
expect(context.isThreadReply).toBe(false);
106+
// thread_ts == ts in a DM: Agents & Assistants root — preserve thread
107+
// context so tool calls (subagent results) thread correctly.
108+
expect(context.messageThreadId).toBe("123");
109+
expect(context.replyToId).toBe("123");
110+
}
111+
});
112+
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+
135+
it("does not set messageThreadId for channel thread-root messages with non-all replyToMode", () => {
136+
for (const replyToMode of ["off", "first", "batched"] as const) {
137+
const context = resolveSlackThreadContext({
138+
replyToMode,
139+
isDirectMessage: false,
140+
message: {
141+
type: "message",
142+
channel: "C1",
143+
channel_type: "channel",
144+
ts: "123",
145+
thread_ts: "123",
146+
},
147+
});
148+
149+
expect(context.isThreadReply).toBe(false);
150+
// thread_ts == ts in a channel: auto-created top-level thread_ts should
151+
// NOT force threaded mode — only DM assistant threads get the override.
152+
expect(context.messageThreadId).toBeUndefined();
153+
expect(context.replyToId).toBe("123");
154+
}
155+
});
156+
91157
it("prefers thread_ts as messageThreadId for replies", () => {
92158
const context = resolveSlackThreadContext({
93159
replyToMode: "off",

extensions/slack/src/threading.ts

Lines changed: 12 additions & 5 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,11 +21,17 @@ export function resolveSlackThreadContext(params: {
2021
const isThreadReply =
2122
hasThreadTs && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
2223
const replyToId = incomingThreadTs ?? messageTs;
23-
const messageThreadId = isThreadReply
24-
? incomingThreadTs
25-
: params.replyToMode === "all"
26-
? messageTs
27-
: undefined;
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
31+
? incomingThreadTs
32+
: params.replyToMode === "all"
33+
? messageTs
34+
: undefined;
2835
return {
2936
incomingThreadTs,
3037
messageTs,

0 commit comments

Comments
 (0)