@@ -26,11 +26,18 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
2626 } ;
2727} ) ;
2828
29+ let handleMSTeamsDmConversationBoundary : typeof import ( "./lifecycle-handler.js" ) . handleMSTeamsDmConversationBoundary ;
2930let handleMSTeamsLifecycleRemove : typeof import ( "./lifecycle-handler.js" ) . handleMSTeamsLifecycleRemove ;
3031
3132type SessionEntry = {
3233 sessionId ?: string ;
3334 updatedAt : number ;
35+ route ?: unknown ;
36+ deliveryContext ?: unknown ;
37+ lastChannel ?: string ;
38+ lastTo ?: string ;
39+ lastAccountId ?: string ;
40+ origin ?: unknown ;
3441} ;
3542
3643function setupStore ( store : Record < string , SessionEntry > ) {
@@ -60,6 +67,7 @@ function createDeps(remove = vi.fn(async () => true)) {
6067 const deps = createMSTeamsMessageHandlerDeps ( ) ;
6168 deps . conversationStore = {
6269 ...deps . conversationStore ,
70+ list : vi . fn ( async ( ) => [ ] ) ,
6371 remove,
6472 } as MSTeamsConversationStore ;
6573 return { deps, remove } ;
@@ -77,7 +85,8 @@ function createContext(activity: Record<string, unknown>): MSTeamsTurnContext {
7785
7886describe ( "handleMSTeamsLifecycleRemove" , ( ) => {
7987 beforeAll ( async ( ) => {
80- ( { handleMSTeamsLifecycleRemove } = await import ( "./lifecycle-handler.js" ) ) ;
88+ ( { handleMSTeamsDmConversationBoundary, handleMSTeamsLifecycleRemove } =
89+ await import ( "./lifecycle-handler.js" ) ) ;
8190 } ) ;
8291
8392 beforeEach ( ( ) => {
@@ -88,9 +97,18 @@ describe("handleMSTeamsLifecycleRemove", () => {
8897 hoisted . resolveStorePath . mockReturnValue ( "/tmp/openclaw-msteams-sessions.json" ) ;
8998 } ) ;
9099
91- it ( "stales a personal app remove session and removes the cached conversation reference" , async ( ) => {
100+ it ( "rotates a personal app remove session and removes the cached conversation reference" , async ( ) => {
92101 const store = {
93- "msteams:direct:user-aad" : { sessionId : "old-session" , updatedAt : 1_000 } ,
102+ "msteams:direct:user-aad" : {
103+ sessionId : "old-session" ,
104+ updatedAt : 1_000 ,
105+ route : { channel : "msteams" } ,
106+ deliveryContext : { channel : "msteams" } ,
107+ lastChannel : "msteams" ,
108+ lastTo : "user:user-aad" ,
109+ lastAccountId : "default" ,
110+ origin : { provider : "msteams" } ,
111+ } ,
94112 "msteams:direct:other-user" : { sessionId : "other-session" , updatedAt : 2_000 } ,
95113 } ;
96114 setupStore ( store ) ;
@@ -118,6 +136,11 @@ describe("handleMSTeamsLifecycleRemove", () => {
118136 } ) ;
119137 expect ( remove ) . toHaveBeenCalledWith ( "19:personal-chat" ) ;
120138 expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 0 ) ;
139+ expect ( store [ "msteams:direct:user-aad" ] . sessionId ) . not . toBe ( "old-session" ) ;
140+ expect ( store [ "msteams:direct:user-aad" ] . route ) . toBeUndefined ( ) ;
141+ expect ( store [ "msteams:direct:user-aad" ] . deliveryContext ) . toBeUndefined ( ) ;
142+ expect ( store [ "msteams:direct:user-aad" ] . lastChannel ) . toBeUndefined ( ) ;
143+ expect ( store [ "msteams:direct:user-aad" ] . origin ) . toBeUndefined ( ) ;
121144 expect ( store [ "msteams:direct:other-user" ] . updatedAt ) . toBe ( 2_000 ) ;
122145 } ) ;
123146
@@ -144,9 +167,38 @@ describe("handleMSTeamsLifecycleRemove", () => {
144167
145168 expect ( result . sessionsReset ) . toBe ( 1 ) ;
146169 expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 0 ) ;
170+ expect ( store [ "msteams:direct:user-aad" ] . sessionId ) . not . toBe ( "old-session" ) ;
147171 } ) ;
148172
149- it ( "ignores installation add events" , async ( ) => {
173+ it ( "ignores first-install add events when no existing session is active" , async ( ) => {
174+ const store : Record < string , SessionEntry > = { } ;
175+ setupStore ( store ) ;
176+ const { deps, remove } = createDeps ( ) ;
177+
178+ const result = await handleMSTeamsLifecycleRemove (
179+ createContext ( {
180+ type : "installationUpdate" ,
181+ action : "add" ,
182+ from : { id : "user-bf" , aadObjectId : "user-aad" } ,
183+ recipient : { id : "bot-id" } ,
184+ conversation : {
185+ id : "19:personal-chat" ,
186+ conversationType : "personal" ,
187+ } ,
188+ } ) ,
189+ deps ,
190+ ) ;
191+
192+ expect ( result ) . toEqual ( {
193+ handled : false ,
194+ reason : "installation-add-existing" ,
195+ conversationRemoved : false ,
196+ sessionsReset : 0 ,
197+ } ) ;
198+ expect ( remove ) . not . toHaveBeenCalled ( ) ;
199+ } ) ;
200+
201+ it ( "rotates a personal app add event when an existing session is active" , async ( ) => {
150202 const store = {
151203 "msteams:direct:user-aad" : { sessionId : "old-session" , updatedAt : 1_000 } ,
152204 } ;
@@ -167,13 +219,53 @@ describe("handleMSTeamsLifecycleRemove", () => {
167219 deps ,
168220 ) ;
169221
170- expect ( result . handled ) . toBe ( false ) ;
222+ expect ( result ) . toEqual ( {
223+ handled : true ,
224+ reason : "installation-add-existing" ,
225+ conversationRemoved : false ,
226+ sessionsReset : 1 ,
227+ } ) ;
228+ expect ( remove ) . not . toHaveBeenCalled ( ) ;
229+ expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 0 ) ;
230+ expect ( store [ "msteams:direct:user-aad" ] . sessionId ) . not . toBe ( "old-session" ) ;
231+ } ) ;
232+
233+ it ( "does not treat channel installation add events as reinstall boundaries" , async ( ) => {
234+ const store = {
235+ "msteams:channel:19:[email protected] " :
{ 236+ sessionId : "base" ,
237+ updatedAt : 1_000 ,
238+ } ,
239+ } ;
240+ setupStore ( store ) ;
241+ const { deps, remove } = createDeps ( ) ;
242+
243+ const result = await handleMSTeamsLifecycleRemove (
244+ createContext ( {
245+ type : "installationUpdate" ,
246+ action : "add" ,
247+ from : { id : "user-bf" , aadObjectId : "user-aad" } ,
248+ recipient : { id : "bot-id" } ,
249+ conversation : {
250+ 251+ conversationType : "channel" ,
252+ } ,
253+ } ) ,
254+ deps ,
255+ ) ;
256+
257+ expect ( result ) . toEqual ( {
258+ handled : false ,
259+ reason : "installation-add-existing" ,
260+ conversationRemoved : false ,
261+ sessionsReset : 0 ,
262+ } ) ;
171263 expect ( remove ) . not . toHaveBeenCalled ( ) ;
172264 expect ( hoisted . listSessionEntries ) . not . toHaveBeenCalled ( ) ;
173- expect ( store [ "msteams:direct:user-aad " ] . updatedAt ) . toBe ( 1_000 ) ;
265+ expect ( store [ "msteams:channel:19:[email protected] " ] . updatedAt ) . toBe ( 1_000 ) ; 174266 } ) ;
175267
176- it ( "stales a channel removal base session and channel thread sessions" , async ( ) => {
268+ it ( "rotates a channel removal base session and channel thread sessions" , async ( ) => {
177269 const channelId = "19:[email protected] " ; 178270 const baseKey = `msteams:channel:${ channelId } ` ;
179271 const threadKey = `${ baseKey } :thread:root-message` ;
@@ -212,7 +304,9 @@ describe("handleMSTeamsLifecycleRemove", () => {
212304 } ) ;
213305 expect ( remove ) . toHaveBeenCalledWith ( channelId ) ;
214306 expect ( store [ baseKey ] . updatedAt ) . toBe ( 0 ) ;
307+ expect ( store [ baseKey ] . sessionId ) . not . toBe ( "base" ) ;
215308 expect ( store [ threadKey ] . updatedAt ) . toBe ( 0 ) ;
309+ expect ( store [ threadKey ] . sessionId ) . not . toBe ( "thread" ) ;
216310 expect ( store [ unrelatedKey ] . updatedAt ) . toBe ( 3_000 ) ;
217311 } ) ;
218312
@@ -282,4 +376,138 @@ describe("handleMSTeamsLifecycleRemove", () => {
282376 expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 2_000 ) ;
283377 expect ( store [ "msteams:direct:user-aad" ] . sessionId ) . toBe ( "fresh-session" ) ;
284378 } ) ;
379+
380+ it ( "rotates a personal DM session when the stored conversation id changes" , async ( ) => {
381+ const store = {
382+ "msteams:direct:user-aad" : {
383+ sessionId : "old-session" ,
384+ updatedAt : 1_000 ,
385+ route : { channel : "msteams" } ,
386+ deliveryContext : { channel : "msteams" } ,
387+ lastChannel : "msteams" ,
388+ lastTo : "user:user-aad" ,
389+ lastAccountId : "default" ,
390+ origin : { provider : "msteams" } ,
391+ } ,
392+ } ;
393+ setupStore ( store ) ;
394+ const { deps, remove } = createDeps ( ) ;
395+ vi . mocked ( deps . conversationStore . list ) . mockResolvedValue ( [
396+ {
397+ conversationId : "a:old-personal-chat" ,
398+ reference : {
399+ lastSeenAt : "2026-07-05T12:00:00.000Z" ,
400+ user : { id : "29:user" , aadObjectId : "user-aad" } ,
401+ agent : { id : "bot-id" , name : "Bot" } ,
402+ bot : { id : "bot-id" , name : "Bot" } ,
403+ conversation : {
404+ id : "a:old-personal-chat" ,
405+ conversationType : "personal" ,
406+ } ,
407+ } ,
408+ } ,
409+ ] ) ;
410+
411+ const result = await handleMSTeamsDmConversationBoundary ( {
412+ deps,
413+ conversationId : "a:new-personal-chat" ,
414+ senderId : "user-aad" ,
415+ botId : "bot-id" ,
416+ routeSessionKey : "msteams:direct:user-aad" ,
417+ agentId : "default" ,
418+ } ) ;
419+
420+ expect ( result ) . toEqual ( {
421+ handled : true ,
422+ previousConversationRemoved : true ,
423+ sessionsReset : 1 ,
424+ } ) ;
425+ expect ( remove ) . toHaveBeenCalledWith ( "a:old-personal-chat" ) ;
426+ expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 0 ) ;
427+ expect ( store [ "msteams:direct:user-aad" ] . sessionId ) . not . toBe ( "old-session" ) ;
428+ expect ( store [ "msteams:direct:user-aad" ] . route ) . toBeUndefined ( ) ;
429+ expect ( store [ "msteams:direct:user-aad" ] . deliveryContext ) . toBeUndefined ( ) ;
430+ expect ( store [ "msteams:direct:user-aad" ] . lastChannel ) . toBeUndefined ( ) ;
431+ expect ( store [ "msteams:direct:user-aad" ] . origin ) . toBeUndefined ( ) ;
432+ } ) ;
433+
434+ it ( "does not rotate a personal DM session when the stored conversation id matches" , async ( ) => {
435+ const store = {
436+ "msteams:direct:user-aad" : { sessionId : "old-session" , updatedAt : 1_000 } ,
437+ } ;
438+ setupStore ( store ) ;
439+ const { deps, remove } = createDeps ( ) ;
440+ vi . mocked ( deps . conversationStore . list ) . mockResolvedValue ( [
441+ {
442+ conversationId : "a:personal-chat" ,
443+ reference : {
444+ lastSeenAt : "2026-07-05T12:00:00.000Z" ,
445+ user : { id : "29:user" , aadObjectId : "user-aad" } ,
446+ agent : { id : "bot-id" , name : "Bot" } ,
447+ conversation : {
448+ id : "a:personal-chat" ,
449+ conversationType : "personal" ,
450+ } ,
451+ } ,
452+ } ,
453+ ] ) ;
454+
455+ const result = await handleMSTeamsDmConversationBoundary ( {
456+ deps,
457+ conversationId : "a:personal-chat" ,
458+ senderId : "user-aad" ,
459+ botId : "bot-id" ,
460+ routeSessionKey : "msteams:direct:user-aad" ,
461+ agentId : "default" ,
462+ } ) ;
463+
464+ expect ( result ) . toEqual ( {
465+ handled : false ,
466+ previousConversationRemoved : false ,
467+ sessionsReset : 0 ,
468+ } ) ;
469+ expect ( remove ) . not . toHaveBeenCalled ( ) ;
470+ expect ( hoisted . listSessionEntries ) . not . toHaveBeenCalled ( ) ;
471+ expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 1_000 ) ;
472+ } ) ;
473+
474+ it ( "does not rotate a personal DM session for a different stored bot" , async ( ) => {
475+ const store = {
476+ "msteams:direct:user-aad" : { sessionId : "old-session" , updatedAt : 1_000 } ,
477+ } ;
478+ setupStore ( store ) ;
479+ const { deps, remove } = createDeps ( ) ;
480+ vi . mocked ( deps . conversationStore . list ) . mockResolvedValue ( [
481+ {
482+ conversationId : "a:old-personal-chat" ,
483+ reference : {
484+ lastSeenAt : "2026-07-05T12:00:00.000Z" ,
485+ user : { id : "29:user" , aadObjectId : "user-aad" } ,
486+ agent : { id : "other-bot-id" , name : "Other Bot" } ,
487+ conversation : {
488+ id : "a:old-personal-chat" ,
489+ conversationType : "personal" ,
490+ } ,
491+ } ,
492+ } ,
493+ ] ) ;
494+
495+ const result = await handleMSTeamsDmConversationBoundary ( {
496+ deps,
497+ conversationId : "a:new-personal-chat" ,
498+ senderId : "user-aad" ,
499+ botId : "bot-id" ,
500+ routeSessionKey : "msteams:direct:user-aad" ,
501+ agentId : "default" ,
502+ } ) ;
503+
504+ expect ( result ) . toEqual ( {
505+ handled : false ,
506+ previousConversationRemoved : false ,
507+ sessionsReset : 0 ,
508+ } ) ;
509+ expect ( remove ) . not . toHaveBeenCalled ( ) ;
510+ expect ( hoisted . listSessionEntries ) . not . toHaveBeenCalled ( ) ;
511+ expect ( store [ "msteams:direct:user-aad" ] . updatedAt ) . toBe ( 1_000 ) ;
512+ } ) ;
285513} ) ;
0 commit comments