From 29342c37b5858a91e5807b8f76f8a18a4cf2c3e5 Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Mon, 2 Mar 2026 14:36:11 -0700 Subject: [PATCH] slack: keep top-level off-mode channel turns in one session --- .../prepare.thread-session-key.test.ts | 80 ++++++++++++------- src/slack/monitor/message-handler/prepare.ts | 13 +-- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts b/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts index 5383311301d..928b80f151a 100644 --- a/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts +++ b/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts @@ -1,7 +1,6 @@ import type { App } from "@slack/bolt"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import { prepareSlackMessage } from "./prepare.js"; import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js"; @@ -20,48 +19,55 @@ function buildCtx(overrides?: { replyToMode?: "all" | "first" | "off" }) { }); } -const account: ResolvedSlackAccount = createSlackTestAccount(); +function buildChannelMessage(overrides?: Partial): SlackMessageEvent { + return { + channel: "C123", + channel_type: "channel", + user: "U1", + text: "hello", + ts: "1770408518.451689", + ...overrides, + } as SlackMessageEvent; +} describe("thread-level session keys", () => { - it("uses thread-level session key for channel messages", async () => { - const ctx = buildCtx(); + it("keeps top-level channel turns in one session when replyToMode=off", async () => { + const ctx = buildCtx({ replyToMode: "off" }); ctx.resolveUserName = async () => ({ name: "Alice" }); + const account = createSlackTestAccount({ replyToMode: "off" }); - const message: SlackMessageEvent = { - channel: "C123", - channel_type: "channel", - user: "U1", - text: "hello", - ts: "1770408518.451689", - } as SlackMessageEvent; - - const prepared = await prepareSlackMessage({ + const first = await prepareSlackMessage({ ctx, account, - message, + message: buildChannelMessage({ ts: "1770408518.451689" }), + opts: { source: "message" }, + }); + const second = await prepareSlackMessage({ + ctx, + account, + message: buildChannelMessage({ ts: "1770408520.000001" }), opts: { source: "message" }, }); - expect(prepared).toBeTruthy(); - // Channel messages should get thread-level session key with :thread: suffix - // The resolved session key is in ctxPayload.SessionKey, not route.sessionKey - const sessionKey = prepared!.ctxPayload.SessionKey as string; - expect(sessionKey).toContain(":thread:"); - expect(sessionKey).toContain("1770408518.451689"); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + const firstSessionKey = first!.ctxPayload.SessionKey as string; + const secondSessionKey = second!.ctxPayload.SessionKey as string; + expect(firstSessionKey).toBe(secondSessionKey); + expect(firstSessionKey).not.toContain(":thread:"); }); - it("uses parent thread_ts for thread replies", async () => { - const ctx = buildCtx(); + it("uses parent thread_ts for thread replies even when replyToMode=off", async () => { + const ctx = buildCtx({ replyToMode: "off" }); ctx.resolveUserName = async () => ({ name: "Bob" }); + const account = createSlackTestAccount({ replyToMode: "off" }); - const message: SlackMessageEvent = { - channel: "C123", - channel_type: "channel", + const message = buildChannelMessage({ user: "U2", text: "reply", ts: "1770408522.168859", thread_ts: "1770408518.451689", - } as SlackMessageEvent; + }); const prepared = await prepareSlackMessage({ ctx, @@ -77,9 +83,27 @@ describe("thread-level session keys", () => { expect(sessionKey).not.toContain("1770408522.168859"); }); - it("does not add thread suffix for DMs", async () => { - const ctx = buildCtx(); + it("keeps top-level channel turns thread-scoped when replyToMode=all", async () => { + const ctx = buildCtx({ replyToMode: "all" }); ctx.resolveUserName = async () => ({ name: "Carol" }); + const account = createSlackTestAccount({ replyToMode: "all" }); + + const prepared = await prepareSlackMessage({ + ctx, + account, + message: buildChannelMessage({ ts: "1770408530.000000" }), + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + const sessionKey = prepared!.ctxPayload.SessionKey as string; + expect(sessionKey).toContain(":thread:1770408530.000000"); + }); + + it("does not add thread suffix for DMs when replyToMode=off", async () => { + const ctx = buildCtx({ replyToMode: "off" }); + ctx.resolveUserName = async () => ({ name: "Carol" }); + const account = createSlackTestAccount({ replyToMode: "off" }); const message: SlackMessageEvent = { channel: "D456", diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index eeb5787c5e5..0e4dd7bea13 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -282,17 +282,20 @@ function resolveSlackRoutingContext(params: { const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadTs = threadContext.incomingThreadTs; const isThreadReply = threadContext.isThreadReply; - // Keep channel/group sessions thread-scoped to avoid cross-thread context bleed. + // 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". const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs ? threadContext.messageTs : undefined; - const canonicalThreadId = isRoomish - ? (threadContext.incomingThreadTs ?? message.ts) - : isThreadReply + const roomThreadId = + isThreadReply && threadTs ? threadTs - : autoThreadId; + : replyToMode === "off" + ? undefined + : threadContext.messageTs; + const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId; const threadKeys = resolveThreadSessionKeys({ baseSessionKey: route.sessionKey, threadId: canonicalThreadId,