fix(msteams): preserve channel reply threading in proactive fallback (#55198)

When a thread reply's turn context is revoked and falls back to proactive messaging, the normalized conversation ID lost the thread suffix, causing replies to land in the channel root instead of the original thread.

Reconstructs the threaded conversation ID (`;messageid=<activityId>`) for channel conversations in the proactive fallback path, while correctly leaving group chat conversations flat.

Fixes #27189

Thanks @hyojin
This commit is contained in:
Hyojin Kwak
2026-04-03 08:27:13 +09:00
committed by GitHub
parent ed8d5b3797
commit 739ed1bf29
2 changed files with 110 additions and 1 deletions

View File

@@ -404,6 +404,100 @@ describe("msteams messenger", () => {
expect(ids).toEqual(["id:one", "id:two", "id:three"]);
});
it("reconstructs threaded conversation ID for channel revoke fallback", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "activity456",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2;messageid=deadbeef",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
};
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 };
// Conversation ID should include the thread suffix for channel messages
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=activity456");
expect(ref.activityId).toBeUndefined();
});
it("does not add thread suffix for group chat revoke fallback", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const groupRef: StoredConversationReference = {
activityId: "activity789",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:group123@thread.v2",
conversationType: "groupChat",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
};
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: groupRef,
context: ctx,
messages: [{ text: "hello" }],
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
// Group chat should NOT have thread suffix — flat conversation
expect(ref.conversation?.id).toBe("19:group123@thread.v2");
expect(ref.activityId).toBeUndefined();
});
it("retries top-level sends on transient (5xx)", async () => {
const attempts: string[] = [];

View File

@@ -494,11 +494,21 @@ export async function sendMSTeamsMessages(params: {
const sendProactively = async (
batch: MSTeamsRenderedMessage[],
startIndex: number,
threadActivityId?: string,
): Promise<string[]> => {
const baseRef = buildConversationReference(params.conversationRef);
const isChannel = params.conversationRef.conversation?.conversationType === "channel";
// For Teams channels, reconstruct the threaded conversation ID so the
// proactive message lands in the correct thread instead of creating a
// new top-level post in the channel.
const conversationId =
isChannel && threadActivityId
? `${baseRef.conversation.id};messageid=${threadActivityId}`
: baseRef.conversation.id;
const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
conversation: { ...baseRef.conversation, id: conversationId },
};
const messageIds: string[] = [];
@@ -513,6 +523,7 @@ export async function sendMSTeamsMessages(params: {
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({
@@ -521,9 +532,13 @@ export async function sendMSTeamsMessages(params: {
fellBack: false,
}),
onRevoked: async () => {
// When the live turn context is revoked (e.g. debounced messages),
// reconstruct the threaded conversation ID so the proactive
// fallback delivers the reply into the correct channel thread.
const remaining = messages.slice(idx);
return {
ids: remaining.length > 0 ? await sendProactively(remaining, idx) : [],
ids:
remaining.length > 0 ? await sendProactively(remaining, idx, threadActivityId) : [],
fellBack: true,
};
},