From 3bbbf789cb82d808cc82f25c6ead66c1f378e31f Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sat, 21 Feb 2026 05:01:39 -0500 Subject: [PATCH] fix(slack): await draft stream flush before messageId check to prevent duplicate messages --- extensions/slack/src/draft-stream.test.ts | 35 +++++++++++++++++++ .../src/monitor/message-handler/dispatch.ts | 5 +++ 2 files changed, 40 insertions(+) diff --git a/extensions/slack/src/draft-stream.test.ts b/extensions/slack/src/draft-stream.test.ts index 6103ecb07e5..1e279e61a71 100644 --- a/extensions/slack/src/draft-stream.test.ts +++ b/extensions/slack/src/draft-stream.test.ts @@ -137,4 +137,39 @@ describe("createSlackDraftStream", () => { expect(stream.messageId()).toBeUndefined(); expect(stream.channelId()).toBeUndefined(); }); + + it("flush resolves in-flight send so messageId is available (race condition fix)", async () => { + let resolveDeferred!: (value: { channelId: string; messageId: string }) => void; + const send = vi.fn( + () => + new Promise((resolve) => { + resolveDeferred = resolve; + }), + ); + const { stream } = createDraftStreamHarness({ send }); + + stream.update("hello"); + // Start flush — send is in-flight but hasn't resolved + const flushPromise = stream.flush(); + + // At this point, sendMessageSlack is in-flight but hasn't resolved + expect(stream.messageId()).toBeUndefined(); + + // Simulate send completing + resolveDeferred({ channelId: "C123", messageId: "999.888" }); + await flushPromise; + + // Now messageId should be populated + expect(stream.messageId()).toBe("999.888"); + }); + + it("flush is safe to call when no draft has been started", async () => { + const { stream, send } = createDraftStreamHarness(); + + // Flush without any update — should be a no-op + await stream.flush(); + + expect(send).not.toHaveBeenCalled(); + expect(stream.messageId()).toBeUndefined(); + }); }); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index f3860c2f6bd..fa5c0b3f51c 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -310,6 +310,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); + // 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 draftMessageId = draftStream?.messageId(); const draftChannelId = draftStream?.channelId(); const finalText = reply.text;