From 93ac2b43fb9a90ad7d878e596a75c55f7a54bb40 Mon Sep 17 00:00:00 2001 From: calder-sandy Date: Sun, 1 Mar 2026 09:24:45 -0800 Subject: [PATCH] feat(slack): per-thread session isolation for DM auto-threading (#26849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(slack): create thread sessions for auto-threaded DM messages When replyToMode="all", every top-level message starts a new Slack thread. Previously, only subsequent replies in that thread got an isolated session (via :thread: suffix). The initial message fell back to the base DM session, mixing context across unrelated conversations. Now, when replyToMode="all" and a message is not already a thread reply, the message's own ts is used as the threadId for session key resolution. This gives the initial message AND all subsequent thread replies the same isolated session. This enables per-thread session isolation for Slack DMs — each new message starts its own thread and session, keeping conversations separate. * Slack: fix auto-thread session key mode check and add changelog --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../monitor/message-handler/prepare.test.ts | 26 +++++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 13 ++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e80a5d7..9d4cb83b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,7 @@ Docs: https://docs.openclaw.ai - Slack/Threading: resolve `replyToMode` per incoming message using chat-type-aware account config (`replyToModeByChatType` and legacy `dm.replyToMode`) so DM/channel reply threading honors overrides instead of always using monitor startup defaults. (#24717) Thanks @dbachelder. - Slack/Threading: track bot participation in message threads (per account/channel/thread) so follow-up messages in those threads can be handled without requiring repeated @mentions, while preserving mention-gating behavior for unrelated threads. (#29165) Thanks @luijoc. - Slack/Threading: stop forcing tool-call reply mode to `all` based on `ThreadLabel` alone; now force thread reply mode only when an explicit thread target exists (`MessageThreadId`/`ReplyToId`), so DM `replyToModeByChatType.direct` overrides are honored outside real thread replies. (#26251) Thanks @dbachelder. +- Slack/Threading: when `replyToMode="all"` auto-threads top-level Slack DMs, seed the thread session key from the message `ts` so the initial message and later replies share the same isolated `:thread:` session instead of falling back to base DM context. (#26849) Thanks @calder-sandy. - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index f40e00631..c41f821c0 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -712,6 +712,32 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.Body).not.toContain("thread_ts"); expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id"); }); + + it("creates thread session for top-level DM when replyToMode=all", async () => { + const { storePath } = makeTmpStorePath(); + const slackCtx = createInboundSlackCtx({ + cfg: { + session: { store: storePath }, + channels: { slack: { enabled: true, replyToMode: "all" } }, + } as OpenClawConfig, + replyToMode: "all", + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const message = createSlackMessage({ ts: "500.000" }); + const prepared = await prepareMessageWith( + slackCtx, + createSlackAccount({ replyToMode: "all" }), + message, + ); + + expect(prepared).toBeTruthy(); + // Session key should include :thread:500.000 for the auto-threaded message + expect(prepared!.ctxPayload.SessionKey).toContain(":thread:500.000"); + // MessageThreadId should be set for the reply + expect(prepared!.ctxPayload.MessageThreadId).toBe("500.000"); + }); }); describe("prepareSlackMessage sender prefix", () => { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 484e64ca4..79fd7957f 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -181,10 +181,19 @@ export async function prepareSlackMessage(params: { const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadTs = threadContext.incomingThreadTs; const isThreadReply = threadContext.isThreadReply; + // When replyToMode="all", every top-level message starts a new thread. + // Use its own ts as threadId so the initial message AND subsequent replies + // in that thread share an isolated session (instead of falling back to the + // base DM/channel session for the first message). + const autoThreadId = + !isThreadReply && replyToMode === "all" && threadContext.messageTs + ? threadContext.messageTs + : undefined; const threadKeys = resolveThreadSessionKeys({ baseSessionKey, - threadId: isThreadReply ? threadTs : undefined, - parentSessionKey: isThreadReply && ctx.threadInheritParent ? baseSessionKey : undefined, + threadId: isThreadReply ? threadTs : autoThreadId, + parentSessionKey: + (isThreadReply || autoThreadId) && ctx.threadInheritParent ? baseSessionKey : undefined, }); const sessionKey = threadKeys.sessionKey; const historyKey =