@@ -480,6 +480,16 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
480480 unbind : vi . fn ( async ( ) => [ ] ) ,
481481 } ) ,
482482} ) ) ;
483+ vi . mock ( "../../bindings/records.js" , ( ) => ( {
484+ resolveConversationBindingRecord : ( conversation : {
485+ channel : string ;
486+ accountId : string ;
487+ conversationId : string ;
488+ parentConversationId ?: string ;
489+ } ) => sessionBindingMocks . resolveByConversation ( conversation ) ,
490+ touchConversationBindingRecord : ( ...args : [ bindingId : string , at ?: number ] ) =>
491+ sessionBindingMocks . touch ( ...args ) ,
492+ } ) ) ;
483493vi . mock ( "../../infra/agent-events.js" , ( ) => ( {
484494 emitAgentEvent : ( params : unknown ) => agentEventMocks . emitAgentEvent ( params ) ,
485495 onAgentEvent : ( listener : unknown ) => agentEventMocks . onAgentEvent ( listener ) ,
@@ -594,6 +604,11 @@ const automaticGroupReplyConfig = {
594604 } ,
595605 } ,
596606} as const satisfies OpenClawConfig ;
607+ const automaticDirectReplyConfig = {
608+ messages : {
609+ visibleReplies : "automatic" ,
610+ } ,
611+ } as const satisfies OpenClawConfig ;
597612let dispatchReplyFromConfig : typeof import ( "./dispatch-from-config.js" ) . dispatchReplyFromConfig ;
598613let dispatchFromConfigTesting : typeof import ( "./dispatch-from-config.js" ) . testing ;
599614let resetInboundDedupe : typeof import ( "./inbound-dedupe.js" ) . resetInboundDedupe ;
@@ -854,6 +869,28 @@ function firstRouteReplyCall(): Record<string, unknown> {
854869 return call as Record < string , unknown > ;
855870}
856871
872+ function installThreadingTestPlugin ( params : { defaultAccountId ?: string ; id : string } ) {
873+ const plugin = createChannelTestPluginBase ( { id : params . id } ) ;
874+ const defaultAccountId = params . defaultAccountId ;
875+ setActivePluginRegistry (
876+ createTestRegistry ( [
877+ {
878+ pluginId : params . id ,
879+ source : "test" ,
880+ plugin : {
881+ ...plugin ,
882+ config : defaultAccountId
883+ ? { ...plugin . config , defaultAccountId : ( ) => defaultAccountId }
884+ : plugin . config ,
885+ threading : {
886+ resolveReplyToMode : ( ) => "all" ,
887+ } ,
888+ } ,
889+ } ,
890+ ] ) ,
891+ ) ;
892+ }
893+
857894function requireToolResultHandler (
858895 handler : GetReplyOptions [ "onToolResult" ] | undefined ,
859896) : NonNullable < GetReplyOptions [ "onToolResult" ] > {
@@ -945,6 +982,25 @@ describe("dispatchReplyFromConfig", () => {
945982 ) ,
946983 } ,
947984 } ;
985+ const passiveThreadingTestPlugins = [
986+ "slack" ,
987+ "telegram" ,
988+ "feishu" ,
989+ "mattermost" ,
990+ "imessage" ,
991+ ] . map ( ( id ) => {
992+ const plugin = createChannelTestPluginBase ( { id } ) ;
993+ return {
994+ pluginId : id ,
995+ source : "test" as const ,
996+ plugin : {
997+ ...plugin ,
998+ threading : {
999+ resolveReplyToMode : ( ) => "all" as const ,
1000+ } ,
1001+ } ,
1002+ } ;
1003+ } ) ;
9481004 setActivePluginRegistry (
9491005 createTestRegistry ( [
9501006 {
@@ -957,6 +1013,7 @@ describe("dispatchReplyFromConfig", () => {
9571013 source : "test" ,
9581014 plugin : signalTestPlugin ,
9591015 } ,
1016+ ...passiveThreadingTestPlugins ,
9601017 ] ) ,
9611018 ) ;
9621019 clearApprovalNativeRouteStateForTest ( ) ;
@@ -1209,7 +1266,8 @@ describe("dispatchReplyFromConfig", () => {
12091266 it ( "does not route when Provider matches OriginatingChannel (even if Surface is missing)" , async ( ) => {
12101267 setNoAbort ( ) ;
12111268 mocks . routeReply . mockClear ( ) ;
1212- const cfg = emptyConfig ;
1269+ installThreadingTestPlugin ( { id : "slack" , defaultAccountId : "work" } ) ;
1270+ const cfg = automaticDirectReplyConfig ;
12131271 const dispatcher = createDispatcher ( ) ;
12141272 const ctx = buildTestCtx ( {
12151273 Provider : "slack" ,
@@ -1227,11 +1285,23 @@ describe("dispatchReplyFromConfig", () => {
12271285
12281286 expect ( mocks . routeReply ) . not . toHaveBeenCalled ( ) ;
12291287 expect ( dispatcher . sendFinalReply ) . toHaveBeenCalledTimes ( 1 ) ;
1288+ const replyDispatchCall = firstMockCall ( hookMocks . runner . runReplyDispatch , "reply dispatch" ) as
1289+ | [
1290+ {
1291+ originatingAccountId ?: unknown ;
1292+ shouldRouteToOriginating ?: unknown ;
1293+ } ,
1294+ unknown ,
1295+ ]
1296+ | undefined ;
1297+ expect ( replyDispatchCall ?. [ 0 ] ?. shouldRouteToOriginating ) . toBe ( false ) ;
1298+ expect ( replyDispatchCall ?. [ 0 ] ?. originatingAccountId ) . toBe ( "work" ) ;
12301299 } ) ;
12311300
12321301 it ( "mirrors ownerless same-channel Slack finals after successful delivery" , async ( ) => {
12331302 setNoAbort ( ) ;
12341303 mocks . routeReply . mockClear ( ) ;
1304+ installThreadingTestPlugin ( { id : "slack" } ) ;
12351305 const dispatcher = createDispatcher ( ) ;
12361306 const ctx = buildTestCtx ( {
12371307 Provider : "slack" ,
@@ -1273,6 +1343,7 @@ describe("dispatchReplyFromConfig", () => {
12731343
12741344 it ( "mirrors reset acknowledgements into the canonically prepared Slack session" , async ( ) => {
12751345 setNoAbort ( ) ;
1346+ hookMocks . runner . hasHooks . mockReturnValue ( false ) ;
12761347 const dispatcher = createDispatcher ( ) ;
12771348 const sessionKey = "Agent:Main:Slack:Channel:C123" ;
12781349 const preparedSessionKey = "agent:main:slack:channel:c123" ;
@@ -1352,16 +1423,19 @@ describe("dispatchReplyFromConfig", () => {
13521423 setNoAbort ( ) ;
13531424 const dispatcher = createDispatcher ( ) ;
13541425 mocks . routeReply . mockClear ( ) ;
1426+ hookMocks . runner . hasHooks . mockReturnValue ( false ) ;
1427+ installThreadingTestPlugin ( { id : "telegram" , defaultAccountId : "default" } ) ;
13551428
13561429 const result = await dispatchReplyFromConfig ( {
13571430 ctx : buildTestCtx ( {
13581431 Provider : "slack" ,
13591432 Surface : "slack" ,
13601433 OriginatingChannel : "telegram" ,
13611434 OriginatingTo : "telegram:999" ,
1435+ AccountId : "default" ,
13621436 SessionKey : "agent:main:telegram:group:999" ,
13631437 } ) ,
1364- cfg : emptyConfig ,
1438+ cfg : automaticDirectReplyConfig ,
13651439 dispatcher,
13661440 replyResolver : async ( ) =>
13671441 setReplyPayloadMetadata (
@@ -1581,6 +1655,7 @@ describe("dispatchReplyFromConfig", () => {
15811655
15821656 it ( "keeps non-Slack routed direct turns behind the active reply operation" , async ( ) => {
15831657 setNoAbort ( ) ;
1658+ installThreadingTestPlugin ( { id : "telegram" } ) ;
15841659 const sessionKey = "agent:main:telegram:direct:1" ;
15851660 const activeOperation = createReplyOperation ( {
15861661 sessionKey,
@@ -1627,6 +1702,7 @@ describe("dispatchReplyFromConfig", () => {
16271702 it ( "routes when OriginatingChannel differs from Provider" , async ( ) => {
16281703 setNoAbort ( ) ;
16291704 mocks . routeReply . mockClear ( ) ;
1705+ installThreadingTestPlugin ( { id : "telegram" } ) ;
16301706 const cfg = emptyConfig ;
16311707 const dispatcher = createDispatcher ( ) ;
16321708 const ctx = buildTestCtx ( {
@@ -1667,6 +1743,7 @@ describe("dispatchReplyFromConfig", () => {
16671743 it ( "routes exec-event replies using persisted session delivery context when current turn has no originating route" , async ( ) => {
16681744 setNoAbort ( ) ;
16691745 mocks . routeReply . mockClear ( ) ;
1746+ installThreadingTestPlugin ( { id : "telegram" } ) ;
16701747 sessionStoreMocks . currentEntry = {
16711748 deliveryContext : {
16721749 channel : "telegram" ,
@@ -1724,6 +1801,7 @@ describe("dispatchReplyFromConfig", () => {
17241801 it ( "routes sessions_send internal webchat handoffs through persisted external delivery context" , async ( ) => {
17251802 setNoAbort ( ) ;
17261803 mocks . routeReply . mockClear ( ) ;
1804+ installThreadingTestPlugin ( { id : "feishu" } ) ;
17271805 sessionStoreMocks . currentEntry = {
17281806 route : {
17291807 channel : "feishu" ,
@@ -1835,6 +1913,7 @@ describe("dispatchReplyFromConfig", () => {
18351913 it ( "honors sendPolicy deny for recovered exec-event delivery channel" , async ( ) => {
18361914 setNoAbort ( ) ;
18371915 mocks . routeReply . mockClear ( ) ;
1916+ installThreadingTestPlugin ( { id : "telegram" } ) ;
18381917 sessionStoreMocks . currentEntry = {
18391918 deliveryContext : {
18401919 channel : "telegram" ,
@@ -1908,6 +1987,7 @@ describe("dispatchReplyFromConfig", () => {
19081987 it ( "uses Slack DM TransportThreadId when ReplyToId is the current message" , async ( ) => {
19091988 setNoAbort ( ) ;
19101989 mocks . routeReply . mockClear ( ) ;
1990+ installThreadingTestPlugin ( { id : "slack" } ) ;
19111991 const cfg = emptyConfig ;
19121992 const dispatcher = createDispatcher ( ) ;
19131993 const ctx = buildTestCtx ( {
@@ -1935,6 +2015,7 @@ describe("dispatchReplyFromConfig", () => {
19352015 it ( "does not resurrect a cleared route thread from origin metadata" , async ( ) => {
19362016 setNoAbort ( ) ;
19372017 mocks . routeReply . mockClear ( ) ;
2018+ installThreadingTestPlugin ( { id : "mattermost" } ) ;
19382019 // Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from
19392020 // origin.threadId on read, but a non-thread session key must still route to channel root.
19402021 sessionStoreMocks . currentEntry = {
@@ -1975,6 +2056,7 @@ describe("dispatchReplyFromConfig", () => {
19752056
19762057 it ( "forces suppressTyping when routing to a different originating channel" , async ( ) => {
19772058 setNoAbort ( ) ;
2059+ installThreadingTestPlugin ( { id : "telegram" } ) ;
19782060 const cfg = emptyConfig ;
19792061 const dispatcher = createDispatcher ( ) ;
19802062 const ctx = buildTestCtx ( {
@@ -2015,6 +2097,7 @@ describe("dispatchReplyFromConfig", () => {
20152097 it ( "routes when provider is webchat but surface carries originating channel metadata" , async ( ) => {
20162098 setNoAbort ( ) ;
20172099 mocks . routeReply . mockClear ( ) ;
2100+ installThreadingTestPlugin ( { id : "telegram" } ) ;
20182101 const cfg = emptyConfig ;
20192102 const dispatcher = createDispatcher ( ) ;
20202103 const ctx = buildTestCtx ( {
@@ -2036,6 +2119,7 @@ describe("dispatchReplyFromConfig", () => {
20362119 it ( "routes Feishu replies when provider is webchat and origin metadata points to Feishu" , async ( ) => {
20372120 setNoAbort ( ) ;
20382121 mocks . routeReply . mockClear ( ) ;
2122+ installThreadingTestPlugin ( { id : "feishu" } ) ;
20392123 const cfg = emptyConfig ;
20402124 const dispatcher = createDispatcher ( ) ;
20412125 const ctx = buildTestCtx ( {
@@ -2076,6 +2160,7 @@ describe("dispatchReplyFromConfig", () => {
20762160 it ( "does not route external origin replies when current surface is internal webchat without explicit delivery" , async ( ) => {
20772161 setNoAbort ( ) ;
20782162 mocks . routeReply . mockClear ( ) ;
2163+ installThreadingTestPlugin ( { id : "imessage" } ) ;
20792164 const cfg = emptyConfig ;
20802165 const dispatcher = createDispatcher ( ) ;
20812166 const ctx = buildTestCtx ( {
@@ -2099,6 +2184,7 @@ describe("dispatchReplyFromConfig", () => {
20992184 it ( "routes external origin replies for internal webchat turns when explicit delivery is set" , async ( ) => {
21002185 setNoAbort ( ) ;
21012186 mocks . routeReply . mockClear ( ) ;
2187+ installThreadingTestPlugin ( { id : "imessage" } ) ;
21022188 const cfg = emptyConfig ;
21032189 const dispatcher = createDispatcher ( ) ;
21042190 const ctx = buildTestCtx ( {
@@ -2128,6 +2214,7 @@ describe("dispatchReplyFromConfig", () => {
21282214 it ( "routes media-only tool results when summaries are suppressed" , async ( ) => {
21292215 setNoAbort ( ) ;
21302216 mocks . routeReply . mockClear ( ) ;
2217+ installThreadingTestPlugin ( { id : "telegram" } ) ;
21312218 const cfg = automaticGroupReplyConfig ;
21322219 const dispatcher = createDispatcher ( ) ;
21332220 const ctx = buildTestCtx ( {
@@ -5497,6 +5584,7 @@ describe("dispatchReplyFromConfig", () => {
54975584
54985585 it ( "deduplicates same-agent inbound replies across main and direct session keys" , async ( ) => {
54995586 setNoAbort ( ) ;
5587+ hookMocks . runner . hasHooks . mockReturnValue ( false ) ;
55005588 const cfg = emptyConfig ;
55015589 const replyResolver = vi . fn ( async ( ) => ( { text : "hi" } ) as ReplyPayload ) ;
55025590 const baseCtx = buildTestCtx ( {
@@ -5530,6 +5618,7 @@ describe("dispatchReplyFromConfig", () => {
55305618 it ( "emits message_received hook with originating channel metadata" , async ( ) => {
55315619 setNoAbort ( ) ;
55325620 hookMocks . runner . hasHooks . mockReturnValue ( true ) ;
5621+ installThreadingTestPlugin ( { id : "telegram" } ) ;
55335622 const cfg = emptyConfig ;
55345623 const dispatcher = createDispatcher ( ) ;
55355624 const ctx = buildTestCtx ( {
@@ -5789,6 +5878,7 @@ describe("dispatchReplyFromConfig", () => {
57895878 // would receive divergent keys on every native redirect.
57905879 setNoAbort ( ) ;
57915880 mocks . routeReply . mockClear ( ) ;
5881+ installThreadingTestPlugin ( { id : "telegram" } ) ;
57925882 const cfg = emptyConfig ;
57935883 const dispatcher = createDispatcher ( ) ;
57945884 const ctx = buildTestCtx ( {
@@ -5825,6 +5915,7 @@ describe("dispatchReplyFromConfig", () => {
58255915 // generalization of the native-redirect branch.
58265916 setNoAbort ( ) ;
58275917 mocks . routeReply . mockClear ( ) ;
5918+ installThreadingTestPlugin ( { id : "telegram" } ) ;
58285919 const cfg = emptyConfig ;
58295920 const dispatcher = createDispatcher ( ) ;
58305921 const ctx = buildTestCtx ( {
@@ -7669,6 +7760,7 @@ describe("before_dispatch hook", () => {
76697760 it ( "uses canonical hook metadata and shared routed final delivery" , async ( ) => {
76707761 ttsMocks . state . synthesizeFinalAudio = true ;
76717762 hookMocks . runner . runBeforeDispatch . mockResolvedValue ( { handled : true , text : "Blocked" } ) ;
7763+ installThreadingTestPlugin ( { id : "telegram" } ) ;
76727764 const dispatcher = createDispatcher ( ) ;
76737765 const ctx = createHookCtx ( {
76747766 Body : "raw body" ,
0 commit comments