mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
9809375fda
* refactor(plugins)!: remove plugin state lease API * docs(plugins): document state lease removal
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
// Memory Core tests cover index plugin behavior.
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import type { OpenClawPluginApi, OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core";
|
|
import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
|
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
|
|
import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js";
|
|
import { buildPromptSection } from "./src/prompt-section.js";
|
|
|
|
const closeMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
const getMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => null));
|
|
const authorizeSearchHitsMock = vi.hoisted(() => vi.fn(async ({ hits }) => hits));
|
|
const createMemoryRuntimeMock = vi.hoisted(() =>
|
|
vi.fn((_host: MemoryCoreRuntimeHost = {}) => ({
|
|
authorizeSearchHits: authorizeSearchHitsMock,
|
|
closeAllMemorySearchManagers: vi.fn(async () => {}),
|
|
closeMemorySearchManager: closeMemorySearchManagerMock,
|
|
getMemorySearchManager: getMemorySearchManagerMock,
|
|
})),
|
|
);
|
|
|
|
vi.mock("./src/runtime-provider.js", () => ({
|
|
createMemoryRuntime: createMemoryRuntimeMock,
|
|
memoryRuntime: {
|
|
closeAllMemorySearchManagers: vi.fn(async () => {}),
|
|
closeMemorySearchManager: closeMemorySearchManagerMock,
|
|
getMemorySearchManager: getMemorySearchManagerMock,
|
|
},
|
|
}));
|
|
|
|
import plugin from "./index.js";
|
|
|
|
const hostRuntime = {
|
|
llm: {
|
|
acquireLocalService: async () => undefined,
|
|
},
|
|
state: {
|
|
openKeyedStore: vi.fn(() => ({
|
|
lookup: vi.fn(),
|
|
register: vi.fn(),
|
|
delete: vi.fn(),
|
|
list: vi.fn(),
|
|
})),
|
|
},
|
|
} as unknown as OpenClawPluginApi["runtime"];
|
|
|
|
function registerMemoryCoreRuntime(): MemoryPluginRuntime {
|
|
let runtime: MemoryPluginRuntime | undefined;
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: hostRuntime,
|
|
registerMemoryCapability(capability) {
|
|
runtime = capability.runtime;
|
|
},
|
|
}),
|
|
);
|
|
if (!runtime) {
|
|
throw new Error("expected memory-core to register a memory runtime");
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
describe("buildPromptSection", () => {
|
|
it("returns empty when no memory tools are available", () => {
|
|
expect(buildPromptSection({ availableTools: new Set() })).toStrictEqual([]);
|
|
});
|
|
|
|
it("describes the two-step flow when both memory tools are available", () => {
|
|
const result = buildPromptSection({
|
|
availableTools: new Set(["memory_search", "memory_get"]),
|
|
});
|
|
expect(result[0]).toBe("## Memory Recall");
|
|
expect(result[1]).toContain("run memory_search");
|
|
expect(result[1]).toContain("then use memory_get");
|
|
expect(result[1]).toContain("indexed session transcripts");
|
|
expect(result).toContain(
|
|
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
|
|
);
|
|
expect(result.at(-1)).toBe("");
|
|
});
|
|
|
|
it("limits the guidance to memory_search when only search is available", () => {
|
|
const result = buildPromptSection({ availableTools: new Set(["memory_search"]) });
|
|
expect(result[0]).toBe("## Memory Recall");
|
|
expect(result[1]).toContain("run memory_search");
|
|
expect(result[1]).toContain("indexed session transcripts");
|
|
expect(result[1]).not.toContain("then use memory_get");
|
|
});
|
|
|
|
it("limits the guidance to memory_get when only get is available", () => {
|
|
const result = buildPromptSection({ availableTools: new Set(["memory_get"]) });
|
|
expect(result[0]).toBe("## Memory Recall");
|
|
expect(result[1]).toContain("run memory_get");
|
|
expect(result[1]).not.toContain("run memory_search");
|
|
});
|
|
|
|
it("includes citations-off instruction when citationsMode is off", () => {
|
|
const result = buildPromptSection({
|
|
availableTools: new Set(["memory_search"]),
|
|
citationsMode: "off",
|
|
});
|
|
expect(result).toContain(
|
|
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("memory-core plugin runtime registration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("registers the dreaming runtime slash command", () => {
|
|
let command: OpenClawPluginCommandDefinition | undefined;
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: hostRuntime,
|
|
registerCommand(definition) {
|
|
command = definition;
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(command?.name).toBe("dreaming");
|
|
expect(command?.acceptsArgs).toBe(true);
|
|
expect(command?.exposeSenderIsOwner).toBe(true);
|
|
expect(command?.description).toContain("Enable or disable");
|
|
});
|
|
|
|
it("registers the standing-intent tool and deterministic prompt hook", () => {
|
|
const toolNames: string[] = [];
|
|
const hooks: string[] = [];
|
|
const subagentRun = vi.fn();
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: { ...hostRuntime, subagent: { run: subagentRun } } as never,
|
|
registerTool(_factory, options?: Parameters<OpenClawPluginApi["registerTool"]>[1]) {
|
|
toolNames.push(...(options?.names ?? []));
|
|
},
|
|
on(hookName) {
|
|
hooks.push(hookName);
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(toolNames).toContain("intent");
|
|
expect(hooks).toContain("before_prompt_build");
|
|
expect(subagentRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scopes both reply hooks to scheduled turns across three registrations", () => {
|
|
for (let cycle = 1; cycle <= 3; cycle += 1) {
|
|
const replyHookTriggers: unknown[] = [];
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: hostRuntime,
|
|
on(hookName, _handler, options) {
|
|
if (hookName === "before_agent_reply") {
|
|
replyHookTriggers.push(options?.eligibleTriggers);
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(replyHookTriggers, `cycle ${cycle}`).toEqual([
|
|
["heartbeat", "cron"],
|
|
["heartbeat", "cron"],
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("hides intent create, list, and cancel from non-owner turns", () => {
|
|
let intentFactory:
|
|
| ((ctx: { config?: OpenClawConfig; senderIsOwner?: boolean }) => unknown)
|
|
| undefined;
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
config: {},
|
|
runtime: hostRuntime,
|
|
registerTool(factory, options) {
|
|
if (options?.names?.includes("intent") && typeof factory === "function") {
|
|
intentFactory = factory as typeof intentFactory;
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
if (!intentFactory) {
|
|
throw new Error("expected standing-intent tool factory");
|
|
}
|
|
|
|
expect(intentFactory({ config: {}, senderIsOwner: false })).toBeNull();
|
|
expect(intentFactory({ config: {} })).toBeNull();
|
|
expect(intentFactory({ config: {}, senderIsOwner: true })).toMatchObject({ name: "intent" });
|
|
});
|
|
|
|
it("keeps memory manager initialization demand-driven", () => {
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: hostRuntime,
|
|
}),
|
|
);
|
|
|
|
expect(createMemoryRuntimeMock).not.toHaveBeenCalled();
|
|
expect(getMemorySearchManagerMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("wires scoped memory search cleanup through the lazy runtime", async () => {
|
|
const runtime = registerMemoryCoreRuntime();
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
await runtime.closeMemorySearchManager?.({ cfg, agentId: "main" });
|
|
|
|
expect(closeMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
|
|
});
|
|
|
|
it("binds the host local-service hook to the registered memory runtime", async () => {
|
|
const runtime = registerMemoryCoreRuntime();
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
await runtime.getMemorySearchManager({ cfg, agentId: "main" });
|
|
|
|
expect(createMemoryRuntimeMock).toHaveBeenCalledWith({
|
|
acquireLocalService: expect.any(Function),
|
|
openKeyedStore: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it("defers nested host runtime access until the injected operation runs", async () => {
|
|
const acquireLocalService = vi.fn(async () => undefined);
|
|
const openKeyedStore = vi.fn(() => ({}));
|
|
const llmGetter = vi.fn(() => ({ acquireLocalService }));
|
|
const stateGetter = vi.fn(() => ({ openKeyedStore }));
|
|
const host = Object.defineProperties(
|
|
{},
|
|
{
|
|
llm: { configurable: true, enumerable: true, get: llmGetter },
|
|
state: { configurable: true, enumerable: true, get: stateGetter },
|
|
},
|
|
) as OpenClawPluginApi["runtime"];
|
|
let runtime: MemoryPluginRuntime | undefined;
|
|
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
runtime: host,
|
|
registerMemoryCapability(capability) {
|
|
runtime = capability.runtime;
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(llmGetter).not.toHaveBeenCalled();
|
|
expect(stateGetter).not.toHaveBeenCalled();
|
|
await runtime?.getMemorySearchManager({ cfg: {}, agentId: "main" });
|
|
const injectedHost = createMemoryRuntimeMock.mock.calls.at(-1)?.[0];
|
|
if (!injectedHost?.acquireLocalService || !injectedHost.openKeyedStore) {
|
|
throw new Error("expected memory-core host operations");
|
|
}
|
|
|
|
const target = { providerId: "local", baseUrl: "http://127.0.0.1:11434" };
|
|
await injectedHost.acquireLocalService(target);
|
|
const storeOptions = { namespace: "lazy-host", maxEntries: 1 };
|
|
injectedHost.openKeyedStore(storeOptions);
|
|
|
|
expect(llmGetter).toHaveBeenCalledOnce();
|
|
expect(acquireLocalService).toHaveBeenCalledWith(target);
|
|
expect(stateGetter).toHaveBeenCalledOnce();
|
|
expect(openKeyedStore).toHaveBeenCalledWith(storeOptions);
|
|
});
|
|
|
|
it("forwards search-hit authorization through the registered memory runtime", async () => {
|
|
const runtime = registerMemoryCoreRuntime();
|
|
const cfg = {} as OpenClawConfig;
|
|
const hits = [
|
|
{
|
|
source: "sessions" as const,
|
|
path: "sessions/private.jsonl",
|
|
startLine: 1,
|
|
endLine: 1,
|
|
score: 1,
|
|
snippet: "private",
|
|
},
|
|
];
|
|
|
|
await expect(
|
|
runtime.authorizeSearchHits?.({
|
|
cfg,
|
|
agentId: "main",
|
|
requesterSessionKey: "agent:main:voice:15550001234",
|
|
sandboxed: false,
|
|
hits,
|
|
}),
|
|
).resolves.toEqual(hits);
|
|
expect(authorizeSearchHitsMock).toHaveBeenCalledWith({
|
|
cfg,
|
|
agentId: "main",
|
|
requesterSessionKey: "agent:main:voice:15550001234",
|
|
sandboxed: false,
|
|
hits,
|
|
});
|
|
expect(createMemoryRuntimeMock).toHaveBeenCalledWith({
|
|
acquireLocalService: expect.any(Function),
|
|
openKeyedStore: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it("binds the host SQLite state hook to tools and CLI runtime", async () => {
|
|
const runtime = registerMemoryCoreRuntime();
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
await runtime.getMemorySearchManager({ cfg, agentId: "main" });
|
|
|
|
const host = createMemoryRuntimeMock.mock.calls.at(-1)?.[0];
|
|
const storeOptions = { namespace: "cli-status-regression", maxEntries: 1 };
|
|
host?.openKeyedStore?.(storeOptions);
|
|
expect(hostRuntime.state.openKeyedStore).toHaveBeenCalledWith(storeOptions);
|
|
});
|
|
});
|
|
|
|
describe("buildMemoryFlushPlan", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
userTimezone: "America/New_York",
|
|
timeFormat: "12",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
it("replaces YYYY-MM-DD using user timezone and appends current time", () => {
|
|
const plan = buildMemoryFlushPlan({
|
|
cfg,
|
|
nowMs: Date.UTC(2026, 1, 16, 15, 0, 0),
|
|
});
|
|
|
|
expect(plan?.prompt).toContain("memory/2026-02-16.md");
|
|
expect(plan?.prompt).toContain(
|
|
"Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York)",
|
|
);
|
|
expect(plan?.prompt).toContain("Reference UTC: 2026-02-16 15:00 UTC");
|
|
expect(plan?.relativePath).toBe("memory/2026-02-16.md");
|
|
});
|
|
|
|
it("appends one current time line to the built-in prompt", () => {
|
|
const plan = buildMemoryFlushPlan({
|
|
cfg,
|
|
nowMs: Date.UTC(2026, 1, 16, 15, 0, 0),
|
|
});
|
|
|
|
expect((plan?.prompt.match(/Current time:/g) ?? []).length).toBe(1);
|
|
});
|
|
|
|
it("defaults to safe prompts and gating values", () => {
|
|
const plan = buildMemoryFlushPlan();
|
|
expect(plan?.softThresholdTokens).toBe(4000);
|
|
expect(plan?.forceFlushTranscriptBytes).toBe(2 * 1024 * 1024);
|
|
expect(plan?.prompt).toContain("memory/");
|
|
expect(plan?.prompt).toContain("MEMORY.md");
|
|
expect(plan?.systemPrompt).toContain("MEMORY.md");
|
|
});
|
|
|
|
it("respects disable flag", () => {
|
|
expect(
|
|
buildMemoryFlushPlan({
|
|
cfg: {
|
|
agents: {
|
|
defaults: { compaction: { memoryFlush: { enabled: false } } },
|
|
},
|
|
},
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("carries configured memory flush model override", () => {
|
|
const plan = buildMemoryFlushPlan({
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
compaction: {
|
|
memoryFlush: {
|
|
model: "ollama/qwen3:8b",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(plan?.model).toBe("ollama/qwen3:8b");
|
|
});
|
|
|
|
it("falls back to defaults when numeric values are invalid", () => {
|
|
const plan = buildMemoryFlushPlan({
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
compaction: {
|
|
memoryFlush: {
|
|
softThresholdTokens: -100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(plan?.softThresholdTokens).toBe(4000);
|
|
expect(plan?.forceFlushTranscriptBytes).toBe(2 * 1024 * 1024);
|
|
});
|
|
|
|
it("parses forceFlushTranscriptBytes from byte-size strings", () => {
|
|
const plan = buildMemoryFlushPlan({
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
compaction: {
|
|
memoryFlush: {
|
|
forceFlushTranscriptBytes: "3mb",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(plan?.forceFlushTranscriptBytes).toBe(3 * 1024 * 1024);
|
|
});
|
|
|
|
it("keeps overwrite guards in the default prompt", () => {
|
|
const prompt = buildMemoryFlushPlan()?.prompt;
|
|
expect(prompt).toMatch(/APPEND/i);
|
|
expect(prompt).toContain("do not overwrite");
|
|
expect(prompt).toContain("timestamped variant");
|
|
expect(prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
|
});
|
|
});
|