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
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
280 lines
8.9 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|