fix: isolate Slack thread sessions (#758)
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user