@openclaw/msteams 2026.3.13 → 2026.5.2-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 +138 -1
  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 +163 -418
  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 +145 -4
  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 +161 -4
  78. package/src/graph-upload.ts +147 -56
  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 +504 -23
  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 +470 -164
  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 +281 -79
  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 +11 -5
  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 -107
  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",
@@ -152,6 +184,8 @@ describe("msteams messenger", () => {
152
184
  }
153
185
  throw new TypeError(REVOCATION_ERROR);
154
186
  },
187
+ updateActivity: noopUpdateActivity,
188
+ deleteActivity: noopDeleteActivity,
155
189
  };
156
190
  }
157
191
 
@@ -164,10 +198,57 @@ describe("msteams messenger", () => {
164
198
  serviceUrl: "https://service.example.com",
165
199
  };
166
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
+
167
246
  it("sends thread messages via the provided context", async () => {
168
247
  const sent: string[] = [];
169
248
  const ctx = {
170
249
  sendActivity: createRecordedSendActivity(sent),
250
+ updateActivity: noopUpdateActivity,
251
+ deleteActivity: noopDeleteActivity,
171
252
  };
172
253
  const adapter = createNoopAdapter();
173
254
 
@@ -192,9 +273,13 @@ describe("msteams messenger", () => {
192
273
  seen.reference = reference;
193
274
  await logic({
194
275
  sendActivity: createRecordedSendActivity(seen.texts),
276
+ updateActivity: noopUpdateActivity,
277
+ deleteActivity: noopDeleteActivity,
195
278
  });
196
279
  },
197
280
  process: async () => {},
281
+ updateActivity: noopUpdateActivity,
282
+ deleteActivity: noopDeleteActivity,
198
283
  };
199
284
 
200
285
  const ids = await sendMSTeamsMessages({
@@ -213,7 +298,7 @@ describe("msteams messenger", () => {
213
298
  conversation?: { id?: string };
214
299
  };
215
300
  expect(ref.activityId).toBeUndefined();
216
- expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
301
+ expect(requireConversationId(ref)).toBe("19:abc@thread.tacv2");
217
302
  });
218
303
 
219
304
  it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
@@ -228,6 +313,8 @@ describe("msteams messenger", () => {
228
313
  sent.push(activity as { text?: string; entities?: unknown[] });
229
314
  return { id: "id:one" };
230
315
  },
316
+ updateActivity: noopUpdateActivity,
317
+ deleteActivity: noopDeleteActivity,
231
318
  };
232
319
 
233
320
  const adapter = createNoopAdapter();
@@ -253,20 +340,26 @@ describe("msteams messenger", () => {
253
340
  expect(ids).toEqual(["id:one"]);
254
341
  expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
255
342
  expect(sent).toHaveLength(1);
256
- expect(sent[0]?.text).toContain("Hello <at>John</at>");
257
- expect(sent[0]?.text).toContain(
343
+ const firstSent = requireSentMessage(sent);
344
+ expect(firstSent.text).toContain("Hello <at>John</at>");
345
+ expect(firstSent.text).toContain(
258
346
  "📎 [upload.txt](https://onedrive.example.com/share/item123)",
259
347
  );
260
- expect(sent[0]?.entities).toEqual([
261
- {
262
- type: "mention",
263
- text: "<at>John</at>",
264
- mentioned: {
265
- id: "29:08q2j2o3jc09au90eucae",
266
- 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
+ },
267
357
  },
268
- },
269
- ]);
358
+ expect.objectContaining({
359
+ additionalType: ["AIGeneratedContent"],
360
+ }),
361
+ ]),
362
+ );
270
363
  } finally {
271
364
  await rm(tmpDir, { recursive: true, force: true });
272
365
  }
@@ -278,6 +371,8 @@ describe("msteams messenger", () => {
278
371
 
279
372
  const ctx = {
280
373
  sendActivity: createRecordedSendActivity(attempts, 429),
374
+ updateActivity: noopUpdateActivity,
375
+ deleteActivity: noopDeleteActivity,
281
376
  };
282
377
  const adapter = createNoopAdapter();
283
378
 
@@ -297,11 +392,72 @@ describe("msteams messenger", () => {
297
392
  expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
298
393
  });
299
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
+
300
454
  it("does not retry thread sends on client errors (4xx)", async () => {
301
455
  const ctx = {
302
456
  sendActivity: async () => {
303
457
  throw Object.assign(new Error("bad request"), { statusCode: 400 });
304
458
  },
459
+ updateActivity: noopUpdateActivity,
460
+ deleteActivity: noopDeleteActivity,
305
461
  };
306
462
 
307
463
  const adapter = createNoopAdapter();
@@ -358,14 +514,125 @@ describe("msteams messenger", () => {
358
514
  expect(ids).toEqual(["id:one", "id:two", "id:three"]);
359
515
  });
360
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
+
361
622
  it("retries top-level sends on transient (5xx)", async () => {
362
623
  const attempts: string[] = [];
363
624
 
364
625
  const adapter: MSTeamsAdapter = {
365
626
  continueConversation: async (_appId, _reference, logic) => {
366
- await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
627
+ await logic({
628
+ sendActivity: createRecordedSendActivity(attempts, 503),
629
+ updateActivity: noopUpdateActivity,
630
+ deleteActivity: noopDeleteActivity,
631
+ });
367
632
  },
368
633
  process: async () => {},
634
+ updateActivity: noopUpdateActivity,
635
+ deleteActivity: noopDeleteActivity,
369
636
  };
370
637
 
371
638
  const ids = await sendMSTeamsMessages({
@@ -380,5 +647,219 @@ describe("msteams messenger", () => {
380
647
  expect(attempts).toEqual(["hello", "hello"]);
381
648
  expect(ids).toEqual(["id:hello"]);
382
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
+ });
383
864
  });
384
865
  });