diff --git a/extensions/slack/src/monitor/message-handler.test.ts b/extensions/slack/src/monitor/message-handler.test.ts index e4a3d84e3ee4..8bc7b3449380 100644 --- a/extensions/slack/src/monitor/message-handler.test.ts +++ b/extensions/slack/src/monitor/message-handler.test.ts @@ -563,6 +563,47 @@ describe("createSlackMessageHandler", () => { expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111"); }); + it("retires a buffered key when replay filtering drops every entry", async () => { + const handler = createSlackMessageHandler({ + ctx: createContext(), + account: { accountId: "default" } as Parameters< + typeof createSlackMessageHandler + >[0]["account"], + }); + const bufferedMessage = { + type: "message" as const, + channel: "C111", + user: "U111", + ts: "1709000000.000300", + text: "duplicate buffered text", + }; + + await handler(bufferedMessage as never, { source: "message" }); + const first = enqueueMock.mock.calls[0]?.[0] as Record; + await runOnFlush([first]); + + await handler(bufferedMessage as never, { source: "message" }); + const duplicate = enqueueMock.mock.calls[1]?.[0] as Record; + await runOnFlush([duplicate]); + expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); + flushKeyMock.mockClear(); + + await handler( + { + type: "message", + subtype: "file_share", + channel: "C111", + user: "U111", + ts: "1709000000.000400", + text: "file follows", + files: [{ id: "F1" }], + } as never, + { source: "message" }, + ); + + expect(flushKeyMock).not.toHaveBeenCalled(); + }); + it("waits for debounced dispatch completion when requested by relay delivery", async () => { const { handler } = createHandlerWithTracker(); const handled = handler( diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index 24622d8425fb..6f913eee7823 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -208,6 +208,29 @@ export function createSlackMessageHandler(params: { .filter((completion) => completion !== undefined); try { await (async () => { + const flushedEntry = entries.at(-1); + if (flushedEntry) { + const teamId = flushedEntry.opts.eventScope?.teamId; + const flushedKey = buildSlackDebounceKey( + flushedEntry.message, + ctx.accountId, + teamId, + ); + const topLevelConversationKey = buildTopLevelSlackConversationKey( + flushedEntry.message, + ctx.accountId, + teamId, + ); + if (flushedKey && topLevelConversationKey) { + const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey); + if (pendingKeys) { + pendingKeys.delete(flushedKey); + if (pendingKeys.size === 0) { + pendingTopLevelDebounceKeys.delete(topLevelConversationKey); + } + } + } + } // Logical-identity claims: Slack sends message + app_mention twins with // distinct event_ids for one post, so the durable queue cannot dedupe // them. Same-flush twins share one claim and one logical message while @@ -271,22 +294,6 @@ export function createSlackMessageHandler(params: { releaseClaims(); return; } - const teamId = last.opts.eventScope?.teamId; - const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId, teamId); - const topLevelConversationKey = buildTopLevelSlackConversationKey( - last.message, - ctx.accountId, - teamId, - ); - if (flushedKey && topLevelConversationKey) { - const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey); - if (pendingKeys) { - pendingKeys.delete(flushedKey); - if (pendingKeys.size === 0) { - pendingTopLevelDebounceKeys.delete(topLevelConversationKey); - } - } - } const combinedText = surviving.length === 1 ? (last.message.text ?? "")