Files
Peter Steinberger 9809375fda refactor(plugins)!: remove the plugin state lease API (#121140)
* refactor(plugins)!: remove plugin state lease API

* docs(plugins): document state lease removal
2026-08-09 11:16:07 -07:00

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/);
});
});