From 9edfefedf7bf5cbaed83cd3f27447aeff62b1981 Mon Sep 17 00:00:00 2001 From: sudie-codes Date: Wed, 8 Apr 2026 19:22:09 -0700 Subject: [PATCH] fix(msteams): route thread replies to correct thread via replyToId (#58030) (#62715) --- extensions/msteams/src/conversation-store.ts | 9 ++ extensions/msteams/src/messenger.test.ts | 141 ++++++++++++++++++ extensions/msteams/src/messenger.ts | 8 +- .../src/monitor-handler/message-handler.ts | 13 +- 4 files changed, 168 insertions(+), 3 deletions(-) diff --git a/extensions/msteams/src/conversation-store.ts b/extensions/msteams/src/conversation-store.ts index 04389b95ae3..56b31d198fa 100644 --- a/extensions/msteams/src/conversation-store.ts +++ b/extensions/msteams/src/conversation-store.ts @@ -36,6 +36,15 @@ export type StoredConversationReference = { graphChatId?: string; /** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */ timezone?: string; + /** + * Thread root message ID for channel thread messages. + * When a message arrives inside a Teams channel thread, the Bot Framework + * sets `conversation.id` to `19:xxx@thread.tacv2;messageid=` and/or + * `replyToId` to the thread root activity ID. This field caches that root ID + * so outbound replies can target the correct thread instead of landing as + * top-level channel posts. + */ + threadId?: string; }; export type MSTeamsConversationStoreEntry = { diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index fa938626e0a..9da944abd4e 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -474,6 +474,147 @@ describe("msteams messenger", () => { expect(reference.activityId).toBeUndefined(); }); + it("uses threadId instead of activityId for channel revoke fallback (#58030)", async () => { + const proactiveSent: string[] = []; + let capturedReference: unknown; + + const channelRef: StoredConversationReference = { + activityId: "current-message-id", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { + id: "19:abc@thread.tacv2", + conversationType: "channel", + }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + // threadId is the thread root, which differs from activityId (current message) + threadId: "thread-root-msg-id", + }; + + const ctx = createRevokedThreadContext(); + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + capturedReference = reference; + await logic({ + sendActivity: createRecordedSendActivity(proactiveSent), + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }); + }, + process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }; + + await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: channelRef, + context: ctx, + messages: [{ text: "hello" }], + }); + + expect(proactiveSent).toEqual(["hello"]); + const ref = capturedReference as { conversation?: { id?: string }; activityId?: string }; + // Should use threadId (thread root), NOT activityId (current message) + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id"); + expect(ref.activityId).toBeUndefined(); + }); + + it("falls back to activityId when threadId is not set (backward compat)", async () => { + const proactiveSent: string[] = []; + let capturedReference: unknown; + + const channelRef: StoredConversationReference = { + activityId: "legacy-activity-id", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { + id: "19:abc@thread.tacv2", + conversationType: "channel", + }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + // No threadId — older stored references may not have it + }; + + const ctx = createRevokedThreadContext(); + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + capturedReference = reference; + await logic({ + sendActivity: createRecordedSendActivity(proactiveSent), + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }); + }, + process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }; + + await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: channelRef, + context: ctx, + messages: [{ text: "hello" }], + }); + + expect(proactiveSent).toEqual(["hello"]); + const ref = capturedReference as { conversation?: { id?: string } }; + // Falls back to activityId when threadId is missing + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id"); + }); + + it("does not add thread suffix for top-level replyStyle even with threadId set", async () => { + let capturedReference: unknown; + const sent: string[] = []; + + const channelRef: StoredConversationReference = { + activityId: "current-msg", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { + id: "19:abc@thread.tacv2", + conversationType: "channel", + }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + threadId: "thread-root-msg-id", + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + capturedReference = reference; + await logic({ + sendActivity: createRecordedSendActivity(sent), + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }); + }, + process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }; + + await sendMSTeamsMessages({ + replyStyle: "top-level", + adapter, + appId: "app123", + conversationRef: channelRef, + messages: [{ text: "hello" }], + }); + + expect(sent).toEqual(["hello"]); + const ref = capturedReference as { conversation?: { id?: string } }; + // Top-level sends should NOT include thread suffix + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2"); + }); + it("retries top-level sends on transient (5xx)", async () => { const attempts: string[] = []; diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 1e62fe948f6..d8aa2767758 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -521,12 +521,16 @@ export async function sendMSTeamsMessages(params: { return messageIds; }; + // Resolve the thread root message ID for channel thread routing. + // `threadId` is the canonical thread root (set on inbound for channel threads); + // fall back to `activityId` for backward compatibility with older stored refs. + const resolvedThreadId = params.conversationRef.threadId ?? params.conversationRef.activityId; + if (params.replyStyle === "thread") { const ctx = params.context; if (!ctx) { throw new Error("Missing context for replyStyle=thread"); } - const threadActivityId = params.conversationRef.activityId; const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { const result = await withRevokedProxyFallback({ @@ -541,7 +545,7 @@ export async function sendMSTeamsMessages(params: { const remaining = messages.slice(idx); return { ids: - remaining.length > 0 ? await sendProactively(remaining, idx, threadActivityId) : [], + remaining.length > 0 ? await sendProactively(remaining, idx, resolvedThreadId) : [], fellBack: true, }; }, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 60c8b9792fb..e06d7679bd7 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -93,8 +93,10 @@ function buildStoredConversationReference(params: { conversationId: string; conversationType: string; teamId?: string; + /** Thread root message ID for channel thread messages. */ + threadId?: string; }): StoredConversationReference { - const { activity, conversationId, conversationType, teamId } = params; + const { activity, conversationId, conversationType, teamId, threadId } = params; const from = activity.from; const conversation = activity.conversation; const agent = activity.recipient; @@ -116,6 +118,7 @@ function buildStoredConversationReference(params: { serviceUrl: activity.serviceUrl, locale: activity.locale, ...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}), + ...(threadId ? { threadId } : {}), }; } @@ -210,11 +213,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId); const conversationType = conversation?.conversationType ?? "personal"; const teamId = activity.channelData?.team?.id; + // For channel thread messages, resolve the thread root message ID so outbound + // replies land in the correct thread. The root ID comes from the `messageid=` + // portion of conversation.id (preferred) or from activity.replyToId. + const threadId = + conversationType === "channel" + ? (conversationMessageId ?? activity.replyToId ?? undefined) + : undefined; const conversationRef = buildStoredConversationReference({ activity, conversationId, conversationType, teamId, + threadId, }); const {