From fb1dfd486bb9aca05055d88c51efe4fbc279a9fc Mon Sep 17 00:00:00 2001 From: xiaotian Date: Thu, 28 May 2026 06:16:17 +0800 Subject: [PATCH] fix(slack): retain delivered final replies during late cleanup Fix Slack draft cleanup after final-visible delivery. Track when Slack has already delivered a visible final reply and stop reusing the draft finalizer for later same-turn final/error payloads. This keeps the first fallback cleanup for transient previews while preventing late cleanup from deleting a visible answer. Fixes #87363 Co-authored-by: tianxiaochannel-oss88 --- .../dispatch.preview-fallback.test.ts | 36 +++++++++++++++++++ .../src/monitor/message-handler/dispatch.ts | 20 ++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 7bc4c86f0bd..b75da03c89b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -1238,6 +1238,42 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.clear).not.toHaveBeenCalled(); }); + it("does not reuse draft cleanup after a normally delivered final reply", async () => { + const draftStream = { + ...createDraftStreamStub(), + flush: vi.fn(noopAsync), + clear: vi.fn(noopAsync), + discardPending: vi.fn(noopAsync), + seal: vi.fn(noopAsync), + }; + createSlackDraftStreamMock.mockReturnValueOnce(draftStream); + mockedDispatchSequence = [ + { + kind: "final", + payload: { text: "answer", mediaUrl: "https://example.com/final.png" }, + }, + { kind: "final", payload: { text: "late cleanup failed", isError: true } }, + ]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled(); + expect(deliverRepliesMock).toHaveBeenCalledTimes(2); + expect(draftStream.clear).toHaveBeenCalledTimes(1); + const firstDelivered = requireRecord( + requireMockCall(deliverRepliesMock, 0, "deliver replies")[0], + "deliver replies params", + ); + expect(firstDelivered.replies).toEqual([ + { text: "answer", mediaUrl: "https://example.com/final.png" }, + ]); + const lateDelivered = requireRecord( + requireMockCall(deliverRepliesMock, 1, "deliver replies")[0], + "deliver replies params", + ); + expect(lateDelivered.replies).toEqual([{ text: "late cleanup failed", isError: true }]); + }); + it("suppresses block streaming when Slack draft preview streaming is active", async () => { mockedBlockStreamingEnabled = true; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 8076fa2c40d..84be39272d7 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -591,6 +591,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag let usedReplyThreadTs: string | undefined; let usedBlockReplyThreadTs: string | undefined; let observedReplyDelivery = false; + let observedFinalReplyDelivery = false; const deliveryTracker = createSlackEventDeliveryTracker(); const resolveDeliveryThreadTs = (params: { kind: ReplyDispatchKind; @@ -693,6 +694,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}), }); observedReplyDelivery = true; + if (params.kind === "final") { + observedFinalReplyDelivery = true; + } const deliveredThreadTs = resolveDeliveredSlackReplyThreadTs({ replyToMode: replyDeliveryMode, payloadReplyToId: params.payload.replyToId, @@ -720,6 +724,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return false; } replyPlan.markSent(); + if (params.kind === "final") { + observedFinalReplyDelivery = true; + } deliveryTracker.markDelivered({ kind: params.kind, payload: params.payload, @@ -798,6 +805,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag // the SDK reports a real Slack response. if (streamSession.delivered) { observedReplyDelivery = true; + if (params.kind === "final") { + observedFinalReplyDelivery = true; + } } rememberDeliveredThreadTs(params.kind, streamThreadTs); replyPlan.markSent(); @@ -829,6 +839,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag // optimistic "done" status until Slack acknowledges a flush. if (streamSession.delivered) { observedReplyDelivery = true; + if (params.kind === "final") { + observedFinalReplyDelivery = true; + } } deliveryTracker.markDelivered({ kind: params.kind, @@ -915,6 +928,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ttsSupplement?.visibleTextAlreadyDelivered !== true && Boolean(draftStream) && !draftPreviewCommitted && + !observedFinalReplyDelivery && previewStreamingEnabled && !payload.text?.trim(); @@ -923,6 +937,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ttsSupplement && draftStream && !draftPreviewCommitted && + !observedFinalReplyDelivery && previewStreamingEnabled && !payload.isError && trimmedFinalText.length > 0 @@ -970,6 +985,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return; } draftPreviewCommitted = true; + observedFinalReplyDelivery = true; observedReplyDelivery = true; replyPlan.markSent(); await deliverNormally({ @@ -987,7 +1003,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag payload, adapter: defineFinalizableLivePreviewAdapter({ draft: - draftStream && !draftPreviewCommitted + draftStream && !draftPreviewCommitted && !observedFinalReplyDelivery ? { flush: draftStream.flush, clear: draftStream.clear, @@ -1030,11 +1046,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag threadTs: edit.threadTs, }); draftPreviewCommitted = true; + observedFinalReplyDelivery = true; }, onPreviewFinalized: (_preview) => { // The preview edit promotes the draft message into the final answer. // Later same-turn payloads must not let fallback cleanup clear it. draftPreviewCommitted = true; + observedFinalReplyDelivery = true; const finalThreadTs = usedReplyThreadTs ?? statusThreadTs; observedReplyDelivery = true; replyPlan.markSent();