diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f7bfc4bcc..5decd90e643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc. - Slack/message actions: send media before the follow-up Block Kit message when Slack `send` includes a file plus presentation or interactive controls, so file attachments are no longer rejected. Fixes #51458. Thanks @HirokiKobayashi-R. - Slack/DMs: honor `dmHistoryLimit` for fresh 1:1 Slack DM sessions by backfilling recent conversation history before the current reply. Fixes #64427. Thanks @brantley-creator. +- Slack/DMs: keep top-level direct messages on the stable DM session even when `replyToMode` targets Slack thread replies, preserving context across DM turns. Fixes #58832. Thanks @daye-jjeong. - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts index 15aed81c391..4eede47b79b 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-routing.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -92,9 +92,9 @@ export function resolveSlackRoutingContext(params: { const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadTs = threadContext.incomingThreadTs; const isThreadReply = threadContext.isThreadReply; - // Keep true thread replies thread-scoped, but preserve channel-level sessions - // for top-level room turns when replyToMode is off. - // For DMs, preserve existing auto-thread behavior when replyToMode="all". + // Keep true thread replies thread-scoped, while top-level DMs keep their + // stable direct-message session even when reply delivery targets a Slack UI + // thread. const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs ? threadContext.messageTs @@ -115,7 +115,15 @@ export function resolveSlackRoutingContext(params: { ? seedCandidateThreadId : undefined; const roomThreadId = isThreadReply && threadTs ? threadTs : undefined; - const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId; + const canonicalThreadId = isDirectMessage + ? isThreadReply + ? threadTs + : undefined + : isRoomish + ? roomThreadId + : isThreadReply + ? threadTs + : autoThreadId; const routedThreadId = canonicalThreadId ?? (isRoomish ? seededRoomThreadId : undefined); const baseConversationId = resolveSlackBaseConversationId({ message, isDirectMessage }); const boundThreadRoute = routedThreadId diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 70c507a6b40..52c1215458a 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -1073,11 +1073,11 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id"); }); - it("creates thread session for top-level DM when replyToMode=all", async () => { + it("keeps top-level DM session stable when replyToMode=all", async () => { const { storePath } = storeFixture.makeTmpStorePath(); const slackCtx = createInboundSlackCtx({ cfg: { - session: { store: storePath }, + session: { store: storePath, dmScope: "per-channel-peer" }, channels: { slack: { enabled: true, replyToMode: "all" } }, } as OpenClawConfig, replyToMode: "all", @@ -1092,9 +1092,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); 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.SessionKey).toBe("agent:main:slack:direct:u1"); expect(prepared!.ctxPayload.MessageThreadId).toBe("500.000"); }); diff --git a/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts index 0a7f39cb358..fb55c3a6eda 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts @@ -4,10 +4,14 @@ import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import { resolveSlackRoutingContext, type SlackRoutingContextDeps } from "./prepare-routing.js"; -function buildCtx(overrides?: { replyToMode?: "all" | "first" | "off" | "batched" }) { +function buildCtx(overrides?: { + replyToMode?: "all" | "first" | "off" | "batched"; + dmScope?: "main" | "per-sender" | "per-channel-peer"; +}) { const replyToMode = overrides?.replyToMode ?? "all"; return { cfg: { + session: { dmScope: overrides?.dmScope }, channels: { slack: { enabled: true, replyToMode }, }, @@ -321,4 +325,28 @@ describe("thread-level session keys", () => { const sessionKey = routing.sessionKey; expect(sessionKey).not.toContain(":thread:"); }); + + it("keeps top-level DMs on the direct session when replyToMode=all", () => { + const ctx = buildCtx({ replyToMode: "all", dmScope: "per-channel-peer" }); + const account = buildAccount("all"); + + const routing = resolveSlackRoutingContext({ + ctx, + account, + message: { + channel: "D456", + channel_type: "im", + user: "U3", + text: "dm message", + ts: "1770408530.000000", + } as SlackMessageEvent, + isDirectMessage: true, + isGroupDm: false, + isRoom: false, + isRoomish: false, + }); + + expect(routing.sessionKey).toBe("agent:main:slack:direct:u3"); + expect(routing.threadContext.messageThreadId).toBe("1770408530.000000"); + }); });