@openclaw/msteams 2026.3.13 → 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +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
package/src/errors.ts CHANGED
@@ -1,3 +1,7 @@
1
+ function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+
1
5
  export function formatUnknownError(err: unknown): string {
2
6
  if (err instanceof Error) {
3
7
  return err.message;
@@ -27,10 +31,6 @@ export function formatUnknownError(err: unknown): string {
27
31
  }
28
32
  }
29
33
 
30
- function isRecord(value: unknown): value is Record<string, unknown> {
31
- return typeof value === "object" && value !== null && !Array.isArray(value);
32
- }
33
-
34
34
  function extractStatusCode(err: unknown): number | null {
35
35
  if (!isRecord(err)) {
36
36
  return null;
@@ -63,6 +63,32 @@ function extractStatusCode(err: unknown): number | null {
63
63
  return null;
64
64
  }
65
65
 
66
+ function extractErrorCode(err: unknown): string | null {
67
+ if (!isRecord(err)) {
68
+ return null;
69
+ }
70
+
71
+ const direct = err.code;
72
+ if (typeof direct === "string" && direct.trim()) {
73
+ return direct;
74
+ }
75
+
76
+ const response = err.response;
77
+ if (!isRecord(response)) {
78
+ return null;
79
+ }
80
+
81
+ const body = response.body;
82
+ if (isRecord(body)) {
83
+ const error = body.error;
84
+ if (isRecord(error) && typeof error.code === "string" && error.code.trim()) {
85
+ return error.code;
86
+ }
87
+ }
88
+
89
+ return null;
90
+ }
91
+
66
92
  function extractRetryAfterMs(err: unknown): number | null {
67
93
  if (!isRecord(err)) {
68
94
  return null;
@@ -123,12 +149,13 @@ function extractRetryAfterMs(err: unknown): number | null {
123
149
  return null;
124
150
  }
125
151
 
126
- export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
152
+ type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
127
153
 
128
- export type MSTeamsSendErrorClassification = {
154
+ type MSTeamsSendErrorClassification = {
129
155
  kind: MSTeamsSendErrorKind;
130
156
  statusCode?: number;
131
157
  retryAfterMs?: number;
158
+ errorCode?: string;
132
159
  };
133
160
 
134
161
  /**
@@ -142,9 +169,17 @@ export type MSTeamsSendErrorClassification = {
142
169
  export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
143
170
  const statusCode = extractStatusCode(err);
144
171
  const retryAfterMs = extractRetryAfterMs(err);
172
+ const errorCode = extractErrorCode(err) ?? undefined;
145
173
 
146
- if (statusCode === 401 || statusCode === 403) {
147
- return { kind: "auth", statusCode };
174
+ if (statusCode === 401) {
175
+ return { kind: "auth", statusCode, errorCode };
176
+ }
177
+
178
+ if (statusCode === 403) {
179
+ if (errorCode === "ContentStreamNotAllowed") {
180
+ return { kind: "permanent", statusCode, errorCode };
181
+ }
182
+ return { kind: "auth", statusCode, errorCode };
148
183
  }
149
184
 
150
185
  if (statusCode === 429) {
@@ -152,6 +187,7 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
152
187
  kind: "throttled",
153
188
  statusCode,
154
189
  retryAfterMs: retryAfterMs ?? undefined,
190
+ errorCode,
155
191
  };
156
192
  }
157
193
 
@@ -160,17 +196,19 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
160
196
  kind: "transient",
161
197
  statusCode,
162
198
  retryAfterMs: retryAfterMs ?? undefined,
199
+ errorCode,
163
200
  };
164
201
  }
165
202
 
166
203
  if (statusCode != null && statusCode >= 400) {
167
- return { kind: "permanent", statusCode };
204
+ return { kind: "permanent", statusCode, errorCode };
168
205
  }
169
206
 
170
207
  return {
171
208
  kind: "unknown",
172
209
  statusCode: statusCode ?? undefined,
173
210
  retryAfterMs: retryAfterMs ?? undefined,
211
+ errorCode,
174
212
  };
175
213
  }
176
214
 
@@ -195,6 +233,9 @@ export function formatMSTeamsSendErrorHint(
195
233
  if (classification.kind === "auth") {
196
234
  return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
197
235
  }
236
+ if (classification.errorCode === "ContentStreamNotAllowed") {
237
+ return "Teams expired the content stream; stop streaming earlier and fall back to normal message delivery";
238
+ }
198
239
  if (classification.kind === "throttled") {
199
240
  return "Teams throttled the bot; backing off may help";
200
241
  }
@@ -0,0 +1,117 @@
1
+ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
2
+
3
+ /** Max chars of the thumbed-down response to include in the reflection prompt. */
4
+ const MAX_RESPONSE_CHARS = 500;
5
+
6
+ type ParsedReflectionResponse = {
7
+ learning: string;
8
+ followUp: boolean;
9
+ userMessage?: string;
10
+ };
11
+
12
+ export function buildReflectionPrompt(params: {
13
+ thumbedDownResponse?: string;
14
+ userComment?: string;
15
+ }): string {
16
+ const parts: string[] = ["A user indicated your previous response wasn't helpful."];
17
+
18
+ if (params.thumbedDownResponse) {
19
+ const truncated =
20
+ params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
21
+ ? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...`
22
+ : params.thumbedDownResponse;
23
+ parts.push(`\nYour response was:\n> ${truncated}`);
24
+ }
25
+
26
+ if (params.userComment) {
27
+ parts.push(`\nUser's comment: "${params.userComment}"`);
28
+ }
29
+
30
+ parts.push(
31
+ "\nBriefly reflect: what could you improve? Consider tone, length, " +
32
+ "accuracy, relevance, and specificity. Reply with a single JSON object " +
33
+ 'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
34
+ '"followUp":false,"userMessage":""}\n' +
35
+ "- learning: a short internal adjustment note (1-2 sentences) for your " +
36
+ "future behavior in this conversation.\n" +
37
+ "- followUp: true only if the user needs a direct follow-up message.\n" +
38
+ "- userMessage: only the exact user-facing message to send; empty string " +
39
+ "when followUp is false.",
40
+ );
41
+
42
+ return parts.join("\n");
43
+ }
44
+
45
+ function parseBooleanLike(value: unknown): boolean | undefined {
46
+ if (typeof value === "boolean") {
47
+ return value;
48
+ }
49
+ if (typeof value === "string") {
50
+ const normalized = normalizeOptionalLowercaseString(value);
51
+ if (normalized === "true" || normalized === "yes") {
52
+ return true;
53
+ }
54
+ if (normalized === "false" || normalized === "no") {
55
+ return false;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
62
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
63
+ return null;
64
+ }
65
+
66
+ const candidate = value as {
67
+ learning?: unknown;
68
+ followUp?: unknown;
69
+ userMessage?: unknown;
70
+ };
71
+ const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
72
+ if (!learning) {
73
+ return null;
74
+ }
75
+
76
+ return {
77
+ learning,
78
+ followUp: parseBooleanLike(candidate.followUp) ?? false,
79
+ userMessage:
80
+ typeof candidate.userMessage === "string" && candidate.userMessage.trim()
81
+ ? candidate.userMessage.trim()
82
+ : undefined,
83
+ };
84
+ }
85
+
86
+ export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
87
+ const trimmed = text.trim();
88
+ if (!trimmed) {
89
+ return null;
90
+ }
91
+
92
+ const candidates = [
93
+ trimmed,
94
+ ...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
95
+ ];
96
+
97
+ for (const candidateText of candidates) {
98
+ const candidate = candidateText.trim();
99
+ if (!candidate) {
100
+ continue;
101
+ }
102
+ try {
103
+ const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
104
+ if (parsed) {
105
+ return parsed;
106
+ }
107
+ } catch {
108
+ // Fall through to the next parse strategy.
109
+ }
110
+ }
111
+
112
+ // Safe fallback: keep the internal learning, but never auto-message the user.
113
+ return {
114
+ learning: trimmed,
115
+ followUp: false,
116
+ };
117
+ }
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /** Default cooldown between reflections per session (5 minutes). */
5
+ export const DEFAULT_COOLDOWN_MS = 300_000;
6
+
7
+ /** Tracks last reflection time per session to enforce cooldown. */
8
+ const lastReflectionBySession = new Map<string, number>();
9
+
10
+ /** Maximum cooldown entries before pruning expired ones. */
11
+ const MAX_COOLDOWN_ENTRIES = 500;
12
+
13
+ function legacySanitizeSessionKey(sessionKey: string): string {
14
+ return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
15
+ }
16
+
17
+ function encodeSessionKey(sessionKey: string): string {
18
+ return Buffer.from(sessionKey, "utf8").toString("base64url");
19
+ }
20
+
21
+ function resolveLearningsFilePath(storePath: string, sessionKey: string): string {
22
+ return `${storePath}/${encodeSessionKey(sessionKey)}.learnings.json`;
23
+ }
24
+
25
+ function resolveLegacyLearningsFilePath(storePath: string, sessionKey: string): string {
26
+ return `${storePath}/${legacySanitizeSessionKey(sessionKey)}.learnings.json`;
27
+ }
28
+
29
+ async function readLearningsFile(
30
+ filePath: string,
31
+ ): Promise<{ exists: boolean; learnings: string[] }> {
32
+ try {
33
+ const content = await fs.readFile(filePath, "utf-8");
34
+ const parsed = JSON.parse(content);
35
+ return { exists: true, learnings: Array.isArray(parsed) ? parsed : [] };
36
+ } catch {
37
+ return { exists: false, learnings: [] };
38
+ }
39
+ }
40
+
41
+ /** Prune expired cooldown entries to prevent unbounded memory growth. */
42
+ function pruneExpiredCooldowns(cooldownMs: number): void {
43
+ if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
44
+ return;
45
+ }
46
+ const now = Date.now();
47
+ for (const [key, time] of lastReflectionBySession) {
48
+ if (now - time >= cooldownMs) {
49
+ lastReflectionBySession.delete(key);
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Check if a reflection is allowed (cooldown not active). */
55
+ export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
56
+ const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
57
+ const lastTime = lastReflectionBySession.get(sessionKey);
58
+ if (lastTime == null) {
59
+ return true;
60
+ }
61
+ return Date.now() - lastTime >= cooldown;
62
+ }
63
+
64
+ /** Record that a reflection was run for a session. */
65
+ export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
66
+ lastReflectionBySession.set(sessionKey, Date.now());
67
+ pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
68
+ }
69
+
70
+ /** Clear reflection cooldown tracking (for tests). */
71
+ export function clearReflectionCooldowns(): void {
72
+ lastReflectionBySession.clear();
73
+ }
74
+
75
+ /** Store a learning derived from feedback reflection in a session companion file. */
76
+ export async function storeSessionLearning(params: {
77
+ storePath: string;
78
+ sessionKey: string;
79
+ learning: string;
80
+ }): Promise<void> {
81
+ const learningsFile = resolveLearningsFilePath(params.storePath, params.sessionKey);
82
+ const legacyLearningsFile = resolveLegacyLearningsFilePath(params.storePath, params.sessionKey);
83
+ const { exists, learnings: existingLearnings } = await readLearningsFile(learningsFile);
84
+ const { learnings: legacyLearnings } =
85
+ exists || legacyLearningsFile === learningsFile
86
+ ? { learnings: [] as string[] }
87
+ : await readLearningsFile(legacyLearningsFile);
88
+
89
+ let learnings = exists ? existingLearnings : legacyLearnings;
90
+
91
+ learnings.push(params.learning);
92
+ if (learnings.length > 10) {
93
+ learnings = learnings.slice(-10);
94
+ }
95
+
96
+ await fs.mkdir(path.dirname(learningsFile), { recursive: true });
97
+ await fs.writeFile(learningsFile, JSON.stringify(learnings, null, 2), "utf-8");
98
+ if (!exists && legacyLearningsFile !== learningsFile) {
99
+ await fs.rm(legacyLearningsFile, { force: true }).catch(() => undefined);
100
+ }
101
+ }
102
+
103
+ /** Load session learnings for injection into extraSystemPrompt. */
104
+ export async function loadSessionLearnings(
105
+ storePath: string,
106
+ sessionKey: string,
107
+ ): Promise<string[]> {
108
+ const learningsFile = resolveLearningsFilePath(storePath, sessionKey);
109
+ const { exists, learnings } = await readLearningsFile(learningsFile);
110
+ if (exists) {
111
+ return learnings;
112
+ }
113
+ return (await readLearningsFile(resolveLegacyLearningsFilePath(storePath, sessionKey))).learnings;
114
+ }
@@ -0,0 +1,237 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { storeSessionLearning } from "./feedback-reflection-store.js";
6
+ import {
7
+ buildFeedbackEvent,
8
+ buildReflectionPrompt,
9
+ clearReflectionCooldowns,
10
+ isReflectionAllowed,
11
+ loadSessionLearnings,
12
+ parseReflectionResponse,
13
+ recordReflectionTime,
14
+ } from "./feedback-reflection.js";
15
+
16
+ describe("buildFeedbackEvent", () => {
17
+ it("builds a well-formed custom event", () => {
18
+ const event = buildFeedbackEvent({
19
+ messageId: "msg-123",
20
+ value: "negative",
21
+ comment: "too verbose",
22
+ sessionKey: "msteams:user1",
23
+ agentId: "default",
24
+ conversationId: "19:abc",
25
+ });
26
+
27
+ expect(event.type).toBe("custom");
28
+ expect(event.event).toBe("feedback");
29
+ expect(event.value).toBe("negative");
30
+ expect(event.comment).toBe("too verbose");
31
+ expect(event.messageId).toBe("msg-123");
32
+ expect(event.ts).toBeGreaterThan(0);
33
+ });
34
+
35
+ it("omits comment when not provided", () => {
36
+ const event = buildFeedbackEvent({
37
+ messageId: "msg-123",
38
+ value: "positive",
39
+ sessionKey: "msteams:user1",
40
+ agentId: "default",
41
+ conversationId: "19:abc",
42
+ });
43
+
44
+ expect(event.comment).toBeUndefined();
45
+ expect(event.value).toBe("positive");
46
+ });
47
+ });
48
+
49
+ describe("buildReflectionPrompt", () => {
50
+ it("includes the thumbed-down response", () => {
51
+ const prompt = buildReflectionPrompt({
52
+ thumbedDownResponse: "Here is a long explanation...",
53
+ });
54
+
55
+ expect(prompt).toContain("previous response wasn't helpful");
56
+ expect(prompt).toContain("Here is a long explanation...");
57
+ expect(prompt).toContain("reflect");
58
+ });
59
+
60
+ it("truncates long responses", () => {
61
+ const longResponse = "x".repeat(600);
62
+ const prompt = buildReflectionPrompt({
63
+ thumbedDownResponse: longResponse,
64
+ });
65
+
66
+ expect(prompt).toContain("...");
67
+ expect(prompt.length).toBeLessThan(longResponse.length + 500);
68
+ });
69
+
70
+ it("includes user comment when provided", () => {
71
+ const prompt = buildReflectionPrompt({
72
+ thumbedDownResponse: "Some response",
73
+ userComment: "Too wordy",
74
+ });
75
+
76
+ expect(prompt).toContain('User\'s comment: "Too wordy"');
77
+ });
78
+
79
+ it("works without optional params", () => {
80
+ const prompt = buildReflectionPrompt({});
81
+ expect(prompt).toContain("previous response wasn't helpful");
82
+ expect(prompt).toContain('"followUp":false');
83
+ });
84
+ });
85
+
86
+ describe("parseReflectionResponse", () => {
87
+ it("parses strict JSON output", () => {
88
+ expect(
89
+ parseReflectionResponse(
90
+ '{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
91
+ ),
92
+ ).toEqual({
93
+ learning: "Be more direct next time.",
94
+ followUp: true,
95
+ userMessage: "Sorry about that. I will keep it tighter.",
96
+ });
97
+ });
98
+
99
+ it("parses JSON inside markdown fences", () => {
100
+ expect(
101
+ parseReflectionResponse(
102
+ '```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
103
+ ),
104
+ ).toEqual({
105
+ learning: "Ask a clarifying question first.",
106
+ followUp: false,
107
+ userMessage: undefined,
108
+ });
109
+ });
110
+
111
+ it("falls back to internal-only learning when parsing fails", () => {
112
+ expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
113
+ learning: "Be more concise.\nFollow up: yes.",
114
+ followUp: false,
115
+ });
116
+ });
117
+ });
118
+
119
+ describe("reflection cooldown", () => {
120
+ afterEach(() => {
121
+ clearReflectionCooldowns();
122
+ vi.restoreAllMocks();
123
+ });
124
+
125
+ it("allows first reflection", () => {
126
+ expect(isReflectionAllowed("session-1")).toBe(true);
127
+ });
128
+
129
+ it("blocks reflection within cooldown", () => {
130
+ recordReflectionTime("session-1");
131
+ expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
132
+ });
133
+
134
+ it("allows reflection after cooldown expires", () => {
135
+ // Manually set a past timestamp
136
+ recordReflectionTime("session-1");
137
+ // Override the map entry to simulate time passing
138
+ clearReflectionCooldowns();
139
+ expect(isReflectionAllowed("session-1", 1)).toBe(true);
140
+ });
141
+
142
+ it("tracks sessions independently", () => {
143
+ recordReflectionTime("session-1");
144
+ expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
145
+ expect(isReflectionAllowed("session-2", 60_000)).toBe(true);
146
+ });
147
+
148
+ it("keeps longer custom cooldown entries during pruning", () => {
149
+ vi.spyOn(Date, "now").mockReturnValue(0);
150
+ recordReflectionTime("target", 600_000);
151
+
152
+ vi.spyOn(Date, "now").mockReturnValue(301_000);
153
+ for (let index = 0; index <= 500; index += 1) {
154
+ recordReflectionTime(`session-${index}`, 600_000);
155
+ }
156
+
157
+ expect(isReflectionAllowed("target", 600_000)).toBe(false);
158
+ });
159
+ });
160
+
161
+ describe("loadSessionLearnings", () => {
162
+ let tmpDir: string;
163
+
164
+ afterEach(async () => {
165
+ if (tmpDir) {
166
+ await rm(tmpDir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ it("returns empty array when file doesn't exist", async () => {
171
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
172
+ const learnings = await loadSessionLearnings(tmpDir, "nonexistent");
173
+ expect(learnings).toEqual([]);
174
+ });
175
+
176
+ it("reads existing learnings", async () => {
177
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
178
+ const safeKey = Buffer.from("msteams:user1", "utf8").toString("base64url");
179
+ const filePath = path.join(tmpDir, `${safeKey}.learnings.json`);
180
+ await writeFile(filePath, JSON.stringify(["Be concise", "Use examples"]), "utf-8");
181
+
182
+ const learnings = await loadSessionLearnings(tmpDir, "msteams:user1");
183
+ expect(learnings).toEqual(["Be concise", "Use examples"]);
184
+ });
185
+
186
+ it("keeps distinct session keys isolated across the filename persistence boundary", async () => {
187
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
188
+
189
+ await storeSessionLearning({
190
+ storePath: tmpDir,
191
+ sessionKey: "msteams:user1",
192
+ learning: "Use bullets",
193
+ });
194
+ await storeSessionLearning({
195
+ storePath: tmpDir,
196
+ sessionKey: "msteams/user1",
197
+ learning: "Avoid bullets",
198
+ });
199
+
200
+ await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual(["Use bullets"]);
201
+ await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual(["Avoid bullets"]);
202
+ });
203
+
204
+ it("reads and migrates legacy sanitized session learning files", async () => {
205
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
206
+ const legacyFile = path.join(tmpDir, "msteams_user1.learnings.json");
207
+ await writeFile(legacyFile, JSON.stringify(["Legacy learning"]), "utf-8");
208
+
209
+ await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
210
+ "Legacy learning",
211
+ ]);
212
+
213
+ await storeSessionLearning({
214
+ storePath: tmpDir,
215
+ sessionKey: "msteams:user1",
216
+ learning: "New learning",
217
+ });
218
+
219
+ const migratedFile = path.join(
220
+ tmpDir,
221
+ `${Buffer.from("msteams:user1", "utf8").toString("base64url")}.learnings.json`,
222
+ );
223
+ await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
224
+ "Legacy learning",
225
+ "New learning",
226
+ ]);
227
+ await expect(rm(legacyFile, { force: false })).rejects.toMatchObject({ code: "ENOENT" });
228
+ await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
229
+ "Legacy learning",
230
+ "New learning",
231
+ ]);
232
+ await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual([]);
233
+ await expect(
234
+ import("node:fs/promises").then((fs) => fs.readFile(migratedFile, "utf-8")),
235
+ ).resolves.toContain("Legacy learning");
236
+ });
237
+ });