Skip to content

Commit a94a78e

Browse files
committed
fix(msteams): thread attachment sends into channel reply threads (#88836)
Microsoft Teams attachment branches (SharePoint native file card and OneDrive file-link fallback) call sendProactiveActivityRaw without a thread root, so channel replies configured with replyStyle: 'thread' arrive as new top-level messages even when the originating message lived in a thread. Text and inline-media sends already preserve replyStyle via sendMSTeamsMessages -> sendMSTeamsActivityWithReference, so the gap is specific to the file-card and file-link branches. Adds a resolveProactiveThreadActivityId helper that mirrors messenger.ts thread routing: only channel refs with replyStyle === 'thread' get a thread root, with the same threadId ?? activityId fallback for older stored refs. Threads the result through sendProactiveActivityRaw -> sendMSTeamsActivityWithReference at both attachment call sites. This follows the conservative direction in clawsweeper's review on #88836: "Keep this issue open and make the Teams file-card/file-link attachment branches use the same thread-aware delivery path as text and inline media, with regression tests for replyStyle: 'thread' and replyStyle: 'top-level'."
1 parent 5b0c4c0 commit a94a78e

2 files changed

Lines changed: 246 additions & 4 deletions

File tree

extensions/msteams/src/send.test.ts

Lines changed: 212 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const mockState = vi.hoisted(() => ({
1818
updateMSTeamsActivityWithReference: vi.fn(async () => ({ id: "updated" })),
1919
deleteMSTeamsActivityWithReference: vi.fn(async () => {}),
2020
uploadAndShareSharePoint: vi.fn(),
21+
uploadAndShareOneDrive: vi.fn(),
2122
getDriveItemProperties: vi.fn(),
2223
buildTeamsFileInfoCard: vi.fn(),
2324
createMSTeamsTokenProvider: vi.fn(),
@@ -85,7 +86,7 @@ vi.mock("./runtime.js", () => ({
8586
vi.mock("./graph-upload.js", () => ({
8687
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
8788
getDriveItemProperties: mockState.getDriveItemProperties,
88-
uploadAndShareOneDrive: vi.fn(),
89+
uploadAndShareOneDrive: mockState.uploadAndShareOneDrive,
8990
}));
9091

9192
vi.mock("./graph-chat.js", () => ({
@@ -154,16 +155,21 @@ function createSharePointSendContext(params: {
154155
conversationId: string;
155156
graphChatId: string | null;
156157
siteId: string;
158+
// Override `replyStyle` and the conversation ref shape for #88836 channel-thread coverage.
159+
// Defaults preserve the historical groupChat / top-level shape used by existing tests.
160+
replyStyle?: "thread" | "top-level";
161+
conversationType?: "personal" | "groupChat" | "channel";
162+
ref?: Record<string, unknown>;
157163
}) {
158164
return {
159165
app: createMockApp(),
160166
appId: "app-id",
161167
conversationId: params.conversationId,
162168
graphChatId: params.graphChatId,
163-
ref: {},
169+
ref: params.ref ?? {},
164170
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
165-
conversationType: "groupChat" as const,
166-
replyStyle: "top-level" as const,
171+
conversationType: params.conversationType ?? ("groupChat" as const),
172+
replyStyle: params.replyStyle ?? ("top-level" as const),
167173
sdkCloudOptions: { cloud: "Public" as const },
168174
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
169175
mediaMaxBytes: 8 * 1024 * 1024,
@@ -443,6 +449,208 @@ describe("sendMessageMSTeams", () => {
443449
expect(uploadPayload.chatId).toBe(botFrameworkConversationId);
444450
expect(uploadPayload.siteId).toBe("site-456");
445451
});
452+
453+
// #88836: attachment branches (SharePoint native file card, OneDrive file link)
454+
// previously sent raw proactive activities without thread context, so channel
455+
// replies with `replyStyle: "thread"` lost their thread placement.
456+
it("threads SharePoint native file card into the channel thread when replyStyle is 'thread' (#88836)", async () => {
457+
const channelConversationId = "19:[email protected]";
458+
const threadRootId = "1700000000000";
459+
460+
mockState.resolveMSTeamsSendContext.mockResolvedValue(
461+
createSharePointSendContext({
462+
conversationId: channelConversationId,
463+
graphChatId: "19:[email protected]",
464+
siteId: "site-thread",
465+
replyStyle: "thread",
466+
conversationType: "channel",
467+
ref: {
468+
conversation: { id: channelConversationId, conversationType: "channel" },
469+
threadId: threadRootId,
470+
channelId: "msteams",
471+
},
472+
}),
473+
);
474+
mockSharePointPdfUpload({
475+
bufferSize: 100,
476+
fileName: "thread-doc.pdf",
477+
itemId: "item-thread",
478+
uniqueId: "{GUID-THREAD}",
479+
});
480+
481+
await sendMessageMSTeams({
482+
cfg: {} as OpenClawConfig,
483+
to: "conversation:19:[email protected]",
484+
text: "threaded file",
485+
mediaUrl: "https://example.com/thread-doc.pdf",
486+
});
487+
488+
// The proactive send must carry threadActivityId so the attachment lands
489+
// in the originating Teams thread rather than as a top-level message.
490+
const sendArgs = mockState.sendMSTeamsActivityWithReference.mock.calls[0];
491+
const sendOptions = sendArgs?.[3] as { threadActivityId?: string } | undefined;
492+
expect(sendOptions?.threadActivityId).toBe(threadRootId);
493+
});
494+
495+
it("falls back to ref.activityId for thread root when ref.threadId is absent (#88836)", async () => {
496+
const channelConversationId = "19:[email protected]";
497+
const legacyActivityId = "1690000000000";
498+
499+
mockState.resolveMSTeamsSendContext.mockResolvedValue(
500+
createSharePointSendContext({
501+
conversationId: channelConversationId,
502+
graphChatId: null,
503+
siteId: "site-legacy",
504+
replyStyle: "thread",
505+
conversationType: "channel",
506+
ref: {
507+
conversation: { id: channelConversationId, conversationType: "channel" },
508+
activityId: legacyActivityId,
509+
channelId: "msteams",
510+
},
511+
}),
512+
);
513+
mockSharePointPdfUpload({
514+
bufferSize: 100,
515+
fileName: "legacy-doc.pdf",
516+
itemId: "item-legacy",
517+
uniqueId: "{GUID-LEGACY}",
518+
});
519+
520+
await sendMessageMSTeams({
521+
cfg: {} as OpenClawConfig,
522+
to: "conversation:19:[email protected]",
523+
text: "legacy ref",
524+
mediaUrl: "https://example.com/legacy-doc.pdf",
525+
});
526+
527+
const sendArgs = mockState.sendMSTeamsActivityWithReference.mock.calls[0];
528+
const sendOptions = sendArgs?.[3] as { threadActivityId?: string } | undefined;
529+
expect(sendOptions?.threadActivityId).toBe(legacyActivityId);
530+
});
531+
532+
it("does not thread the SharePoint file card when replyStyle is 'top-level' (#88836)", async () => {
533+
const channelConversationId = "19:[email protected]";
534+
535+
mockState.resolveMSTeamsSendContext.mockResolvedValue(
536+
createSharePointSendContext({
537+
conversationId: channelConversationId,
538+
graphChatId: null,
539+
siteId: "site-top",
540+
replyStyle: "top-level",
541+
conversationType: "channel",
542+
ref: {
543+
conversation: { id: channelConversationId, conversationType: "channel" },
544+
threadId: "should-not-be-used",
545+
channelId: "msteams",
546+
},
547+
}),
548+
);
549+
mockSharePointPdfUpload({
550+
bufferSize: 100,
551+
fileName: "top-doc.pdf",
552+
itemId: "item-top",
553+
uniqueId: "{GUID-TOP}",
554+
});
555+
556+
await sendMessageMSTeams({
557+
cfg: {} as OpenClawConfig,
558+
to: "conversation:19:[email protected]",
559+
text: "top-level file",
560+
mediaUrl: "https://example.com/top-doc.pdf",
561+
});
562+
563+
// Explicit top-level intent must not silently thread even when the ref has
564+
// a threadId; preserves the existing replyStyle contract.
565+
const sendArgs = mockState.sendMSTeamsActivityWithReference.mock.calls[0];
566+
const sendOptions = sendArgs?.[3] as { threadActivityId?: string } | undefined;
567+
expect(sendOptions?.threadActivityId).toBeUndefined();
568+
});
569+
570+
it("does not thread when the ref is a personal/group chat even with replyStyle 'thread' (#88836)", async () => {
571+
// Defensive: messenger.ts only threads channel refs. The attachment path
572+
// must mirror that owner-boundary so personal/group chats never get a
573+
// threadActivityId suffix even if a stored ref accidentally carries one.
574+
const personalConversationId = "19:[email protected]";
575+
576+
mockState.resolveMSTeamsSendContext.mockResolvedValue(
577+
createSharePointSendContext({
578+
conversationId: personalConversationId,
579+
graphChatId: null,
580+
siteId: "site-personal",
581+
replyStyle: "thread",
582+
conversationType: "personal",
583+
ref: {
584+
conversation: { id: personalConversationId, conversationType: "personal" },
585+
threadId: "should-not-be-used",
586+
channelId: "msteams",
587+
},
588+
}),
589+
);
590+
mockSharePointPdfUpload({
591+
bufferSize: 100,
592+
fileName: "personal-doc.pdf",
593+
itemId: "item-personal",
594+
uniqueId: "{GUID-PERSONAL}",
595+
});
596+
597+
await sendMessageMSTeams({
598+
cfg: {} as OpenClawConfig,
599+
to: "conversation:19:[email protected]",
600+
text: "personal file",
601+
mediaUrl: "https://example.com/personal-doc.pdf",
602+
});
603+
604+
const sendArgs = mockState.sendMSTeamsActivityWithReference.mock.calls[0];
605+
const sendOptions = sendArgs?.[3] as { threadActivityId?: string } | undefined;
606+
expect(sendOptions?.threadActivityId).toBeUndefined();
607+
});
608+
609+
it("threads OneDrive file-link fallback into the channel thread when replyStyle is 'thread' (#88836)", async () => {
610+
// No SharePoint site configured -> OneDrive markdown link fallback path.
611+
// The bug report's exact symptom lives here too: the file-link branch
612+
// also called sendProactiveActivityRaw without thread context.
613+
const channelConversationId = "19:[email protected]";
614+
const threadRootId = "1701111111111";
615+
616+
mockState.resolveMSTeamsSendContext.mockResolvedValue(
617+
createSharePointSendContext({
618+
conversationId: channelConversationId,
619+
graphChatId: null,
620+
siteId: "", // empty siteId triggers OneDrive fallback
621+
replyStyle: "thread",
622+
conversationType: "channel",
623+
ref: {
624+
conversation: { id: channelConversationId, conversationType: "channel" },
625+
threadId: threadRootId,
626+
channelId: "msteams",
627+
},
628+
}),
629+
);
630+
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
631+
buffer: Buffer.alloc(100, "pdf"),
632+
contentType: "application/pdf",
633+
fileName: "onedrive-doc.pdf",
634+
kind: "file",
635+
});
636+
mockState.requiresFileConsent.mockReturnValue(false);
637+
mockState.uploadAndShareOneDrive.mockResolvedValueOnce({
638+
itemId: "od-item",
639+
shareUrl: "https://onedrive.example.com/share/onedrive-doc.pdf",
640+
name: "onedrive-doc.pdf",
641+
});
642+
643+
await sendMessageMSTeams({
644+
cfg: {} as OpenClawConfig,
645+
to: "conversation:19:[email protected]",
646+
text: "onedrive link",
647+
mediaUrl: "https://example.com/onedrive-doc.pdf",
648+
});
649+
650+
const sendArgs = mockState.sendMSTeamsActivityWithReference.mock.calls[0];
651+
const sendOptions = sendArgs?.[3] as { threadActivityId?: string } | undefined;
652+
expect(sendOptions?.threadActivityId).toBe(threadRootId);
653+
});
446654
});
447655

448656
describe("MSTeams continueConversation failure handling", () => {

extensions/msteams/src/send.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,9 @@ export async function sendMessageMSTeams(
307307
ref,
308308
activity,
309309
serviceUrlBoundary: sdkCloudOptions,
310+
// Thread the file card into the originating channel thread when
311+
// the ref carries `replyStyle: "thread"` (#88836).
312+
threadActivityId: resolveProactiveThreadActivityId(ctx),
310313
});
311314

312315
log.info("sent native file card", {
@@ -351,6 +354,9 @@ export async function sendMessageMSTeams(
351354
ref,
352355
activity,
353356
serviceUrlBoundary: sdkCloudOptions,
357+
// Same thread-aware delivery as the SharePoint native file card path
358+
// so the OneDrive file-link fallback also lands in-thread (#88836).
359+
threadActivityId: resolveProactiveThreadActivityId(ctx),
354360
});
355361

356362
log.info("sent message with OneDrive file link", {
@@ -446,19 +452,47 @@ type ProactiveActivityParams = {
446452
activity: Record<string, unknown>;
447453
errorPrefix: string;
448454
serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"];
455+
// Forward channel thread root for proactive attachment sends so file-card /
456+
// file-link branches land in the originating Teams thread when the channel
457+
// ref has `replyStyle: "thread"`. Mirrors messenger.ts thread routing
458+
// (#88836). Plain personal/group chats and explicit `top-level` sends pass
459+
// undefined so we never silently thread a non-channel ref.
460+
threadActivityId?: string;
449461
};
450462

463+
/**
464+
* Resolve the channel thread root for proactive attachment sends (#88836).
465+
* Returns undefined for non-channel refs or `replyStyle: "top-level"` so
466+
* personal/group chats and explicit top-level sends never get a thread
467+
* suffix. The thread root falls back to `activityId` for older stored refs
468+
* that pre-date `threadId`, matching the resolution used in messenger.ts.
469+
*/
470+
function resolveProactiveThreadActivityId(
471+
ctx: MSTeamsProactiveContext,
472+
): string | undefined {
473+
if (ctx.replyStyle !== "thread") {
474+
return undefined;
475+
}
476+
const isChannel = ctx.ref.conversation?.conversationType === "channel";
477+
if (!isChannel) {
478+
return undefined;
479+
}
480+
return ctx.ref.threadId ?? ctx.ref.activityId;
481+
}
482+
451483
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
452484

453485
async function sendProactiveActivityRaw({
454486
app,
455487
ref,
456488
activity,
457489
serviceUrlBoundary,
490+
threadActivityId,
458491
}: ProactiveActivityRawParams): Promise<string> {
459492
const baseRef = buildConversationReference(ref);
460493
const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, {
461494
serviceUrlBoundary,
495+
threadActivityId,
462496
});
463497
return extractMessageId(response) ?? "unknown";
464498
}

0 commit comments

Comments
 (0)