Files
openclaw/extensions/slack/src/threading-tool-context.test.ts
Kagura ffdc7aa7a6 fix(slack): route DM thread replies to main session instead of thread-scoped session (#82418)
* fix(slack): route DM thread replies to main session instead of thread-scoped session

DM thread replies (user replies inside a thread under a bot message in a
DM) were routed to a thread-specific session key instead of the user's
main DM session.  This caused the agent to never receive the inbound on
the expected session, making the bot appear unresponsive.

The root cause was in prepare-routing.ts: canonicalThreadId for
isDirectMessage was set to threadTs when isThreadReply was true, creating
a session key like agent:main:slack:direct:u3🧵<ts>.  DM threads
are a UI affordance — not a session boundary — so all DM messages should
route to the main DM session regardless of thread_ts.

Also adds a diagnostic logVerbose warning when assistant_app_thread
message_changed events fail sender resolution (Case 2 of #82390),
which was previously completely silent.

Fixes #82390

* chore(slack): polish DM thread routing PR

* test(slack): update DM thread routing contract

* test(slack): flatten non-main DM thread expectations

* fix(slack): preserve bound DM thread routes

* test(slack): align DM thread session fixtures

* fix(slack): keep flattened DM thread metadata scoped

* fix(slack): preserve DM thread delivery routes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-16 16:24:36 +01:00

269 lines
7.2 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const emptyCfg = {} as OpenClawConfig;
function resolveReplyToModeWithConfig(params: {
slackConfig: Record<string, unknown>;
context: Record<string, unknown>;
}) {
const cfg = {
channels: {
slack: params.slackConfig,
},
} as OpenClawConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: params.context as never,
});
return result.replyToMode;
}
describe("buildSlackThreadingToolContext", () => {
it("uses top-level replyToMode by default", () => {
const cfg = {
channels: {
slack: { replyToMode: "first" },
},
} as OpenClawConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel" },
});
expect(result.replyToMode).toBe("first");
});
it("uses chat-type replyToMode overrides for direct messages when configured", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" },
},
context: { ChatType: "direct" },
}),
).toBe("all");
});
it("uses top-level replyToMode for channels when no channel override is set", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" },
},
context: { ChatType: "channel" },
}),
).toBe("off");
});
it("falls back to top-level when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} as OpenClawConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("first");
});
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
context: { ChatType: "direct" },
}),
).toBe("all");
});
it("uses all mode when MessageThreadId is present", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
context: {
ChatType: "direct",
ThreadLabel: "thread-label",
MessageThreadId: "1771999998.834199",
},
}),
).toBe("all");
});
it("does not force all mode from ThreadLabel alone", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
context: {
ChatType: "direct",
ThreadLabel: "label-without-real-thread",
},
}),
).toBe("off");
});
it("uses ReplyToId as the current thread when MessageThreadId is omitted", () => {
const result = buildSlackThreadingToolContext({
cfg: {
channels: {
slack: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
},
} as OpenClawConfig,
accountId: null,
context: {
ChatType: "direct",
To: "user:U8SUVSVGS",
NativeChannelId: "D8SRXRDNF",
CurrentMessageId: "1772000000.111111",
ReplyToId: "1771999998.834199",
},
});
expect(result.currentThreadTs).toBe("1771999998.834199");
expect(result.replyToMode).toBe("all");
});
it("uses TransportThreadId when ReplyToId matches the current message", () => {
const result = buildSlackThreadingToolContext({
cfg: {
channels: {
slack: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
},
} as OpenClawConfig,
accountId: null,
context: {
ChatType: "direct",
CurrentMessageId: "1771999998.834199",
ReplyToId: "1771999998.834199",
TransportThreadId: "1771999998.834199",
},
});
expect(result.currentThreadTs).toBe("1771999998.834199");
expect(result.replyToMode).toBe("all");
});
it("keeps top-level ReplyToId as an anchor without forcing configured off mode", () => {
const result = buildSlackThreadingToolContext({
cfg: {
channels: {
slack: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
},
} as OpenClawConfig,
accountId: null,
context: {
ChatType: "direct",
CurrentMessageId: "1771999998.834199",
ReplyToId: "1771999998.834199",
},
});
expect(result.currentThreadTs).toBe("1771999998.834199");
expect(result.replyToMode).toBe("off");
});
it("keeps top-level ReplyToId as the first-reply anchor for single-use modes", () => {
const result = buildSlackThreadingToolContext({
cfg: {
channels: {
slack: {
replyToMode: "first",
},
},
} as OpenClawConfig,
accountId: null,
context: {
ChatType: "direct",
CurrentMessageId: "1771999998.834199",
ReplyToId: "1771999998.834199",
},
});
expect(result.currentThreadTs).toBe("1771999998.834199");
expect(result.replyToMode).toBe("first");
});
it("keeps configured channel behavior when not in a thread", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { channel: "first" },
},
},
} as OpenClawConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel", ThreadLabel: "label-only" },
});
expect(result.replyToMode).toBe("first");
});
it("defaults to off when no replyToMode is configured", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("off");
});
it("extracts currentChannelId from channel: prefixed To", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: { ChatType: "channel", To: "channel:C1234ABC" },
});
expect(result.currentChannelId).toBe("C1234ABC");
});
it("uses NativeChannelId for DM when To is user-prefixed", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: {
ChatType: "direct",
To: "user:U8SUVSVGS",
NativeChannelId: "D8SRXRDNF",
},
});
expect(result.currentChannelId).toBe("D8SRXRDNF");
});
it("returns undefined currentChannelId when neither channel: To nor NativeChannelId is set", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: { ChatType: "direct", To: "user:U8SUVSVGS" },
});
expect(result.currentChannelId).toBeUndefined();
});
});