openclaw - ✅(Solved) Fix Slack DM delivery mirrors can split sessions by routing bare D... IM channel IDs as channel sessions [1 pull requests, 2 comments, 3 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#80091Fetched 2026-05-11 03:18:47
View on GitHub
Comments
2
Participants
3
Timeline
5
Reactions
2
Author
Assignees
Timeline (top)
commented ×2assigned ×1closed ×1cross-referenced ×1

A Slack DM thread can split into two OpenClaw session buckets when an outbound message.send/delivery-mirror path is given Slack's native IM channel ID (D...) instead of the peer user ID (U...). Inbound Slack DM routing canonicalizes the same physical conversation as a direct user session, but outbound route reconstruction in the Slack plugin treats a bare D... target as a normal channel target.

Observed result for the same visible Slack DM thread and same Slack thread_ts:

# Old/context-rich delivery-mirror session created by outbound route reconstruction
agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

# Later/current inbound DM session for the same physical Slack thread
agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

This caused the assistant to lose prior Slack thread context even though the Slack UI still showed the same DM thread.

Root Cause

So a bare D0AEWSDHAQH becomes kind: "channel", id: "D0AEWSDHAQH". Because the type-check block only runs for G..., D... is never checked with conversations.info and remains a channel route.

Fix Action

Fixed

PR fix notes

PR #80111: fix:Slack DM delivery mirrors can split sessions by routing bare D... IM channel IDs as channel sessions

Description (problem / solution / changelog)

Summary

Fixes Slack outbound delivery-mirror session reconstruction for native Slack IM channel IDs (D...). A message.send call can legitimately target Slack’s concrete DM channel, but OpenClaw session routing should treat that same conversation as a direct Slack user session keyed by the peer U... user ID.

This PR updates the Slack outbound route resolver so D... and channel:D... targets resolve through Slack conversation metadata and only create a mirror route when Slack confirms channel.is_im plus channel.user. In that case the mirror session is canonicalized to slack:direct:<u...>, matching inbound Slack DM routing. If the lookup fails or Slack does not return the peer user, the mirror route is skipped instead of writing a known-wrong slack:channel:<d...> session key.

Fixes #80091.

Root Cause

Inbound Slack DM routing already treats D-prefixed Slack channels as IMs and keys the route by the message sender user ID. The outbound delivery-mirror path did not do the same. It parsed bare outbound targets with defaultKind: "channel", only reclassified G... IDs, and therefore allowed a Slack IM channel ID such as D0AEWSDHAQH to produce a session key like:

agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

Later inbound messages in the same visible Slack DM thread route to the canonical direct user session, for example:

agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

That split loses prior thread context even though the Slack UI shows one DM thread.

What Changed

  • Added Slack conversation metadata lookup that can return both conversation type and the IM peer user.
  • Kept the existing resolveSlackChannelType() contract as a compatibility wrapper.
  • Canonicalized outbound mirror routes for bare D... and channel:D... targets to direct:U... when Slack confirms the mapping.
  • Preserved Slack send delivery behavior. channel:D... can still be used as the concrete Slack delivery target; only mirror/session reconstruction changes.
  • Preserved existing channel and MPIM behavior for C... and G... targets.
  • Avoided caching incomplete native IM metadata so transient Slack lookup failures do not suppress later successful mirror routing.
  • Added regression coverage for the affected paths.

User Impact

After this change, sending into a Slack DM thread through message.send with a native D... target no longer creates a separate channel-scoped OpenClaw session. Outbound delivery mirrors and later inbound DM turns resolve to the same direct user session whenever Slack exposes the peer user mapping.

Existing bad agent:*:slack:channel:d... sessions are not migrated in this PR. This PR prevents new bad mirror sessions from being created.

Real Behavior Proof

Pending live after-fix proof from a real Slack-connected OpenClaw setup.

Planned proof:

  1. Use a real Slack IM channel ID (D...) and real thread timestamp from a Slack DM where OpenClaw is configured.
  2. Confirm live Slack metadata resolves the IM channel to the peer user:
conversations.info(channel=D...)
  channel.is_im: true
  channel.user: U...
  1. Run the same outbound mirror route probe before and after this fix.
  2. Record copied terminal output showing the route changes from the pre-fix channel session:
agent:main:slack:channel:<d...>:thread:<thread_ts>

to the post-fix direct user session:

agent:main:slack:direct:<u...>:thread:<thread_ts>

Unit tests, typechecks, and CI below are supplemental only and do not replace this live proof.

Validation

  • git diff --check
  • pnpm test extensions/slack/src/channel.test.ts extensions/slack/src/channel-type.test.ts
  • pnpm test:extension slack
  • pnpm tsgo:extensions
  • pnpm exec oxfmt --check --threads=1 extensions/slack/src/channel.ts extensions/slack/src/channel-type.ts extensions/slack/src/channel.test.ts extensions/slack/src/channel-type.test.ts CHANGELOG.md
  • codex review --base origin/main

codex review result: no discrete correctness issues found.

AI Assistance

Implemented with Codex. I understand the submitted change and verified the behavior at the code and targeted validation level; live real-behavior proof is pending as noted above.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/slack/src/channel-type.test.ts (modified, +132/-1)
  • extensions/slack/src/channel-type.ts (modified, +55/-23)
  • extensions/slack/src/channel.test.ts (modified, +123/-0)
  • extensions/slack/src/channel.ts (modified, +19/-7)

Code Example

# Old/context-rich delivery-mirror session created by outbound route reconstruction
agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

# Later/current inbound DM session for the same physical Slack thread
agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

---

agent:main:slack:direct:<user_id>

---

OpenClaw 2026.5.4 (325df3e)

---

Old/context-rich transcript:
~/.openclaw/agents/main/sessions/3da21597-532c-43ed-beeb-de6ac38df46d.jsonl

New/context-poor transcript:
~/.openclaw/agents/main/sessions/1cb2f009-927d-4a16-bfa7-a6e6cfa7a8b3-topic-1778110574.653649.jsonl

Session index:
~/.openclaw/agents/main/sessions/sessions.json

---

~/.openclaw/agents/main/sessions/bef987a1-21a2-4227-88c0-38a7fcdb6668-topic-1778110574.653649.jsonl.reset.2026-05-08T18-01-24.680Z

---

{
  "name": "message.send",
  "arguments": {
    "action": "send",
    "channel": "slack",
    "target": "D0AEWSDHAQH",
    "threadId": "1778110574.653649"
  }
}

---

message.send target=D0AEWSDHAQH threadId=1778110574.653649
  -> outbound mirror route reconstruction
  -> session key agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

---

conversations.info(channel=D0AEWSDHAQH)
  ok: true
  channel.id: D0AEWSDHAQH
  channel.is_im: true
  channel.user: U09G2DJ0275

conversations.members(channel=D0AEWSDHAQH)
  ok: true
  members: ["U09G2DJ0275", "U0AEHT2L7B4"]

conversations.open(users=U09G2DJ0275)
  ok: true
  channel.id: D0AEWSDHAQH
  channel.is_im: true
  channel.user: U09G2DJ0275

---

npm pack openclaw@2026.5.7 --pack-destination /private/tmp
mkdir -p /private/tmp/openclaw-npm-2026.5.7
tar -xzf /private/tmp/openclaw-2026.5.7.tgz -C /private/tmp/openclaw-npm-2026.5.7 --strip-components=1
jq -r '.version' /private/tmp/openclaw-npm-2026.5.7/package.json
# 2026.5.7

---

// dist/channel-Bf55xMfV.js
async function resolveSlackOutboundSessionRoute(params) {
  const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
  if (!parsed) return null;
  const isDm = parsed.kind === "user";
  let peerKind = isDm ? "direct" : "channel";
  if (!isDm && /^G/i.test(parsed.id)) {
    const channelType = await resolveSlackChannelType({
      cfg: params.cfg,
      accountId: params.accountId,
      channelId: parsed.id
    });
    if (channelType === "group") peerKind = "group";
    if (channelType === "dm") peerKind = "direct";
  }
  const peer = { kind: peerKind, id: parsed.id };
  ...
}

---

// dist/target-parsing-WE4Kvnq9.js
if (options.defaultKind) return buildMessagingTarget(options.defaultKind, trimmed, trimmed);

---

// extensions/slack/src/channel.ts
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
const isDm = parsed.kind === "user";
let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel";
if (!isDm && /^G/i.test(parsed.id)) {
  const channelType = await resolveSlackChannelType(...);
  if (channelType === "group") peerKind = "group";
  if (channelType === "dm") peerKind = "direct";
}
const peer: RoutePeer = { kind: peerKind, id: parsed.id };

---

src/infra/outbound/message-action-runner.ts
  handleSendAction(...)
    -> prepareOutboundMirrorRoute(...)

src/infra/outbound/message-action-threading.ts
  prepareOutboundMirrorRoute(...)
    -> resolveOutboundSessionRoute({ target: params.to, threadId, currentSessionKey, ... })
    -> ensureOutboundSessionEntry(...)
    -> actionParams.__sessionKey = outboundRoute.sessionKey

src/infra/outbound/outbound-session.ts
  resolveOutboundSessionRoute(...)
    -> getChannelPlugin("slack").messaging.resolveOutboundSessionRoute(...)

extensions/slack/src/channel.ts
  resolveSlackOutboundSessionRoute(...)
    -> parse bare D... as channel
    -> builds agent:main:slack:channel:d...[:thread:<thread_ts>]

---

// extensions/slack/src/monitor/channel-type.ts
if (trimmed.startsWith("D")) {
  return "im";
}
...
// D-prefix channel IDs are always DMs — override a contradicting channel_type.
if (inferred === "im" && normalized !== "im") {
  return "im";
}

---

// extensions/slack/src/monitor/message-handler/prepare-routing.ts
peer: {
  kind: params.isDirectMessage ? "direct" : params.isRoom ? "channel" : "group",
  id: params.isDirectMessage ? (params.message.user ?? "unknown") : params.message.channel,
}

---

{
  channel: "slack",
  agentId: "main",
  target: "D0AEWSDHAQH",
  threadId: "1778110574.653649",
  cfg: { session: { dmScope: "per-channel-peer" }, channels: { slack: { ... } } }
}

---

agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

---

agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

---

peer = { kind: "direct", id: channel.user }
chatType = "direct"
from = `slack:${channel.user}`
to = `user:${channel.user}`
// retain native channel id in delivery context if needed
RAW_BUFFERClick to expand / collapse

Summary

A Slack DM thread can split into two OpenClaw session buckets when an outbound message.send/delivery-mirror path is given Slack's native IM channel ID (D...) instead of the peer user ID (U...). Inbound Slack DM routing canonicalizes the same physical conversation as a direct user session, but outbound route reconstruction in the Slack plugin treats a bare D... target as a normal channel target.

Observed result for the same visible Slack DM thread and same Slack thread_ts:

# Old/context-rich delivery-mirror session created by outbound route reconstruction
agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

# Later/current inbound DM session for the same physical Slack thread
agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

This caused the assistant to lose prior Slack thread context even though the Slack UI still showed the same DM thread.

Why this appears to be a bug

The docs and inbound code both indicate Slack DMs should route as direct, not channel:

  • docs/channels/slack.md: “DMs route as direct; channels as channel; MPIMs as group.”
  • docs/channels/channel-routing.md: direct messages are controlled by session.dmScope; channels/groups are isolated as channel/group sessions.
  • Inbound Slack code explicitly treats D... channels as DMs, even if Slack sends a contradictory or missing channel_type.

The split is not explained by session.dmScope. In the observed config, session.dmScope was set to "per-channel-peer", which should produce user-scoped Slack DM keys such as:

agent:main:slack:direct:<user_id>

The problem occurs before dmScope can do the right thing: the outbound path classifies the peer as channel instead of direct.

Historical evidence from one affected install

Installed version originally investigated:

OpenClaw 2026.5.4 (325df3e)

The same issue still appears present in the published npm package for 2026.5.7.

Observed session files for the same physical Slack DM thread timestamp:

Old/context-rich transcript:
~/.openclaw/agents/main/sessions/3da21597-532c-43ed-beeb-de6ac38df46d.jsonl

New/context-poor transcript:
~/.openclaw/agents/main/sessions/1cb2f009-927d-4a16-bfa7-a6e6cfa7a8b3-topic-1778110574.653649.jsonl

Session index:
~/.openclaw/agents/main/sessions/sessions.json

A reset backup transcript showed the exact outbound tool call that created the delivery-mirror session:

~/.openclaw/agents/main/sessions/bef987a1-21a2-4227-88c0-38a7fcdb6668-topic-1778110574.653649.jsonl.reset.2026-05-08T18-01-24.680Z

Relevant record around 2026-05-08T02:24:48.802Z:

{
  "name": "message.send",
  "arguments": {
    "action": "send",
    "channel": "slack",
    "target": "D0AEWSDHAQH",
    "threadId": "1778110574.653649"
  }
}

The bad delivery-mirror transcript starts immediately after that, around 2026-05-08T02:24:49.195Z, with an OpenClaw delivery-mirror assistant record around 2026-05-08T02:24:49.196Z.

That timing strongly points to this sequence:

message.send target=D0AEWSDHAQH threadId=1778110574.653649
  -> outbound mirror route reconstruction
  -> session key agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

No gateway restart/reload/reconnect was observed around that creation window in the local gateway logs. The route flip was tied to the outbound delivery-mirror creation, not to restart recovery.

Slack API validation from the affected workspace

Using the configured Slack bot token, the workspace could map the D... IM channel to the peer user:

conversations.info(channel=D0AEWSDHAQH)
  ok: true
  channel.id: D0AEWSDHAQH
  channel.is_im: true
  channel.user: U09G2DJ0275

conversations.members(channel=D0AEWSDHAQH)
  ok: true
  members: ["U09G2DJ0275", "U0AEHT2L7B4"]

conversations.open(users=U09G2DJ0275)
  ok: true
  channel.id: D0AEWSDHAQH
  channel.is_im: true
  channel.user: U09G2DJ0275

So at least with the recommended/minimal Slack scopes in this install, OpenClaw had enough permission to resolve D... -> U....

Code path analysis, 2026.5.7 npm package

I inspected the actual published npm tarball, not just GitHub release notes:

npm pack [email protected] --pack-destination /private/tmp
mkdir -p /private/tmp/openclaw-npm-2026.5.7
tar -xzf /private/tmp/openclaw-2026.5.7.tgz -C /private/tmp/openclaw-npm-2026.5.7 --strip-components=1
jq -r '.version' /private/tmp/openclaw-npm-2026.5.7/package.json
# 2026.5.7

The built package still has the problematic outbound classification:

// dist/channel-Bf55xMfV.js
async function resolveSlackOutboundSessionRoute(params) {
  const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
  if (!parsed) return null;
  const isDm = parsed.kind === "user";
  let peerKind = isDm ? "direct" : "channel";
  if (!isDm && /^G/i.test(parsed.id)) {
    const channelType = await resolveSlackChannelType({
      cfg: params.cfg,
      accountId: params.accountId,
      channelId: parsed.id
    });
    if (channelType === "group") peerKind = "group";
    if (channelType === "dm") peerKind = "direct";
  }
  const peer = { kind: peerKind, id: parsed.id };
  ...
}

The target parser makes this worse for bare IDs:

// dist/target-parsing-WE4Kvnq9.js
if (options.defaultKind) return buildMessagingTarget(options.defaultKind, trimmed, trimmed);

So a bare D0AEWSDHAQH becomes kind: "channel", id: "D0AEWSDHAQH". Because the type-check block only runs for G..., D... is never checked with conversations.info and remains a channel route.

Readable source shows the same logic:

// extensions/slack/src/channel.ts
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
const isDm = parsed.kind === "user";
let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel";
if (!isDm && /^G/i.test(parsed.id)) {
  const channelType = await resolveSlackChannelType(...);
  if (channelType === "group") peerKind = "group";
  if (channelType === "dm") peerKind = "direct";
}
const peer: RoutePeer = { kind: peerKind, id: parsed.id };

Delivery-mirror creation path

The path that creates the delivery-mirror session is:

src/infra/outbound/message-action-runner.ts
  handleSendAction(...)
    -> prepareOutboundMirrorRoute(...)

src/infra/outbound/message-action-threading.ts
  prepareOutboundMirrorRoute(...)
    -> resolveOutboundSessionRoute({ target: params.to, threadId, currentSessionKey, ... })
    -> ensureOutboundSessionEntry(...)
    -> actionParams.__sessionKey = outboundRoute.sessionKey

src/infra/outbound/outbound-session.ts
  resolveOutboundSessionRoute(...)
    -> getChannelPlugin("slack").messaging.resolveOutboundSessionRoute(...)

extensions/slack/src/channel.ts
  resolveSlackOutboundSessionRoute(...)
    -> parse bare D... as channel
    -> builds agent:main:slack:channel:d...[:thread:<thread_ts>]

Delivery mirrors themselves are intentional: appendAssistantMessageToSessionTranscript(...) writes provider: "openclaw", model: "delivery-mirror", and replay code treats those as transcript-only records. The issue is not that delivery mirrors exist; the issue is that their route can be non-canonical for Slack DMs.

Inbound path behaves differently/correctly

The inbound Slack path already has explicit D... handling:

// extensions/slack/src/monitor/channel-type.ts
if (trimmed.startsWith("D")) {
  return "im";
}
...
// D-prefix channel IDs are always DMs — override a contradicting channel_type.
if (inferred === "im" && normalized !== "im") {
  return "im";
}

Inbound routing then uses the Slack user as the direct peer:

// extensions/slack/src/monitor/message-handler/prepare-routing.ts
peer: {
  kind: params.isDirectMessage ? "direct" : params.isRoom ? "channel" : "group",
  id: params.isDirectMessage ? (params.message.user ?? "unknown") : params.message.channel,
}

For the same Slack DM, inbound therefore generates a slack:direct:<U...> session, while outbound delivery reconstruction can generate slack:channel:<D...>.

Reproduction / validation steps for a fresh session

A fresh Codex/OpenClaw maintainer can validate this without my local logs:

  1. Inspect extensions/slack/src/channel.ts in resolveSlackOutboundSessionRoute.
  2. Confirm it parses params.target with defaultKind: "channel".
  3. Confirm only G... IDs are passed to resolveSlackChannelType.
  4. Confirm bare D... IDs therefore become channel peers.
  5. With session.dmScope: "per-channel-peer", evaluate outbound route reconstruction for:
{
  channel: "slack",
  agentId: "main",
  target: "D0AEWSDHAQH",
  threadId: "1778110574.653649",
  cfg: { session: { dmScope: "per-channel-peer" }, channels: { slack: { ... } } }
}

Expected canonical result for a Slack IM channel whose peer is U09G2DJ0275:

agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649

Current result from the route logic:

agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649

Related PRs checked that do not fix this

I checked these because they are Slack/session-routing adjacent:

  • #78522 fixes channel root-vs-thread session splitting for requireMention: false channels. It explicitly does not change DMs/MPIMs or outbound routing.
  • #75356 preserves Slack mention-source metadata for implicit thread wakes. It does not touch Slack outbound target normalization or delivery mirrors.

Proposed fix

Minimal safe direction:

  1. In resolveSlackOutboundSessionRoute, treat bare D... IDs as native Slack IM channel IDs, not normal channels.
  2. Resolve D... -> U... using conversations.info(channel=D...) when possible. resolveSlackChannelType already calls conversations.info, but currently discards channel.user; it may need to return richer info for IM channels.
  3. When channel.is_im === true && channel.user:
peer = { kind: "direct", id: channel.user }
chatType = "direct"
from = `slack:${channel.user}`
to = `user:${channel.user}`
// retain native channel id in delivery context if needed
  1. If Slack API lookup fails or does not return channel.user, still do not persist D... as channel:D.... Safer fallback options:
    • classify as direct with peer id D... and preserve nativeChannelId, or
    • use conversations.members(D...) and choose the non-bot member when available, or
    • return null and skip mirror session creation rather than creating a known-wrong channel session.
  2. Add alias/migration handling for existing agent:*:slack:channel:d... session entries so old delivery mirrors can be found/merged with canonical agent:*:slack:direct:u... sessions once the mapping is known.

Suggested tests

Add focused tests around Slack outbound route reconstruction and delivery mirrors:

  • Bare D... outbound target with conversations.info returning { is_im: true, user: "U..." } produces agent:<agent>:slack:direct:<u...>.
  • Same case with threadId preserves the thread suffix on the canonical direct session.
  • Bare D... with lookup failure does not produce agent:<agent>:slack:channel:<d...>.
  • channel:D... is either canonicalized to direct via lookup or rejected/handled as a native IM channel, but not silently stored as a normal channel session.
  • Existing G... MPIM behavior remains group.
  • Inbound DM route and outbound delivery-mirror route for the same physical Slack DM thread produce the same session key under session.dmScope: "per-channel-peer".

Assessment

Confidence: high that this is a real bug in Slack outbound route/session canonicalization and not a local config issue.

The local config value session.dmScope: "per-channel-peer" is not the cause. It exposes the bug because correct direct-message routing should key by Slack user ID, while the bad outbound mirror path bypasses direct-message classification entirely and stores the same conversation as a channel keyed by Slack's IM channel ID.

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 DM delivery mirrors can split sessions by routing bare D... IM channel IDs as channel sessions [1 pull requests, 2 comments, 3 participants]