@openclaw/msteams 2026.3.12 → 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +161 -9
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +174 -437
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +148 -14
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +258 -0
  78. package/src/graph-upload.ts +87 -8
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +522 -45
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +477 -174
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +301 -106
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +34 -40
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -101
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -1,27 +1,30 @@
1
1
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
- import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk/msteams";
3
+ import { SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking";
4
+ import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
5
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
5
6
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
7
7
  import type { StoredConversationReference } from "./conversation-store.js";
8
8
  const graphUploadMockState = vi.hoisted(() => ({
9
9
  uploadAndShareOneDrive: vi.fn(),
10
+ uploadAndShareSharePoint: vi.fn(),
11
+ getDriveItemProperties: vi.fn(),
10
12
  }));
11
13
 
12
- vi.mock("./graph-upload.js", async () => {
13
- const actual = await vi.importActual<typeof import("./graph-upload.js")>("./graph-upload.js");
14
+ vi.mock("./graph-upload.js", () => {
14
15
  return {
15
- ...actual,
16
16
  uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
17
+ uploadAndShareSharePoint: graphUploadMockState.uploadAndShareSharePoint,
18
+ getDriveItemProperties: graphUploadMockState.getDriveItemProperties,
17
19
  };
18
20
  });
19
21
 
20
- import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
21
22
  import {
22
- type MSTeamsAdapter,
23
+ buildActivity,
24
+ buildConversationReference,
23
25
  renderReplyPayloadsToMessages,
24
26
  sendMSTeamsMessages,
27
+ type MSTeamsAdapter,
25
28
  } from "./messenger.js";
26
29
  import { setMSTeamsRuntime } from "./runtime.js";
27
30
 
@@ -39,7 +42,10 @@ const chunkMarkdownText = (text: string, limit: number) => {
39
42
  return chunks;
40
43
  };
41
44
 
42
- const runtimeStub: PluginRuntime = createPluginRuntimeMock({
45
+ const runtimeStub = {
46
+ config: {
47
+ loadConfig: () => ({}),
48
+ },
43
49
  channel: {
44
50
  text: {
45
51
  chunkMarkdownText,
@@ -48,11 +54,16 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
48
54
  convertMarkdownTables: (text: string) => text,
49
55
  },
50
56
  },
51
- });
57
+ } as unknown as PluginRuntime;
58
+
59
+ const noopUpdateActivity = async () => {};
60
+ const noopDeleteActivity = async () => {};
52
61
 
53
62
  const createNoopAdapter = (): MSTeamsAdapter => ({
54
63
  continueConversation: async () => {},
55
64
  process: async () => {},
65
+ updateActivity: noopUpdateActivity,
66
+ deleteActivity: noopDeleteActivity,
56
67
  });
57
68
 
58
69
  const createRecordedSendActivity = (
@@ -74,19 +85,40 @@ const createRecordedSendActivity = (
74
85
 
75
86
  const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
76
87
 
88
+ function requireConversationId(ref: { conversation?: { id?: string } }) {
89
+ if (!ref.conversation?.id) {
90
+ throw new Error("expected Teams top-level send to preserve conversation id");
91
+ }
92
+ return ref.conversation.id;
93
+ }
94
+
95
+ function requireSentMessage(sent: Array<{ text?: string; entities?: unknown[] }>) {
96
+ const firstSent = sent[0];
97
+ if (!firstSent?.text) {
98
+ throw new Error("expected Teams message send to include rendered text");
99
+ }
100
+ return firstSent;
101
+ }
102
+
77
103
  const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
78
104
  continueConversation: async (_appId, _reference, logic) => {
79
105
  await logic({
80
106
  sendActivity: createRecordedSendActivity(proactiveSent),
107
+ updateActivity: noopUpdateActivity,
108
+ deleteActivity: noopDeleteActivity,
81
109
  });
82
110
  },
83
111
  process: async () => {},
112
+ updateActivity: noopUpdateActivity,
113
+ deleteActivity: noopDeleteActivity,
84
114
  });
85
115
 
86
116
  describe("msteams messenger", () => {
87
117
  beforeEach(() => {
88
118
  setMSTeamsRuntime(runtimeStub);
89
119
  graphUploadMockState.uploadAndShareOneDrive.mockReset();
120
+ graphUploadMockState.uploadAndShareSharePoint.mockReset();
121
+ graphUploadMockState.getDriveItemProperties.mockReset();
90
122
  graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
91
123
  itemId: "item123",
92
124
  webUrl: "https://onedrive.example.com/item123",
@@ -139,6 +171,24 @@ describe("msteams messenger", () => {
139
171
  });
140
172
 
141
173
  describe("sendMSTeamsMessages", () => {
174
+ function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) {
175
+ let attempt = 0;
176
+ return {
177
+ sendActivity: async (activity: unknown) => {
178
+ const { text } = activity as { text?: string };
179
+ const content = text ?? "";
180
+ attempt += 1;
181
+ if (params?.failAfterAttempt && attempt < params.failAfterAttempt) {
182
+ params.sent?.push(content);
183
+ return { id: `id:${content}` };
184
+ }
185
+ throw new TypeError(REVOCATION_ERROR);
186
+ },
187
+ updateActivity: noopUpdateActivity,
188
+ deleteActivity: noopDeleteActivity,
189
+ };
190
+ }
191
+
142
192
  const baseRef: StoredConversationReference = {
143
193
  activityId: "activity123",
144
194
  user: { id: "user123", name: "User" },
@@ -148,10 +198,57 @@ describe("msteams messenger", () => {
148
198
  serviceUrl: "https://service.example.com",
149
199
  };
150
200
 
201
+ async function sendAndCaptureRevokeFallbackReference(params: {
202
+ conversation: StoredConversationReference["conversation"];
203
+ activityId?: string;
204
+ threadId?: string;
205
+ }) {
206
+ const proactiveSent: string[] = [];
207
+ let capturedReference: unknown;
208
+ const conversationRef: StoredConversationReference = {
209
+ activityId: params.activityId ?? "activity456",
210
+ user: { id: "user123", name: "User" },
211
+ agent: { id: "bot123", name: "Bot" },
212
+ conversation: params.conversation,
213
+ channelId: "msteams",
214
+ serviceUrl: "https://service.example.com",
215
+ ...(params.threadId ? { threadId: params.threadId } : {}),
216
+ };
217
+ const adapter: MSTeamsAdapter = {
218
+ continueConversation: async (_appId, reference, logic) => {
219
+ capturedReference = reference;
220
+ await logic({
221
+ sendActivity: createRecordedSendActivity(proactiveSent),
222
+ updateActivity: noopUpdateActivity,
223
+ deleteActivity: noopDeleteActivity,
224
+ });
225
+ },
226
+ process: async () => {},
227
+ updateActivity: noopUpdateActivity,
228
+ deleteActivity: noopDeleteActivity,
229
+ };
230
+
231
+ await sendMSTeamsMessages({
232
+ replyStyle: "thread",
233
+ adapter,
234
+ appId: "app123",
235
+ conversationRef,
236
+ context: createRevokedThreadContext(),
237
+ messages: [{ text: "hello" }],
238
+ });
239
+
240
+ return {
241
+ proactiveSent,
242
+ reference: capturedReference as { conversation?: { id?: string }; activityId?: string },
243
+ };
244
+ }
245
+
151
246
  it("sends thread messages via the provided context", async () => {
152
247
  const sent: string[] = [];
153
248
  const ctx = {
154
249
  sendActivity: createRecordedSendActivity(sent),
250
+ updateActivity: noopUpdateActivity,
251
+ deleteActivity: noopDeleteActivity,
155
252
  };
156
253
  const adapter = createNoopAdapter();
157
254
 
@@ -176,9 +273,13 @@ describe("msteams messenger", () => {
176
273
  seen.reference = reference;
177
274
  await logic({
178
275
  sendActivity: createRecordedSendActivity(seen.texts),
276
+ updateActivity: noopUpdateActivity,
277
+ deleteActivity: noopDeleteActivity,
179
278
  });
180
279
  },
181
280
  process: async () => {},
281
+ updateActivity: noopUpdateActivity,
282
+ deleteActivity: noopDeleteActivity,
182
283
  };
183
284
 
184
285
  const ids = await sendMSTeamsMessages({
@@ -197,7 +298,7 @@ describe("msteams messenger", () => {
197
298
  conversation?: { id?: string };
198
299
  };
199
300
  expect(ref.activityId).toBeUndefined();
200
- expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
301
+ expect(requireConversationId(ref)).toBe("19:abc@thread.tacv2");
201
302
  });
202
303
 
203
304
  it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
@@ -212,6 +313,8 @@ describe("msteams messenger", () => {
212
313
  sent.push(activity as { text?: string; entities?: unknown[] });
213
314
  return { id: "id:one" };
214
315
  },
316
+ updateActivity: noopUpdateActivity,
317
+ deleteActivity: noopDeleteActivity,
215
318
  };
216
319
 
217
320
  const adapter = createNoopAdapter();
@@ -237,20 +340,26 @@ describe("msteams messenger", () => {
237
340
  expect(ids).toEqual(["id:one"]);
238
341
  expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
239
342
  expect(sent).toHaveLength(1);
240
- expect(sent[0]?.text).toContain("Hello <at>John</at>");
241
- expect(sent[0]?.text).toContain(
343
+ const firstSent = requireSentMessage(sent);
344
+ expect(firstSent.text).toContain("Hello <at>John</at>");
345
+ expect(firstSent.text).toContain(
242
346
  "📎 [upload.txt](https://onedrive.example.com/share/item123)",
243
347
  );
244
- expect(sent[0]?.entities).toEqual([
245
- {
246
- type: "mention",
247
- text: "<at>John</at>",
248
- mentioned: {
249
- id: "29:08q2j2o3jc09au90eucae",
250
- name: "John",
348
+ expect(sent[0]?.entities).toEqual(
349
+ expect.arrayContaining([
350
+ {
351
+ type: "mention",
352
+ text: "<at>John</at>",
353
+ mentioned: {
354
+ id: "29:08q2j2o3jc09au90eucae",
355
+ name: "John",
356
+ },
251
357
  },
252
- },
253
- ]);
358
+ expect.objectContaining({
359
+ additionalType: ["AIGeneratedContent"],
360
+ }),
361
+ ]),
362
+ );
254
363
  } finally {
255
364
  await rm(tmpDir, { recursive: true, force: true });
256
365
  }
@@ -262,6 +371,8 @@ describe("msteams messenger", () => {
262
371
 
263
372
  const ctx = {
264
373
  sendActivity: createRecordedSendActivity(attempts, 429),
374
+ updateActivity: noopUpdateActivity,
375
+ deleteActivity: noopDeleteActivity,
265
376
  };
266
377
  const adapter = createNoopAdapter();
267
378
 
@@ -281,11 +392,72 @@ describe("msteams messenger", () => {
281
392
  expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
282
393
  });
283
394
 
395
+ it("retries full activity preparation when media upload fails transiently", async () => {
396
+ const tmpDir = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "msteams-retry-"));
397
+ const localFile = path.join(tmpDir, "retry.txt");
398
+ await writeFile(localFile, "hello");
399
+
400
+ try {
401
+ const attempts: string[] = [];
402
+ const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
403
+ let uploadAttempts = 0;
404
+ graphUploadMockState.uploadAndShareOneDrive.mockImplementation(async () => {
405
+ uploadAttempts += 1;
406
+ if (uploadAttempts === 1) {
407
+ throw Object.assign(new Error("transient upload failure"), { statusCode: 429 });
408
+ }
409
+ return {
410
+ itemId: "item123",
411
+ webUrl: "https://onedrive.example.com/item123",
412
+ shareUrl: "https://onedrive.example.com/share/item123",
413
+ name: "retry.txt",
414
+ };
415
+ });
416
+
417
+ const ctx = {
418
+ sendActivity: createRecordedSendActivity(attempts),
419
+ updateActivity: noopUpdateActivity,
420
+ deleteActivity: noopDeleteActivity,
421
+ };
422
+ const adapter = createNoopAdapter();
423
+
424
+ const ids = await sendMSTeamsMessages({
425
+ replyStyle: "thread",
426
+ adapter,
427
+ appId: "app123",
428
+ conversationRef: {
429
+ ...baseRef,
430
+ conversation: {
431
+ ...baseRef.conversation,
432
+ conversationType: "channel",
433
+ },
434
+ },
435
+ context: ctx,
436
+ messages: [{ text: "one", mediaUrl: localFile }],
437
+ tokenProvider: {
438
+ getAccessToken: async () => "token",
439
+ },
440
+ retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
441
+ onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
442
+ });
443
+
444
+ expect(uploadAttempts).toBe(2);
445
+ expect(attempts).toHaveLength(1);
446
+ expect(attempts[0]).toContain("📎 [retry.txt]");
447
+ expect(ids).toEqual([`id:${attempts[0]}`]);
448
+ expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
449
+ } finally {
450
+ await rm(tmpDir, { recursive: true, force: true });
451
+ }
452
+ });
453
+
284
454
  it("does not retry thread sends on client errors (4xx)", async () => {
285
455
  const ctx = {
286
456
  sendActivity: async () => {
287
457
  throw Object.assign(new Error("bad request"), { statusCode: 400 });
288
458
  },
459
+ updateActivity: noopUpdateActivity,
460
+ deleteActivity: noopDeleteActivity,
289
461
  };
290
462
 
291
463
  const adapter = createNoopAdapter();
@@ -305,13 +477,7 @@ describe("msteams messenger", () => {
305
477
 
306
478
  it("falls back to proactive messaging when thread context is revoked", async () => {
307
479
  const proactiveSent: string[] = [];
308
-
309
- const ctx = {
310
- sendActivity: async () => {
311
- throw new TypeError(REVOCATION_ERROR);
312
- },
313
- };
314
-
480
+ const ctx = createRevokedThreadContext();
315
481
  const adapter = createFallbackAdapter(proactiveSent);
316
482
 
317
483
  const ids = await sendMSTeamsMessages({
@@ -331,21 +497,7 @@ describe("msteams messenger", () => {
331
497
  it("falls back only for remaining thread messages after context revocation", async () => {
332
498
  const threadSent: string[] = [];
333
499
  const proactiveSent: string[] = [];
334
- let attempt = 0;
335
-
336
- const ctx = {
337
- sendActivity: async (activity: unknown) => {
338
- const { text } = activity as { text?: string };
339
- const content = text ?? "";
340
- attempt += 1;
341
- if (attempt === 1) {
342
- threadSent.push(content);
343
- return { id: `id:${content}` };
344
- }
345
- throw new TypeError(REVOCATION_ERROR);
346
- },
347
- };
348
-
500
+ const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent });
349
501
  const adapter = createFallbackAdapter(proactiveSent);
350
502
 
351
503
  const ids = await sendMSTeamsMessages({
@@ -362,14 +514,125 @@ describe("msteams messenger", () => {
362
514
  expect(ids).toEqual(["id:one", "id:two", "id:three"]);
363
515
  });
364
516
 
517
+ it("reconstructs threaded conversation ID for channel revoke fallback", async () => {
518
+ const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
519
+ conversation: {
520
+ id: "19:abc@thread.tacv2;messageid=deadbeef",
521
+ conversationType: "channel",
522
+ },
523
+ });
524
+
525
+ expect(proactiveSent).toEqual(["hello"]);
526
+ // Conversation ID should include the thread suffix for channel messages
527
+ expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=activity456");
528
+ expect(reference.activityId).toBeUndefined();
529
+ });
530
+
531
+ it("does not add thread suffix for group chat revoke fallback", async () => {
532
+ const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
533
+ conversation: {
534
+ id: "19:group123@thread.v2",
535
+ conversationType: "groupChat",
536
+ },
537
+ });
538
+
539
+ expect(proactiveSent).toEqual(["hello"]);
540
+ // Group chat should NOT have thread suffix — flat conversation
541
+ expect(reference.conversation?.id).toBe("19:group123@thread.v2");
542
+ expect(reference.activityId).toBeUndefined();
543
+ });
544
+
545
+ it("uses threadId instead of activityId for channel revoke fallback (#58030)", async () => {
546
+ const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
547
+ activityId: "current-message-id",
548
+ conversation: {
549
+ id: "19:abc@thread.tacv2",
550
+ conversationType: "channel",
551
+ },
552
+ // threadId is the thread root, which differs from activityId (current message)
553
+ threadId: "thread-root-msg-id",
554
+ });
555
+
556
+ expect(proactiveSent).toEqual(["hello"]);
557
+ // Should use threadId (thread root), NOT activityId (current message)
558
+ expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
559
+ expect(reference.activityId).toBeUndefined();
560
+ });
561
+
562
+ it("falls back to activityId when threadId is not set (backward compat)", async () => {
563
+ const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
564
+ activityId: "legacy-activity-id",
565
+ conversation: {
566
+ id: "19:abc@thread.tacv2",
567
+ conversationType: "channel",
568
+ },
569
+ // No threadId — older stored references may not have it
570
+ });
571
+
572
+ expect(proactiveSent).toEqual(["hello"]);
573
+ // Falls back to activityId when threadId is missing
574
+ expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
575
+ });
576
+
577
+ it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
578
+ let capturedReference: unknown;
579
+ const sent: string[] = [];
580
+
581
+ const channelRef: StoredConversationReference = {
582
+ activityId: "current-msg",
583
+ user: { id: "user123", name: "User" },
584
+ agent: { id: "bot123", name: "Bot" },
585
+ conversation: {
586
+ id: "19:abc@thread.tacv2",
587
+ conversationType: "channel",
588
+ },
589
+ channelId: "msteams",
590
+ serviceUrl: "https://service.example.com",
591
+ threadId: "thread-root-msg-id",
592
+ };
593
+
594
+ const adapter: MSTeamsAdapter = {
595
+ continueConversation: async (_appId, reference, logic) => {
596
+ capturedReference = reference;
597
+ await logic({
598
+ sendActivity: createRecordedSendActivity(sent),
599
+ updateActivity: noopUpdateActivity,
600
+ deleteActivity: noopDeleteActivity,
601
+ });
602
+ },
603
+ process: async () => {},
604
+ updateActivity: noopUpdateActivity,
605
+ deleteActivity: noopDeleteActivity,
606
+ };
607
+
608
+ await sendMSTeamsMessages({
609
+ replyStyle: "top-level",
610
+ adapter,
611
+ appId: "app123",
612
+ conversationRef: channelRef,
613
+ messages: [{ text: "hello" }],
614
+ });
615
+
616
+ expect(sent).toEqual(["hello"]);
617
+ const ref = capturedReference as { conversation?: { id?: string } };
618
+ // Top-level sends should NOT include thread suffix
619
+ expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
620
+ });
621
+
365
622
  it("retries top-level sends on transient (5xx)", async () => {
366
623
  const attempts: string[] = [];
367
624
 
368
625
  const adapter: MSTeamsAdapter = {
369
626
  continueConversation: async (_appId, _reference, logic) => {
370
- await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
627
+ await logic({
628
+ sendActivity: createRecordedSendActivity(attempts, 503),
629
+ updateActivity: noopUpdateActivity,
630
+ deleteActivity: noopDeleteActivity,
631
+ });
371
632
  },
372
633
  process: async () => {},
634
+ updateActivity: noopUpdateActivity,
635
+ deleteActivity: noopDeleteActivity,
373
636
  };
374
637
 
375
638
  const ids = await sendMSTeamsMessages({
@@ -384,5 +647,219 @@ describe("msteams messenger", () => {
384
647
  expect(attempts).toEqual(["hello", "hello"]);
385
648
  expect(ids).toEqual(["id:hello"]);
386
649
  });
650
+
651
+ it("delivers all blocks in a multi-block reply via a single continueConversation call (#29379)", async () => {
652
+ // Regression: multiple text blocks (e.g. text -> tool -> text) must all
653
+ // reach the user. Previously each deliver() call opened a separate
654
+ // continueConversation(); Teams silently drops blocks 2+ in that case.
655
+ // The fix batches all rendered messages into one sendMSTeamsMessages call
656
+ // so they share a single continueConversation().
657
+ const conversationCallTexts: string[][] = [];
658
+ const adapter: MSTeamsAdapter = {
659
+ continueConversation: async (_appId, _reference, logic) => {
660
+ const batchTexts: string[] = [];
661
+ await logic({
662
+ sendActivity: async (activity: unknown) => {
663
+ const { text } = activity as { text?: string };
664
+ batchTexts.push(text ?? "");
665
+ return { id: `id:${text ?? ""}` };
666
+ },
667
+ updateActivity: noopUpdateActivity,
668
+ deleteActivity: noopDeleteActivity,
669
+ });
670
+ conversationCallTexts.push(batchTexts);
671
+ },
672
+ process: async () => {},
673
+ updateActivity: noopUpdateActivity,
674
+ deleteActivity: noopDeleteActivity,
675
+ };
676
+
677
+ // Three blocks (text + code + text) sent together in one call.
678
+ const ids = await sendMSTeamsMessages({
679
+ replyStyle: "top-level",
680
+ adapter,
681
+ appId: "app123",
682
+ conversationRef: baseRef,
683
+ messages: [
684
+ { text: "Let me look that up..." },
685
+ { text: "```\nresult = 42\n```" },
686
+ { text: "The answer is 42." },
687
+ ],
688
+ });
689
+
690
+ // All three blocks delivered.
691
+ expect(ids).toHaveLength(3);
692
+ // All three arrive in a single continueConversation() call, not three.
693
+ expect(conversationCallTexts).toHaveLength(1);
694
+ expect(conversationCallTexts[0]).toEqual([
695
+ "Let me look that up...",
696
+ "```\nresult = 42\n```",
697
+ "The answer is 42.",
698
+ ]);
699
+ });
700
+ });
701
+
702
+ describe("buildActivity AI metadata", () => {
703
+ const baseRef: StoredConversationReference = {
704
+ activityId: "activity123",
705
+ user: { id: "user123", name: "User" },
706
+ agent: { id: "bot123", name: "Bot" },
707
+ conversation: { id: "conv123", conversationType: "personal" },
708
+ channelId: "msteams",
709
+ serviceUrl: "https://service.example.com",
710
+ };
711
+
712
+ it("adds AI-generated entity to text messages", async () => {
713
+ const activity = await buildActivity({ text: "hello" }, baseRef);
714
+ const entities = activity.entities as Array<Record<string, unknown>>;
715
+ expect(entities).toEqual(
716
+ expect.arrayContaining([
717
+ expect.objectContaining({
718
+ type: "https://schema.org/Message",
719
+ "@type": "Message",
720
+ additionalType: ["AIGeneratedContent"],
721
+ }),
722
+ ]),
723
+ );
724
+ });
725
+
726
+ it("adds AI-generated entity to media-only messages", async () => {
727
+ const activity = await buildActivity({ mediaUrl: "https://example.com/img.png" }, baseRef);
728
+ const entities = activity.entities as Array<Record<string, unknown>>;
729
+ expect(entities).toEqual(
730
+ expect.arrayContaining([
731
+ expect.objectContaining({
732
+ additionalType: ["AIGeneratedContent"],
733
+ }),
734
+ ]),
735
+ );
736
+ });
737
+
738
+ it("preserves mention entities alongside AI entity", async () => {
739
+ const activity = await buildActivity({ text: "hi <at>@User</at>" }, baseRef);
740
+ const entities = activity.entities as Array<Record<string, unknown>>;
741
+ // Should have at least the AI entity
742
+ expect(entities.length).toBeGreaterThanOrEqual(1);
743
+ expect(entities).toEqual(
744
+ expect.arrayContaining([
745
+ expect.objectContaining({
746
+ additionalType: ["AIGeneratedContent"],
747
+ }),
748
+ ]),
749
+ );
750
+ });
751
+
752
+ it("sets feedbackLoopEnabled in channelData when enabled", async () => {
753
+ const activity = await buildActivity(
754
+ { text: "hello" },
755
+ baseRef,
756
+ undefined,
757
+ undefined,
758
+ undefined,
759
+ {
760
+ feedbackLoopEnabled: true,
761
+ },
762
+ );
763
+ const channelData = activity.channelData as Record<string, unknown>;
764
+ expect(channelData.feedbackLoopEnabled).toBe(true);
765
+ });
766
+
767
+ it("defaults feedbackLoopEnabled to false", async () => {
768
+ const activity = await buildActivity({ text: "hello" }, baseRef);
769
+ const channelData = activity.channelData as Record<string, unknown>;
770
+ expect(channelData.feedbackLoopEnabled).toBe(false);
771
+ });
772
+ });
773
+
774
+ // Regression coverage for #58774: proactive Teams sends fail with HTTP 403
775
+ // when the Bot Framework connector does not see `tenantId` / `aadObjectId`
776
+ // on the outbound conversation reference.
777
+ describe("buildConversationReference tenant/aad forwarding (#58774)", () => {
778
+ const storedWithChannelDataTenant: StoredConversationReference = {
779
+ activityId: "activity-1",
780
+ user: { id: "user123", name: "User", aadObjectId: "aad-user-123" },
781
+ agent: { id: "bot123", name: "Bot" },
782
+ conversation: {
783
+ id: "19:abc@thread.tacv2",
784
+ conversationType: "channel",
785
+ },
786
+ // Canonical channelData source captured by message-handler inbound code.
787
+ tenantId: "tenant-abc",
788
+ aadObjectId: "aad-user-123",
789
+ channelId: "msteams",
790
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
791
+ };
792
+
793
+ it("forwards top-level tenantId and aadObjectId onto the outbound reference", () => {
794
+ const reference = buildConversationReference(storedWithChannelDataTenant);
795
+ expect(reference.tenantId).toBe("tenant-abc");
796
+ expect(reference.aadObjectId).toBe("aad-user-123");
797
+ expect(reference.conversation.tenantId).toBe("tenant-abc");
798
+ expect(reference.user?.aadObjectId).toBe("aad-user-123");
799
+ });
800
+
801
+ it("falls back to conversation.tenantId when no top-level tenantId is stored (legacy ref)", () => {
802
+ const legacy: StoredConversationReference = {
803
+ activityId: "activity-legacy",
804
+ user: { id: "user-legacy", name: "Legacy", aadObjectId: "aad-legacy" },
805
+ agent: { id: "bot-legacy", name: "Bot" },
806
+ conversation: {
807
+ id: "a:personal-chat",
808
+ conversationType: "personal",
809
+ tenantId: "tenant-legacy",
810
+ },
811
+ channelId: "msteams",
812
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
813
+ };
814
+ const reference = buildConversationReference(legacy);
815
+ expect(reference.tenantId).toBe("tenant-legacy");
816
+ expect(reference.aadObjectId).toBe("aad-legacy");
817
+ });
818
+
819
+ it("omits tenantId and aadObjectId when neither source is available", () => {
820
+ const minimal: StoredConversationReference = {
821
+ activityId: "activity-2",
822
+ user: { id: "user456", name: "User" },
823
+ agent: { id: "bot456", name: "Bot" },
824
+ conversation: { id: "19:xyz@thread.tacv2", conversationType: "channel" },
825
+ channelId: "msteams",
826
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
827
+ };
828
+ const reference = buildConversationReference(minimal);
829
+ expect(reference.tenantId).toBeUndefined();
830
+ expect(reference.aadObjectId).toBeUndefined();
831
+ expect(reference.conversation.tenantId).toBeUndefined();
832
+ });
833
+
834
+ it("propagates tenantId/aadObjectId through sendMSTeamsMessages proactive path", async () => {
835
+ let capturedReference:
836
+ | { tenantId?: string; aadObjectId?: string; user?: { aadObjectId?: string } }
837
+ | undefined;
838
+ const adapter: MSTeamsAdapter = {
839
+ continueConversation: async (_appId, reference, logic) => {
840
+ capturedReference = reference as typeof capturedReference;
841
+ await logic({
842
+ sendActivity: async () => ({ id: "ok" }),
843
+ updateActivity: noopUpdateActivity,
844
+ deleteActivity: noopDeleteActivity,
845
+ });
846
+ },
847
+ process: async () => {},
848
+ updateActivity: noopUpdateActivity,
849
+ deleteActivity: noopDeleteActivity,
850
+ };
851
+
852
+ await sendMSTeamsMessages({
853
+ replyStyle: "top-level",
854
+ adapter,
855
+ appId: "app123",
856
+ conversationRef: storedWithChannelDataTenant,
857
+ messages: [{ text: "hello" }],
858
+ });
859
+
860
+ expect(capturedReference?.tenantId).toBe("tenant-abc");
861
+ expect(capturedReference?.aadObjectId).toBe("aad-user-123");
862
+ expect(capturedReference?.user?.aadObjectId).toBe("aad-user-123");
863
+ });
387
864
  });
388
865
  });