refactor: unify threading contexts

This commit is contained in:
Peter Steinberger
2026-01-21 20:01:12 +00:00
parent 76600e80ba
commit 45c1ccdfcf
15 changed files with 452 additions and 481 deletions

View File

@@ -20,6 +20,7 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackGroupRequireMention,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
SlackConfigSchema,
@@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
return {
currentChannelId: context.To?.startsWith("channel:")
? context.To.slice("channel:".length)
: undefined,
currentThreadTs: context.ReplyToId,
replyToMode: effectiveReplyToMode,
hasRepliedRef,
};
},
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,

View File

@@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => {
expect(result.currentChannelId).toBe("chat_id:7");
});
it("prefers MessageThreadId for Slack tool threading", () => {
const sessionCtx = {
Provider: "slack",
To: "channel:C1",
MessageThreadId: "123.456",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: { channels: { slack: { replyToMode: "all" } } } as ClawdbotConfig,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("C1");
expect(result.currentThreadTs).toBe("123.456");
});
});

View File

@@ -24,18 +24,11 @@ export function buildThreadingToolContext(params: {
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) return {};
const provider = normalizeChannelId(rawProvider);
// WhatsApp context isolation keys off conversation id, not the bot's own number.
const threadingTo =
rawProvider === "whatsapp"
? (sessionCtx.From ?? sessionCtx.To)
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
? (sessionCtx.From ?? sessionCtx.To)
: sessionCtx.To;
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
return {
currentChannelId: threadingTo?.trim() || undefined,
currentChannelId: sessionCtx.To?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
hasRepliedRef,
};
@@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: {
accountId: sessionCtx.AccountId,
context: {
Channel: sessionCtx.Provider,
To: threadingTo,
From: sessionCtx.From,
To: sessionCtx.To,
ChatType: sessionCtx.ChatType,
ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel,
MessageThreadId: sessionCtx.MessageThreadId,

View File

@@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
@@ -150,11 +151,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const channelId = context.From?.trim() || context.To?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
discord: {
@@ -221,18 +225,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
return {
currentChannelId: context.To?.startsWith("channel:")
? context.To.slice("channel:".length)
: undefined,
currentThreadTs: context.ReplyToId,
replyToMode: effectiveReplyToMode,
hasRepliedRef,
};
},
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},
signal: {
@@ -259,11 +252,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.filter(Boolean),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const isDirect = context.ChatType?.toLowerCase() === "direct";
const channelId =
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
imessage: {
@@ -286,11 +284,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const isDirect = context.ChatType?.toLowerCase() === "direct";
const channelId =
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
};

View File

@@ -210,7 +210,9 @@ export type ChannelThreadingAdapter = {
export type ChannelThreadingContext = {
Channel?: string;
From?: string;
To?: string;
ChatType?: string;
ReplyToId?: string;
ReplyToIdFull?: string;
ThreadLabel?: string;

View File

@@ -235,6 +235,7 @@ export {
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
} from "../channels/plugins/normalize/slack.js";
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
// Channel: Telegram
export {

View File

@@ -0,0 +1,145 @@
import { vi } from "vitest";
type SlackHandler = (args: unknown) => Promise<void>;
const slackTestState = vi.hoisted(() => ({
config: {} as Record<string, unknown>,
sendMock: vi.fn(),
replyMock: vi.fn(),
updateLastRouteMock: vi.fn(),
reactMock: vi.fn(),
readAllowFromStoreMock: vi.fn(),
upsertPairingRequestMock: vi.fn(),
}));
export const getSlackTestState = () => slackTestState;
export const getSlackHandlers = () =>
(
globalThis as {
__slackHandlers?: Map<string, SlackHandler>;
}
).__slackHandlers;
export const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
export async function waitForSlackEvent(name: string) {
for (let i = 0; i < 10; i += 1) {
if (getSlackHandlers()?.has(name)) return;
await flush();
}
}
export const defaultSlackTestConfig = () => ({
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
});
export function resetSlackTestState(config: Record<string, unknown> = defaultSlackTestConfig()) {
slackTestState.config = config;
slackTestState.sendMock.mockReset().mockResolvedValue(undefined);
slackTestState.replyMock.mockReset();
slackTestState.updateLastRouteMock.mockReset();
slackTestState.reactMock.mockReset();
slackTestState.readAllowFromStoreMock.mockReset().mockResolvedValue([]);
slackTestState.upsertPairingRequestMock.mockReset().mockResolvedValue({
code: "PAIRCODE",
created: true,
});
getSlackHandlers()?.clear();
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => slackTestState.config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => slackTestState.replyMock(...args),
}));
vi.mock("./resolve-channels.js", () => ({
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./resolve-users.js", () => ({
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) =>
slackTestState.upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@slack/bolt", () => {
const handlers = new Map<string, SlackHandler>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
const client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
assistant: {
threads: {
setStatus: vi.fn().mockResolvedValue({ ok: true }),
},
},
reactions: {
add: (...args: unknown[]) => slackTestState.reactMock(...args),
},
};
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
class App {
client = client;
event(name: string, handler: SlackHandler) {
handlers.set(name, handler);
}
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}
class HTTPReceiver {
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
});

View File

@@ -1,143 +1,29 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import {
defaultSlackTestConfig,
flush,
getSlackClient,
getSlackHandlers,
getSlackTestState,
resetSlackTestState,
waitForSlackEvent,
} from "./monitor.test-helpers.js";
import { monitorSlackProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const getSlackHandlers = () =>
(
globalThis as {
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
}
).__slackHandlers;
const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./resolve-channels.js", () => ({
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./resolve-users.js", () => ({
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@slack/bolt", () => {
const handlers = new Map<string, (args: unknown) => Promise<void>>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
const client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
assistant: {
threads: {
setStatus: vi.fn().mockResolvedValue({ ok: true }),
},
},
reactions: {
add: (...args: unknown[]) => reactMock(...args),
},
};
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
class App {
client = client;
event(name: string, handler: (args: unknown) => Promise<void>) {
handlers.set(name, handler);
}
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}
class HTTPReceiver {
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForEvent(name: string) {
for (let i = 0; i < 10; i += 1) {
if (getSlackHandlers()?.has(name)) return;
await flush();
}
}
const slackTestState = getSlackTestState();
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
beforeEach(() => {
resetInboundDedupe();
getSlackHandlers()?.clear();
config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
reactMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
resetSlackTestState(defaultSlackTestConfig());
});
describe("monitorSlackProvider tool results", () => {
it("forces thread replies when replyToId is set", async () => {
replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" });
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -199,7 +85,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -226,12 +112,12 @@ describe("monitorSlackProvider tool results", () => {
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
config = {
...config,
slackTestState.config = {
...slackTestState.config,
channels: {
...config.channels,
...slackTestState.config.channels,
slack: {
...config.channels?.slack,
...slackTestState.config.channels?.slack,
dm: { enabled: true, policy: "pairing", allowFrom: [] },
},
},
@@ -244,7 +130,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -271,12 +157,12 @@ describe("monitorSlackProvider tool results", () => {
});
it("does not resend pairing code when a request is already pending", async () => {
config = {
...config,
slackTestState.config = {
...slackTestState.config,
channels: {
...config.channels,
...slackTestState.config.channels,
slack: {
...config.channels?.slack,
...slackTestState.config.channels?.slack,
dm: { enabled: true, policy: "pairing", allowFrom: [] },
},
},
@@ -292,7 +178,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");

View File

@@ -3,137 +3,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import {
defaultSlackTestConfig,
flush,
getSlackTestState,
getSlackClient,
getSlackHandlers,
resetSlackTestState,
waitForSlackEvent,
} from "./monitor.test-helpers.js";
import { monitorSlackProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const getSlackHandlers = () =>
(
globalThis as {
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
}
).__slackHandlers;
const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./resolve-channels.js", () => ({
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./resolve-users.js", () => ({
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@slack/bolt", () => {
const handlers = new Map<string, (args: unknown) => Promise<void>>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
const client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
assistant: {
threads: {
setStatus: vi.fn().mockResolvedValue({ ok: true }),
},
},
reactions: {
add: (...args: unknown[]) => reactMock(...args),
},
};
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
class App {
client = client;
event(name: string, handler: (args: unknown) => Promise<void>) {
handlers.set(name, handler);
}
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}
class HTTPReceiver {
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForEvent(name: string) {
for (let i = 0; i < 10; i += 1) {
if (getSlackHandlers()?.has(name)) return;
await flush();
}
}
const slackTestState = getSlackTestState();
const { sendMock, replyMock } = slackTestState;
beforeEach(() => {
resetInboundDedupe();
getSlackHandlers()?.clear();
config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
reactMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
resetSlackTestState(defaultSlackTestConfig());
});
describe("monitorSlackProvider tool results", () => {
@@ -150,7 +36,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -190,7 +76,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -215,7 +101,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("does not derive responsePrefix from routed agent identity when unset", async () => {
config = {
slackTestState.config = {
agents: {
list: [
{
@@ -256,7 +142,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -281,7 +167,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("preserves RawBody without injecting processed room history", async () => {
config = {
slackTestState.config = {
messages: { ackReactionScope: "group-mentions" },
channels: {
slack: {
@@ -305,7 +191,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -344,7 +230,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("scopes thread history to the thread by default", async () => {
config = {
slackTestState.config = {
messages: { ackReactionScope: "group-mentions" },
channels: {
slack: {
@@ -368,7 +254,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -431,7 +317,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -470,7 +356,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("accepts channel messages when mentionPatterns match", async () => {
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
@@ -491,7 +377,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -515,7 +401,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("treats replies to bot threads as implicit mentions", async () => {
config = {
slackTestState.config = {
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
@@ -532,7 +418,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -558,7 +444,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
config = {
slackTestState.config = {
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
@@ -576,7 +462,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -610,7 +496,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -635,7 +521,7 @@ describe("monitorSlackProvider tool results", () => {
it("threads replies when incoming message is in a thread", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
@@ -656,7 +542,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");

View File

@@ -1,143 +1,29 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import {
defaultSlackTestConfig,
flush,
getSlackClient,
getSlackHandlers,
getSlackTestState,
resetSlackTestState,
waitForSlackEvent,
} from "./monitor.test-helpers.js";
import { monitorSlackProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const getSlackHandlers = () =>
(
globalThis as {
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
}
).__slackHandlers;
const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./resolve-channels.js", () => ({
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./resolve-users.js", () => ({
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@slack/bolt", () => {
const handlers = new Map<string, (args: unknown) => Promise<void>>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
const client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
assistant: {
threads: {
setStatus: vi.fn().mockResolvedValue({ ok: true }),
},
},
reactions: {
add: (...args: unknown[]) => reactMock(...args),
},
};
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
class App {
client = client;
event(name: string, handler: (args: unknown) => Promise<void>) {
handlers.set(name, handler);
}
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}
class HTTPReceiver {
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForEvent(name: string) {
for (let i = 0; i < 10; i += 1) {
if (getSlackHandlers()?.has(name)) return;
await flush();
}
}
const slackTestState = getSlackTestState();
const { sendMock, replyMock } = slackTestState;
beforeEach(() => {
resetInboundDedupe();
getSlackHandlers()?.clear();
config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
reactMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
resetSlackTestState(defaultSlackTestConfig());
});
describe("monitorSlackProvider tool results", () => {
it("threads top-level replies when replyToMode is all", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -191,7 +77,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -224,7 +110,7 @@ describe("monitorSlackProvider tool results", () => {
it("keeps thread parent inheritance opt-in", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });
config = {
slackTestState.config = {
messages: { responsePrefix: "PFX" },
channels: {
slack: {
@@ -242,7 +128,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -286,7 +172,7 @@ describe("monitorSlackProvider tool results", () => {
});
}
config = {
slackTestState.config = {
messages: { responsePrefix: "PFX" },
channels: {
slack: {
@@ -303,7 +189,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -338,7 +224,7 @@ describe("monitorSlackProvider tool results", () => {
it("scopes thread session keys to the routed agent", async () => {
replyMock.mockResolvedValue({ text: "ok" });
config = {
slackTestState.config = {
messages: { responsePrefix: "PFX" },
channels: {
slack: {
@@ -369,7 +255,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -400,7 +286,7 @@ describe("monitorSlackProvider tool results", () => {
it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => {
replyMock.mockResolvedValue({ text: "root reply" });
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
@@ -421,7 +307,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
@@ -446,7 +332,7 @@ describe("monitorSlackProvider tool results", () => {
it("threads first reply when replyToMode is first and message is not threaded", async () => {
replyMock.mockResolvedValue({ text: "first reply" });
config = {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
@@ -467,7 +353,7 @@ describe("monitorSlackProvider tool results", () => {
abortSignal: controller.signal,
});
await waitForEvent("message");
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");

View File

@@ -77,4 +77,72 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared).toBeTruthy();
expectInboundContextContract(prepared!.ctxPayload as any);
});
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
const slackCtx = createSlackMonitorContext({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all" } },
} as ClawdbotConfig,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "all",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "clawd",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "group-mentions",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
const account: ResolvedSlackAccount = {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
config: { replyToMode: "all" },
};
const message: SlackMessageEvent = {
channel: "D123",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1.000",
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx: slackCtx,
account,
message,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
});
});

View File

@@ -33,6 +33,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { sendMessageSlack } from "../../send.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackThreadContext } from "../../threading.js";
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
@@ -188,9 +189,9 @@ export async function prepareSlackMessage(params: {
});
const baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
const isThreadReply = hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
const threadTs = threadContext.incomingThreadTs;
const isThreadReply = threadContext.isThreadReply;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: isThreadReply ? threadTs : undefined,
@@ -474,9 +475,9 @@ export async function prepareSlackMessage(params: {
Provider: "slack" as const,
Surface: "slack" as const,
MessageSid: message.ts,
ReplyToId: message.thread_ts ?? message.ts,
// Preserve thread context for routed tool notifications (thread replies only).
MessageThreadId: isThreadReply ? threadTs : undefined,
ReplyToId: threadContext.replyToId,
// Preserve thread context for routed tool notifications.
MessageThreadId: threadContext.messageThreadId,
ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,

View File

@@ -0,0 +1,29 @@
import type {
ChannelThreadingContext,
ChannelThreadingToolContext,
} from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSlackAccount } from "./accounts.js";
export function buildSlackThreadingToolContext(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext {
const configuredReplyToMode =
resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
}).replyToMode ?? "off";
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
return {
currentChannelId: params.context.To?.startsWith("channel:")
? params.context.To.slice("channel:".length)
: undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
replyToMode: effectiveReplyToMode,
hasRepliedRef: params.hasRepliedRef,
};
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveSlackThreadTargets } from "./threading.js";
import { resolveSlackThreadContext, resolveSlackThreadTargets } from "./threading.js";
describe("resolveSlackThreadTargets", () => {
it("threads replies when message is already threaded", () => {
@@ -45,4 +45,35 @@ describe("resolveSlackThreadTargets", () => {
expect(replyThreadTs).toBeUndefined();
expect(statusThreadTs).toBe("123");
});
it("sets messageThreadId for top-level messages when replyToMode is all", () => {
const context = resolveSlackThreadContext({
replyToMode: "all",
message: {
type: "message",
channel: "C1",
ts: "123",
},
});
expect(context.isThreadReply).toBe(false);
expect(context.messageThreadId).toBe("123");
expect(context.replyToId).toBe("123");
});
it("prefers thread_ts as messageThreadId for replies", () => {
const context = resolveSlackThreadContext({
replyToMode: "off",
message: {
type: "message",
channel: "C1",
ts: "123",
thread_ts: "456",
},
});
expect(context.isThreadReply).toBe(true);
expect(context.messageThreadId).toBe("456");
expect(context.replyToId).toBe("456");
});
});

View File

@@ -1,13 +1,44 @@
import type { ReplyToMode } from "../config/types.js";
import type { SlackAppMentionEvent, SlackMessageEvent } from "./types.js";
export type SlackThreadContext = {
incomingThreadTs?: string;
messageTs?: string;
isThreadReply: boolean;
replyToId?: string;
messageThreadId?: string;
};
export function resolveSlackThreadContext(params: {
message: SlackMessageEvent | SlackAppMentionEvent;
replyToMode: ReplyToMode;
}): SlackThreadContext {
const incomingThreadTs = params.message.thread_ts;
const eventTs = params.message.event_ts;
const messageTs = params.message.ts ?? eventTs;
const hasThreadTs = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0;
const isThreadReply =
hasThreadTs && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
const replyToId = incomingThreadTs ?? messageTs;
const messageThreadId = isThreadReply
? incomingThreadTs
: params.replyToMode === "all"
? messageTs
: undefined;
return {
incomingThreadTs,
messageTs,
isThreadReply,
replyToId,
messageThreadId,
};
}
export function resolveSlackThreadTargets(params: {
message: SlackMessageEvent | SlackAppMentionEvent;
replyToMode: ReplyToMode;
}) {
const incomingThreadTs = params.message.thread_ts;
const eventTs = params.message.event_ts;
const messageTs = params.message.ts ?? eventTs;
const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params);
const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined);
const statusThreadTs = replyThreadTs ?? messageTs;
return { replyThreadTs, statusThreadTs };