@openclaw/msteams 2026.5.2 → 2026.5.3-beta.2

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 (197) hide show
  1. package/dist/api.js +3 -0
  2. package/dist/channel-D7hdreTh.js +984 -0
  3. package/dist/channel-config-api.js +2 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.runtime-BC1ruIfN.js +573 -0
  6. package/dist/config-schema-B8QezH6t.js +15 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/graph-users-9uQJepqr.js +1354 -0
  9. package/dist/index.js +22 -0
  10. package/dist/oauth-BWJyilR1.js +114 -0
  11. package/dist/oauth.token-xxpoLWy5.js +115 -0
  12. package/dist/policy-DTnU2GR7.js +142 -0
  13. package/dist/probe-D_H8yFps.js +2194 -0
  14. package/dist/resolve-allowlist-D41JSziq.js +219 -0
  15. package/dist/runtime-api-DV1iVMn1.js +28 -0
  16. package/dist/runtime-api.js +2 -0
  17. package/dist/secret-contract-BuoEXmPS.js +35 -0
  18. package/dist/secret-contract-api.js +2 -0
  19. package/dist/setup-entry.js +15 -0
  20. package/dist/setup-plugin-api.js +64 -0
  21. package/dist/setup-surface-BLkFQYIQ.js +313 -0
  22. package/dist/src-CFp1QpFd.js +4064 -0
  23. package/dist/test-api.js +2 -0
  24. package/package.json +14 -6
  25. package/api.ts +0 -3
  26. package/channel-config-api.ts +0 -1
  27. package/channel-plugin-api.ts +0 -2
  28. package/config-api.ts +0 -4
  29. package/contract-api.ts +0 -4
  30. package/index.ts +0 -20
  31. package/runtime-api.ts +0 -73
  32. package/secret-contract-api.ts +0 -5
  33. package/setup-entry.ts +0 -13
  34. package/setup-plugin-api.ts +0 -3
  35. package/src/ai-entity.ts +0 -7
  36. package/src/approval-auth.ts +0 -44
  37. package/src/attachments/bot-framework.test.ts +0 -461
  38. package/src/attachments/bot-framework.ts +0 -362
  39. package/src/attachments/download.ts +0 -311
  40. package/src/attachments/graph.test.ts +0 -416
  41. package/src/attachments/graph.ts +0 -484
  42. package/src/attachments/html.ts +0 -122
  43. package/src/attachments/payload.ts +0 -14
  44. package/src/attachments/remote-media.test.ts +0 -137
  45. package/src/attachments/remote-media.ts +0 -112
  46. package/src/attachments/shared.test.ts +0 -530
  47. package/src/attachments/shared.ts +0 -626
  48. package/src/attachments/types.ts +0 -47
  49. package/src/attachments.graph.test.ts +0 -342
  50. package/src/attachments.helpers.test.ts +0 -246
  51. package/src/attachments.test-helpers.ts +0 -17
  52. package/src/attachments.test.ts +0 -687
  53. package/src/attachments.ts +0 -18
  54. package/src/block-streaming-config.test.ts +0 -61
  55. package/src/channel-api.ts +0 -1
  56. package/src/channel.actions.test.ts +0 -742
  57. package/src/channel.directory.test.ts +0 -200
  58. package/src/channel.runtime.ts +0 -56
  59. package/src/channel.setup.ts +0 -77
  60. package/src/channel.test.ts +0 -128
  61. package/src/channel.ts +0 -1136
  62. package/src/config-schema.ts +0 -6
  63. package/src/config-ui-hints.ts +0 -12
  64. package/src/conversation-store-fs.test.ts +0 -74
  65. package/src/conversation-store-fs.ts +0 -149
  66. package/src/conversation-store-helpers.test.ts +0 -202
  67. package/src/conversation-store-helpers.ts +0 -105
  68. package/src/conversation-store-memory.ts +0 -51
  69. package/src/conversation-store.shared.test.ts +0 -225
  70. package/src/conversation-store.ts +0 -71
  71. package/src/directory-live.test.ts +0 -156
  72. package/src/directory-live.ts +0 -111
  73. package/src/doctor.ts +0 -27
  74. package/src/errors.test.ts +0 -133
  75. package/src/errors.ts +0 -246
  76. package/src/feedback-reflection-prompt.ts +0 -117
  77. package/src/feedback-reflection-store.ts +0 -114
  78. package/src/feedback-reflection.test.ts +0 -237
  79. package/src/feedback-reflection.ts +0 -283
  80. package/src/file-consent-helpers.test.ts +0 -326
  81. package/src/file-consent-helpers.ts +0 -126
  82. package/src/file-consent-invoke.ts +0 -150
  83. package/src/file-consent.test.ts +0 -363
  84. package/src/file-consent.ts +0 -287
  85. package/src/graph-chat.ts +0 -55
  86. package/src/graph-group-management.test.ts +0 -318
  87. package/src/graph-group-management.ts +0 -168
  88. package/src/graph-members.test.ts +0 -89
  89. package/src/graph-members.ts +0 -48
  90. package/src/graph-messages.actions.test.ts +0 -243
  91. package/src/graph-messages.read.test.ts +0 -391
  92. package/src/graph-messages.search.test.ts +0 -213
  93. package/src/graph-messages.test-helpers.ts +0 -50
  94. package/src/graph-messages.ts +0 -534
  95. package/src/graph-teams.test.ts +0 -215
  96. package/src/graph-teams.ts +0 -114
  97. package/src/graph-thread.test.ts +0 -246
  98. package/src/graph-thread.ts +0 -146
  99. package/src/graph-upload.test.ts +0 -258
  100. package/src/graph-upload.ts +0 -531
  101. package/src/graph-users.ts +0 -29
  102. package/src/graph.test.ts +0 -516
  103. package/src/graph.ts +0 -293
  104. package/src/inbound.test.ts +0 -221
  105. package/src/inbound.ts +0 -148
  106. package/src/index.ts +0 -4
  107. package/src/media-helpers.test.ts +0 -202
  108. package/src/media-helpers.ts +0 -105
  109. package/src/mentions.test.ts +0 -244
  110. package/src/mentions.ts +0 -114
  111. package/src/messenger.test.ts +0 -865
  112. package/src/messenger.ts +0 -605
  113. package/src/monitor-handler/access.ts +0 -125
  114. package/src/monitor-handler/inbound-media.test.ts +0 -289
  115. package/src/monitor-handler/inbound-media.ts +0 -180
  116. package/src/monitor-handler/message-handler-mock-support.test-support.ts +0 -28
  117. package/src/monitor-handler/message-handler.authz.test.ts +0 -669
  118. package/src/monitor-handler/message-handler.dm-media.test.ts +0 -54
  119. package/src/monitor-handler/message-handler.test-support.ts +0 -100
  120. package/src/monitor-handler/message-handler.thread-parent.test.ts +0 -223
  121. package/src/monitor-handler/message-handler.thread-session.test.ts +0 -77
  122. package/src/monitor-handler/message-handler.ts +0 -1000
  123. package/src/monitor-handler/reaction-handler.test.ts +0 -267
  124. package/src/monitor-handler/reaction-handler.ts +0 -210
  125. package/src/monitor-handler/thread-session.ts +0 -17
  126. package/src/monitor-handler.adaptive-card.test.ts +0 -162
  127. package/src/monitor-handler.feedback-authz.test.ts +0 -314
  128. package/src/monitor-handler.file-consent.test.ts +0 -423
  129. package/src/monitor-handler.sso.test.ts +0 -563
  130. package/src/monitor-handler.test-helpers.ts +0 -180
  131. package/src/monitor-handler.ts +0 -534
  132. package/src/monitor-handler.types.ts +0 -27
  133. package/src/monitor-types.ts +0 -6
  134. package/src/monitor.lifecycle.test.ts +0 -278
  135. package/src/monitor.test.ts +0 -119
  136. package/src/monitor.ts +0 -442
  137. package/src/oauth.flow.ts +0 -77
  138. package/src/oauth.shared.ts +0 -37
  139. package/src/oauth.test.ts +0 -305
  140. package/src/oauth.token.ts +0 -158
  141. package/src/oauth.ts +0 -130
  142. package/src/outbound.test.ts +0 -130
  143. package/src/outbound.ts +0 -71
  144. package/src/pending-uploads-fs.test.ts +0 -246
  145. package/src/pending-uploads-fs.ts +0 -235
  146. package/src/pending-uploads.test.ts +0 -173
  147. package/src/pending-uploads.ts +0 -121
  148. package/src/policy.test.ts +0 -240
  149. package/src/policy.ts +0 -262
  150. package/src/polls-store-memory.ts +0 -32
  151. package/src/polls.test.ts +0 -160
  152. package/src/polls.ts +0 -323
  153. package/src/presentation.ts +0 -68
  154. package/src/probe.test.ts +0 -77
  155. package/src/probe.ts +0 -132
  156. package/src/reply-dispatcher.test.ts +0 -437
  157. package/src/reply-dispatcher.ts +0 -346
  158. package/src/reply-stream-controller.test.ts +0 -235
  159. package/src/reply-stream-controller.ts +0 -147
  160. package/src/resolve-allowlist.test.ts +0 -250
  161. package/src/resolve-allowlist.ts +0 -309
  162. package/src/revoked-context.ts +0 -17
  163. package/src/runtime.ts +0 -9
  164. package/src/sdk-types.ts +0 -59
  165. package/src/sdk.test.ts +0 -666
  166. package/src/sdk.ts +0 -884
  167. package/src/secret-contract.ts +0 -49
  168. package/src/secret-input.ts +0 -7
  169. package/src/send-context.ts +0 -231
  170. package/src/send.test.ts +0 -493
  171. package/src/send.ts +0 -637
  172. package/src/sent-message-cache.test.ts +0 -15
  173. package/src/sent-message-cache.ts +0 -56
  174. package/src/session-route.ts +0 -40
  175. package/src/setup-core.ts +0 -160
  176. package/src/setup-surface.test.ts +0 -202
  177. package/src/setup-surface.ts +0 -320
  178. package/src/sso-token-store.test.ts +0 -72
  179. package/src/sso-token-store.ts +0 -166
  180. package/src/sso.ts +0 -300
  181. package/src/storage.ts +0 -25
  182. package/src/store-fs.ts +0 -44
  183. package/src/streaming-message.test.ts +0 -262
  184. package/src/streaming-message.ts +0 -297
  185. package/src/test-runtime.ts +0 -16
  186. package/src/thread-parent-context.test.ts +0 -224
  187. package/src/thread-parent-context.ts +0 -159
  188. package/src/token-response.ts +0 -11
  189. package/src/token.test.ts +0 -259
  190. package/src/token.ts +0 -195
  191. package/src/user-agent.test.ts +0 -86
  192. package/src/user-agent.ts +0 -53
  193. package/src/webhook-timeouts.ts +0 -27
  194. package/src/welcome-card.test.ts +0 -81
  195. package/src/welcome-card.ts +0 -57
  196. package/test-api.ts +0 -1
  197. package/tsconfig.json +0 -16
@@ -1,297 +0,0 @@
1
- /**
2
- * Teams streaming message using the streaminfo entity protocol.
3
- *
4
- * Follows the official Teams SDK pattern:
5
- * 1. First chunk → POST a typing activity with streaminfo entity (streamType: "streaming")
6
- * 2. Subsequent chunks → POST typing activities with streaminfo + incrementing streamSequence
7
- * 3. Finalize → POST a message activity with streaminfo (streamType: "final")
8
- *
9
- * Uses the shared draft-stream-loop for throttling (avoids rate limits).
10
- */
11
-
12
- import { createDraftStreamLoop, type DraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
13
- import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
14
-
15
- /** Default throttle interval between stream updates (ms).
16
- * Teams docs recommend buffering tokens for 1.5-2s; limit is 1 req/s. */
17
- const DEFAULT_THROTTLE_MS = 1500;
18
-
19
- /** Minimum chars before sending the first streaming message. */
20
- const MIN_INITIAL_CHARS = 20;
21
-
22
- /** Teams message text limit. */
23
- const TEAMS_MAX_CHARS = 4000;
24
-
25
- /**
26
- * Stop streaming before Teams expires the content stream server-side.
27
- * The exact service limit is opaque, so stay comfortably under it.
28
- */
29
- const MAX_STREAM_AGE_MS = 45_000;
30
-
31
- type StreamSendFn = (activity: Record<string, unknown>) => Promise<unknown>;
32
-
33
- type TeamsStreamOptions = {
34
- /** Function to send an activity (POST to Bot Framework). */
35
- sendActivity: StreamSendFn;
36
- /** Whether to enable feedback loop on the final message. */
37
- feedbackLoopEnabled?: boolean;
38
- /** Throttle interval in ms. Default: 600. */
39
- throttleMs?: number;
40
- /** Called on errors during streaming. */
41
- onError?: (err: unknown) => void;
42
- };
43
-
44
- import { AI_GENERATED_ENTITY } from "./ai-entity.js";
45
- import { formatUnknownError } from "./errors.js";
46
-
47
- function extractId(response: unknown): string | undefined {
48
- if (response && typeof response === "object" && "id" in response) {
49
- return readStringValue((response as { id?: unknown }).id);
50
- }
51
- return undefined;
52
- }
53
-
54
- function buildStreamInfoEntity(
55
- streamId: string | undefined,
56
- streamType: "informative" | "streaming" | "final",
57
- streamSequence?: number,
58
- ): Record<string, unknown> {
59
- const entity: Record<string, unknown> = {
60
- type: "streaminfo",
61
- streamType,
62
- };
63
- // streamId is only present after the first chunk (returned by the service)
64
- if (streamId) {
65
- entity.streamId = streamId;
66
- }
67
- // streamSequence must be present for start/continue, but NOT for final
68
- if (streamSequence != null) {
69
- entity.streamSequence = streamSequence;
70
- }
71
- return entity;
72
- }
73
-
74
- export class TeamsHttpStream {
75
- private sendActivity: StreamSendFn;
76
- private feedbackLoopEnabled: boolean;
77
- private onError?: (err: unknown) => void;
78
-
79
- private accumulatedText = "";
80
- private streamId: string | undefined = undefined;
81
- private sequenceNumber = 0;
82
- private stopped = false;
83
- private finalized = false;
84
- private streamFailed = false;
85
- private lastStreamedText = "";
86
- private streamStartedAt: number | undefined = undefined;
87
- private loop: DraftStreamLoop;
88
-
89
- constructor(options: TeamsStreamOptions) {
90
- this.sendActivity = options.sendActivity;
91
- this.feedbackLoopEnabled = options.feedbackLoopEnabled ?? false;
92
- this.onError = options.onError;
93
-
94
- this.loop = createDraftStreamLoop({
95
- throttleMs: options.throttleMs ?? DEFAULT_THROTTLE_MS,
96
- isStopped: () => this.stopped,
97
- sendOrEditStreamMessage: (text) => this.pushStreamChunk(text),
98
- });
99
- }
100
-
101
- /**
102
- * Send an informative status update (blue progress bar in Teams).
103
- * Call this immediately when a message is received, before LLM starts generating.
104
- * Establishes the stream so subsequent chunks continue from this stream ID.
105
- */
106
- async sendInformativeUpdate(text: string): Promise<void> {
107
- if (this.stopped || this.finalized) {
108
- return;
109
- }
110
-
111
- this.sequenceNumber++;
112
-
113
- const activity: Record<string, unknown> = {
114
- type: "typing",
115
- text,
116
- entities: [buildStreamInfoEntity(this.streamId, "informative", this.sequenceNumber)],
117
- };
118
-
119
- try {
120
- const response = await this.sendActivity(activity);
121
- if (!this.streamId) {
122
- this.streamId = extractId(response);
123
- }
124
- } catch (err) {
125
- this.onError?.(err);
126
- }
127
- }
128
-
129
- /**
130
- * Ingest partial text from the LLM token stream.
131
- * Called by onPartialReply — accumulates text and throttles updates.
132
- */
133
- update(text: string): void {
134
- if (this.stopped || this.finalized) {
135
- return;
136
- }
137
- this.accumulatedText = text;
138
-
139
- // Wait for minimum chars before first send (avoids push notification flicker)
140
- if (!this.streamId && this.accumulatedText.length < MIN_INITIAL_CHARS) {
141
- return;
142
- }
143
-
144
- // Text exceeded Teams limit — finalize immediately with what we have
145
- // so the user isn't left waiting while the LLM keeps generating.
146
- if (this.accumulatedText.length > TEAMS_MAX_CHARS) {
147
- this.streamFailed = true;
148
- void this.finalize();
149
- return;
150
- }
151
-
152
- // Stop early before Teams expires the stream server-side. finalize() will
153
- // close the stream with the last good content, and reply-stream-controller
154
- // will deliver any remaining suffix via normal fallback delivery.
155
- if (this.streamStartedAt && Date.now() - this.streamStartedAt >= MAX_STREAM_AGE_MS) {
156
- this.streamFailed = true;
157
- void this.finalize();
158
- return;
159
- }
160
-
161
- // Don't append cursor — Teams requires each chunk to be a prefix of subsequent chunks.
162
- // The cursor character would cause "content should contain previously streamed content" errors.
163
- this.loop.update(this.accumulatedText);
164
- }
165
-
166
- /**
167
- * Finalize the stream — send the final message activity.
168
- */
169
- async finalize(): Promise<void> {
170
- if (this.finalized) {
171
- return;
172
- }
173
- this.finalized = true;
174
- this.stopped = true;
175
- this.loop.stop();
176
- await this.loop.waitForInFlight();
177
-
178
- // If no text was streamed (e.g. agent sent a card via tool instead of
179
- // streaming text), just return. Teams auto-clears the informative progress
180
- // bar after its streaming timeout. Sending an empty final message fails
181
- // with 403.
182
- if (!this.accumulatedText.trim()) {
183
- return;
184
- }
185
-
186
- // If streaming failed (>4000 chars or POST errors), close the stream
187
- // with the last successfully streamed text so Teams removes the "Stop"
188
- // button and replaces the partial chunks. deliver() handles the complete
189
- // response since hasContent returns false when streamFailed is true.
190
- if (this.streamFailed) {
191
- if (this.streamId) {
192
- try {
193
- await this.sendActivity({
194
- type: "message",
195
- text: this.lastStreamedText || "",
196
- channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
197
- entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")],
198
- });
199
- } catch {
200
- // Best effort — stream will auto-close after Teams timeout
201
- }
202
- }
203
- return;
204
- }
205
-
206
- // Send final message activity.
207
- // Per the spec: type=message, streamType=final, NO streamSequence.
208
- try {
209
- const entities: Array<Record<string, unknown>> = [AI_GENERATED_ENTITY];
210
- if (this.streamId) {
211
- entities.push(buildStreamInfoEntity(this.streamId, "final"));
212
- }
213
-
214
- const finalActivity: Record<string, unknown> = {
215
- type: "message",
216
- text: this.accumulatedText,
217
- channelData: {
218
- feedbackLoopEnabled: this.feedbackLoopEnabled,
219
- },
220
- entities,
221
- };
222
-
223
- await this.sendActivity(finalActivity);
224
- } catch (err) {
225
- this.onError?.(err);
226
- }
227
- }
228
-
229
- /** Whether streaming successfully delivered content (at least one chunk sent, not failed). */
230
- get hasContent(): boolean {
231
- return this.accumulatedText.length > 0 && !this.streamFailed;
232
- }
233
-
234
- /** Whether streaming failed and fallback delivery is needed. */
235
- get isFailed(): boolean {
236
- return this.streamFailed;
237
- }
238
-
239
- /** Number of characters successfully streamed before failure. */
240
- get streamedLength(): number {
241
- return this.lastStreamedText.length;
242
- }
243
-
244
- /** Whether the stream has been finalized. */
245
- get isFinalized(): boolean {
246
- return this.finalized;
247
- }
248
-
249
- /** Whether streaming fell back (not used in this implementation). */
250
- get isFallback(): boolean {
251
- return false;
252
- }
253
-
254
- /**
255
- * Send a single streaming chunk as a typing activity with streaminfo.
256
- * Per the Teams REST API spec:
257
- * - First chunk: no streamId, streamSequence=1 → returns 201 with { id: streamId }
258
- * - Subsequent chunks: include streamId, increment streamSequence → returns 202
259
- */
260
- private async pushStreamChunk(text: string): Promise<boolean> {
261
- if (this.stopped && !this.finalized) {
262
- return false;
263
- }
264
-
265
- this.sequenceNumber++;
266
-
267
- const activity: Record<string, unknown> = {
268
- type: "typing",
269
- text,
270
- entities: [buildStreamInfoEntity(this.streamId, "streaming", this.sequenceNumber)],
271
- };
272
-
273
- try {
274
- const response = await this.sendActivity(activity);
275
- if (!this.streamStartedAt) {
276
- this.streamStartedAt = Date.now();
277
- }
278
- if (!this.streamId) {
279
- this.streamId = extractId(response);
280
- }
281
- this.lastStreamedText = text;
282
- return true;
283
- } catch (err) {
284
- const axiosData = (err as { response?: { data?: unknown; status?: number } })?.response;
285
- const statusCode = axiosData?.status ?? (err as { statusCode?: number })?.statusCode;
286
- const responseBody = axiosData?.data ? JSON.stringify(axiosData.data).slice(0, 300) : "";
287
- const msg = formatUnknownError(err);
288
- this.onError?.(
289
- new Error(
290
- `stream POST failed (HTTP ${statusCode ?? "?"}): ${msg}${responseBody ? ` body=${responseBody}` : ""}`,
291
- ),
292
- );
293
- this.streamFailed = true;
294
- return false;
295
- }
296
- }
297
- }
@@ -1,16 +0,0 @@
1
- import os from "node:os";
2
- import path from "node:path";
3
- import type { PluginRuntime } from "../runtime-api.js";
4
-
5
- export const msteamsRuntimeStub = {
6
- state: {
7
- resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
8
- const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
9
- if (override) {
10
- return override;
11
- }
12
- const resolvedHome = homedir ? homedir() : os.homedir();
13
- return path.join(resolvedHome, ".openclaw");
14
- },
15
- },
16
- } as unknown as PluginRuntime;
@@ -1,224 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { GraphThreadMessage } from "./graph-thread.js";
3
- import {
4
- _resetThreadParentContextCachesForTest,
5
- fetchParentMessageCached,
6
- formatParentContextEvent,
7
- markParentContextInjected,
8
- shouldInjectParentContext,
9
- summarizeParentMessage,
10
- } from "./thread-parent-context.js";
11
-
12
- describe("summarizeParentMessage", () => {
13
- it("returns undefined for missing message", () => {
14
- expect(summarizeParentMessage(undefined)).toBeUndefined();
15
- });
16
-
17
- it("returns undefined when body is blank", () => {
18
- const msg: GraphThreadMessage = {
19
- id: "p1",
20
- from: { user: { displayName: "Alice" } },
21
- body: { content: " ", contentType: "text" },
22
- };
23
- expect(summarizeParentMessage(msg)).toBeUndefined();
24
- });
25
-
26
- it("extracts sender + plain text", () => {
27
- const msg: GraphThreadMessage = {
28
- id: "p1",
29
- from: { user: { displayName: "Alice" } },
30
- body: { content: "Hello world", contentType: "text" },
31
- };
32
- expect(summarizeParentMessage(msg)).toEqual({ sender: "Alice", text: "Hello world" });
33
- });
34
-
35
- it("strips HTML for html contentType", () => {
36
- const msg: GraphThreadMessage = {
37
- id: "p1",
38
- from: { user: { displayName: "Bob" } },
39
- body: { content: "<p>Hi <b>there</b></p>", contentType: "html" },
40
- };
41
- expect(summarizeParentMessage(msg)).toEqual({ sender: "Bob", text: "Hi there" });
42
- });
43
-
44
- it("collapses whitespace in text contentType", () => {
45
- const msg: GraphThreadMessage = {
46
- id: "p1",
47
- from: { user: { displayName: "Carol" } },
48
- body: { content: "line one\n line two\t\ttrailing", contentType: "text" },
49
- };
50
- expect(summarizeParentMessage(msg)).toEqual({
51
- sender: "Carol",
52
- text: "line one line two trailing",
53
- });
54
- });
55
-
56
- it("falls back to application displayName", () => {
57
- const msg: GraphThreadMessage = {
58
- id: "p1",
59
- from: { application: { displayName: "BotApp" } },
60
- body: { content: "heads up", contentType: "text" },
61
- };
62
- expect(summarizeParentMessage(msg)).toEqual({ sender: "BotApp", text: "heads up" });
63
- });
64
-
65
- it("falls back to unknown when sender is missing", () => {
66
- const msg: GraphThreadMessage = {
67
- id: "p1",
68
- body: { content: "orphan", contentType: "text" },
69
- };
70
- expect(summarizeParentMessage(msg)).toEqual({ sender: "unknown", text: "orphan" });
71
- });
72
-
73
- it("truncates overly long parent text", () => {
74
- const msg: GraphThreadMessage = {
75
- id: "p1",
76
- from: { user: { displayName: "Dana" } },
77
- body: { content: "x".repeat(1000), contentType: "text" },
78
- };
79
- const summary = summarizeParentMessage(msg);
80
- expect(summary?.text.length).toBeLessThanOrEqual(400);
81
- expect(summary?.text.endsWith("…")).toBe(true);
82
- });
83
- });
84
-
85
- describe("formatParentContextEvent", () => {
86
- it("formats as Replying to @sender: body", () => {
87
- expect(formatParentContextEvent({ sender: "Alice", text: "hello there" })).toBe(
88
- "Replying to @Alice: hello there",
89
- );
90
- });
91
- });
92
-
93
- describe("fetchParentMessageCached", () => {
94
- beforeEach(() => {
95
- _resetThreadParentContextCachesForTest();
96
- });
97
-
98
- it("invokes the fetcher on first call", async () => {
99
- const mockMsg: GraphThreadMessage = {
100
- id: "p1",
101
- body: { content: "hi", contentType: "text" },
102
- };
103
- const fetcher = vi.fn(async () => mockMsg);
104
-
105
- const result = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
106
-
107
- expect(result).toEqual(mockMsg);
108
- expect(fetcher).toHaveBeenCalledTimes(1);
109
- expect(fetcher).toHaveBeenCalledWith("tok", "g1", "c1", "p1");
110
- });
111
-
112
- it("returns cached value on repeat fetch without invoking fetcher", async () => {
113
- const mockMsg: GraphThreadMessage = {
114
- id: "p1",
115
- body: { content: "hi", contentType: "text" },
116
- };
117
- const fetcher = vi.fn(async () => mockMsg);
118
-
119
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
120
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
121
- const third = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
122
-
123
- expect(fetcher).toHaveBeenCalledTimes(1);
124
- expect(third).toEqual(mockMsg);
125
- });
126
-
127
- it("caches undefined (Graph error) so failures do not re-fetch on burst", async () => {
128
- const fetcher = vi.fn(async () => undefined);
129
-
130
- const first = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
131
- const second = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
132
-
133
- expect(first).toBeUndefined();
134
- expect(second).toBeUndefined();
135
- expect(fetcher).toHaveBeenCalledTimes(1);
136
- });
137
-
138
- it("scopes cache by groupId/channelId/parentId", async () => {
139
- const fetcher = vi.fn(async (_tok, _g, _c, parentId) => ({
140
- id: parentId,
141
- body: { content: `content-${parentId}`, contentType: "text" },
142
- }));
143
-
144
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
145
- await fetchParentMessageCached("tok", "g1", "c1", "p2", fetcher);
146
- await fetchParentMessageCached("tok", "g2", "c1", "p1", fetcher);
147
-
148
- expect(fetcher).toHaveBeenCalledTimes(3);
149
- });
150
-
151
- it("re-fetches after TTL expires", async () => {
152
- vi.useFakeTimers();
153
- try {
154
- const fetcher = vi.fn(async () => ({
155
- id: "p1",
156
- body: { content: "hi", contentType: "text" },
157
- }));
158
-
159
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
160
- // 5 min TTL: advance just beyond.
161
- vi.advanceTimersByTime(5 * 60 * 1000 + 1);
162
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
163
-
164
- expect(fetcher).toHaveBeenCalledTimes(2);
165
- } finally {
166
- vi.useRealTimers();
167
- }
168
- });
169
-
170
- it("evicts oldest entries when exceeding the 100-entry cap", async () => {
171
- const fetcher = vi.fn(async (_tok, _g, _c, parentId) => ({
172
- id: String(parentId),
173
- body: { content: `v-${parentId}`, contentType: "text" },
174
- }));
175
-
176
- // Fill cache with 100 distinct parents.
177
- for (let i = 0; i < 100; i += 1) {
178
- await fetchParentMessageCached("tok", "g1", "c1", `p${i}`, fetcher);
179
- }
180
- expect(fetcher).toHaveBeenCalledTimes(100);
181
-
182
- // First entry should still be cached (no evictions yet).
183
- await fetchParentMessageCached("tok", "g1", "c1", "p0", fetcher);
184
- expect(fetcher).toHaveBeenCalledTimes(100);
185
-
186
- // Push one more distinct parent to trigger an eviction.
187
- // The just-touched p0 is now the newest; the next-oldest (p1) should be evicted.
188
- await fetchParentMessageCached("tok", "g1", "c1", "p100", fetcher);
189
- expect(fetcher).toHaveBeenCalledTimes(101);
190
-
191
- // Fetching p1 again should miss the cache.
192
- await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
193
- expect(fetcher).toHaveBeenCalledTimes(102);
194
-
195
- // p0 is still cached because we refreshed it.
196
- await fetchParentMessageCached("tok", "g1", "c1", "p0", fetcher);
197
- expect(fetcher).toHaveBeenCalledTimes(102);
198
- });
199
- });
200
-
201
- describe("shouldInjectParentContext / markParentContextInjected", () => {
202
- beforeEach(() => {
203
- _resetThreadParentContextCachesForTest();
204
- });
205
-
206
- it("returns true for first observation", () => {
207
- expect(shouldInjectParentContext("session-1", "parent-1")).toBe(true);
208
- });
209
-
210
- it("returns false after marking the same parent", () => {
211
- markParentContextInjected("session-1", "parent-1");
212
- expect(shouldInjectParentContext("session-1", "parent-1")).toBe(false);
213
- });
214
-
215
- it("returns true again when a different parent appears in the session", () => {
216
- markParentContextInjected("session-1", "parent-1");
217
- expect(shouldInjectParentContext("session-1", "parent-2")).toBe(true);
218
- });
219
-
220
- it("dedupe is scoped per session key", () => {
221
- markParentContextInjected("session-1", "parent-1");
222
- expect(shouldInjectParentContext("session-2", "parent-1")).toBe(true);
223
- });
224
- });
@@ -1,159 +0,0 @@
1
- // Parent-message context injection for Teams channel thread replies.
2
- //
3
- // When an inbound message arrives as a reply inside a Teams channel thread,
4
- // the triggering message often makes no sense on its own (for example, a
5
- // one-word "yes" or "go ahead"). Per-thread session isolation (PR #62713)
6
- // gives each thread its own session, but the first message in a brand-new
7
- // thread session still has no parent context.
8
- //
9
- // This module fetches the parent message via Graph and prepends a compact
10
- // `Replying to @sender: …` system event to the next agent turn so the agent
11
- // knows what is being responded to. Fetches are cached to avoid repeated
12
- // Graph calls within the same active thread, and per-session dedupe ensures
13
- // the same parent is not re-injected on every subsequent reply in the
14
- // thread.
15
-
16
- import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
17
- import type { GraphThreadMessage } from "./graph-thread.js";
18
-
19
- // LRU cache for parent message fetches. Keyed by `teamId:channelId:parentId`.
20
- // 5-minute TTL and 100-entry cap keep active-thread chatter fast without
21
- // holding stale data when a thread goes quiet. Eviction uses Map insertion
22
- // order for LRU semantics (get() re-inserts on hit).
23
- const PARENT_CACHE_TTL_MS = 5 * 60 * 1000;
24
- const PARENT_CACHE_MAX = 100;
25
-
26
- type ParentCacheEntry = {
27
- message: GraphThreadMessage | undefined;
28
- expiresAt: number;
29
- };
30
-
31
- const parentCache = new Map<string, ParentCacheEntry>();
32
-
33
- // Per-session dedupe: remembers the most recent parent id we injected for a
34
- // given session key. When the same thread session sees another reply against
35
- // the same parent, we skip re-enqueueing the identical system event. We keep
36
- // a small LRU so idle sessions eventually drop out.
37
- const INJECTED_MAX = 200;
38
- const injectedParents = new Map<string, string>();
39
-
40
- type ThreadParentContextFetcher = (
41
- token: string,
42
- groupId: string,
43
- channelId: string,
44
- messageId: string,
45
- ) => Promise<GraphThreadMessage | undefined>;
46
-
47
- function touchLru<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
48
- if (map.has(key)) {
49
- map.delete(key);
50
- } else if (map.size >= max) {
51
- // Drop the oldest (first-inserted) entry.
52
- const firstKey = map.keys().next().value;
53
- if (firstKey !== undefined) {
54
- map.delete(firstKey);
55
- }
56
- }
57
- map.set(key, value);
58
- }
59
-
60
- function buildParentCacheKey(groupId: string, channelId: string, parentId: string): string {
61
- return `${groupId}\u0000${channelId}\u0000${parentId}`;
62
- }
63
-
64
- /**
65
- * Fetch a channel parent message with an LRU+TTL cache.
66
- *
67
- * Uses the injected `fetchParent` (defaults to `fetchChannelMessage`) so
68
- * tests can swap in a stub without mocking the Graph transport.
69
- */
70
- export async function fetchParentMessageCached(
71
- token: string,
72
- groupId: string,
73
- channelId: string,
74
- parentId: string,
75
- fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
76
- ): Promise<GraphThreadMessage | undefined> {
77
- const key = buildParentCacheKey(groupId, channelId, parentId);
78
- const now = Date.now();
79
- const cached = parentCache.get(key);
80
- if (cached && cached.expiresAt > now) {
81
- // Refresh LRU ordering on hit.
82
- parentCache.delete(key);
83
- parentCache.set(key, cached);
84
- return cached.message;
85
- }
86
- const message = await fetchParent(token, groupId, channelId, parentId);
87
- touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
88
- return message;
89
- }
90
-
91
- type ParentContextSummary = {
92
- /** Display name of the parent message author, or "unknown". */
93
- sender: string;
94
- /** Stripped, single-line parent body text (or empty if unresolved). */
95
- text: string;
96
- };
97
-
98
- const PARENT_TEXT_MAX_CHARS = 400;
99
-
100
- /**
101
- * Extract a compact summary (sender + plain-text body) from a Graph parent
102
- * message. Returns undefined when the parent cannot be summarized (missing
103
- * or blank body).
104
- */
105
- export function summarizeParentMessage(
106
- message: GraphThreadMessage | undefined,
107
- ): ParentContextSummary | undefined {
108
- if (!message) {
109
- return undefined;
110
- }
111
- const sender =
112
- message.from?.user?.displayName ?? message.from?.application?.displayName ?? "unknown";
113
- const contentType = message.body?.contentType ?? "text";
114
- const raw = message.body?.content ?? "";
115
- const text =
116
- contentType === "html" ? stripHtmlFromTeamsMessage(raw) : raw.replace(/\s+/g, " ").trim();
117
- if (!text) {
118
- return undefined;
119
- }
120
- return {
121
- sender,
122
- text:
123
- text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}…` : text,
124
- };
125
- }
126
-
127
- /**
128
- * Build the single-line `Replying to @sender: body` system event text.
129
- * Callers should pass this text to `enqueueSystemEvent` together with a
130
- * stable contextKey derived from the parent id.
131
- */
132
- export function formatParentContextEvent(summary: ParentContextSummary): string {
133
- return `Replying to @${summary.sender}: ${summary.text}`;
134
- }
135
-
136
- /**
137
- * Decide whether a parent context event should be enqueued for the current
138
- * session. Returns `false` when we already injected the same parent for this
139
- * session recently (prevents re-prepending identical context on every reply
140
- * in the thread).
141
- */
142
- export function shouldInjectParentContext(sessionKey: string, parentId: string): boolean {
143
- const key = sessionKey;
144
- return injectedParents.get(key) !== parentId;
145
- }
146
-
147
- /**
148
- * Record that `parentId` was just injected for `sessionKey` so subsequent
149
- * replies with the same parent can short-circuit via `shouldInjectParentContext`.
150
- */
151
- export function markParentContextInjected(sessionKey: string, parentId: string): void {
152
- touchLru(injectedParents, sessionKey, parentId, INJECTED_MAX);
153
- }
154
-
155
- // Exported for test isolation.
156
- export function _resetThreadParentContextCachesForTest(): void {
157
- parentCache.clear();
158
- injectedParents.clear();
159
- }