openclaw/extensions/msteams/src/feedback-reflection.test.ts

186 lines
5.6 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildFeedbackEvent,
buildReflectionPrompt,
clearReflectionCooldowns,
isReflectionAllowed,
loadSessionLearnings,
parseReflectionResponse,
recordReflectionTime,
} from "./feedback-reflection.js";
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("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;
afterEach(async () => {
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-"));
const learnings = await loadSessionLearnings(tmpDir, "nonexistent");
expect(learnings).toEqual([]);
});
it("reads existing learnings", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
// Colons are sanitized to underscores in filenames (Windows compat)
const safeKey = "msteams_user1";
const filePath = path.join(tmpDir, `${safeKey}.learnings.json`);
await writeFile(filePath, JSON.stringify(["Be concise", "Use examples"]), "utf-8");
const learnings = await loadSessionLearnings(tmpDir, "msteams:user1");
expect(learnings).toEqual(["Be concise", "Use examples"]);
});
});