Top-level channel messages were creating isolated per-message sessions because roomThreadId fell through to threadContext.messageTs whenever replyToMode was not off.
Introduced in #10686, every new channel message got its own session key (agent:...🧵<messageTs>), breaking conversation continuity.
Fix: only derive thread-specific session keys for actual thread replies. Top-level channel messages stay on the per-channel session key regardless of replyToMode.
Fixes#32285
Slack's Events API includes the parent message's files array in every
thread reply event payload. This caused OpenClaw to re-download and
attach the parent's files to every text-only thread reply, creating
ghost media attachments.
The fix filters out files that belong to the thread starter by comparing
file IDs. The resolveSlackThreadStarter result is already cached, so
this adds no extra API calls.
Closes#32203
Thread history and thread starter were being fetched and included on
every message in a Slack thread, causing unnecessary token bloat. The
session transcript already contains the full conversation history, so
re-fetching and re-injecting thread history on each turn is redundant.
Now thread history is only fetched for new thread sessions
(!threadSessionPreviousTimestamp). Existing sessions rely on their
transcript for context.
Fixes#32121
The native streaming path (chatStream) and preview final edit path
(chat.update) send raw Markdown text without converting to Slack
mrkdwn format. This causes **bold** to appear as literal asterisks
instead of rendered bold text.
Apply markdownToSlackMrkdwn() in streaming.ts (start/append/stop) and
in dispatch.ts (preview final edit via chat.update) to match the
non-streaming delivery path behavior.
Closes#31892
* fix(slack): use thread-level sessions for channels to prevent context mixing
All messages in a Slack channel share a single session, causing context from
different threads to mix together. When users have multiple conversations in
different threads of the same channel, the agent sees combined context from
all threads, leading to confused responses.
Session key was: `slack:channel:${channelId}` (no thread identifier)
1. **Thread-level session keys**: Each message in channels/groups now gets
its own session based on thread_ts:
- Thread replies: use the parent thread's ts
- New messages: use the message's own ts (becomes thread root)
- DMs: unchanged (no thread-level sessions needed)
New session key format: `slack:channel:${channelId}🧵${threadTs}`
2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours.
Users often pause conversations, and the short TTL caused unnecessary
API calls and thread resolution failures.
3. **Increased cache size**: Changed from 500 to 10,000 entries to support
busy workspaces with many active threads.
1. Create two threads in the same Slack channel
2. In Thread A: tell the bot your name is "Alice" and ask about "billing"
3. In Thread B: tell the bot your name is "Bob" and ask about "API"
4. Reply in Thread A and ask "what's my name?" - should say "Alice"
5. Check sessions: each thread should have a unique session key with 🧵 suffix
Fixes context bleed issues related to #758
* fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions
The context.ts file has a separate function for resolving session keys for
system events (reactions, file uploads, etc.). This also needs to support
thread-level sessions to ensure all Slack events route to the correct
thread-specific session.
Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey
and updated the implementation to use thread-level keys for channels/groups.
* fix(slack): preserve DM thread sessions for thread replies
The previous change broke thread-level sessions for DMs that have threads.
DMs with parent_user_id should still get thread-level sessions.
- For channels/groups: always use thread-level sessions
- For DMs: use thread-level sessions only when isThreadReply is true
* fix(slack): use thread-level sessionKey for previousTimestamp
Fixes the bug where previousTimestamp was read from the base channel
session key (route.sessionKey) instead of the resolved thread-level
sessionKey. This caused the elapsed-time calculation in the inbound
envelope to always pull from the channel session rather than the
thread session.
Also adds regression tests for the thread-level session key behavior.
Co-authored-by: Tony Dehnke <tdehnke@gmail.com>
* fix(slack): narrow #10686 to surgical thread-session patch
* test(slack): satisfy context/account typing in thread-session tests
* docs(changelog): record surgical slack thread-session fix
---------
Co-authored-by: Pablo Carvalho <pablo@telnyx.com>
Co-authored-by: Tony Dehnke <tdehnke@gmail.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(slack): create thread sessions for auto-threaded DM messages
When replyToMode="all", every top-level message starts a new Slack thread.
Previously, only subsequent replies in that thread got an isolated session
(via 🧵<threadTs> suffix). The initial message fell back to the base
DM session, mixing context across unrelated conversations.
Now, when replyToMode="all" and a message is not already a thread reply,
the message's own ts is used as the threadId for session key resolution.
This gives the initial message AND all subsequent thread replies the same
isolated session.
This enables per-thread session isolation for Slack DMs — each new message
starts its own thread and session, keeping conversations separate.
* Slack: fix auto-thread session key mode check and add changelog
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): use SLACK_USER_TOKEN when connecting to Slack (closes#26480)
* test(slack): fix account fixture typing for user token source
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(slack): track thread participation for auto-reply without @mention
* fix(slack): scope thread participation cache by accountId and capture actual reply thread ts
* fix(slack): capture reply thread ts from all delivery paths and only after success
* Slack: add changelog for thread participation cache behavior
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): resolve replyToMode per-message using chat type
The Slack monitor resolved replyToMode once at startup from the
top-level config, ignoring replyToModeByChatType overrides. This caused
DM replies to be threaded even when replyToModeByChatType.direct was
set to "off".
Now the inbound message handler calls resolveSlackReplyToMode(account,
chatType) per-message — the same function already used by the outbound
dock and tool threading context — so per-chat-type overrides take
effect on the inbound path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Slack: add changelog for per-message replyToMode resolution
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
Replace the hardcoded limit of 5 with the existing
MAX_SLACK_MEDIA_FILES constant (8) from media.ts for consistency.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a Slack message contains only files/audio (no text) and every file
download fails, `resolveSlackMedia` returns null and `rawBody` becomes
empty, causing `prepareSlackMessage` to silently drop the message.
Build a fallback placeholder from the original file names so the agent
still receives the message, matching the pattern already used in
`resolveSlackThreadHistory` for file-only thread entries.
Closes#25064
* fix: make replyToMode 'off' actually prevent threading in Slack
Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':
1. Typing indicator created threads via statusThreadTs fallback (#16868)
- resolveSlackThreadTargets fell back to messageTs for statusThreadTs
- 'is typing...' was posted as thread reply, creating a thread
- Fix: remove messageTs fallback, let statusThreadTs be undefined
2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
- Slack dock had allowExplicitReplyTagsWhenOff: true
- Reply tags from system prompt always threaded regardless of config
- Fix: set allowExplicitReplyTagsWhenOff to false for Slack
3. Contradictory replyToMode defaults in codebase (#20827)
- monitor/provider.ts defaulted to 'all'
- accounts.ts defaulted to 'off' (matching docs)
- Fix: align provider.ts default to 'off' per documentation
Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827
* fix(slack): respect replyToMode in DMs even with typing indicator thread
When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.
Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.
Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).
This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity
Fixes#16868
* refactor(slack): eliminate redundant resolveSlackThreadContext call
- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach
* docs(slack): add JSDoc to resolveSlackThreadTargets
Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.
* docs(changelog): record Slack replyToMode off threading fixes
---------
Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
* fix(slack): preserve thread_ts in queue drain and deliveryContext
Two related fixes for Slack thread reply routing:
1. Queue drain drops string thread_ts (#11195)
- `typeof threadId === "number"` in drain.ts only matches Telegram numeric
topic IDs. Slack thread_ts is a string like "1770474140.187459" which
fails the check, causing threadKey to become empty.
- Changed to `threadId != null && threadId !== ""` to accept both number
and string thread IDs.
- Applies to all 3 occurrences in drain.ts: cross-channel detection,
thread key building, and collected originatingThreadId extraction.
2. DM deliveryContext missing thread_ts (#10837)
- updateLastRoute calls for Slack DMs in both prepare.ts and dispatch.ts
built deliveryContext without threadId, so the session's delivery context
never included thread_ts for DM threads.
- Added threadId from threadContext.messageThreadId / ctxPayload.MessageThreadId
to both updateLastRoute call sites.
Tests: 3 new cases in queue.collect-routing.test.ts
- Collects messages with matching string thread_ts (same Slack thread)
- Separates messages with different string thread_ts (different threads)
- Treats empty string threadId same as absent
Closes#10837, closes#11195
* fix(slack): preserve string thread context in queue + DM route updates
---------
Co-authored-by: RobClawd <clawd@RobClawds-Mac-mini.local>
* fix(slack): pass recipient_team_id and recipient_user_id to streaming API calls
The Slack Agents & AI Apps streaming API (chat.startStream / chat.stopStream)
requires recipient_team_id and recipient_user_id parameters. Without them,
stopStream fails with 'missing_recipient_team_id' (all contexts) or
'missing_recipient_user_id' (DM contexts), causing streamed messages to
disappear after generation completes.
This passes:
- team_id (from auth.test at provider startup, stored in monitor context)
- user_id (from the incoming message sender, for DM recipient identification)
through to the ChatStreamer via recipient_team_id and recipient_user_id options.
Fixes#19839, #20847, #20299, #19791, #20337
AI-assisted: Written with Claude (Opus 4.6) via OpenClaw. Lightly tested
(unit tests pass, live workspace verification in progress).
* fix(slack): disable block streaming when native streaming is active
When Slack native streaming (`chat.startStream`/`stopStream`) is enabled,
`disableBlockStreaming` was set to `false`, which activated the app-level
block streaming pipeline. This pipeline intercepted agent output, sent it
via block replies, then dropped the final payloads that would have flowed
through `deliverWithStreaming` to the Slack streaming API — resulting in
zero replies delivered.
Set `disableBlockStreaming: true` when native streaming is active so the
final reply flows through the Slack streaming API path as intended.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>