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

@@ -10,13 +10,11 @@
### Changes
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Usage: add MiniMax coding plan usage tracking.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Voice Call: add Plivo provider for the voice-call plugin. (#846) — thanks @vrknetha.
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist.
@@ -30,78 +28,61 @@
- CLI/Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
- Browser: copy the installed Chrome extension path to clipboard after `clawdbot browser extension install/path`.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
### Fixes
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14
### Changes
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
#### Agents
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
#### Browser
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- UI: move Docs link into the left navigation menu.
#### macOS
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
#### Messaging / Channels
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: honor SenderE164 for owner command auth when SenderId is LID; let owner control commands bypass group mention gating.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
#### Gateway / Daemon / Sessions
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
#### Tools
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
#### Auth / Models
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### Config / Doctor
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
#### Packaging / Auto-reply
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Sandbox
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
## 2026.1.13

View File

@@ -269,6 +269,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).

View File

@@ -1050,6 +1050,10 @@ Slack runs in Socket Mode and requires both a bot token and app token:
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["U123"],
replyToMode: "off", // off | first | all
thread: {
historyScope: "thread", // thread | channel
inheritParent: false
},
actions: {
reactions: true,
messages: true,
@@ -1083,6 +1087,10 @@ Reaction notification modes:
- `all`: all reactions on all messages.
- `allowlist`: reactions from `channels.slack.reactionAllowlist` on all messages (empty list disables).
Thread session isolation:
- `channels.slack.thread.historyScope` controls whether thread history is per-thread (`thread`, default) or shared across the channel (`channel`).
- `channels.slack.thread.inheritParent` controls whether new thread sessions inherit the parent channel transcript (default: false).
Slack action groups (gate `slack` tool actions):
| Action group | Default | Notes |
| --- | --- | --- |

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,