fix: isolate Slack thread sessions (#758)

This commit is contained in:
Peter Steinberger
2026-01-15 08:01:15 +00:00
parent 627cc1f04b
commit c269d5f258
11 changed files with 221 additions and 70 deletions

View File

@@ -202,6 +202,8 @@ const FIELD_LABELS: Record<string, string> = {
"channels.discord.token": "Discord Bot Token",
"channels.slack.botToken": "Slack Bot Token",
"channels.slack.appToken": "Slack App Token",
"channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
"channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path",
"plugins.enabled": "Enable Plugins",
@@ -243,6 +245,10 @@ const FIELD_HELP: Record<string, string> = {
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
"channels.slack.allowBots":
"Allow bot-authored messages to trigger Slack replies (default: false).",
"channels.slack.thread.historyScope":
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
"auth.cooldowns.billingBackoffHours":

View File

@@ -60,6 +60,13 @@ export type SlackSlashCommandConfig = {
ephemeral?: boolean;
};
export type SlackThreadConfig = {
/** Scope for thread history context (thread|channel). Default: thread. */
historyScope?: "thread" | "channel";
/** If true, thread sessions inherit the parent channel transcript. Default: false. */
inheritParent?: boolean;
};
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -101,6 +108,8 @@ export type SlackAccountConfig = {
reactionAllowlist?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/** Thread session behavior. */
thread?: SlackThreadConfig;
actions?: SlackActionConfig;
slashCommand?: SlackSlashCommandConfig;
dm?: SlackDmConfig;

View File

@@ -203,6 +203,11 @@ export const SlackChannelSchema = z.object({
systemPrompt: z.string().optional(),
});
export const SlackThreadSchema = z.object({
historyScope: z.enum(["thread", "channel"]).optional(),
inheritParent: z.boolean().optional(),
});
export const SlackAccountSchema = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
@@ -224,6 +229,7 @@ export const SlackAccountSchema = z.object({
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
thread: SlackThreadSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),

View File

@@ -327,6 +327,81 @@ describe("monitorSlackProvider tool results", () => {
expect(capturedCtx.CommandBody).toBe("second");
});
it("scopes thread history to the thread by default", async () => {
config = {
messages: { ackReactionScope: "group-mentions" },
channels: {
slack: {
historyLimit: 5,
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: false } },
},
},
};
const capturedCtx: Array<{ Body?: string }> = [];
replyMock.mockImplementation(async (ctx) => {
capturedCtx.push(ctx ?? {});
return undefined;
});
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "thread-a-one",
ts: "200",
thread_ts: "100",
channel: "C1",
channel_type: "channel",
},
});
await handler({
event: {
type: "message",
user: "U1",
text: "thread-a-two",
ts: "201",
thread_ts: "100",
channel: "C1",
channel_type: "channel",
},
});
await handler({
event: {
type: "message",
user: "U2",
text: "thread-b-one",
ts: "301",
thread_ts: "300",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(3);
expect(capturedCtx[1]?.Body).toContain("thread-a-one");
expect(capturedCtx[2]?.Body).not.toContain("thread-a-one");
expect(capturedCtx[2]?.Body).not.toContain("thread-a-two");
});
it("updates assistant thread status when replies start", async () => {
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onReplyStart?.();

View File

@@ -202,10 +202,60 @@ describe("monitorSlackProvider tool results", () => {
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("agent:main:main:thread:123");
expect(ctx.ParentSessionKey).toBe("agent:main:main");
expect(ctx.ParentSessionKey).toBeUndefined();
});
it("forks thread sessions and injects starter context", async () => {
it("keeps thread parent inheritance opt-in", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });
config = {
messages: { responsePrefix: "PFX" },
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: false } },
thread: { inheritParent: true },
},
},
};
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "hello",
ts: "123",
thread_ts: "111.222",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
const ctx = replyMock.mock.calls[0]?.[0] as {
SessionKey?: string;
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1");
});
it("injects starter context for thread replies", async () => {
replyMock.mockResolvedValue({ text: "ok" });
const client = getSlackClient();
@@ -265,7 +315,7 @@ describe("monitorSlackProvider tool results", () => {
ThreadLabel?: string;
};
expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1");
expect(ctx.ParentSessionKey).toBeUndefined();
expect(ctx.ThreadStarterBody).toContain("starter message");
expect(ctx.ThreadLabel).toContain("Slack thread #general");
});
@@ -329,7 +379,7 @@ describe("monitorSlackProvider tool results", () => {
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("agent:support:slack:channel:C1:thread:111.222");
expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1");
expect(ctx.ParentSessionKey).toBeUndefined();
});
it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => {

View File

@@ -74,6 +74,8 @@ export type SlackMonitorContext = {
reactionMode: SlackReactionNotificationMode;
reactionAllowlist: Array<string | number>;
replyToMode: "off" | "first" | "all";
threadHistoryScope: "thread" | "channel";
threadInheritParent: boolean;
slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>;
textLimit: number;
ackReactionScope: string;
@@ -133,6 +135,8 @@ export function createSlackMonitorContext(params: {
reactionMode: SlackReactionNotificationMode;
reactionAllowlist: Array<string | number>;
replyToMode: SlackMonitorContext["replyToMode"];
threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
threadInheritParent: SlackMonitorContext["threadInheritParent"];
slashCommand: SlackMonitorContext["slashCommand"];
textLimit: number;
ackReactionScope: string;
@@ -363,6 +367,8 @@ export function createSlackMonitorContext(params: {
reactionMode: params.reactionMode,
reactionAllowlist: params.reactionAllowlist,
replyToMode: params.replyToMode,
threadHistoryScope: params.threadHistoryScope,
threadInheritParent: params.threadInheritParent,
slashCommand: params.slashCommand,
textLimit: params.textLimit,
ackReactionScope: params.ackReactionScope,

View File

@@ -263,7 +263,6 @@ export async function prepareSlackMessage(params: {
: null;
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const historyKey = message.channel;
const historyEntry =
isRoomish && ctx.historyLimit > 0
? {
@@ -291,9 +290,11 @@ export async function prepareSlackMessage(params: {
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: isThreadReply ? threadTs : undefined,
parentSessionKey: isThreadReply ? baseSessionKey : undefined,
parentSessionKey: isThreadReply && ctx.threadInheritParent ? baseSessionKey : undefined,
});
const sessionKey = threadKeys.sessionKey;
const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,

View File

@@ -74,6 +74,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@@ -129,6 +131,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
reactionMode,
reactionAllowlist,
replyToMode,
threadHistoryScope,
threadInheritParent,
slashCommand,
textLimit,
ackReactionScope,