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.
* fix(mattermost): prevent duplicate messages when block streaming + threading are active
Remove replyToId from createBlockReplyPayloadKey so identical content is
deduplicated regardless of threading target. Add explicit threading dock
to the Mattermost plugin with resolveReplyToMode reading from config
(default "all"), and add replyToMode to the Mattermost config schema.
Fixes#41219
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(mattermost): address PR review — per-account replyToMode and test clarity
Read replyToMode from the merged per-account config via
resolveMattermostAccount so account-level overrides are honored in
multi-account setups. Add replyToMode to MattermostAccountConfig type.
Rename misleading test to clarify it exercises shouldDropFinalPayloads
short-circuit, not payload key dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Replies: keep block-pipeline reply targets distinct
* Tests: cover block reply target-aware dedupe
* Update CHANGELOG.md
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(cron): prevent duplicate proactive delivery on transient retry
* refactor: scope skipQueue to retryTransient path only
Non-retrying direct delivery (structured content / thread) keeps the
write-ahead queue so recoverPendingDeliveries can replay after a crash.
Addresses review feedback from codex-connector.
* fix: preserve write-ahead queue on initial delivery attempt
The first call through retryTransientDirectCronDelivery now keeps the
write-ahead queue entry so recoverPendingDeliveries can replay after a
crash. Only subsequent retry attempts set skipQueue to prevent
duplicate sends.
Addresses second codex-connector review on ea5ae5c.
* ci: retrigger checks
* Cron: bypass write-ahead queue for direct isolated delivery
* Tests: assert isolated cron skipQueue invariants
* Changelog: add cron duplicate-delivery fix entry
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix: prevent duplicate assistant messages in TUI (fixes#35278)
When startAssistant() is called multiple times with the same runId,
it was creating duplicate AssistantMessageComponent instances instead
of reusing the existing one. This caused messages to appear twice in
the terminal UI.
The fix checks if a component already exists for the runId before
creating a new one. If it exists, we update its text instead of
appending a duplicate component.
Test coverage includes verification that:
- Only one component is created when startAssistant is called twice
- The second text replaces the first
- Component count remains 1 (prevents regression)
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
* Changelog: add TUI duplicate-render fix entry
---------
Co-authored-by: 沐沐 <mumu@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Fixes#5090
Without this plist key, macOS silently denies Reminders access when
running through OpenClaw.app, preventing the apple-reminders skill
from requesting permission.
(cherry picked from commit e5774471c851b773dd2bffd51dd5d28d95a8a7ca)