#23118: fix(slack): await draft stream flush before messageId check
channel: slack
size: XS
Cluster:
Block Streaming Enhancements
## Summary
Fixes #19373.
When Slack streaming is enabled, `sendMessageSlack()` is called fire-and-forget inside the draft stream. The deliver callback in `dispatch.ts` then reads `draftStream.messageId()` to decide whether it can finalize via preview-edit — but the send hasn't resolved yet, so `messageId()` returns `undefined`. This causes `canFinalizeViaPreviewEdit` to evaluate `false`, and the dispatcher falls through to normal delivery, posting a **duplicate message**.
This PR adds `await draftStream?.flush()` before the `messageId()` read so the in-flight send resolves first.
## Root Cause
In `dispatchPreparedSlackMessage` (dispatch.ts), the deliver callback evaluates:
```typescript
const draftMessageId = draftStream?.messageId();
const canFinalizeViaPreviewEdit = draftMessageId && draftChannelId && ...;
```
But `messageId()` is only populated after the draft stream's `sendMessageSlack()` promise resolves. Since the send is fire-and-forget (kicked off by `stream.update()`), there's a race: if the deliver callback runs before the send completes, `messageId()` returns `undefined` and the message is delivered normally — duplicating the streamed draft.
The Discord handler already does `await flushDraft()` before reading `messageId` (at `message-handler.process.ts:555`), but the Slack handler was missing the equivalent call.
## Changes
**`src/slack/monitor/message-handler/dispatch.ts`** — Add flush before messageId read:
```diff
}
+ // Flush any in-flight draft send so messageId() is populated.
+ // Without this, a race between the fire-and-forget sendMessageSlack()
+ // and the deliver callback can cause canFinalizeViaPreviewEdit to
+ // evaluate false, resulting in a duplicate Slack message.
+ await draftStream?.flush();
+
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
const draftMessageId = draftStream?.messageId();
```
**`src/slack/draft-stream.test.ts`** — Two new tests:
```diff
+ it("flush resolves in-flight send so messageId is available (race condition fix)", async () => {
+ // Creates a deferred send, starts flush, verifies messageId is undefined,
+ // resolves the send, then verifies messageId is populated
+ });
+
+ it("flush is safe to call when no draft has been started", async () => {
+ // Verifies flush is a no-op when no update has been called
+ });
```
## Comparison with #20248 and #20623
| Aspect | This PR | #20248 | #20623 |
|--------|---------|--------|--------|
| **Fix location** | Before `messageId()` read | Before final reply delivery (after `messageId()` read) | Similar to this PR |
| **Root cause addressed** | Yes — flush ensures `messageId()` is populated | Partially — flushes too late for `canFinalizeViaPreviewEdit` | Yes, but bundled with streaming fix |
| **Scope** | Minimal (1 line + comment) | Minimal (flush + stop) | Broader (also fixes `recipient_team_id`) |
| **Tests** | 2 new tests | None | None |
| **Streaming fix bundled** | No (already merged as #20988) | No | Yes (redundant with #20988) |
The key difference from #20248: that PR flushes before the final `deliverReplies` call, but the `canFinalizeViaPreviewEdit` evaluation happens _before_ delivery. If `messageId()` is still `undefined` at evaluation time, the code has already decided to skip preview-edit and deliver normally — flushing afterward doesn't prevent the duplicate. This PR flushes _before_ the evaluation, which is the correct fix point.
## Test Plan
- 2 new tests in `src/slack/draft-stream.test.ts`
- `flush resolves in-flight send so messageId is available` — verifies the race condition fix
- `flush is safe to call when no draft has been started` — verifies no-op safety
## Related
- Closes #19373
- Related: #22242, #22254
- Competing: #20248, #20623
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Adds `await draftStream?.flush()` before reading `messageId()` in the deliver callback to ensure in-flight draft sends complete before the `canFinalizeViaPreviewEdit` check. Without this flush, a race condition causes `messageId()` to return `undefined`, preventing preview-edit finalization and resulting in duplicate Slack messages.
- Fixes race condition where fire-and-forget `sendMessageSlack()` hasn't resolved when `messageId()` is checked
- Matches Discord handler pattern (line 559 in `message-handler.process.ts`)
- Includes two well-structured tests verifying flush behavior and safety
<h3>Confidence Score: 5/5</h3>
- This PR is safe to merge with no risk
- The fix is minimal, well-tested, and follows an established pattern from Discord. The race condition is clearly identified and the solution directly addresses the root cause by ensuring the async send completes before reading the result.
- No files require special attention
<sub>Last reviewed commit: 132c809</sub>
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Most Similar PRs
#20248: fix(slack): flush draft stream before final reply to preserve messa...
by Utkarshbhimte · 2026-02-18
89.7%
#22440: fix(slack): prevent duplicate final replies from draft previews
by Solvely-Colin · 2026-02-21
87.6%
#20623: fix(slack): duplicate replies and missing streaming recipient params
by rahulsub-be · 2026-02-19
87.1%
#19479: fix(telegram): skip redundant final edit in partial streaming mode
by v8hid · 2026-02-17
79.4%
#21754: slack: pass inbound team_id into stream routing and startStream
by AIflow-Labs · 2026-02-20
78.2%
#22096: fix(slack): traverse .original for Slack SDK errors; pass recipient...
by maiclaw · 2026-02-20
78.2%
#19673: fix(telegram): avoid starting streaming replies with only 1-2 words
by emanuelst · 2026-02-18
77.3%
#23804: fix(slack): preserve string thread context in queue + DM route
by vincentkoc · 2026-02-22
76.5%
#18460: fix(telegram): send fallback when streamMode partial drops all mess...
by BinHPdev · 2026-02-16
76.3%
#21218: fix(slack): pass recipient_team_id and recipient_user_id to chat stre…
by apham0001 · 2026-02-19
76.2%