@@ -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", () => ({
8586vi . mock ( "./graph-upload.js" , ( ) => ( {
8687 uploadAndShareSharePoint : mockState . uploadAndShareSharePoint ,
8788 getDriveItemProperties : mockState . getDriveItemProperties ,
88- uploadAndShareOneDrive : vi . fn ( ) ,
89+ uploadAndShareOneDrive : mockState . uploadAndShareOneDrive ,
8990} ) ) ;
9091
9192vi . 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
448656describe ( "MSTeams continueConversation failure handling" , ( ) => {
0 commit comments