@kodelyth/msteams 2026.5.42 → 2026.6.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 (177) hide show
  1. package/klaw.plugin.json +726 -2
  2. package/package.json +18 -6
  3. package/api.ts +0 -3
  4. package/channel-config-api.ts +0 -1
  5. package/channel-plugin-api.ts +0 -2
  6. package/config-api.ts +0 -4
  7. package/contract-api.ts +0 -4
  8. package/index.ts +0 -20
  9. package/runtime-api.ts +0 -66
  10. package/secret-contract-api.ts +0 -5
  11. package/setup-entry.ts +0 -13
  12. package/setup-plugin-api.ts +0 -3
  13. package/src/ai-entity.ts +0 -7
  14. package/src/approval-auth.ts +0 -44
  15. package/src/attachments/bot-framework.test.ts +0 -506
  16. package/src/attachments/bot-framework.ts +0 -348
  17. package/src/attachments/download.ts +0 -328
  18. package/src/attachments/graph.test.ts +0 -441
  19. package/src/attachments/graph.ts +0 -489
  20. package/src/attachments/html.ts +0 -122
  21. package/src/attachments/payload.ts +0 -14
  22. package/src/attachments/remote-media.test.ts +0 -187
  23. package/src/attachments/remote-media.ts +0 -86
  24. package/src/attachments/shared.test.ts +0 -547
  25. package/src/attachments/shared.ts +0 -655
  26. package/src/attachments/types.ts +0 -47
  27. package/src/attachments.graph.test.ts +0 -414
  28. package/src/attachments.helpers.test.ts +0 -245
  29. package/src/attachments.test-helpers.ts +0 -17
  30. package/src/attachments.test.ts +0 -754
  31. package/src/attachments.ts +0 -18
  32. package/src/block-streaming-config.test.ts +0 -61
  33. package/src/channel-api.ts +0 -1
  34. package/src/channel.actions.test.ts +0 -797
  35. package/src/channel.directory.test.ts +0 -176
  36. package/src/channel.message-adapter.test.ts +0 -227
  37. package/src/channel.runtime.ts +0 -56
  38. package/src/channel.setup.ts +0 -77
  39. package/src/channel.test.ts +0 -136
  40. package/src/channel.ts +0 -1176
  41. package/src/config-schema.ts +0 -6
  42. package/src/config-ui-hints.ts +0 -40
  43. package/src/conversation-store-fs.test.ts +0 -81
  44. package/src/conversation-store-fs.ts +0 -149
  45. package/src/conversation-store-helpers.test.ts +0 -202
  46. package/src/conversation-store-helpers.ts +0 -105
  47. package/src/conversation-store-memory.ts +0 -51
  48. package/src/conversation-store.shared.test.ts +0 -260
  49. package/src/conversation-store.ts +0 -71
  50. package/src/directory-live.test.ts +0 -156
  51. package/src/directory-live.ts +0 -111
  52. package/src/doctor.ts +0 -27
  53. package/src/errors.test.ts +0 -154
  54. package/src/errors.ts +0 -270
  55. package/src/feedback-reflection-prompt.ts +0 -117
  56. package/src/feedback-reflection-store.ts +0 -113
  57. package/src/feedback-reflection.test.ts +0 -237
  58. package/src/feedback-reflection.ts +0 -268
  59. package/src/file-consent-helpers.test.ts +0 -328
  60. package/src/file-consent-helpers.ts +0 -115
  61. package/src/file-consent-invoke.ts +0 -150
  62. package/src/file-consent.test.ts +0 -378
  63. package/src/file-consent.ts +0 -223
  64. package/src/graph-chat.ts +0 -36
  65. package/src/graph-group-management.test.ts +0 -332
  66. package/src/graph-group-management.ts +0 -168
  67. package/src/graph-members.test.ts +0 -89
  68. package/src/graph-members.ts +0 -48
  69. package/src/graph-messages.actions.test.ts +0 -253
  70. package/src/graph-messages.read.test.ts +0 -391
  71. package/src/graph-messages.search.test.ts +0 -227
  72. package/src/graph-messages.test-helpers.ts +0 -50
  73. package/src/graph-messages.ts +0 -534
  74. package/src/graph-teams.test.ts +0 -222
  75. package/src/graph-teams.ts +0 -114
  76. package/src/graph-thread.test.ts +0 -252
  77. package/src/graph-thread.ts +0 -146
  78. package/src/graph-upload.test.ts +0 -253
  79. package/src/graph-upload.ts +0 -531
  80. package/src/graph-users.ts +0 -29
  81. package/src/graph.test.ts +0 -540
  82. package/src/graph.ts +0 -308
  83. package/src/inbound.test.ts +0 -221
  84. package/src/inbound.ts +0 -148
  85. package/src/index.ts +0 -4
  86. package/src/media-helpers.test.ts +0 -220
  87. package/src/media-helpers.ts +0 -105
  88. package/src/mentions.test.ts +0 -254
  89. package/src/mentions.ts +0 -114
  90. package/src/messenger.test.ts +0 -961
  91. package/src/messenger.ts +0 -608
  92. package/src/monitor-handler/access.ts +0 -136
  93. package/src/monitor-handler/inbound-media.test.ts +0 -314
  94. package/src/monitor-handler/inbound-media.ts +0 -180
  95. package/src/monitor-handler/message-handler-mock-support.test-support.ts +0 -28
  96. package/src/monitor-handler/message-handler.authz.test.ts +0 -739
  97. package/src/monitor-handler/message-handler.dm-media.test.ts +0 -54
  98. package/src/monitor-handler/message-handler.test-support.ts +0 -99
  99. package/src/monitor-handler/message-handler.thread-parent.test.ts +0 -225
  100. package/src/monitor-handler/message-handler.thread-session.test.ts +0 -132
  101. package/src/monitor-handler/message-handler.ts +0 -1003
  102. package/src/monitor-handler/reaction-handler.test.ts +0 -325
  103. package/src/monitor-handler/reaction-handler.ts +0 -122
  104. package/src/monitor-handler/thread-session.ts +0 -30
  105. package/src/monitor-handler.adaptive-card.test.ts +0 -158
  106. package/src/monitor-handler.feedback-authz.test.ts +0 -357
  107. package/src/monitor-handler.file-consent.test.ts +0 -443
  108. package/src/monitor-handler.sso.test.ts +0 -576
  109. package/src/monitor-handler.test-helpers.ts +0 -181
  110. package/src/monitor-handler.ts +0 -538
  111. package/src/monitor-handler.types.ts +0 -27
  112. package/src/monitor-types.ts +0 -6
  113. package/src/monitor.lifecycle.test.ts +0 -457
  114. package/src/monitor.test.ts +0 -119
  115. package/src/monitor.ts +0 -476
  116. package/src/oauth.flow.ts +0 -77
  117. package/src/oauth.shared.ts +0 -37
  118. package/src/oauth.test.ts +0 -350
  119. package/src/oauth.token.ts +0 -162
  120. package/src/oauth.ts +0 -130
  121. package/src/outbound.test.ts +0 -400
  122. package/src/outbound.ts +0 -198
  123. package/src/pending-uploads-fs.test.ts +0 -261
  124. package/src/pending-uploads-fs.ts +0 -235
  125. package/src/pending-uploads.test.ts +0 -186
  126. package/src/pending-uploads.ts +0 -121
  127. package/src/policy.test.ts +0 -156
  128. package/src/policy.ts +0 -245
  129. package/src/polls-store-memory.ts +0 -32
  130. package/src/polls.test.ts +0 -169
  131. package/src/polls.ts +0 -312
  132. package/src/presentation.ts +0 -93
  133. package/src/probe.test.ts +0 -79
  134. package/src/probe.ts +0 -132
  135. package/src/reply-dispatcher.test.ts +0 -543
  136. package/src/reply-dispatcher.ts +0 -523
  137. package/src/reply-stream-controller.test.ts +0 -424
  138. package/src/reply-stream-controller.ts +0 -334
  139. package/src/resolve-allowlist.test.ts +0 -253
  140. package/src/resolve-allowlist.ts +0 -309
  141. package/src/revoked-context.ts +0 -17
  142. package/src/runtime.ts +0 -12
  143. package/src/sdk-types.ts +0 -59
  144. package/src/sdk.test.ts +0 -727
  145. package/src/sdk.ts +0 -916
  146. package/src/secret-contract.ts +0 -49
  147. package/src/secret-input.ts +0 -7
  148. package/src/send-context.test.ts +0 -93
  149. package/src/send-context.ts +0 -269
  150. package/src/send.test.ts +0 -588
  151. package/src/send.ts +0 -697
  152. package/src/sent-message-cache.test.ts +0 -106
  153. package/src/sent-message-cache.ts +0 -174
  154. package/src/session-route.ts +0 -40
  155. package/src/setup-core.ts +0 -162
  156. package/src/setup-surface.test.ts +0 -175
  157. package/src/setup-surface.ts +0 -319
  158. package/src/sso-token-store.test.ts +0 -74
  159. package/src/sso-token-store.ts +0 -166
  160. package/src/sso.ts +0 -300
  161. package/src/storage.ts +0 -25
  162. package/src/store-fs.ts +0 -42
  163. package/src/streaming-message.test.ts +0 -323
  164. package/src/streaming-message.ts +0 -327
  165. package/src/test-runtime.ts +0 -16
  166. package/src/thread-parent-context.test.ts +0 -224
  167. package/src/thread-parent-context.ts +0 -159
  168. package/src/token-response.ts +0 -11
  169. package/src/token.test.ts +0 -268
  170. package/src/token.ts +0 -194
  171. package/src/user-agent.test.ts +0 -121
  172. package/src/user-agent.ts +0 -53
  173. package/src/webhook-timeouts.ts +0 -27
  174. package/src/welcome-card.test.ts +0 -104
  175. package/src/welcome-card.ts +0 -57
  176. package/test-api.ts +0 -1
  177. package/tsconfig.json +0 -16
@@ -1,327 +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 "klaw/plugin-sdk/channel-lifecycle";
13
- import { readStringValue } from "klaw/plugin-sdk/string-coerce-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 finalMessageId: string | undefined = undefined;
87
- private streamStartedAt: number | undefined = undefined;
88
- private loop: DraftStreamLoop;
89
-
90
- constructor(options: TeamsStreamOptions) {
91
- this.sendActivity = options.sendActivity;
92
- this.feedbackLoopEnabled = options.feedbackLoopEnabled ?? false;
93
- this.onError = options.onError;
94
-
95
- this.loop = createDraftStreamLoop({
96
- throttleMs: options.throttleMs ?? DEFAULT_THROTTLE_MS,
97
- isStopped: () => this.stopped,
98
- sendOrEditStreamMessage: (text) => this.pushStreamChunk(text),
99
- });
100
- }
101
-
102
- /**
103
- * Send an informative status update (blue progress bar in Teams).
104
- * Call this immediately when a message is received, before LLM starts generating.
105
- * Establishes the stream so subsequent chunks continue from this stream ID.
106
- */
107
- async sendInformativeUpdate(text: string): Promise<void> {
108
- if (this.stopped || this.finalized) {
109
- return;
110
- }
111
-
112
- this.sequenceNumber++;
113
-
114
- const activity: Record<string, unknown> = {
115
- type: "typing",
116
- text,
117
- entities: [buildStreamInfoEntity(this.streamId, "informative", this.sequenceNumber)],
118
- };
119
-
120
- try {
121
- const response = await this.sendActivity(activity);
122
- if (!this.streamId) {
123
- this.streamId = extractId(response);
124
- }
125
- } catch (err) {
126
- this.onError?.(err);
127
- }
128
- }
129
-
130
- /**
131
- * Ingest partial text from the LLM token stream.
132
- * Called by onPartialReply — accumulates text and throttles updates.
133
- */
134
- update(text: string): void {
135
- if (this.stopped || this.finalized) {
136
- return;
137
- }
138
- this.accumulatedText = text;
139
-
140
- // Wait for minimum chars before first send (avoids push notification flicker)
141
- if (!this.streamId && this.accumulatedText.length < MIN_INITIAL_CHARS) {
142
- return;
143
- }
144
-
145
- // Text exceeded Teams limit — finalize immediately with what we have
146
- // so the user isn't left waiting while the LLM keeps generating.
147
- if (this.accumulatedText.length > TEAMS_MAX_CHARS) {
148
- this.streamFailed = true;
149
- void this.finalize();
150
- return;
151
- }
152
-
153
- // Stop early before Teams expires the stream server-side. finalize() will
154
- // close the stream with the last good content, and reply-stream-controller
155
- // will deliver any remaining suffix via normal fallback delivery.
156
- if (this.streamStartedAt && Date.now() - this.streamStartedAt >= MAX_STREAM_AGE_MS) {
157
- this.streamFailed = true;
158
- void this.finalize();
159
- return;
160
- }
161
-
162
- // Don't append cursor — Teams requires each chunk to be a prefix of subsequent chunks.
163
- // The cursor character would cause "content should contain previously streamed content" errors.
164
- this.loop.update(this.accumulatedText);
165
- }
166
-
167
- /**
168
- * Replace an informative progress update with final answer text.
169
- * Returns false when the stream could not safely carry the final text, so
170
- * callers can deliver the answer through the normal Teams message path.
171
- */
172
- async replaceInformativeWithFinal(text: string): Promise<boolean> {
173
- if (this.stopped || this.finalized) {
174
- return false;
175
- }
176
- this.update(text);
177
- await this.loop.flush();
178
- await this.finalize();
179
- return !this.streamFailed && this.hasContent;
180
- }
181
-
182
- /**
183
- * Finalize the stream — send the final message activity.
184
- */
185
- async finalize(): Promise<string | undefined> {
186
- if (this.finalized) {
187
- return this.finalMessageId;
188
- }
189
- this.finalized = true;
190
- this.stopped = true;
191
- this.loop.stop();
192
- await this.loop.waitForInFlight();
193
-
194
- // If no text was streamed (e.g. agent sent a card via tool instead of
195
- // streaming text), just return. Teams auto-clears the informative progress
196
- // bar after its streaming timeout. Sending an empty final message fails
197
- // with 403.
198
- if (!this.accumulatedText.trim()) {
199
- return this.finalMessageId;
200
- }
201
-
202
- // If streaming failed (>4000 chars or POST errors), close the stream
203
- // with the last successfully streamed text so Teams removes the "Stop"
204
- // button and replaces the partial chunks. deliver() handles the complete
205
- // response since hasContent returns false when streamFailed is true.
206
- if (this.streamFailed) {
207
- if (this.streamId) {
208
- try {
209
- const response = await this.sendActivity({
210
- type: "message",
211
- text: this.lastStreamedText || "",
212
- channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
213
- entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")],
214
- });
215
- this.finalMessageId = extractId(response);
216
- } catch {
217
- // Best effort — stream will auto-close after Teams timeout
218
- }
219
- }
220
- return this.finalMessageId;
221
- }
222
-
223
- // Send final message activity.
224
- // Per the spec: type=message, streamType=final, NO streamSequence.
225
- try {
226
- const entities: Array<Record<string, unknown>> = [AI_GENERATED_ENTITY];
227
- if (this.streamId) {
228
- entities.push(buildStreamInfoEntity(this.streamId, "final"));
229
- }
230
-
231
- const finalActivity: Record<string, unknown> = {
232
- type: "message",
233
- text: this.accumulatedText,
234
- channelData: {
235
- feedbackLoopEnabled: this.feedbackLoopEnabled,
236
- },
237
- entities,
238
- };
239
-
240
- const response = await this.sendActivity(finalActivity);
241
- this.finalMessageId = extractId(response);
242
- } catch (err) {
243
- this.streamFailed = true;
244
- this.onError?.(err);
245
- }
246
- return this.finalMessageId;
247
- }
248
-
249
- /** Whether streaming successfully delivered content (at least one chunk sent, not failed). */
250
- get hasContent(): boolean {
251
- return this.accumulatedText.length > 0 && !this.streamFailed;
252
- }
253
-
254
- /** Whether streaming failed and fallback delivery is needed. */
255
- get isFailed(): boolean {
256
- return this.streamFailed;
257
- }
258
-
259
- /** Number of characters successfully streamed before failure. */
260
- get streamedLength(): number {
261
- return this.lastStreamedText.length;
262
- }
263
-
264
- /** Whether the stream has been finalized. */
265
- get isFinalized(): boolean {
266
- return this.finalized;
267
- }
268
-
269
- /** Platform id returned by the final message activity, when available. */
270
- get messageId(): string | undefined {
271
- return this.finalMessageId;
272
- }
273
-
274
- /** Stream id returned by the first streaminfo activity, when available. */
275
- get previewStreamId(): string | undefined {
276
- return this.streamId;
277
- }
278
-
279
- /** Whether streaming fell back (not used in this implementation). */
280
- get isFallback(): boolean {
281
- return false;
282
- }
283
-
284
- /**
285
- * Send a single streaming chunk as a typing activity with streaminfo.
286
- * Per the Teams REST API spec:
287
- * - First chunk: no streamId, streamSequence=1 → returns 201 with { id: streamId }
288
- * - Subsequent chunks: include streamId, increment streamSequence → returns 202
289
- */
290
- private async pushStreamChunk(text: string): Promise<boolean> {
291
- if (this.stopped && !this.finalized) {
292
- return false;
293
- }
294
-
295
- this.sequenceNumber++;
296
-
297
- const activity: Record<string, unknown> = {
298
- type: "typing",
299
- text,
300
- entities: [buildStreamInfoEntity(this.streamId, "streaming", this.sequenceNumber)],
301
- };
302
-
303
- try {
304
- const response = await this.sendActivity(activity);
305
- if (!this.streamStartedAt) {
306
- this.streamStartedAt = Date.now();
307
- }
308
- if (!this.streamId) {
309
- this.streamId = extractId(response);
310
- }
311
- this.lastStreamedText = text;
312
- return true;
313
- } catch (err) {
314
- const axiosData = (err as { response?: { data?: unknown; status?: number } })?.response;
315
- const statusCode = axiosData?.status ?? (err as { statusCode?: number })?.statusCode;
316
- const responseBody = axiosData?.data ? JSON.stringify(axiosData.data).slice(0, 300) : "";
317
- const msg = formatUnknownError(err);
318
- this.onError?.(
319
- new Error(
320
- `stream POST failed (HTTP ${statusCode ?? "?"}): ${msg}${responseBody ? ` body=${responseBody}` : ""}`,
321
- ),
322
- );
323
- this.streamFailed = true;
324
- return false;
325
- }
326
- }
327
- }
@@ -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.KLAW_STATE_DIR?.trim() || env.KLAW_STATE_DIR?.trim();
9
- if (override) {
10
- return override;
11
- }
12
- const resolvedHome = homedir ? homedir() : os.homedir();
13
- return path.join(resolvedHome, ".klaw");
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
- });