Skip to content

Commit 158e595

Browse files
committed
fix(msteams): harden personal app reinstall session reset
1 parent fd1acc9 commit 158e595

3 files changed

Lines changed: 449 additions & 51 deletions

File tree

extensions/msteams/src/monitor-handler/lifecycle-handler.test.ts

Lines changed: 235 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
2930
let handleMSTeamsLifecycleRemove: typeof import("./lifecycle-handler.js").handleMSTeamsLifecycleRemove;
3031

3132
type 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

3643
function 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

7886
describe("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

Comments
 (0)