Files
adolf/extensions/msteams/src/feedback-reflection.test.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

280 lines
8.9 KiB
TypeScript

// Msteams tests cover feedback reflection plugin behavior.
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { storeSessionLearning } from "./feedback-reflection-store.js";
import {
buildFeedbackEvent,
buildReflectionPrompt,
clearReflectionCooldowns,
isReflectionAllowed,
loadSessionLearnings,
parseReflectionResponse,
recordReflectionTime,
} from "./feedback-reflection.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
describe("buildFeedbackEvent", () => {
it("builds a well-formed custom event", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "negative",
comment: "too verbose",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.type).toBe("custom");
expect(event.event).toBe("feedback");
expect(event.value).toBe("negative");
expect(event.comment).toBe("too verbose");
expect(event.messageId).toBe("msg-123");
expect(event.ts).toBeGreaterThan(0);
});
it("omits comment when not provided", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "positive",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.comment).toBeUndefined();
expect(event.value).toBe("positive");
});
});
describe("buildReflectionPrompt", () => {
it("includes the thumbed-down response", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Here is a long explanation...",
});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain("Here is a long explanation...");
expect(prompt).toContain("reflect");
});
it("truncates long responses", () => {
const longResponse = "x".repeat(600);
const prompt = buildReflectionPrompt({
thumbedDownResponse: longResponse,
});
expect(prompt).toContain("...");
expect(prompt.length).toBeLessThan(longResponse.length + 500);
});
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(499)}...`);
expect(prompt).not.toContain("\ud83e");
expect(prompt).not.toContain("\udd9e");
});
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
});
it("includes user comment when provided", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Some response",
userComment: "Too wordy",
});
expect(prompt).toContain('User\'s comment: "Too wordy"');
});
it("works without optional params", () => {
const prompt = buildReflectionPrompt({});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain('"followUp":false');
});
});
describe("parseReflectionResponse", () => {
it("parses strict JSON output", () => {
expect(
parseReflectionResponse(
'{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
),
).toEqual({
learning: "Be more direct next time.",
followUp: true,
userMessage: "Sorry about that. I will keep it tighter.",
});
});
it("parses JSON inside markdown fences", () => {
expect(
parseReflectionResponse(
'```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
),
).toEqual({
learning: "Ask a clarifying question first.",
followUp: false,
userMessage: undefined,
});
});
it("falls back to internal-only learning when parsing fails", () => {
expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
learning: "Be more concise.\nFollow up: yes.",
followUp: false,
});
});
});
describe("reflection cooldown", () => {
afterEach(() => {
clearReflectionCooldowns();
vi.restoreAllMocks();
});
it("allows first reflection", () => {
expect(isReflectionAllowed("session-1")).toBe(true);
});
it("blocks reflection within cooldown", () => {
recordReflectionTime("session-1");
expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
});
it("allows reflection after cooldown expires", () => {
// Manually set a past timestamp
recordReflectionTime("session-1");
// Override the map entry to simulate time passing
clearReflectionCooldowns();
expect(isReflectionAllowed("session-1", 1)).toBe(true);
});
it("tracks sessions independently", () => {
recordReflectionTime("session-1");
expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
expect(isReflectionAllowed("session-2", 60_000)).toBe(true);
});
it("keeps longer custom cooldown entries during pruning", () => {
vi.spyOn(Date, "now").mockReturnValue(0);
recordReflectionTime("target", 600_000);
vi.spyOn(Date, "now").mockReturnValue(301_000);
for (let index = 0; index <= 500; index += 1) {
recordReflectionTime(`session-${index}`, 600_000);
}
expect(isReflectionAllowed("target", 600_000)).toBe(false);
});
});
describe("loadSessionLearnings", () => {
let tmpDir: string;
beforeEach(() => {
resetPluginStateStoreForTests();
setMSTeamsRuntime(msteamsRuntimeStub);
});
afterEach(async () => {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("returns empty array when file doesn't exist", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
const learnings = await loadSessionLearnings(tmpDir, "nonexistent");
expect(learnings).toStrictEqual([]);
});
it("reads persisted learnings from plugin state", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Be concise",
});
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Use examples",
});
const learnings = await loadSessionLearnings(tmpDir, "msteams:user1");
expect(learnings).toEqual(["Be concise", "Use examples"]);
});
it("keeps distinct session keys isolated across the filename persistence boundary", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Use bullets",
});
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams/user1",
learning: "Avoid bullets",
});
await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual(["Use bullets"]);
await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual(["Avoid bullets"]);
});
it("keeps the same session key isolated by store path", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
const workStorePath = path.join(tmpDir, "work");
const opsStorePath = path.join(tmpDir, "ops");
await storeSessionLearning({
storePath: workStorePath,
sessionKey: "msteams:user1",
learning: "Use bullets",
});
await storeSessionLearning({
storePath: opsStorePath,
sessionKey: "msteams:user1",
learning: "Avoid bullets",
});
await expect(loadSessionLearnings(workStorePath, "msteams:user1")).resolves.toEqual([
"Use bullets",
]);
await expect(loadSessionLearnings(opsStorePath, "msteams:user1")).resolves.toEqual([
"Avoid bullets",
]);
});
});