Skip to content

Slack DM delivery mirrors can split sessions by routing bare D... IM channel IDs as channel sessions #80091

Description

@theo674

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:

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions