diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 83c652911624..7012206689a9 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -100,16 +100,44 @@ describe("sendMessageSlack thread participation", () => { clearSlackThreadParticipationCache(); const client = createSlackSendTestClient(); - await sendMessageSlack("channel:C123", "hello thread", { + const result = await sendMessageSlack("channel:C123", "hello thread", { token: "xoxb-test", cfg: SLACK_TEST_CFG, client, threadTs: "1712345678.123456", }); + expect(result.threadTs).toBe("1712345678.123456"); + expect(result.receipt.threadId).toBe("1712345678.123456"); expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(true); }); + it("records canonical Slack response thread participation instead of requested child thread", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + client.chat.postMessage.mockResolvedValueOnce({ + ts: "1781932190.115869", + channel: "C123", + message: { + ts: "1781932190.115869", + thread_ts: "1781803536.235489", + }, + }); + + const result = await sendMessageSlack("channel:C123", "hello thread", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "1781932168.648159", + }); + + expect(postedMessage(client).thread_ts).toBe("1781932168.648159"); + expect(result.threadTs).toBe("1781803536.235489"); + expect(result.receipt.threadId).toBe("1781803536.235489"); + expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true); + expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false); + }); + it("does not record participation for unthreaded sends", async () => { clearSlackThreadParticipationCache(); const client = createSlackSendTestClient(); @@ -174,6 +202,40 @@ describe("sendMessageSlack chunking", () => { ).toStrictEqual([]); expect(postedTexts.join("")).toBe(message); }); + + it("preserves the first canonical response thread across chunked sends", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + client.chat.postMessage + .mockResolvedValueOnce({ + ts: "1781932190.115869", + channel: "C123", + message: { + ts: "1781932190.115869", + thread_ts: "1781803536.235489", + }, + }) + .mockResolvedValueOnce({ + ts: "1781932191.000000", + channel: "C123", + }); + const message = "a".repeat(8500); + + const result = await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "1781932168.648159", + }); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(2); + expect(postedMessage(client).thread_ts).toBe("1781932168.648159"); + expect(postedMessage(client, 1).thread_ts).toBe("1781932168.648159"); + expect(result.threadTs).toBe("1781803536.235489"); + expect(result.receipt.threadId).toBe("1781803536.235489"); + expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true); + expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false); + }); }); describe("sendMessageSlack blocks", () => { @@ -202,6 +264,34 @@ describe("sendMessageSlack blocks", () => { expect((receiptPart?.raw as Record | undefined)?.channelId).toBe("C123"); }); + it("uses canonical Slack response thread for block receipts and participation", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + client.chat.postMessage.mockResolvedValueOnce({ + ts: "1781932190.115869", + channel: "C123", + message: { + ts: "1781932190.115869", + thread_ts: "1781803536.235489", + }, + }); + + const result = await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "1781932168.648159", + blocks: [{ type: "divider" }], + }); + + expect(postedMessage(client).thread_ts).toBe("1781932168.648159"); + expect(result.threadTs).toBe("1781803536.235489"); + expect(result.receipt.threadId).toBe("1781803536.235489"); + expect(result.receipt.parts[0]?.kind).toBe("card"); + expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true); + expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false); + }); + it("posts user-target block messages directly without conversations.open", async () => { const client = createSlackSendTestClient(); client.conversations.open.mockRejectedValueOnce(new Error("missing_scope")); diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 26bef71aac37..adb407a2f944 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -360,10 +360,18 @@ async function postSlackMessageBestEffort(params: { } } +function resolvePostedMessageThreadTs(response: { + message?: { thread_ts?: unknown }; +}): string | undefined { + const threadTs = response.message?.thread_ts; + return typeof threadTs === "string" ? normalizeSlackThreadTsCandidate(threadTs) : undefined; +} + export type SlackSendResult = { messageId: string; channelId: string; receipt: MessageReceipt; + threadTs?: string; }; function createSlackSendReceipt(params: { @@ -664,7 +672,7 @@ export async function sendMessageSlack( blocks, }), ); - const threadTs = normalizeSlackThreadTsCandidate(opts.threadTs); + const threadTs = result.threadTs ?? normalizeSlackThreadTsCandidate(opts.threadTs); if (threadTs && result.channelId && account.accountId) { recordSlackThreadParticipation(account.accountId, result.channelId, threadTs); } @@ -737,14 +745,17 @@ async function sendMessageSlackQueuedInner(params: { }); const messageId = response.ts ?? "unknown"; const deliveredChannelId = resolvePostedMessageChannelId(response, channelId); + const deliveredThreadTs = + resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs); return { messageId, channelId: deliveredChannelId, + threadTs: deliveredThreadTs, receipt: createSlackSendReceipt({ platformMessageIds: [messageId], channelId: deliveredChannelId, kind: "card", - threadTs: opts.threadTs, + threadTs: deliveredThreadTs, }), }; } @@ -774,6 +785,7 @@ async function sendMessageSlackQueuedInner(params: { const sentMessageIds: string[] = []; let lastMessageId = ""; let deliveredChannelId = channelId; + let canonicalDeliveredThreadTs: string | undefined; if (opts.mediaUrl) { const [firstChunk, ...rest] = resolvedChunks; lastMessageId = await uploadSlackFile({ @@ -803,6 +815,7 @@ async function sendMessageSlackQueuedInner(params: { }); lastMessageId = response.ts ?? lastMessageId; deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId); + canonicalDeliveredThreadTs ??= resolvePostedMessageThreadTs(response); if (response.ts) { sentMessageIds.push(response.ts); } @@ -821,6 +834,7 @@ async function sendMessageSlackQueuedInner(params: { }); lastMessageId = response.ts ?? lastMessageId; deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId); + canonicalDeliveredThreadTs ??= resolvePostedMessageThreadTs(response); if (response.ts) { sentMessageIds.push(response.ts); } @@ -828,14 +842,17 @@ async function sendMessageSlackQueuedInner(params: { } const messageId = lastMessageId || "unknown"; + const deliveredThreadTs = + canonicalDeliveredThreadTs ?? normalizeSlackThreadTsCandidate(opts.threadTs); return { messageId, channelId: deliveredChannelId, + threadTs: deliveredThreadTs, receipt: createSlackSendReceipt({ platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId], channelId: deliveredChannelId, kind: opts.mediaUrl ? "media" : "text", - threadTs: opts.threadTs, + threadTs: deliveredThreadTs, }), }; }