The variable was assigned but never read since fallback delivery is
intentionally unconditional in this path. Fixes lint error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tsc strict mode refuses to narrow mutable let variables through spreads even
with const binding, !== null guards, and non-null assertions. Object.assign
avoids the TS2698 issue entirely since it accepts any source arguments.
Both tsc and tsgo fail to narrow a mutable let variable through a spread
expression regardless of guards or non-null assertions. The fix: assign the
outer let to a block-scoped const, then check the const !== null in a
separate if. TypeScript always narrows a const through a distinct null check
so the spread is accepted by all compiler variants.
tsc does not narrow mutable let variables through spread type expressions
even when an explicit !== null guard precedes the spread. The assertion is
safe — lastStreamPayload !== null is checked in the same condition.
tsgo (native TypeScript compiler) does not narrow a let variable through a
truthy check in a compound && condition when it appears in a spread
expression. Replace the intermediate const + truthy check with an explicit
!== null guard which tsgo reliably narrows.
When a streaming append/start call fails and chat.delete also fails, the
stream message is left in 'streaming' state — never finalized via
chat.stopStream, which may render as invisible or broken on mobile Slack.
streamSession.stopped is already set to true so the end-of-dispatch
finalizer also skips the stream, leaving the payload with no recovery path.
Remove the orphanDeleted guard from the deliverWithStreaming catch block:
always call deliverNormally here even if deletion failed, to ensure the user
receives the complete answer. A cosmetic duplicate on desktop clients is
preferable to a silently truncated answer.
The guard is intentionally kept in the finalizer catch: there the stream
message has already been fully finalized (all content visible), so skipping
deliverNormally on deletion failure avoids a true content duplicate.
TypeScript cannot narrow a mutable let variable through a null-check guard
when it is used in a spread expression (TS2698: Spread types may only be
created from object types). Binding to a local const lets the compiler
narrow the type correctly.
If chat.delete throws after a stream failure, deliverNormally was called
unconditionally — leaving both the unfinalizable stream message and the
fallback reply visible, recreating the exact duplicate this PR prevents.
Fix: introduce orphanDeleted flag in both failure paths (deliverWithStreaming
catch and the finalizer catch). deliverNormally is now only called when:
- there was no orphaned stream message to begin with (streamMessageTs
undefined = stream never flushed to Slack), OR
- chat.delete succeeded
If deletion fails, the stream message is still visible with its full content,
so skipping the fallback is the correct behaviour — the user sees the content
without a duplicate.
When appendSlackStream throws for a later payload, the fallback was calling
deliverNormally(payload, ...) with only the current chunk — dropping all text
from earlier payloads that was already live in the stream message.
dispatchReplyFromConfig can emit multiple final payloads per turn (it
iterates the replies array), so a mid-stream Slack API error could silently
truncate the visible answer.
Fix: accumulate all successfully-streamed text in streamedText (updated after
each successful startSlackStream / appendSlackStream). On failure, re-deliver
{ ...payload, text: streamedText + current chunk } so the user always gets
the complete content. The finalizer fallback (stopSlackStream failure) also
uses streamedText for the same reason.
Prevents ghost/duplicate messages on mobile Slack when streaming fails.
## Problem
When a streaming API call fails mid-stream, the partially-created stream
message (sent to Slack via chat.startStream) would persist alongside the
fallback normal reply, causing a duplicate on mobile clients.
Two issues also existed in the original cleanup approach:
1. streamTs is declared private on ChatStreamer in @slack/web-api — accessing
it directly fails TypeScript strict-mode / pnpm check at compile time.
2. When stopSlackStream fails in the finalizer, the orphaned message was
deleted but no fallback reply was sent — user gets silence.
## Fix
### src/slack/streaming.ts
- Add streamMessageTs?: string to SlackStreamSession. Populated lazily from
the first non-null response returned by streamer.append() — which is the
ChatStartStreamResponse carrying the stream message ts. Never undefined if
a message was actually sent to Slack; undefined means nothing to clean up.
- Capture ts in startSlackStream (from the initial append response).
- Also backfill in appendSlackStream in case the first append was buffered
(text < SDK buffer_size of 256 chars → returns null).
### src/slack/monitor/message-handler/dispatch.ts
- On streaming failure: mark stream stopped, delete orphaned message via
streamMessageTs (not private streamer.streamTs), then fall back to normal
delivery.
- On finalizer stopSlackStream failure: delete orphaned message + call
deliverNormally(lastStreamPayload) so the user gets a response.
- Track lastStreamPayload in outer scope across deliverWithStreaming calls.
* feat(slack): add typingReaction config for DM typing indicator fallback
Adds a reaction-based typing indicator for Slack DMs that works without
assistant mode. When `channels.slack.typingReaction` is set (e.g.
"hourglass_flowing_sand"), the emoji is added to the user's message when
processing starts and removed when the reply is sent.
Addresses #19809
* test(slack): add typingReaction to createSlackMonitorContext test callers
* test(slack): add typingReaction to test context callers
* test(slack): add typingReaction to context fixture
* docs(changelog): credit Slack typingReaction feature
* test(slack): align existing-thread history expectation
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
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
* 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>
* 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>
The streaming check was calling replyPlan.nextThreadTs() at setup time
to determine if a thread_ts existed, which consumed the first reference
before the deliver callback ran. Use incomingThreadTs/statusThreadTs
directly for the streaming eligibility check instead.
- Import ChatStreamer from @slack/web-api/dist/chat-stream.js (not re-exported from index)
- Fix TypeScript control flow narrowing for streamSession used in closure
Adds support for Slack's Agents & AI Apps text streaming APIs
(chat.startStream, chat.appendStream, chat.stopStream) to deliver
LLM responses as a single updating message instead of separate
messages per block.
Changes:
- New src/slack/streaming.ts with stream lifecycle helpers using
the SDK's ChatStreamer (client.chatStream())
- New 'streaming' config option on SlackAccountConfig
- Updated dispatch.ts to route block replies through the stream
when enabled, with graceful fallback to normal delivery
- Docs in docs/channels/slack.md covering setup and requirements
The streaming integration works by intercepting the deliver callback
in the reply dispatcher. When streaming is enabled and a thread
context exists, the first text delivery starts a stream, subsequent
deliveries append to it, and the stream is finalized after dispatch
completes. Media payloads and error cases fall back to normal
message delivery.
Refs:
- https://docs.slack.dev/ai/developing-ai-apps#streaming
- https://docs.slack.dev/reference/methods/chat.startStream
- https://docs.slack.dev/reference/methods/chat.appendStream
- https://docs.slack.dev/reference/methods/chat.stopStream