@@ -399,6 +399,233 @@ describe("mattermostPlugin", () => {
399399 } ) ,
400400 ) ;
401401 } ) ;
402+
403+ describe ( "thread context inheritance" , ( ) => {
404+ it ( "falls back to toolContext.currentThreadTs when no replyTo param is provided" , async ( ) => {
405+ const cfg = createMattermostTestConfig ( ) ;
406+
407+ await mattermostPlugin . actions ?. handleAction ?.( {
408+ channel : "mattermost" ,
409+ action : "send" ,
410+ params : { to : "channel:CHAN1" , message : "hello" } ,
411+ cfg,
412+ accountId : "default" ,
413+ toolContext : {
414+ currentThreadTs : "thread-root-id" ,
415+ currentChannelId : "channel:CHAN1" ,
416+ replyToMode : "all" ,
417+ } ,
418+ } as any ) ;
419+
420+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
421+ "channel:CHAN1" ,
422+ "hello" ,
423+ expect . objectContaining ( { replyToId : "thread-root-id" } ) ,
424+ ) ;
425+ } ) ;
426+
427+ it ( "explicit replyTo param wins over toolContext.currentThreadTs" , async ( ) => {
428+ const cfg = createMattermostTestConfig ( ) ;
429+
430+ await mattermostPlugin . actions ?. handleAction ?.( {
431+ channel : "mattermost" ,
432+ action : "send" ,
433+ params : { to : "channel:CHAN1" , message : "hello" , replyTo : "explicit-root" } ,
434+ cfg,
435+ accountId : "default" ,
436+ toolContext : {
437+ currentThreadTs : "session-thread-id" ,
438+ currentChannelId : "channel:CHAN1" ,
439+ replyToMode : "all" ,
440+ } ,
441+ } as any ) ;
442+
443+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
444+ "channel:CHAN1" ,
445+ "hello" ,
446+ expect . objectContaining ( { replyToId : "explicit-root" } ) ,
447+ ) ;
448+ } ) ;
449+
450+ it ( "does not inherit thread context when sending to a different channel" , async ( ) => {
451+ const cfg = createMattermostTestConfig ( ) ;
452+
453+ await mattermostPlugin . actions ?. handleAction ?.( {
454+ channel : "mattermost" ,
455+ action : "send" ,
456+ params : { to : "channel:OTHER" , message : "hello" } ,
457+ cfg,
458+ accountId : "default" ,
459+ toolContext : {
460+ currentThreadTs : "thread-root-id" ,
461+ currentChannelId : "channel:CHAN1" ,
462+ replyToMode : "all" ,
463+ } ,
464+ } as any ) ;
465+
466+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
467+ "channel:OTHER" ,
468+ "hello" ,
469+ expect . objectContaining ( { replyToId : undefined } ) ,
470+ ) ;
471+ } ) ;
472+
473+ it ( "inherits thread context for replyToMode=first before first reply" , async ( ) => {
474+ const cfg = createMattermostTestConfig ( ) ;
475+ const hasRepliedRef = { value : false } ;
476+
477+ await mattermostPlugin . actions ?. handleAction ?.( {
478+ channel : "mattermost" ,
479+ action : "send" ,
480+ params : { to : "channel:CHAN1" , message : "hello" } ,
481+ cfg,
482+ accountId : "default" ,
483+ toolContext : {
484+ currentThreadTs : "thread-root-id" ,
485+ currentChannelId : "channel:CHAN1" ,
486+ replyToMode : "first" ,
487+ hasRepliedRef,
488+ } ,
489+ } as any ) ;
490+
491+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
492+ "channel:CHAN1" ,
493+ "hello" ,
494+ expect . objectContaining ( { replyToId : "thread-root-id" } ) ,
495+ ) ;
496+ // hasRepliedRef must be flipped so the next tool send goes to channel root
497+ expect ( hasRepliedRef . value ) . toBe ( true ) ;
498+ } ) ;
499+
500+ it ( "does not inherit thread context for replyToMode=first after first reply" , async ( ) => {
501+ const cfg = createMattermostTestConfig ( ) ;
502+ const hasRepliedRef = { value : true } ;
503+
504+ await mattermostPlugin . actions ?. handleAction ?.( {
505+ channel : "mattermost" ,
506+ action : "send" ,
507+ params : { to : "channel:CHAN1" , message : "hello" } ,
508+ cfg,
509+ accountId : "default" ,
510+ toolContext : {
511+ currentThreadTs : "thread-root-id" ,
512+ currentChannelId : "channel:CHAN1" ,
513+ replyToMode : "first" ,
514+ hasRepliedRef,
515+ } ,
516+ } as any ) ;
517+
518+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
519+ "channel:CHAN1" ,
520+ "hello" ,
521+ expect . objectContaining ( { replyToId : undefined } ) ,
522+ ) ;
523+ } ) ;
524+
525+ it ( "does not inherit thread context when replyToMode is off" , async ( ) => {
526+ const cfg = createMattermostTestConfig ( ) ;
527+
528+ await mattermostPlugin . actions ?. handleAction ?.( {
529+ channel : "mattermost" ,
530+ action : "send" ,
531+ params : { to : "channel:CHAN1" , message : "hello" } ,
532+ cfg,
533+ accountId : "default" ,
534+ toolContext : {
535+ currentThreadTs : "thread-root-id" ,
536+ currentChannelId : "channel:CHAN1" ,
537+ replyToMode : "off" ,
538+ } ,
539+ } as any ) ;
540+
541+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
542+ "channel:CHAN1" ,
543+ "hello" ,
544+ expect . objectContaining ( { replyToId : undefined } ) ,
545+ ) ;
546+ } ) ;
547+
548+ it ( "inherits thread context when replyToMode=off is promoted to all by existing thread" , async ( ) => {
549+ // buildToolContext promotes off→all when an existing thread is detected
550+ // (MessageThreadId set on the inbound post). handleAction receives the
551+ // already-promoted toolContext from the agent runner.
552+ const cfg = createMattermostTestConfig ( ) ;
553+
554+ await mattermostPlugin . actions ?. handleAction ?.( {
555+ channel : "mattermost" ,
556+ action : "send" ,
557+ params : { to : "channel:CHAN1" , message : "hello" } ,
558+ cfg,
559+ accountId : "default" ,
560+ toolContext : {
561+ currentThreadTs : "thread-root-id" ,
562+ currentChannelId : "channel:CHAN1" ,
563+ replyToMode : "all" , // promoted from off by buildToolContext
564+ } ,
565+ } as any ) ;
566+
567+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
568+ "channel:CHAN1" ,
569+ "hello" ,
570+ expect . objectContaining ( { replyToId : "thread-root-id" } ) ,
571+ ) ;
572+ } ) ;
573+
574+ it ( "preserves replyToMode=first when thread exists (does not promote to all)" , async ( ) => {
575+ // replyToMode=first must NOT be upgraded to all — hasRepliedRef gates further sends.
576+ const cfg = createMattermostTestConfig ( ) ;
577+ const hasRepliedRef = { value : true } ; // first reply already done
578+
579+ await mattermostPlugin . actions ?. handleAction ?.( {
580+ channel : "mattermost" ,
581+ action : "send" ,
582+ params : { to : "channel:CHAN1" , message : "hello" } ,
583+ cfg,
584+ accountId : "default" ,
585+ toolContext : {
586+ currentThreadTs : "thread-root-id" ,
587+ currentChannelId : "channel:CHAN1" ,
588+ replyToMode : "first" ,
589+ hasRepliedRef,
590+ } ,
591+ } as any ) ;
592+
593+ // After first reply, subsequent sends go to channel root, not thread
594+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
595+ "channel:CHAN1" ,
596+ "hello" ,
597+ expect . objectContaining ( { replyToId : undefined } ) ,
598+ ) ;
599+ } ) ;
600+
601+ it ( "flips hasRepliedRef when explicit replyTo is used under replyToMode=first" , async ( ) => {
602+ // Explicit replyTo should also consume the first-reply slot
603+ const cfg = createMattermostTestConfig ( ) ;
604+ const hasRepliedRef = { value : false } ;
605+
606+ await mattermostPlugin . actions ?. handleAction ?.( {
607+ channel : "mattermost" ,
608+ action : "send" ,
609+ params : { to : "channel:CHAN1" , message : "hello" , replyTo : "explicit-root" } ,
610+ cfg,
611+ accountId : "default" ,
612+ toolContext : {
613+ currentThreadTs : "thread-root-id" ,
614+ currentChannelId : "channel:CHAN1" ,
615+ replyToMode : "first" ,
616+ hasRepliedRef,
617+ } ,
618+ } as any ) ;
619+
620+ expect ( sendMessageMattermostMock ) . toHaveBeenCalledWith (
621+ "channel:CHAN1" ,
622+ "hello" ,
623+ expect . objectContaining ( { replyToId : "explicit-root" } ) ,
624+ ) ;
625+ // Explicit threaded send must also flip hasRepliedRef
626+ expect ( hasRepliedRef . value ) . toBe ( true ) ;
627+ } ) ;
628+ } ) ;
402629 } ) ;
403630
404631 describe ( "outbound" , ( ) => {
0 commit comments