openclaw - ✅(Solved) Fix Slack: top-level channel messages with replyToMode="all" cause dual-session processing [1 pull requests, 1 comments, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#64078Fetched 2026-04-11 06:16:26
View on GitHub
Comments
1
Participants
1
Timeline
3
Reactions
0
Participants
Timeline (top)
commented ×1cross-referenced ×1referenced ×1

Root Cause

4. Root Cause (Source Code Analysis)

Fix Action

Fix / Workaround

8. Workaround

No clean workaround exists. Options:

PR fix notes

PR #64080: Slack: unify auto-thread routing for channel top-level messages under replyToMode=all

Description (problem / solution / changelog)

Problem

When replyToMode="all" is enabled on a Slack channel, each top-level message triggers two independent LLM processing paths:

  1. A parent channel session (via the base session key agent:...:slack:channel:<channelId>)
  2. A thread-scoped session (via messageThreadId = messageTs used for reply delivery)

This causes:

  • Duplicate LLM work — every message processed twice
  • Inflated costs — observed $18.46 wasted on a single parent session accumulating 82 duplicate responses
  • Cross-sender context bleed — unrelated senders' conversations mix in the shared parent session
  • Privacy issues — messages from different users visible in shared context

Root Cause

In resolveSlackRoutingContext(), the canonicalThreadId computation has an isRoomish branch that discards autoThreadId for room/channel messages:

// Before (buggy):
const roomThreadId = isThreadReply && threadTs ? threadTs : undefined;
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;

For top-level channel messages: isThreadReply is false, so roomThreadId = undefined, and the session key falls back to the shared parent channel session. Meanwhile, messageThreadId is correctly set to messageTs for reply delivery — creating the split-brain routing.

DMs did not have this bug because they used the autoThreadId path.

Fix

Remove the room/DM asymmetry. Use autoThreadId for all conversation types:

// After (fixed):
const canonicalThreadId =
  isThreadReply && threadTs ? threadTs : autoThreadId;

This ensures the session key (baseKey:thread:<messageTs>) matches the messageThreadId used for reply delivery, eliminating the dual-processing path.

Behavior Changes

ScenarioBeforeAfter
Top-level channel msg, replyToMode=allParent channel session + thread session (dual)Thread session only (:thread:<ts>)
Top-level channel msg, replyToMode=offParent channel sessionParent channel session (unchanged)
Thread replyThread session (:thread:<parent_ts>)Thread session (unchanged)
DMsThread session (:thread:<ts>)Thread session (unchanged)

Tests

  • Updated existing test: removed all from the "keeps top-level on per-channel session" loop (now only off/first)
  • Added 5 new test cases:
    1. Each top-level channel message gets its own thread session with replyToMode=all
    2. SessionKey and MessageThreadId are aligned
    3. Thread replies route to parent thread session
    4. Distinct senders get separate sessions
    5. replyToMode=off/first behavior preserved

Fixes #64078

Changed files

  • extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts (modified, +99/-2)
  • extensions/slack/src/monitor/message-handler/prepare.ts (modified, +15/-10)

Code Example

{
  "channels": {
    "slack": {
      "replyToMode": "all",
      "channels": {
        "C0ANBM0SJKF": {
          "allow": true,
          "requireMention": false
        }
      }
    }
  }
}

---

"agent:main:slack:channel:c0anbm0sjkf:thread:1775660310.007009" → sessionId: "3da7b8e2-..."
"agent:main:slack:channel:c0anbm0sjkf:thread:1775679025.746549" → sessionId: "805dc0f3-..."
"agent:main:slack:channel:c0anbm0sjkf:thread:1775689714.554029" → sessionId: "df14ba11-..."
... (one per message)

---

"agent:main:slack:channel:c0anbm0sjkf" → sessionId: "7f7593bb-..."

---

function resolveSlackRoutingContext(params) {
    const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
    
    // ... route resolution ...
    
    const replyToMode = resolveSlackReplyToMode(account, chatType);  // → "all"
    const threadContext = resolveSlackThreadContext({ message, replyToMode });
    
    const threadTs = threadContext.incomingThreadTs;    // undefined (top-level message)
    const isThreadReply = threadContext.isThreadReply;  // false (top-level message)
    
    // autoThreadId IS computed correctly for replyToMode="all":
    const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs 
        ? threadContext.messageTs   // → "1775660310.007009" (the message's own ts)
        : void 0;
    
    // ⚠️ BUG: canonicalThreadId DISCARDS autoThreadId for rooms:
    const canonicalThreadId = isRoomish 
        ? (isThreadReply && threadTs ? threadTs : void 0)   // → void 0 (always, for top-level)
        : (isThreadReply ? threadTs : autoThreadId);         // DMs would use autoThreadId
    
    // With canonicalThreadId = void 0, session key = base channel key (no thread suffix):
    const threadKeys = resolveThreadSessionKeys({
        baseSessionKey: route.sessionKey,      // "agent:main:slack:channel:c0anbm0sjkf"
        threadId: canonicalThreadId,           // void 0
        parentSessionKey: canonicalThreadId && ctx.threadInheritParent 
            ? route.sessionKey : void 0        // void 0
    });
    
    // Result: sessionKey = "agent:main:slack:channel:c0anbm0sjkf" (parent channel session)
    const sessionKey = threadKeys.sessionKey;
    
    return { route, chatType, replyToMode, threadContext, threadTs, isThreadReply, 
             threadKeys, sessionKey, /* ... */ };
}

---

function resolveSlackThreadContext(params) {
    const incomingThreadTs = params.message.thread_ts;       // undefined (top-level)
    const eventTs = params.message.event_ts;
    const messageTs = params.message.ts ?? eventTs;          // "1775660310.007009"
    
    const isThreadReply = typeof incomingThreadTs === "string" 
        && incomingThreadTs.length > 0 
        && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
    // → false (no thread_ts)
    
    return {
        incomingThreadTs,    // undefined
        messageTs,           // "1775660310.007009"
        isThreadReply,       // false
        replyToId: incomingThreadTs ?? messageTs,  // "1775660310.007009"
        
        // messageThreadId IS set correctly for reply delivery:
        messageThreadId: isThreadReply 
            ? incomingThreadTs 
            : params.replyToMode === "all" ? messageTs : void 0
        // → "1775660310.007009"
    };
}

---

function resolveThreadSessionKeys(params) {
    const threadId = (params.threadId ?? "").trim();
    
    // When threadId is empty (void 0 from canonicalThreadId):
    if (!threadId) return {
        sessionKey: params.baseSessionKey,  // → parent channel session key
        parentSessionKey: void 0
    };
    
    // When threadId is provided (non-room path):
    const normalizedThreadId = (params.normalizeThreadId ?? ((value) => value.toLowerCase()))(threadId);
    return {
        sessionKey: `${params.baseSessionKey}:thread:${normalizedThreadId}`,
        parentSessionKey: params.parentSessionKey
    };
}

---

{
    SessionKey: sessionKey,              // Parent channel session key (from routing)
    MessageThreadId: threadContext.messageThreadId,  // Per-message thread ID (for reply delivery)
    // ...
}

---

const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : void 0)     // ROOMS: only uses threadTs for actual thread replies
    : (isThreadReply ? threadTs : autoThreadId);           // DMs: uses autoThreadId for top-level messages

---

// Current (buggy):
const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : void 0)
    : (isThreadReply ? threadTs : autoThreadId);

// Proposed fix:
const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : autoThreadId)  // ← use autoThreadId
    : (isThreadReply ? threadTs : autoThreadId);

// Or simplified (since both branches are now identical):
const canonicalThreadId = isThreadReply && threadTs ? threadTs : autoThreadId;

---

function resolveSlackThreadContext(params) {
	const incomingThreadTs = params.message.thread_ts;
	const eventTs = params.message.event_ts;
	const messageTs = params.message.ts ?? eventTs;
	const isThreadReply = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0 && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
	return {
		incomingThreadTs,
		messageTs,
		isThreadReply,
		replyToId: incomingThreadTs ?? messageTs,
		messageThreadId: isThreadReply ? incomingThreadTs : params.replyToMode === "all" ? messageTs : void 0
	};
}

---

function resolveSlackThreadTargets(params) {
	const { incomingThreadTs, messageTs, isThreadReply } = resolveSlackThreadContext(params);
	const replyThreadTs = isThreadReply ? incomingThreadTs : params.replyToMode === "all" ? messageTs : void 0;
	return {
		replyThreadTs,
		statusThreadTs: replyThreadTs,
		isThreadReply
	};
}

---

function resolveSlackRoutingContext(params) {
	const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
	const route = resolveAgentRoute({
		cfg: ctx.cfg,
		channel: "slack",
		accountId: account.accountId,
		teamId: ctx.teamId || void 0,
		peer: {
			kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group",
			id: isDirectMessage ? message.user ?? "unknown" : message.channel
		}
	});
	const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
	const replyToMode = resolveSlackReplyToMode(account, chatType);
	const threadContext = resolveSlackThreadContext({
		message,
		replyToMode
	});
	const threadTs = threadContext.incomingThreadTs;
	const isThreadReply = threadContext.isThreadReply;
	const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs ? threadContext.messageTs : void 0;
	const canonicalThreadId = isRoomish ? isThreadReply && threadTs ? threadTs : void 0 : isThreadReply ? threadTs : autoThreadId;
	const threadKeys = resolveThreadSessionKeys({
		baseSessionKey: route.sessionKey,
		threadId: canonicalThreadId,
		parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : void 0
	});
	const sessionKey = threadKeys.sessionKey;
	return {
		route,
		chatType,
		replyToMode,
		threadContext,
		threadTs,
		isThreadReply,
		threadKeys,
		sessionKey,
		historyKey: isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel
	};
}

---

function resolveThreadSessionKeys(params) {
	const threadId = (params.threadId ?? "").trim();
	if (!threadId) return {
		sessionKey: params.baseSessionKey,
		parentSessionKey: void 0
	};
	const normalizedThreadId = (params.normalizeThreadId ?? ((value) => value.toLowerCase()))(threadId);
	return {
		sessionKey: params.useSuffix ?? true ? `${params.baseSessionKey}:thread:${normalizedThreadId}` : params.baseSessionKey,
		parentSessionKey: params.parentSessionKey
	};
}

---

// Inside prepareSlackMessage():
const ctxPayload = buildInboundContext({
    // ...
    SessionKey: sessionKey,                          // ← Parent channel session key
    MessageThreadId: threadContext.messageThreadId,   // ← Per-message thread ID
    ParentSessionKey: threadKeys.parentSessionKey,    // ← void 0
    // ...
});

---

{
  "mode": "socket",
  "enabled": true,
  "replyToMode": "all",
  "groupPolicy": "allowlist",
  "dmPolicy": "allowlist",
  "streaming": "block",
  "nativeStreaming": true,
  "allowBots": true,
  "channels": {
    "C0ANBM0SJKF": {
      "allow": true,
      "requireMention": false
    }
  }
}

---

{
  "agent:main:slack:channel:c0anbm0sjkf": {
    "sessionId": "7f7593bb-c78d-4677-83a2-afb7853d7ed7",
    "updatedAt": 1775710558469,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775660310.007009": {
    "sessionId": "3da7b8e2-3956-4ef4-b603-039704dcd35b",
    "updatedAt": 1775696077967,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775679025.746549": {
    "sessionId": "805dc0f3-4825-42ff-b2a4-3b1de1ea5923",
    "updatedAt": 1775695384636,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775679068.095299": {
    "sessionId": "e1fddd17-6187-494e-b556-8f9e17b77e6a",
    "updatedAt": 1775695715853,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775686608.909049": {
    "sessionId": "668e5247-5c24-4356-ad88-b1f0992739aa",
    "updatedAt": 1775687127587,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775689714.554029": {
    "sessionId": "df14ba11-6a7d-4993-bcea-34db89f1e998",
    "updatedAt": 1775695753084,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775691340.591129": {
    "sessionId": "25502d5b-a259-434b-995a-bba4bcc06071",
    "updatedAt": 1775719657511,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775694933.768589": {
    "sessionId": "e328f4e8-773d-4e62-9d72-ae2d7d0e7327",
    "updatedAt": 1775695230953,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775695293.271419": {
    "sessionId": "1988fe0b-d82b-40bf-b905-d4c7b8cf418f",
    "updatedAt": 1775696477892,
    "displayName": "Slack thread #fox-email: <@U0AEHL4S6TC> Tell me what you've learned..."
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775699200.009569": {
    "sessionId": "221e12bd-2aee-4134-8a25-7e3986b0783c",
    "updatedAt": 1775699332990,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775710479.753369": {
    "sessionId": "9e0f9089-26a6-4592-8d12-21badb6a6821",
    "updatedAt": 1775710517133,
    "displayName": "slack:g-c0anbm0sjkf"
  }
}

---

1. [user]      Slack message from Janice Pena — asking about Travel Nursing salary data
 2. [assistant] cost=$0.88 — processes Janice's salary question (with thinking)
 3-15. [tool calls] — reads spreadsheet, processes salary data, sends Slack reply
16. [assistant] cost=$0.08NO_REPLY
17. [user]      Slack message from Renea Nielsen — asking for compliance review of email copy
18. [assistant] cost=$0.76 — processes Renea's compliance request (with thinking)
19-32. [tool calls] — searches memory, reads compliance KB, sends compliance review
33. [user]      QUEUED messages: more from Renea (different message)
34-36. [assistant+tools] — processes queued Renea message
37. [user]      Slack edit notification
38. [assistant] cost=$0.79 — processes edit
39. [user]      Slack delete + Janice's new message
40. [assistant] cost=$0.80Janice asking about what Fox has learned from her
... continues with Kevin's messages, Edward's messages, etc.
RAW_BUFFERClick to expand / collapse

OpenClaw Bug Report: Dual Session Processing on Slack Channels with replyToMode: "all"

Product: OpenClaw
Version tested: 2026.4.5 (source code analysis), 2026.4.2 (live reproduction)
Severity: High (causes 2x–4x token spend, context pollution across senders)
Date: 2026-04-09


1. Summary

When replyToMode: "all" is configured on a Slack channel (not a DM), every top-level channel message is processed by the LLM twice — once in a per-message thread-scoped session (used for threaded reply delivery) and once in the shared parent channel session. The parent channel session accumulates ALL messages from ALL senders into a single growing context, causing:

  1. Massive token waste — every message triggers two independent LLM calls with completely different response IDs
  2. Cross-sender context pollution — all senders' conversations bleed into the parent session
  3. Unbounded cost growth — the parent session context grows with every message across all threads

2. Reproduction

Config (Fox agent, OpenClaw 4.2)

{
  "channels": {
    "slack": {
      "replyToMode": "all",
      "channels": {
        "C0ANBM0SJKF": {
          "allow": true,
          "requireMention": false
        }
      }
    }
  }
}

Steps

  1. Configure a Slack channel with allow: true, requireMention: false, and global replyToMode: "all"
  2. Have User A post a top-level message in the channel
  3. The agent replies in a thread under User A's message (correct behavior)
  4. Have User B post a different top-level message in the same channel
  5. The agent replies in a thread under User B's message (correct behavior)
  6. Observe: Both User A's and User B's messages are in the parent channel session, and the LLM was called independently for both the thread session AND the parent session for each message

Observed behavior on April 8, 2026

10 top-level messages from 4 different senders (Janice, Renea, Kevin, Edward) in #fox-email over ~5 hours:

#Sendermessage_tsThread Session IDThread CostParent Session Cost
1Janice1775660310.0070093da7b8e2$1.11Included in $18.46
2Renea1775679025.746549805dc0f3$0.29Included in $18.46
3Renea1775679068.095299e1fddd17$0.68Included in $18.46
4Janice1775686608.909049668e5247$0.23Included in $18.46
5Kevin1775689714.554029df14ba11$1.77Included in $18.46
6Kevin1775691340.59112925502d5b$0.33Included in $18.46
7Renea1775694933.768589e328f4e8$0.34Included in $18.46
8Renea1775695293.2714191988fe0b$0.18Included in $18.46
9Edward1775699200.009569221e12bd$0.00Included in $18.46
10Edward1775710479.7533699e0f9089$0.00Included in $18.46

Thread sessions total: $4.93 (correct, isolated per-message)
Parent session total: $18.46 (82 LLM turns, all messages accumulated)
Response ID overlap: 0 out of 82+94 — every response is a unique, independent LLM call
Effective cost multiplier: ~4.7x ($23.39 actual vs ~$4.93 expected)


3. Session Store Evidence

The sessions.json file confirms the dual routing. Each message creates BOTH:

A. A thread-scoped session key (correct):

"agent:main:slack:channel:c0anbm0sjkf:thread:1775660310.007009" → sessionId: "3da7b8e2-..."
"agent:main:slack:channel:c0anbm0sjkf:thread:1775679025.746549" → sessionId: "805dc0f3-..."
"agent:main:slack:channel:c0anbm0sjkf:thread:1775689714.554029" → sessionId: "df14ba11-..."
... (one per message)

B. The parent channel session key (all messages route here too):

"agent:main:slack:channel:c0anbm0sjkf" → sessionId: "7f7593bb-..."

Session file 7f7593bb-...-topic-1775660310.007009.jsonl contains all 10 senders' messages (179 lines, 82 LLM turns, $18.46).


4. Root Cause (Source Code Analysis)

File: dist/prepare-D5Swazfl.js (maps to extensions/slack/src/routing.ts or similar)

The routing function: resolveSlackRoutingContext() (line ~920)

function resolveSlackRoutingContext(params) {
    const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
    
    // ... route resolution ...
    
    const replyToMode = resolveSlackReplyToMode(account, chatType);  // → "all"
    const threadContext = resolveSlackThreadContext({ message, replyToMode });
    
    const threadTs = threadContext.incomingThreadTs;    // undefined (top-level message)
    const isThreadReply = threadContext.isThreadReply;  // false (top-level message)
    
    // autoThreadId IS computed correctly for replyToMode="all":
    const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs 
        ? threadContext.messageTs   // → "1775660310.007009" (the message's own ts)
        : void 0;
    
    // ⚠️ BUG: canonicalThreadId DISCARDS autoThreadId for rooms:
    const canonicalThreadId = isRoomish 
        ? (isThreadReply && threadTs ? threadTs : void 0)   // → void 0 (always, for top-level)
        : (isThreadReply ? threadTs : autoThreadId);         // DMs would use autoThreadId
    
    // With canonicalThreadId = void 0, session key = base channel key (no thread suffix):
    const threadKeys = resolveThreadSessionKeys({
        baseSessionKey: route.sessionKey,      // "agent:main:slack:channel:c0anbm0sjkf"
        threadId: canonicalThreadId,           // void 0
        parentSessionKey: canonicalThreadId && ctx.threadInheritParent 
            ? route.sessionKey : void 0        // void 0
    });
    
    // Result: sessionKey = "agent:main:slack:channel:c0anbm0sjkf" (parent channel session)
    const sessionKey = threadKeys.sessionKey;
    
    return { route, chatType, replyToMode, threadContext, threadTs, isThreadReply, 
             threadKeys, sessionKey, /* ... */ };
}

The thread context function: resolveSlackThreadContext() (line ~540)

function resolveSlackThreadContext(params) {
    const incomingThreadTs = params.message.thread_ts;       // undefined (top-level)
    const eventTs = params.message.event_ts;
    const messageTs = params.message.ts ?? eventTs;          // "1775660310.007009"
    
    const isThreadReply = typeof incomingThreadTs === "string" 
        && incomingThreadTs.length > 0 
        && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
    // → false (no thread_ts)
    
    return {
        incomingThreadTs,    // undefined
        messageTs,           // "1775660310.007009"
        isThreadReply,       // false
        replyToId: incomingThreadTs ?? messageTs,  // "1775660310.007009"
        
        // messageThreadId IS set correctly for reply delivery:
        messageThreadId: isThreadReply 
            ? incomingThreadTs 
            : params.replyToMode === "all" ? messageTs : void 0
        // → "1775660310.007009"
    };
}

The session key function: resolveThreadSessionKeys() (session-key-BR3Z-ljs.js, line ~255)

function resolveThreadSessionKeys(params) {
    const threadId = (params.threadId ?? "").trim();
    
    // When threadId is empty (void 0 from canonicalThreadId):
    if (!threadId) return {
        sessionKey: params.baseSessionKey,  // → parent channel session key
        parentSessionKey: void 0
    };
    
    // When threadId is provided (non-room path):
    const normalizedThreadId = (params.normalizeThreadId ?? ((value) => value.toLowerCase()))(threadId);
    return {
        sessionKey: `${params.baseSessionKey}:thread:${normalizedThreadId}`,
        parentSessionKey: params.parentSessionKey
    };
}

The message construction (line ~1232):

{
    SessionKey: sessionKey,              // Parent channel session key (from routing)
    MessageThreadId: threadContext.messageThreadId,  // Per-message thread ID (for reply delivery)
    // ...
}

What happens next

The SessionKey field routes the inbound message processing to the parent channel session. The MessageThreadId field is used later in the delivery/reply layer to create a separate thread-scoped session for reply targeting.

Both sessions process the message independently with the LLM.


5. The Asymmetry Between Rooms and DMs

The code has an explicit asymmetry in the canonicalThreadId ternary:

const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : void 0)     // ROOMS: only uses threadTs for actual thread replies
    : (isThreadReply ? threadTs : autoThreadId);           // DMs: uses autoThreadId for top-level messages

For DMs with replyToMode: "all": Top-level messages correctly get canonicalThreadId = autoThreadId = messageTs, so sessionKey = baseKey:thread:<messageTs> — each message gets its own session.

For rooms/channels with replyToMode: "all": Top-level messages get canonicalThreadId = void 0, so sessionKey = baseKey — ALL messages share the parent channel session.

This asymmetry is the bug. The DM path was fixed in version 2026.2.25 (PR #26849: "when replyToMode="all" auto-threads top-level Slack DMs, seed the thread session key from the message ts"). The equivalent fix was never applied to the room/channel path.


6. Relevant Changelog Entries

Version 2026.2.25 (DM fix — applied, but only for DMs):

Slack/Threading: when replyToMode="all" auto-threads top-level Slack DMs, seed the thread session key from the message ts so the initial message and later replies share the same isolated :thread: session instead of falling back to base DM context. (#26849)

Version 2026.2.27 (Thread isolation — applied, but doesn't cover this case):

Slack/Thread session isolation: route channel/group top-level messages into thread-scoped sessions (:thread:<ts>) and read inbound previousTimestamp from the resolved thread session key, preventing cross-thread context bleed and stale timestamp lookups. (#10686)

Note: Despite the description saying "route channel/group top-level messages into thread-scoped sessions," the actual code still has void 0 for rooms in the ternary. Either this fix was incomplete, regressed, or the description doesn't accurately reflect the code's behavior.

Version 2026.3.2 (replyToMode=off fix — different case):

Slack/session routing: keep top-level channel messages in one shared session when replyToMode=off, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193)

This fix is specifically for replyToMode=off. The phrase "preserving thread-scoped keys for true thread replies and non-off modes" suggests thread-scoped keys should work for replyToMode=all on channels — but the code doesn't implement this for top-level (non-thread-reply) room messages.


7. Proposed Fix

In resolveSlackRoutingContext(), change the canonicalThreadId computation to use autoThreadId for rooms:

// Current (buggy):
const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : void 0)
    : (isThreadReply ? threadTs : autoThreadId);

// Proposed fix:
const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : autoThreadId)  // ← use autoThreadId
    : (isThreadReply ? threadTs : autoThreadId);

// Or simplified (since both branches are now identical):
const canonicalThreadId = isThreadReply && threadTs ? threadTs : autoThreadId;

This ensures that when replyToMode: "all" is active on a channel, each top-level message gets its own thread-scoped session key (baseKey:thread:<messageTs>) instead of falling back to the parent channel session.

Impact of fix: The inbound processing session key will match the thread-scoped session that's already being created for reply delivery, eliminating the duplicate processing.


8. Workaround

No clean workaround exists. Options:

  1. Set replyToMode: "off" on the channel — but this means the agent won't reply in threads, which defeats the purpose for multi-sender channels
  2. Set requireMention: true — reduces message volume but doesn't fix the dual-session routing
  3. Accept the cost — the parent session will keep growing until reset

9. Files Referenced

FilePurpose
dist/prepare-D5Swazfl.jsSlack inbound routing (lines ~540, ~920)
dist/session-key-BR3Z-ljs.jsSession key resolution (line ~255)
CHANGELOG.mdVersion history for related fixes
sessions/sessions.json (Fox)Live session store showing dual routing
sessions/7f7593bb-...-topic-1775660310.007009.jsonl (Fox)Parent accumulator session (179 lines, $18.46)
sessions/3da7b8e2-...jsonl through 9e0f9089-...jsonl (Fox)Individual thread sessions ($4.93 combined)

10. Full Source Code Excerpts

resolveSlackThreadContext (prepare-D5Swazfl.js, line 538)

function resolveSlackThreadContext(params) {
	const incomingThreadTs = params.message.thread_ts;
	const eventTs = params.message.event_ts;
	const messageTs = params.message.ts ?? eventTs;
	const isThreadReply = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0 && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
	return {
		incomingThreadTs,
		messageTs,
		isThreadReply,
		replyToId: incomingThreadTs ?? messageTs,
		messageThreadId: isThreadReply ? incomingThreadTs : params.replyToMode === "all" ? messageTs : void 0
	};
}

resolveSlackThreadTargets (prepare-D5Swazfl.js, line ~555)

function resolveSlackThreadTargets(params) {
	const { incomingThreadTs, messageTs, isThreadReply } = resolveSlackThreadContext(params);
	const replyThreadTs = isThreadReply ? incomingThreadTs : params.replyToMode === "all" ? messageTs : void 0;
	return {
		replyThreadTs,
		statusThreadTs: replyThreadTs,
		isThreadReply
	};
}

resolveSlackRoutingContext (prepare-D5Swazfl.js, line 908)

function resolveSlackRoutingContext(params) {
	const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
	const route = resolveAgentRoute({
		cfg: ctx.cfg,
		channel: "slack",
		accountId: account.accountId,
		teamId: ctx.teamId || void 0,
		peer: {
			kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group",
			id: isDirectMessage ? message.user ?? "unknown" : message.channel
		}
	});
	const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
	const replyToMode = resolveSlackReplyToMode(account, chatType);
	const threadContext = resolveSlackThreadContext({
		message,
		replyToMode
	});
	const threadTs = threadContext.incomingThreadTs;
	const isThreadReply = threadContext.isThreadReply;
	const autoThreadId = !isThreadReply && replyToMode === "all" && threadContext.messageTs ? threadContext.messageTs : void 0;
	const canonicalThreadId = isRoomish ? isThreadReply && threadTs ? threadTs : void 0 : isThreadReply ? threadTs : autoThreadId;
	const threadKeys = resolveThreadSessionKeys({
		baseSessionKey: route.sessionKey,
		threadId: canonicalThreadId,
		parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : void 0
	});
	const sessionKey = threadKeys.sessionKey;
	return {
		route,
		chatType,
		replyToMode,
		threadContext,
		threadTs,
		isThreadReply,
		threadKeys,
		sessionKey,
		historyKey: isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel
	};
}

resolveThreadSessionKeys (session-key-BR3Z-ljs.js, line 255)

function resolveThreadSessionKeys(params) {
	const threadId = (params.threadId ?? "").trim();
	if (!threadId) return {
		sessionKey: params.baseSessionKey,
		parentSessionKey: void 0
	};
	const normalizedThreadId = (params.normalizeThreadId ?? ((value) => value.toLowerCase()))(threadId);
	return {
		sessionKey: params.useSuffix ?? true ? `${params.baseSessionKey}:thread:${normalizedThreadId}` : params.baseSessionKey,
		parentSessionKey: params.parentSessionKey
	};
}

Message construction (prepare-D5Swazfl.js, line ~1215)

// Inside prepareSlackMessage():
const ctxPayload = buildInboundContext({
    // ...
    SessionKey: sessionKey,                          // ← Parent channel session key
    MessageThreadId: threadContext.messageThreadId,   // ← Per-message thread ID
    ParentSessionKey: threadKeys.parentSessionKey,    // ← void 0
    // ...
});

11. Fox Slack Config (Sanitized)

{
  "mode": "socket",
  "enabled": true,
  "replyToMode": "all",
  "groupPolicy": "allowlist",
  "dmPolicy": "allowlist",
  "streaming": "block",
  "nativeStreaming": true,
  "allowBots": true,
  "channels": {
    "C0ANBM0SJKF": {
      "allow": true,
      "requireMention": false
    }
  }
}

Note: threadInheritParent is not set (defaults to false/undefined). threadHistoryScope is not set.


12. Session Store Snapshot (Fox, sessions.json excerpt)

All entries for channel c0anbm0sjkf (#fox-email):

{
  "agent:main:slack:channel:c0anbm0sjkf": {
    "sessionId": "7f7593bb-c78d-4677-83a2-afb7853d7ed7",
    "updatedAt": 1775710558469,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775660310.007009": {
    "sessionId": "3da7b8e2-3956-4ef4-b603-039704dcd35b",
    "updatedAt": 1775696077967,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775679025.746549": {
    "sessionId": "805dc0f3-4825-42ff-b2a4-3b1de1ea5923",
    "updatedAt": 1775695384636,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775679068.095299": {
    "sessionId": "e1fddd17-6187-494e-b556-8f9e17b77e6a",
    "updatedAt": 1775695715853,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775686608.909049": {
    "sessionId": "668e5247-5c24-4356-ad88-b1f0992739aa",
    "updatedAt": 1775687127587,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775689714.554029": {
    "sessionId": "df14ba11-6a7d-4993-bcea-34db89f1e998",
    "updatedAt": 1775695753084,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775691340.591129": {
    "sessionId": "25502d5b-a259-434b-995a-bba4bcc06071",
    "updatedAt": 1775719657511,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775694933.768589": {
    "sessionId": "e328f4e8-773d-4e62-9d72-ae2d7d0e7327",
    "updatedAt": 1775695230953,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775695293.271419": {
    "sessionId": "1988fe0b-d82b-40bf-b905-d4c7b8cf418f",
    "updatedAt": 1775696477892,
    "displayName": "Slack thread #fox-email: <@U0AEHL4S6TC> Tell me what you've learned..."
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775699200.009569": {
    "sessionId": "221e12bd-2aee-4134-8a25-7e3986b0783c",
    "updatedAt": 1775699332990,
    "displayName": "slack:#fox-email"
  },
  "agent:main:slack:channel:c0anbm0sjkf:thread:1775710479.753369": {
    "sessionId": "9e0f9089-26a6-4592-8d12-21badb6a6821",
    "updatedAt": 1775710517133,
    "displayName": "slack:g-c0anbm0sjkf"
  }
}

Key observation: Each message has its own :thread: session key AND all messages also route to the base agent:main:slack:channel:c0anbm0sjkf session.


13. Parent Session Message Flow (First 20 entries)

Showing the parent session (7f7593bb) accumulating messages from multiple senders:

 1. [user]      Slack message from Janice Pena — asking about Travel Nursing salary data
 2. [assistant] cost=$0.88 — processes Janice's salary question (with thinking)
 3-15. [tool calls] — reads spreadsheet, processes salary data, sends Slack reply
16. [assistant] cost=$0.08 — NO_REPLY
17. [user]      Slack message from Renea Nielsen — asking for compliance review of email copy
18. [assistant] cost=$0.76 — processes Renea's compliance request (with thinking)
19-32. [tool calls] — searches memory, reads compliance KB, sends compliance review
33. [user]      QUEUED messages: more from Renea (different message)
34-36. [assistant+tools] — processes queued Renea message
37. [user]      Slack edit notification
38. [assistant] cost=$0.79 — processes edit
39. [user]      Slack delete + Janice's new message
40. [assistant] cost=$0.80 — Janice asking about what Fox has learned from her
... continues with Kevin's messages, Edward's messages, etc.

All 10 senders' conversations are interleaved in this single session. Each successive message pays for the full accumulated context of all prior messages.


14. Cost Breakdown

Thread sessions (correct, isolated):

Session IDSender(s)LinesCost
3da7b8e2Janice60$1.11
805dc0f3Renea16$0.29
e1fddd17Renea43$0.68
668e5247Janice10$0.23
df14ba11Kevin105$1.77
25502d5bKevin--$0.33
e328f4e8Renea--$0.34
1988fe0bRenea--$0.18
221e12bdEdward--$0.00
9e0f9089Edward--$0.00
Total$4.93

Parent session (buggy accumulator):

Session IDAll senders combinedLinesCost
7f7593bbJanice+Renea+Kevin+Edward179$18.46

Totals:

  • Actual spend: $23.39
  • Expected spend (thread sessions only): $4.93
  • Waste: $18.46 (79% of total)
  • Multiplier: 4.7x

15. Questions for the OpenClaw Team

  1. Was the isRoomish branch in the canonicalThreadId ternary intentionally different from the DM branch? If so, what's the expected behavior for replyToMode: "all" on channels?

  2. The changelog for version 2026.2.27 (#10686) says "route channel/group top-level messages into thread-scoped sessions." Was this fix intended to cover this case? If so, it appears to have regressed or was incomplete.

  3. Is there a second code path that creates the thread-scoped session from MessageThreadId that we haven't identified? Understanding both paths would help validate the fix.

  4. Would the proposed fix (using autoThreadId for rooms) break any intentional behavior where channels with replyToMode: "all" are expected to maintain a shared parent context?

extent analysis

TL;DR

The most likely fix for the dual session processing issue on Slack channels with replyToMode: "all" is to update the canonicalThreadId computation in resolveSlackRoutingContext() to use autoThreadId for rooms, ensuring each top-level message gets its own thread-scoped session key.

Guidance

  1. Review the resolveSlackRoutingContext() function: Verify that the canonicalThreadId computation is the root cause of the issue, where isRoomish branches discard autoThreadId for top-level messages.
  2. Apply the proposed fix: Update the canonicalThreadId computation to use autoThreadId for rooms, as suggested in the issue report.
  3. Test the fix: Validate that the updated code correctly routes top-level messages to their own thread-scoped sessions, eliminating the duplicate processing and cost waste.
  4. Monitor session store and cost metrics: After applying the fix, observe the session store and cost metrics to ensure the fix is effective and does not introduce any regressions.

Example

The proposed fix involves changing the canonicalThreadId computation in resolveSlackRoutingContext():

const canonicalThreadId = isRoomish 
    ? (isThreadReply && threadTs ? threadTs : autoThreadId)  // ← use autoThreadId
    : (isThreadReply ? threadTs : autoThreadId);

Or simplifying it to:

const canonicalThreadId = isThreadReply && threadTs ? threadTs : autoThreadId;

Notes

  • The fix assumes that using autoThreadId for rooms will not break any intentional behavior where channels with replyToMode: "all" are expected to maintain a shared parent context.
  • It is essential to test the fix thoroughly to ensure it does not introduce any regressions or unexpected behavior.

Recommendation

Apply the proposed fix to update the canonicalThreadId computation in resolveSlackRoutingContext(), as it directly addresses the identified root cause of the issue. This fix should eliminate the duplicate processing and cost waste associated with the dual session processing on Slack channels with replyToMode: "all".

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix Slack: top-level channel messages with replyToMode="all" cause dual-session processing [1 pull requests, 1 comments, 1 participants]