slack: keep top-level off-mode channel turns in one session

This commit is contained in:
bmendonca3
2026-03-02 14:36:11 -07:00
committed by Peter Steinberger
parent cc18e43832
commit 29342c37b5
2 changed files with 60 additions and 33 deletions

View File

@@ -1,7 +1,6 @@
import type { App } from "@slack/bolt"; import type { App } from "@slack/bolt";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js"; import type { OpenClawConfig } from "../../../config/config.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js"; import type { SlackMessageEvent } from "../../types.js";
import { prepareSlackMessage } from "./prepare.js"; import { prepareSlackMessage } from "./prepare.js";
import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.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>): SlackMessageEvent {
return {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1770408518.451689",
...overrides,
} as SlackMessageEvent;
}
describe("thread-level session keys", () => { describe("thread-level session keys", () => {
it("uses thread-level session key for channel messages", async () => { it("keeps top-level channel turns in one session when replyToMode=off", async () => {
const ctx = buildCtx(); const ctx = buildCtx({ replyToMode: "off" });
ctx.resolveUserName = async () => ({ name: "Alice" }); ctx.resolveUserName = async () => ({ name: "Alice" });
const account = createSlackTestAccount({ replyToMode: "off" });
const message: SlackMessageEvent = { const first = await prepareSlackMessage({
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1770408518.451689",
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx, ctx,
account, account,
message, message: buildChannelMessage({ ts: "1770408518.451689" }),
opts: { source: "message" },
});
const second = await prepareSlackMessage({
ctx,
account,
message: buildChannelMessage({ ts: "1770408520.000001" }),
opts: { source: "message" }, opts: { source: "message" },
}); });
expect(prepared).toBeTruthy(); expect(first).toBeTruthy();
// Channel messages should get thread-level session key with :thread: suffix expect(second).toBeTruthy();
// The resolved session key is in ctxPayload.SessionKey, not route.sessionKey const firstSessionKey = first!.ctxPayload.SessionKey as string;
const sessionKey = prepared!.ctxPayload.SessionKey as string; const secondSessionKey = second!.ctxPayload.SessionKey as string;
expect(sessionKey).toContain(":thread:"); expect(firstSessionKey).toBe(secondSessionKey);
expect(sessionKey).toContain("1770408518.451689"); expect(firstSessionKey).not.toContain(":thread:");
}); });
it("uses parent thread_ts for thread replies", async () => { it("uses parent thread_ts for thread replies even when replyToMode=off", async () => {
const ctx = buildCtx(); const ctx = buildCtx({ replyToMode: "off" });
ctx.resolveUserName = async () => ({ name: "Bob" }); ctx.resolveUserName = async () => ({ name: "Bob" });
const account = createSlackTestAccount({ replyToMode: "off" });
const message: SlackMessageEvent = { const message = buildChannelMessage({
channel: "C123",
channel_type: "channel",
user: "U2", user: "U2",
text: "reply", text: "reply",
ts: "1770408522.168859", ts: "1770408522.168859",
thread_ts: "1770408518.451689", thread_ts: "1770408518.451689",
} as SlackMessageEvent; });
const prepared = await prepareSlackMessage({ const prepared = await prepareSlackMessage({
ctx, ctx,
@@ -77,9 +83,27 @@ describe("thread-level session keys", () => {
expect(sessionKey).not.toContain("1770408522.168859"); expect(sessionKey).not.toContain("1770408522.168859");
}); });
it("does not add thread suffix for DMs", async () => { it("keeps top-level channel turns thread-scoped when replyToMode=all", async () => {
const ctx = buildCtx(); const ctx = buildCtx({ replyToMode: "all" });
ctx.resolveUserName = async () => ({ name: "Carol" }); 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 = { const message: SlackMessageEvent = {
channel: "D456", channel: "D456",

View File

@@ -282,17 +282,20 @@ function resolveSlackRoutingContext(params: {
const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadContext = resolveSlackThreadContext({ message, replyToMode });
const threadTs = threadContext.incomingThreadTs; const threadTs = threadContext.incomingThreadTs;
const isThreadReply = threadContext.isThreadReply; 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". // For DMs, preserve existing auto-thread behavior when replyToMode="all".
const autoThreadId = const autoThreadId =
!isThreadReply && replyToMode === "all" && threadContext.messageTs !isThreadReply && replyToMode === "all" && threadContext.messageTs
? threadContext.messageTs ? threadContext.messageTs
: undefined; : undefined;
const canonicalThreadId = isRoomish const roomThreadId =
? (threadContext.incomingThreadTs ?? message.ts) isThreadReply && threadTs
: isThreadReply
? threadTs ? threadTs
: autoThreadId; : replyToMode === "off"
? undefined
: threadContext.messageTs;
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;
const threadKeys = resolveThreadSessionKeys({ const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey, baseSessionKey: route.sessionKey,
threadId: canonicalThreadId, threadId: canonicalThreadId,