@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.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- 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
|
-
|
|
152
|
+
type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
|
|
127
153
|
|
|
128
|
-
|
|
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
|
|
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
|
+
});
|