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 <tianxiaochannel@gmail.com>
This commit is contained in:
xiaotian
2026-05-28 06:16:17 +08:00
committed by GitHub
parent cf47580a45
commit fb1dfd486b
2 changed files with 55 additions and 1 deletions

View File

@@ -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;

View File

@@ -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();