Skip to content

[Bug]: Slack DM thread attachments posted outside thread when sent via message tool #38409

@Taskle

Description

@Taskle

Summary

When an agent is in a Slack DM thread and sends an attachment (image, audio, TTS output, file) via the message tool (action=send with media or buffer), the attachment is posted outside the thread as a new top-level message instead of continuing in the existing thread.

Text auto-replies work correctly — only explicit message tool sends of media are affected.

Steps to Reproduce

  1. Configure OpenClaw with Slack (DM mode; replyToMode at default "off" or unset)
  2. Start a thread in a Slack DM by replying to an existing message
  3. Ask the agent something that causes it to send media via the message tool (e.g. TTS audio, a generated image, an uploaded file)
  4. Observe that the attachment lands in the main DM, not in the thread

Root Cause

Two gaps in the DM thread context chain inside the message tool path:

Gap 1 — buildSlackThreadingToolContext (src/slack/threading-tool-context.ts)

currentChannelId is only populated for channel:xxx-prefixed To values. In DMs, To is user:U0AC3LBA08M, so currentChannelId is always undefined:

// current — always undefined in DMs
currentChannelId: params.context.To?.startsWith("channel:")
  ? params.context.To.slice("channel:".length)
  : undefined,

Gap 2 — resolveSlackAutoThreadId (src/infra/outbound/message-action-params.ts)

Because currentChannelId is undefined, the guard at the top of the function exits early for every DM:

if (!context?.currentThreadTs || !context.currentChannelId) {
  return undefined; // ← always exits here for DMs
}

Even if currentChannelId were set, the next guard rejects DM (user:) targets outright:

if (!parsedTarget || parsedTarget.kind !== "channel") {
  return undefined; // ← user: targets have kind="user", not "channel"
}

The auto-reply path is unaffecteddeliverReplies uses replyPlan.nextThreadTs() which applies the isThreadReply → "all" override directly, bypassing resolveSlackAutoThreadId entirely. Only explicit message tool sends are broken.

Fix

src/slack/threading-tool-context.ts — preserve user:xxx as currentChannelId for DM sessions:

const to = params.context.To;
const currentChannelId = to?.startsWith("channel:")
  ? to.slice("channel:".length)
  : to?.startsWith("user:")
    ? to  // "user:U0AC3LBA08M" — preserved for DM thread matching
    : undefined;

src/infra/outbound/message-action-params.ts — build a canonical address for comparison instead of requiring kind === "channel":

const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!parsedTarget) return undefined;

// channel: compare raw ID; user/DM: compare as "user:<id>"
const targetAddress =
  parsedTarget.kind === "channel" ? parsedTarget.id : `user:${parsedTarget.id}`;
if (targetAddress.toLowerCase() !== context.currentChannelId.toLowerCase()) return undefined;

Tests

21 new assertions across two test files:

  • src/slack/threading-tool-context.test.ts — 3 new cases covering channel prefix stripping, DM user: preservation, and absent To
  • src/infra/outbound/message-action-params.test.ts (new file) — 11 cases covering channel threads, DM threads, cross-target isolation, replyToMode gating, and missing-context guards

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