mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
186 lines
5.6 KiB
TypeScript
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"]);
|
|
});
|
|
});
|