Files
openclaw/extensions/active-memory/index.test.ts
2026-04-10 20:14:49 +01:00

1449 lines
46 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin from "./index.js";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const hoisted = vi.hoisted(() => {
const sessionStore: Record<string, Record<string, unknown>> = {
"agent:main:main": {
sessionId: "s-main",
updatedAt: 0,
},
};
return {
sessionStore,
updateSessionStore: vi.fn(
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
updater(sessionStore);
},
),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
updateSessionStore: hoisted.updateSessionStore,
};
});
describe("active-memory plugin", () => {
const hooks: Record<string, Function> = {};
const registeredCommands: Record<string, any> = {};
const runEmbeddedPiAgent = vi.fn();
let stateDir = "";
let configFile: Record<string, unknown> = {};
const api: any = {
pluginConfig: {
agents: ["main"],
logging: true,
},
config: {},
id: "active-memory",
name: "Active Memory",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: {
agent: {
runEmbeddedPiAgent,
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
loadSessionStore: vi.fn(() => hoisted.sessionStore),
saveSessionStore: vi.fn(async () => {}),
},
},
state: {
resolveStateDir: () => stateDir,
},
config: {
loadConfig: () => configFile,
writeConfigFile: vi.fn(async (nextConfig: Record<string, unknown>) => {
configFile = nextConfig;
}),
},
},
registerCommand: vi.fn((command) => {
registeredCommands[command.name] = command;
}),
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
beforeEach(async () => {
vi.clearAllMocks();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-"));
configFile = {
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
},
},
},
},
};
api.pluginConfig = {
agents: ["main"],
logging: true,
};
api.config = {};
hoisted.sessionStore["agent:main:main"] = {
sessionId: "s-main",
updatedAt: 0,
};
for (const key of Object.keys(hooks)) {
delete hooks[key];
}
for (const key of Object.keys(registeredCommands)) {
delete registeredCommands[key];
}
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
await plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(async () => {
vi.restoreAllMocks();
if (stateDir) {
await fs.rm(stateDir, { recursive: true, force: true });
stateDir = "";
}
});
it("registers a before_prompt_build hook", () => {
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
});
it("registers a session-scoped active-memory toggle command", async () => {
const command = registeredCommands["active-memory"];
const sessionKey = "agent:main:active-memory-toggle";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-active-memory-toggle",
updatedAt: 0,
};
expect(command).toMatchObject({
name: "active-memory",
acceptsArgs: true,
});
const offResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
sessionKey,
args: "off",
commandBody: "/active-memory off",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(offResult.text).toContain("off for this session");
const statusResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
sessionKey,
args: "status",
commandBody: "/active-memory status",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(statusResult.text).toBe("Active Memory: off for this session.");
const disabledResult = await hooks.before_prompt_build(
{ prompt: "what wings should i order? active memory toggle", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey,
messageProvider: "webchat",
},
);
expect(disabledResult).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
const onResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
sessionKey,
args: "on",
commandBody: "/active-memory on",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(onResult.text).toContain("on for this session");
await hooks.before_prompt_build(
{ prompt: "what wings should i order? active memory toggle", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey,
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
});
it("supports an explicit global active-memory config toggle", async () => {
const command = registeredCommands["active-memory"];
const offResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
args: "off --global",
commandBody: "/active-memory off --global",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(offResult.text).toBe("Active Memory: off globally.");
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(configFile).toMatchObject({
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
enabled: false,
agents: ["main"],
},
},
},
},
});
const statusOffResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
args: "status --global",
commandBody: "/active-memory status --global",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(statusOffResult.text).toBe("Active Memory: off globally.");
await hooks.before_prompt_build(
{ prompt: "what wings should i order while global active memory is off?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:global-toggle",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
const onResult = await command.handler({
channel: "webchat",
isAuthorizedSender: true,
args: "on --global",
commandBody: "/active-memory on --global",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(onResult.text).toBe("Active Memory: on globally.");
expect(configFile).toMatchObject({
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
enabled: true,
agents: ["main"],
},
},
},
},
});
await hooks.before_prompt_build(
{ prompt: "what wings should i order after global active memory is back on?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:global-toggle",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
});
it("does not run for non-interactive contexts", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "heartbeat",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("treats non-webchat main sessions as direct chats under the default dmScope", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("treats non-default main session keys as direct chats", async () => {
api.config = { session: { mainKey: "home" } };
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:home",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("runs for group sessions when group chat types are explicitly allowed", async () => {
api.pluginConfig = {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("injects system context on a successful recall hit", async () => {
const result = await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [
{ role: "user", content: "i want something greasy tonight" },
{ role: "assistant", content: "let's narrow it down" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"lemon pepper wings",
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "github-copilot",
model: "gpt-5.4-mini",
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
});
});
it("frames the blocking memory subagent as a memory search agent for another model", async () => {
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? strict-style-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.prompt).toContain("You are a memory search agent.");
expect(runParams?.prompt).toContain("Another model is preparing the final user-facing answer.");
expect(runParams?.prompt).toContain(
"Your job is to search memory and return only the most relevant memory context for that model.",
);
expect(runParams?.prompt).toContain(
"You receive conversation context, including the user's latest message.",
);
expect(runParams?.prompt).toContain("Use only memory_search and memory_get.");
expect(runParams?.prompt).toContain(
"If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.",
);
expect(runParams?.prompt).toContain(
"Questions like 'what is my favorite food', 'do you remember my flight preferences', or 'what do i usually get' should normally return memory when relevant results exist.",
);
expect(runParams?.prompt).toContain("Return exactly one of these two forms:");
expect(runParams?.prompt).toContain("1. NONE");
expect(runParams?.prompt).toContain("2. one compact plain-text summary");
expect(runParams?.prompt).toContain(
"Write the summary as a memory note about the user, not as a reply to the user.",
);
expect(runParams?.prompt).toContain(
"Do not return bullets, numbering, labels, XML, JSON, or markdown list formatting.",
);
expect(runParams?.prompt).toContain("Good examples:");
expect(runParams?.prompt).toContain("Bad examples:");
expect(runParams?.prompt).toContain(
"Return: User's favorite food is ramen; tacos also come up often.",
);
});
it("defaults prompt style by query mode when no promptStyle is configured", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? preference-style-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.prompt).toContain("Prompt style: strict.");
expect(runParams?.prompt).toContain(
"If the latest user message does not strongly call for memory, reply with NONE.",
);
});
it("honors an explicit promptStyle override", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
promptStyle: "preference-only",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What is my favorite food?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.prompt).toContain("Prompt style: preference-only.");
expect(runParams?.prompt).toContain(
"Optimize for favorites, preferences, habits, routines, taste, and recurring personal facts.",
);
});
it("keeps thinking off by default but allows an explicit thinking override", async () => {
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? default-thinking-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
thinkLevel: "off",
reasoningLevel: "off",
});
api.pluginConfig = {
agents: ["main"],
thinking: "medium",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? thinking-override-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
thinkLevel: "medium",
reasoningLevel: "off",
});
});
it("allows appending extra prompt instructions without replacing the base prompt", async () => {
api.pluginConfig = {
agents: ["main"],
promptAppend: "Prefer stable long-term preferences over one-off events.",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? prompt-append-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).toContain("You are a memory search agent.");
expect(prompt).toContain("Additional operator instructions:");
expect(prompt).toContain("Prefer stable long-term preferences over one-off events.");
expect(prompt).toContain("Conversation context:");
expect(prompt).toContain("What is my favorite food? prompt-append-check");
});
it("allows replacing the base prompt while still appending conversation context", async () => {
api.pluginConfig = {
agents: ["main"],
promptOverride: "Custom memory prompt. Return NONE or one user fact.",
promptAppend: "Extra custom instruction.",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What is my favorite food? prompt-override-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).toContain("Custom memory prompt. Return NONE or one user fact.");
expect(prompt).not.toContain("You are a memory search agent.");
expect(prompt).toContain("Additional operator instructions:");
expect(prompt).toContain("Extra custom instruction.");
expect(prompt).toContain("Conversation context:");
expect(prompt).toContain("What is my favorite food? prompt-override-check");
});
it("preserves leading digits in a plain-text summary", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "2024 trip to tokyo and 2% milk both matter here." }],
});
const result = await hooks.before_prompt_build(
{
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"2024 trip to tokyo",
);
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
});
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
await hooks.before_prompt_build(
{ prompt: "what should i grab on the way?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:direct:12345:thread:99",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
);
});
it("falls back to the current session model when no plugin model is configured", async () => {
api.pluginConfig = {
agents: ["main"],
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
modelProviderId: "qwen",
modelId: "glm-5",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "qwen",
model: "glm-5",
});
});
it("can disable default remote model fallback", async () => {
api.pluginConfig = {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:resolved-only",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("persists a readable debug summary alongside the status line", async () => {
const sessionKey = "agent:main:debug";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "User prefers lemon pepper wings, and blue cheese still wins." }],
});
await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [],
},
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(hoisted.updateSessionStore).toHaveBeenCalled();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: expect.arrayContaining([
expect.stringContaining("🧩 Active Memory: ok"),
expect.stringContaining(
"🔎 Active Memory Debug: User prefers lemon pepper wings, and blue cheese still wins.",
),
]),
},
]);
});
it("replaces stale structured active-memory lines on a later empty run", async () => {
const sessionKey = "agent:main:stale-active-memory-lines";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{
pluginId: "active-memory",
lines: [
"🧩 Active Memory: ok 13.4s recent 34 chars",
"🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
],
},
{ pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
],
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
await hooks.before_prompt_build(
{ prompt: "what's up with you?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{
pluginId: "active-memory",
lines: [
"🧩 Active Memory: ok 13.4s recent 34 chars",
"🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
],
},
{ pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{ pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
{
pluginId: "active-memory",
lines: [expect.stringContaining("🧩 Active Memory: empty")],
},
]);
});
it("returns nothing when the subagent says none", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
});
it("does not cache timeout results", async () => {
api.pluginConfig = {
agents: ["main"],
timeoutMs: 250,
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
return await new Promise((resolve, reject) => {
const abortHandler = () => reject(new Error("aborted"));
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
setTimeout(() => {
params.abortSignal?.removeEventListener("abort", abortHandler);
resolve({ payloads: [] });
}, 2_000);
});
});
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
expect(lastAbortSignal?.aborted).toBe(true);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("does not share cached recall results across session-id-only contexts", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-b",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("uses a canonical agent session key when only sessionId is available", async () => {
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
sessionId: "session-a",
updatedAt: 25,
};
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id only", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
);
expect(hoisted.sessionStore["agent:main:telegram:direct:12345"]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: expect.arrayContaining([expect.stringContaining("🧩 Active Memory: ok")]),
},
]);
});
it("uses the resolved canonical session key for non-webchat chat-type checks", async () => {
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
sessionId: "session-a",
updatedAt: 25,
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id only telegram", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
const sessionKey = "noncanonical-session";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
});
it("supports message mode by sending only the latest user message", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
expect(prompt).not.toContain("Recent conversation tail:");
});
it("supports full mode by sending the whole conversation", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "full",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
{ role: "user", content: "packing is annoying" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Full conversation context:");
expect(prompt).toContain("user: i have a flight tomorrow");
expect(prompt).toContain("assistant: got it");
expect(prompt).toContain("user: packing is annoying");
});
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "recent",
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{
role: "assistant",
content:
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
},
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Treat the latest user message as the primary query.");
expect(prompt).toContain(
"Use recent conversation only to disambiguate what the latest user message means.",
);
expect(prompt).toContain(
"Do not return memory just because it matched the broader recent topic; return memory only if it clearly helps with the latest user message itself.",
);
expect(prompt).toContain(
"If recent context and the latest user message point to different memory domains, prefer the domain that best matches the latest user message.",
);
expect(prompt).toContain(
"ignore that surfaced text unless the latest user message clearly requires re-checking it.",
);
expect(prompt).toContain(
"Latest user message: I might see a movie while I wait for the flight.",
);
expect(prompt).toContain(
"Return: User's favorite movie snack is buttery popcorn with extra salt.",
);
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
expect(prompt).not.toContain("Memory Search:");
expect(prompt).not.toContain("Active Memory:");
expect(prompt).not.toContain("Active Memory Debug:");
expect(prompt).not.toContain("spicy ramen; tacos");
});
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "User prefers aisle seats and extra buffer on connections." }],
});
const result = await hooks.before_prompt_build(
{ prompt: "u remember my flight preferences", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("aisle seat"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"extra buffer on connections",
);
});
it("applies total summary truncation after normalizing the subagent reply", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 40,
};
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
text: "alpha beta gamma delta epsilon zetalongword",
},
],
});
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? word-boundary-truncation-40", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("alpha beta gamma"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"alpha beta gamma delta epsilon",
);
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain("zetalo");
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
"zetalongword",
);
});
it("uses the configured maxSummaryChars value in the subagent prompt", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 90,
};
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:prompt-count-check",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
"If something is useful, reply with one compact plain-text summary under 90 characters total.",
);
});
it("keeps subagent transcripts off disk by default by using a temp session file", async () => {
const mkdtempSpy = vi
.spyOn(fs, "mkdtemp")
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript path", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(mkdtempSpy).toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
"/tmp/openclaw-active-memory-temp/session.jsonl",
);
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
recursive: true,
force: true,
});
});
it("persists subagent transcripts in a separate directory when enabled", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory-subagents",
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
const sessionKey = "agent:main:persist-transcript";
await hooks.before_prompt_build(
{ prompt: "what wings should i order? persist transcript", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const expectedDir = path.join(
stateDir,
"plugins",
"active-memory",
"transcripts",
"agents",
"main",
"active-memory-subagents",
);
expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
expect(mkdtempSpy).not.toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
new RegExp(
`^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
),
);
expect(rmSpy).not.toHaveBeenCalled();
expect(
vi
.mocked(api.logger.info)
.mock.calls.some((call: unknown[]) =>
String(call[0]).includes(`transcript=${expectedDir}${path.sep}`),
),
).toBe(true);
});
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "C:/temp/escape",
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:unsafe-transcript",
messageProvider: "webchat",
},
);
const expectedDir = path.join(
stateDir,
"plugins",
"active-memory",
"transcripts",
"agents",
"main",
"active-memory",
);
expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
new RegExp(
`^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
),
);
});
it("scopes persisted subagent transcripts by agent", async () => {
api.pluginConfig = {
agents: ["main", "support/agent"],
persistTranscripts: true,
transcriptDir: "active-memory-subagents",
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? support agent transcript", messages: [] },
{
agentId: "support/agent",
trigger: "user",
sessionKey: "agent:support/agent:persist-transcript",
messageProvider: "webchat",
},
);
const expectedDir = path.join(
stateDir,
"plugins",
"active-memory",
"transcripts",
"agents",
"support%2Fagent",
"active-memory-subagents",
);
expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
new RegExp(
`^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
),
);
});
it("sanitizes control characters out of debug lines", async () => {
const sessionKey = "agent:main:debug-sanitize";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
});
await hooks.before_prompt_build(
{ prompt: "what should i order?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
const lines =
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
?.lines ?? [];
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
expect(lines.some((line) => line.includes("\r"))).toBe(false);
});
it("caps the active-memory cache size and evicts the oldest entries", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
await plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(
{ prompt: `cache pressure prompt ${index}`, messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
}
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
await hooks.before_prompt_build(
{ prompt: "cache pressure prompt 0", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(
infoLines.some(
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
),
).toBe(false);
});
});