mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
ddddb3dec1
Two crashes when a memory plugin misbehaves in bridge mode: - listActiveMemoryPublicArtifacts sorted plugin-returned artifacts without validating them; an artifact missing any of the string fields the comparator dereferences (kind, workspaceDir, relativePath, absolutePath, contentType) crashed wiki status and every other bridge consumer with "Cannot read properties of undefined (reading 'localeCompare')". @mem0/openclaw-mem0 <= 1.0.14 shipped record-shaped artifacts with none of those fields, typed against a drifted SDK stub. Validate the shape, drop malformed entries (and non-array listings), and warn once naming the offending plugin -- the same treatment agentIds already got. - resolveMemoryWikiStatus gated artifact counting on vaultMode/enabled but not bridge.readMemoryArtifacts, so the wiki.status gateway method still enumerated artifacts (and hit the crash above) with the flag off, even though the sync path (bridge.ts) and CLI gateway routing honor it. The documented workaround therefore never worked for the wiki_status agent tool. Add the flag to the gate; the count reports null when imports are disabled, matching non-bridge modes.
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
// Covers plugin-backed memory state registration and reset behavior.
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
buildMemoryPromptSection,
|
|
clearMemoryPluginState,
|
|
getMemoryCapabilityRegistration,
|
|
getMemoryRuntime,
|
|
hasMemoryRuntime,
|
|
listMemoryCorpusSupplements,
|
|
listMemoryPromptSupplements,
|
|
listActiveMemoryPublicArtifacts,
|
|
registerMemoryCapability,
|
|
registerMemoryCorpusSupplement,
|
|
registerMemoryFlushPlanResolver,
|
|
registerMemoryPromptSupplement,
|
|
registerMemoryPromptSection,
|
|
registerMemoryRuntime,
|
|
resolveMemoryFlushPlan,
|
|
restoreMemoryPluginState,
|
|
type MemoryPluginPublicArtifact,
|
|
} from "./memory-state.js";
|
|
|
|
function createMemoryRuntime() {
|
|
return {
|
|
async getMemorySearchManager() {
|
|
return { manager: null, error: "missing" };
|
|
},
|
|
resolveMemoryBackendConfig() {
|
|
return { backend: "builtin" as const };
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMemoryFlushPlan(relativePath: string) {
|
|
return {
|
|
softThresholdTokens: 1,
|
|
forceFlushTranscriptBytes: 2,
|
|
reserveTokensFloor: 3,
|
|
prompt: relativePath,
|
|
systemPrompt: relativePath,
|
|
relativePath,
|
|
};
|
|
}
|
|
|
|
function expectClearedMemoryState() {
|
|
expect(resolveMemoryFlushPlan({})).toBeNull();
|
|
expect(buildMemoryPromptSection({ availableTools: new Set(["memory_search"]) })).toStrictEqual(
|
|
[],
|
|
);
|
|
expect(listMemoryCorpusSupplements()).toStrictEqual([]);
|
|
expect(getMemoryRuntime()).toBeUndefined();
|
|
}
|
|
|
|
function createMemoryStateSnapshot() {
|
|
return {
|
|
capability: getMemoryCapabilityRegistration(),
|
|
corpusSupplements: listMemoryCorpusSupplements(),
|
|
promptSupplements: listMemoryPromptSupplements(),
|
|
};
|
|
}
|
|
|
|
function registerMemoryState(params: {
|
|
promptSection?: string[];
|
|
relativePath?: string;
|
|
runtime?: ReturnType<typeof createMemoryRuntime>;
|
|
}) {
|
|
registerMemoryCapability("memory-core", {
|
|
...(params.promptSection ? { promptBuilder: () => params.promptSection ?? [] } : {}),
|
|
...(params.relativePath
|
|
? { flushPlanResolver: () => createMemoryFlushPlan(params.relativePath ?? "") }
|
|
: {}),
|
|
...(params.runtime ? { runtime: params.runtime } : {}),
|
|
});
|
|
}
|
|
|
|
describe("memory plugin state", () => {
|
|
afterEach(() => {
|
|
clearMemoryPluginState();
|
|
});
|
|
|
|
it("returns empty defaults when no memory plugin state is registered", () => {
|
|
expectClearedMemoryState();
|
|
});
|
|
|
|
it("delegates prompt building to the registered memory plugin", () => {
|
|
registerMemoryPromptSection(({ availableTools }) => {
|
|
if (!availableTools.has("memory_search")) {
|
|
return [];
|
|
}
|
|
return ["## Custom Memory", "Use custom memory tools.", ""];
|
|
});
|
|
|
|
expect(buildMemoryPromptSection({ availableTools: new Set(["memory_search"]) })).toEqual([
|
|
"## Custom Memory",
|
|
"Use custom memory tools.",
|
|
"",
|
|
]);
|
|
});
|
|
|
|
it("adapts deprecated split registration to the unified memory capability", () => {
|
|
const runtime = createMemoryRuntime();
|
|
const promptBuilder = () => ["legacy prompt"];
|
|
const flushPlanResolver = () => createMemoryFlushPlan("memory/legacy.md");
|
|
|
|
registerMemoryPromptSection(promptBuilder);
|
|
registerMemoryFlushPlanResolver(flushPlanResolver);
|
|
registerMemoryRuntime(runtime);
|
|
|
|
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["legacy prompt"]);
|
|
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/legacy.md");
|
|
expect(getMemoryRuntime()).toBe(runtime);
|
|
expect(getMemoryCapabilityRegistration()).toStrictEqual({
|
|
pluginId: "legacy-memory-v1",
|
|
capability: {
|
|
promptBuilder,
|
|
flushPlanResolver,
|
|
runtime,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("prefers the registered memory capability over earlier legacy split state", async () => {
|
|
const runtime = createMemoryRuntime();
|
|
const promptBuilder = () => ["capability prompt"];
|
|
const flushPlanResolver = () => createMemoryFlushPlan("memory/capability.md");
|
|
|
|
registerMemoryPromptSection(() => ["legacy prompt"]);
|
|
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/legacy.md"));
|
|
registerMemoryRuntime({
|
|
async getMemorySearchManager() {
|
|
return { manager: null, error: "legacy" };
|
|
},
|
|
resolveMemoryBackendConfig() {
|
|
return { backend: "builtin" as const };
|
|
},
|
|
});
|
|
registerMemoryCapability("memory-core", {
|
|
promptBuilder,
|
|
flushPlanResolver,
|
|
runtime,
|
|
});
|
|
|
|
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["capability prompt"]);
|
|
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/capability.md");
|
|
await expect(
|
|
getMemoryRuntime()?.getMemorySearchManager({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
}),
|
|
).resolves.toEqual({ manager: null, error: "missing" });
|
|
expect(hasMemoryRuntime()).toBe(true);
|
|
expect(getMemoryCapabilityRegistration()).toStrictEqual({
|
|
pluginId: "memory-core",
|
|
capability: {
|
|
promptBuilder,
|
|
flushPlanResolver,
|
|
runtime,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("lists active public memory artifacts in deterministic order", async () => {
|
|
registerMemoryCapability("memory-core", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return [
|
|
{
|
|
kind: "daily-note",
|
|
workspaceDir: "/tmp/workspace-b",
|
|
relativePath: "memory/2026-04-06.md",
|
|
absolutePath: "/tmp/workspace-b/memory/2026-04-06.md",
|
|
agentIds: ["beta"],
|
|
contentType: "markdown" as const,
|
|
},
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace-a",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace-a/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown" as const,
|
|
},
|
|
];
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace-a",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace-a/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
{
|
|
kind: "daily-note",
|
|
workspaceDir: "/tmp/workspace-b",
|
|
relativePath: "memory/2026-04-06.md",
|
|
absolutePath: "/tmp/workspace-b/memory/2026-04-06.md",
|
|
agentIds: ["beta"],
|
|
contentType: "markdown",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("normalizes public memory artifacts without agent ids", async () => {
|
|
const legacyArtifact = {
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
contentType: "markdown" as const,
|
|
} as Omit<MemoryPluginPublicArtifact, "agentIds"> as MemoryPluginPublicArtifact;
|
|
|
|
registerMemoryCapability("memory-core", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return [legacyArtifact];
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
agentIds: [],
|
|
contentType: "markdown",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("drops malformed public memory artifacts instead of crashing the sort", async () => {
|
|
// Record-shaped artifact as shipped by @mem0/openclaw-mem0 <= 1.0.14 —
|
|
// none of the file-backed fields the sort dereferences.
|
|
const recordShapedArtifact = {
|
|
id: "mem0:memory:1",
|
|
type: "memory",
|
|
title: "A memory",
|
|
content: "memory text",
|
|
} as unknown as MemoryPluginPublicArtifact;
|
|
|
|
registerMemoryCapability("openclaw-mem0", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return [
|
|
recordShapedArtifact,
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown" as const,
|
|
},
|
|
];
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("ignores a non-array public artifact listing", async () => {
|
|
registerMemoryCapability("openclaw-mem0", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return { artifacts: [] } as unknown as MemoryPluginPublicArtifact[];
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([]);
|
|
});
|
|
|
|
it("preserves sidecar runtime fields when a memory plugin adds public artifacts only", async () => {
|
|
const runtime = createMemoryRuntime();
|
|
const flushPlanResolver = () => createMemoryFlushPlan("memory/sidecar.md");
|
|
|
|
registerMemoryCapability("memory-core", {
|
|
flushPlanResolver,
|
|
runtime,
|
|
});
|
|
registerMemoryCapability("memory-lancedb", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return [
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown" as const,
|
|
},
|
|
];
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/sidecar.md");
|
|
expect(getMemoryRuntime()).toBe(runtime);
|
|
expect(getMemoryCapabilityRegistration()?.pluginId).toBe("memory-lancedb");
|
|
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir: "/tmp/workspace",
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: "/tmp/workspace/MEMORY.md",
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("passes citations mode through to the prompt builder", () => {
|
|
registerMemoryPromptSection(({ citationsMode }) => [
|
|
`citations: ${citationsMode ?? "default"}`,
|
|
]);
|
|
|
|
expect(
|
|
buildMemoryPromptSection({
|
|
availableTools: new Set(),
|
|
citationsMode: "off",
|
|
}),
|
|
).toEqual(["citations: off"]);
|
|
});
|
|
|
|
it("appends prompt supplements in plugin-id order", () => {
|
|
registerMemoryPromptSection(() => ["primary"]);
|
|
registerMemoryPromptSupplement("memory-wiki", () => ["wiki"]);
|
|
registerMemoryPromptSupplement("alpha-helper", () => ["alpha"]);
|
|
|
|
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
|
|
"primary",
|
|
"alpha",
|
|
"wiki",
|
|
]);
|
|
});
|
|
|
|
it("ignores malformed prompt builder output", () => {
|
|
registerMemoryPromptSection(() => ["primary", 1, undefined] as never);
|
|
registerMemoryPromptSupplement("async-helper", () => Promise.resolve(["async"]) as never);
|
|
registerMemoryPromptSupplement("valid-helper", () => ["valid", false] as never);
|
|
|
|
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["primary", "valid"]);
|
|
});
|
|
|
|
it("stores memory corpus supplements", async () => {
|
|
const supplement = {
|
|
search: async () => [{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }],
|
|
get: async () => null,
|
|
};
|
|
|
|
registerMemoryCorpusSupplement("memory-wiki", supplement);
|
|
|
|
expect(listMemoryCorpusSupplements()).toHaveLength(1);
|
|
await expect(
|
|
listMemoryCorpusSupplements()[0]?.supplement.search({ query: "alpha" }),
|
|
).resolves.toEqual([{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }]);
|
|
});
|
|
|
|
it("uses the registered flush plan resolver", () => {
|
|
registerMemoryFlushPlanResolver(() => ({
|
|
softThresholdTokens: 1,
|
|
forceFlushTranscriptBytes: 2,
|
|
reserveTokensFloor: 3,
|
|
prompt: "prompt",
|
|
systemPrompt: "system",
|
|
relativePath: "memory/test.md",
|
|
}));
|
|
|
|
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/test.md");
|
|
});
|
|
|
|
it("stores the registered memory runtime", async () => {
|
|
const runtime = createMemoryRuntime();
|
|
|
|
registerMemoryRuntime(runtime);
|
|
|
|
expect(getMemoryRuntime()).toBe(runtime);
|
|
await expect(
|
|
getMemoryRuntime()?.getMemorySearchManager({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
}),
|
|
).resolves.toEqual({ manager: null, error: "missing" });
|
|
});
|
|
|
|
it("restoreMemoryPluginState swaps both prompt and flush state", () => {
|
|
const runtime = createMemoryRuntime();
|
|
registerMemoryState({
|
|
promptSection: ["first"],
|
|
relativePath: "memory/first.md",
|
|
runtime,
|
|
});
|
|
registerMemoryPromptSupplement("memory-wiki", () => ["wiki supplement"]);
|
|
registerMemoryCorpusSupplement("memory-wiki", {
|
|
search: async () => [{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }],
|
|
get: async () => null,
|
|
});
|
|
const snapshot = createMemoryStateSnapshot();
|
|
|
|
clearMemoryPluginState();
|
|
expectClearedMemoryState();
|
|
|
|
restoreMemoryPluginState(snapshot);
|
|
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
|
|
"first",
|
|
"wiki supplement",
|
|
]);
|
|
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/first.md");
|
|
expect(listMemoryCorpusSupplements()).toHaveLength(1);
|
|
expect(getMemoryRuntime()).toBe(runtime);
|
|
});
|
|
|
|
it("clearMemoryPluginState resets both registries", () => {
|
|
registerMemoryState({
|
|
promptSection: ["stale section"],
|
|
relativePath: "memory/stale.md",
|
|
runtime: createMemoryRuntime(),
|
|
});
|
|
|
|
clearMemoryPluginState();
|
|
|
|
expectClearedMemoryState();
|
|
});
|
|
});
|