Skip to content

Slack: top-level channel messages with replyToMode="all" cause dual-session processing #64078

@carayim-dev

Description

@carayim-dev

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:

# Sender message_ts Thread Session ID Thread Cost Parent Session Cost
1 Janice 1775660310.007009 3da7b8e2 $1.11 Included in $18.46
2 Renea 1775679025.746549 805dc0f3 $0.29 Included in $18.46
3 Renea 1775679068.095299 e1fddd17 $0.68 Included in $18.46
4 Janice 1775686608.909049 668e5247 $0.23 Included in $18.46
5 Kevin 1775689714.554029 df14ba11 $1.77 Included in $18.46
6 Kevin 1775691340.591129 25502d5b $0.33 Included in $18.46
7 Renea 1775694933.768589 e328f4e8 $0.34 Included in $18.46
8 Renea 1775695293.271419 1988fe0b $0.18 Included in $18.46
9 Edward 1775699200.009569 221e12bd $0.00 Included in $18.46
10 Edward 1775710479.753369 9e0f9089 $0.00 Included 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

File Purpose
dist/prepare-D5Swazfl.js Slack inbound routing (lines ~540, ~920)
dist/session-key-BR3Z-ljs.js Session key resolution (line ~255)
CHANGELOG.md Version 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 ID Sender(s) Lines Cost
3da7b8e2 Janice 60 $1.11
805dc0f3 Renea 16 $0.29
e1fddd17 Renea 43 $0.68
668e5247 Janice 10 $0.23
df14ba11 Kevin 105 $1.77
25502d5b Kevin -- $0.33
e328f4e8 Renea -- $0.34
1988fe0b Renea -- $0.18
221e12bd Edward -- $0.00
9e0f9089 Edward -- $0.00
Total $4.93

Parent session (buggy accumulator):

Session ID All senders combined Lines Cost
7f7593bb Janice+Renea+Kevin+Edward 179 $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 (fix(slack): use thread-level sessions for channels to prevent context mixing #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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions