fix(slack): finalize replyToMode off threading behavior (#23799)

* fix: make replyToMode 'off' actually prevent threading in Slack

Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':

1. Typing indicator created threads via statusThreadTs fallback (#16868)
   - resolveSlackThreadTargets fell back to messageTs for statusThreadTs
   - 'is typing...' was posted as thread reply, creating a thread
   - Fix: remove messageTs fallback, let statusThreadTs be undefined

2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
   - Slack dock had allowExplicitReplyTagsWhenOff: true
   - Reply tags from system prompt always threaded regardless of config
   - Fix: set allowExplicitReplyTagsWhenOff to false for Slack

3. Contradictory replyToMode defaults in codebase (#20827)
   - monitor/provider.ts defaulted to 'all'
   - accounts.ts defaulted to 'off' (matching docs)
   - Fix: align provider.ts default to 'off' per documentation

Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827

* fix(slack): respect replyToMode in DMs even with typing indicator thread

When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.

Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.

Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).

This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity

Fixes #16868

* refactor(slack): eliminate redundant resolveSlackThreadContext call

- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach

* docs(slack): add JSDoc to resolveSlackThreadTargets

Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.

* docs(changelog): record Slack replyToMode off threading fixes

---------

Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
This commit is contained in:
Vincent Koc
2026-02-22 13:27:50 -05:00
committed by GitHub
parent cd7b2814af
commit 89a1e99815
11 changed files with 41 additions and 17 deletions

View File

@@ -99,7 +99,7 @@ Docs: https://docs.openclaw.ai
- Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic.
- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia.
- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2.
- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc.
- Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12.
- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.

View File

@@ -241,7 +241,7 @@ Manual reply tags are supported:
- `[[reply_to_current]]`
- `[[reply_to:<id>]]`
Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. The difference reflects the platform threading models: Slack threads hide messages from the channel, while Telegram replies remain visible in the main chat flow.
## Media, chunking, and delivery

View File

@@ -183,7 +183,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowExplicitReplyTagsWhenOff: true,
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
messaging: {

View File

@@ -206,7 +206,7 @@ describe("applyReplyThreading auto-threading", () => {
expect(result[0].replyToId).toBeUndefined();
});
it("keeps explicit tags for Slack when off mode allows tags", () => {
it("strips explicit tags for Slack when off mode disallows tags", () => {
const result = applyReplyThreading({
payloads: [{ text: "[[reply_to_current]]A" }],
replyToMode: "off",
@@ -215,8 +215,7 @@ describe("applyReplyThreading auto-threading", () => {
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBe("42");
expect(result[0].replyToTag).toBe(true);
expect(result[0].replyToId).toBeUndefined();
});
it("keeps explicit tags for Telegram when off mode is enabled", () => {

View File

@@ -492,7 +492,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowExplicitReplyTagsWhenOff: true,
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},

View File

@@ -300,6 +300,7 @@ describe("monitorSlackProvider tool results", () => {
return { text: "final reply" };
});
setDirectMessageReplyMode("all");
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent(),
});

View File

@@ -86,7 +86,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
}
const { statusThreadTs } = resolveSlackThreadTargets({
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
message,
replyToMode: ctx.replyToMode,
});
@@ -103,6 +103,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
incomingThreadTs,
messageTs,
hasRepliedRef,
chatType: prepared.isDirectMessage ? "direct" : "channel",
isThreadReply,
});
const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;

View File

@@ -121,7 +121,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
const replyToMode = slackCfg.replyToMode ?? "all";
const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);

View File

@@ -88,10 +88,19 @@ function createSlackReplyReferencePlanner(params: {
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasReplied?: boolean;
chatType?: "direct" | "channel" | "group";
isThreadReply?: boolean;
}) {
// When already inside a Slack thread, always stay in it regardless of
// replyToMode — thread_ts is required to keep messages in the thread.
const effectiveMode = params.incomingThreadTs ? "all" : params.replyToMode;
// When already inside a Slack thread, stay in it — but for DMs where the
// "thread" was created by the typing indicator (not a real thread reply),
// respect the user's replyToMode setting.
// See: https://github.com/openclaw/openclaw/issues/16868
const effectiveMode =
params.chatType === "direct" && !params.isThreadReply
? params.replyToMode
: params.incomingThreadTs
? "all"
: params.replyToMode;
return createReplyReferencePlanner({
replyToMode: effectiveMode,
existingId: params.incomingThreadTs,
@@ -105,12 +114,16 @@ export function createSlackReplyDeliveryPlan(params: {
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasRepliedRef: { value: boolean };
chatType?: "direct" | "channel" | "group";
isThreadReply?: boolean;
}): SlackReplyDeliveryPlan {
const replyReference = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasRepliedRef.value,
chatType: params.chatType,
isThreadReply: params.isThreadReply,
});
return {
nextThreadTs: () => replyReference.use(),

View File

@@ -31,7 +31,7 @@ describe("resolveSlackThreadTargets", () => {
expect(statusThreadTs).toBe("123");
});
it("keeps status threading even when reply threading is off", () => {
it("does not thread status indicator when reply threading is off", () => {
const { replyThreadTs, statusThreadTs } = resolveSlackThreadTargets({
replyToMode: "off",
message: {
@@ -42,7 +42,7 @@ describe("resolveSlackThreadTargets", () => {
});
expect(replyThreadTs).toBeUndefined();
expect(statusThreadTs).toBe("123");
expect(statusThreadTs).toBeUndefined();
});
it("sets messageThreadId for top-level messages when replyToMode is all", () => {

View File

@@ -34,12 +34,21 @@ export function resolveSlackThreadContext(params: {
};
}
/**
* Resolves Slack thread targeting for replies and status indicators.
*
* @returns replyThreadTs - Thread timestamp for reply messages
* @returns statusThreadTs - Thread timestamp for status indicators (typing, etc.)
* @returns isThreadReply - true if this is a genuine user reply in a thread,
* false if thread_ts comes from a bot status message (e.g. typing indicator)
*/
export function resolveSlackThreadTargets(params: {
message: SlackMessageEvent | SlackAppMentionEvent;
replyToMode: ReplyToMode;
}) {
const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params);
const ctx = resolveSlackThreadContext(params);
const { incomingThreadTs, messageTs, isThreadReply } = ctx;
const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined);
const statusThreadTs = replyThreadTs ?? messageTs;
return { replyThreadTs, statusThreadTs };
const statusThreadTs = replyThreadTs;
return { replyThreadTs, statusThreadTs, isThreadReply };
}