refactor(memory-core): consolidate manager test fixtures (#122671)

* test(memory-core): consolidate manager index fixtures

* test(memory-core): table-drive manager state cases

* test(memory-core): type shared fixture surface
This commit is contained in:
Peter Steinberger
2026-08-12 08:50:09 -07:00
committed by GitHub
parent 0f4640a887
commit 1f8fdf4ef0
12 changed files with 1191 additions and 3818 deletions
File diff suppressed because it is too large Load Diff
@@ -21,28 +21,26 @@ describe("memory manager async state", () => {
await closePromise;
});
it("reports pending sync failures during close", async () => {
it.each([
{
name: "pending sync",
pendingKey: "pendingSync" as const,
error: new Error("sync failed"),
},
{
name: "pending provider initialization",
pendingKey: "pendingProviderInit" as const,
error: new Error("provider init failed"),
},
])("reports $name failures during close", async ({ pendingKey, error }) => {
const onError = vi.fn();
const syncError = new Error("sync failed");
await awaitPendingManagerWork({
pendingSync: Promise.reject(syncError),
[pendingKey]: Promise.reject(error),
onError,
});
expect(onError).toHaveBeenCalledWith(syncError);
});
it("reports pending provider initialization failures during close", async () => {
const onError = vi.fn();
const providerError = new Error("provider init failed");
await awaitPendingManagerWork({
pendingProviderInit: Promise.reject(providerError),
onError,
});
expect(onError).toHaveBeenCalledWith(providerError);
expect(onError).toHaveBeenCalledWith(error);
});
it("does not report errors for completed pending close work", async () => {
@@ -0,0 +1,604 @@
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
type GetMemorySearchManager = typeof import("./index.js").getMemorySearchManager;
type ManagerConfig = Parameters<GetMemorySearchManager>[0]["cfg"];
type ManagerResult = Awaited<ReturnType<GetMemorySearchManager>>;
export type ManagerIndexFixtureConfig = {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<ManagerConfig["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
};
type ProviderCall = {
provider?: string;
model?: string;
outputDimensionality?: number;
};
type ProviderControls = {
embedBatchCalls: number;
embeddedBatchTexts: string[];
embedBatchInputCalls: number;
providerRuntimeBatchCalls: string[][];
providerRuntimeBatchGate: Promise<void> | null;
providerRuntimeBatchErrors: unknown[];
providerRuntimeBatchFailuresRemaining: number;
providerRuntimeActiveBatchCalls: number;
providerRuntimeMaxActiveBatchCalls: number;
providerCloseCalls: number;
providerCloseFailuresRemaining: number;
providerCloseFailure: unknown;
providerCreationFailure: string | null;
providerNullResult: string | null;
providerCloseGate: Promise<void> | null;
providerInitGate: Promise<void> | null;
providerCalls: ProviderCall[];
forceNoProvider: boolean;
identityAlias: {
provider: string;
canonicalModel: string;
cacheModel: string;
};
createLocalWorkerExitError: () => Error;
};
export type ManagerIndexFixture = {
paths: {
readonly root: string;
readonly workspace: string;
readonly memory: string;
};
provider: ProviderControls;
createConfig: (params: ManagerIndexFixtureConfig) => ManagerConfig;
requireManager: (result: ManagerResult, missingMessage?: string) => MemoryIndexManager;
trackManager: (manager: MemoryIndexManager) => void;
resetManager: (manager: MemoryIndexManager) => void;
getPersistentManager: (cfg: ManagerConfig) => Promise<MemoryIndexManager>;
getFreshManager: (
cfg: ManagerConfig,
purpose?: "default" | "status" | "cli",
) => Promise<MemoryIndexManager>;
getFtsSessionManager: (params: { stateDirName: string }) => Promise<MemoryIndexManager | null>;
seedSessionTranscript: (params: {
messages: Array<{
content: string;
role: "assistant" | "user";
senderIsOwner?: boolean;
timestamp: number | string;
}>;
sessionId: string;
sessionKey?: string;
}) => Promise<void>;
setStateDir: (stateDir: string) => void;
restoreStateDir: () => void;
};
const providerState = vi.hoisted(() => ({
embedBatchCalls: 0,
embeddedBatchTexts: [] as string[],
embedBatchInputCalls: 0,
providerRuntimeBatchCalls: [] as string[][],
providerRuntimeBatchGate: null as Promise<void> | null,
providerRuntimeBatchErrors: [] as unknown[],
providerRuntimeBatchFailuresRemaining: 0,
providerRuntimeActiveBatchCalls: 0,
providerRuntimeMaxActiveBatchCalls: 0,
providerCloseCalls: 0,
providerCloseFailuresRemaining: 0,
providerCloseFailure: new Error("provider close failed") as unknown,
providerCreationFailure: null as string | null,
providerNullResult: null as string | null,
providerCloseGate: null as Promise<void> | null,
providerInitGate: null as Promise<void> | null,
providerCalls: [] as ProviderCall[],
forceNoProvider: false,
identityAlias: {
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
},
}));
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
function createLocalWorkerExitError(): Error {
return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
code: "LOCAL_EMBEDDING_WORKER_EXITED",
reason: "exit",
exitCode: 134,
});
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === providerState.identityAlias.provider
? {
provider: {
id: providerState.identityAlias.provider,
model: providerState.identityAlias.canonicalModel,
},
cacheKeyData: {
provider: providerState.identityAlias.provider,
model: providerState.identityAlias.canonicalModel,
},
aliases: [
{
model: providerState.identityAlias.cacheModel,
cacheKeyData: {
provider: providerState.identityAlias.provider,
model: providerState.identityAlias.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: ProviderCall) => {
providerState.providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerState.providerInitGate;
if (options.provider === providerState.providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerState.providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (providerState.forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === providerState.identityAlias.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === providerState.identityAlias.provider &&
(requestedModel === providerState.identityAlias.canonicalModel ||
requestedModel === providerState.identityAlias.cacheModel)
? providerState.identityAlias.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerState.providerCloseCalls += 1;
await providerState.providerCloseGate;
if (providerState.providerCloseFailuresRemaining > 0) {
providerState.providerCloseFailuresRemaining -= 1;
throw providerState.providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
providerState.embedBatchCalls += 1;
providerState.embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
providerState.embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === providerState.identityAlias.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: providerState.identityAlias.canonicalModel,
},
indexIdentityAliases: [
{
model: providerState.identityAlias.cacheModel,
cacheKeyData: {
provider: providerId,
model: providerState.identityAlias.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerState.providerRuntimeActiveBatchCalls += 1;
providerState.providerRuntimeMaxActiveBatchCalls = Math.max(
providerState.providerRuntimeMaxActiveBatchCalls,
providerState.providerRuntimeActiveBatchCalls,
);
try {
await providerState.providerRuntimeBatchGate;
providerState.providerRuntimeBatchCalls.push(
batch.chunks.map((chunk) => chunk.text),
);
if (providerState.providerRuntimeBatchErrors.length > 0) {
throw providerState.providerRuntimeBatchErrors.shift();
}
if (providerState.providerRuntimeBatchFailuresRemaining > 0) {
providerState.providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerState.providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
export function createManagerIndexFixture(deps: {
getMemorySearchManager: GetMemorySearchManager;
closeAllMemorySearchManagers: typeof import("./index.js").closeAllMemorySearchManagers;
}): ManagerIndexFixture {
const provider = Object.assign(providerState, { createLocalWorkerExitError });
let root = "";
let workspace = "";
let memory = "";
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const managers = new Set<MemoryIndexManager>();
const setStateDir = (stateDir: string): void => {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
};
const restoreStateDir = (): void => {
if (originalStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalStateDir);
}
};
const resetManager = (manager: MemoryIndexManager): void => {
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
};
const createConfig = (params: ManagerIndexFixtureConfig): ManagerConfig =>
isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled ? { batch: { enabled: true } } : undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: { workspace },
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
} as OpenClawConfig);
const requireManager = (
result: ManagerResult,
missingMessage = "manager missing",
): MemoryIndexManager => {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
};
const trackManager = (manager: MemoryIndexManager): void => {
managers.add(manager);
};
const getPersistentManager = async (cfg: ManagerConfig): Promise<MemoryIndexManager> => {
const manager = requireManager(await deps.getMemorySearchManager({ cfg, agentId: "main" }));
trackManager(manager);
resetManager(manager);
return manager;
};
const getFreshManager = async (
cfg: ManagerConfig,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> => {
const manager = requireManager(
await deps.getMemorySearchManager({ cfg, agentId: "main", purpose }),
);
trackManager(manager);
return manager;
};
const seedSessionTranscript: ManagerIndexFixture["seedSessionTranscript"] = async (params) => {
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
const storePath = path.join(sessionsDir, "sessions.json");
const sessionKey = params.sessionKey ?? `agent:main:memory:${params.sessionId}`;
const updatedAt = Date.now();
await fs.mkdir(sessionsDir, { recursive: true });
await upsertSessionEntry({
agentId: "main",
sessionKey,
storePath,
entry: { sessionId: params.sessionId, updatedAt },
});
for (const message of params.messages) {
await appendSessionTranscriptMessageByIdentity({
agentId: "main",
sessionId: params.sessionId,
sessionKey,
storePath,
message: {
role: message.role,
timestamp: message.timestamp,
content: [{ type: "text", text: message.content }],
...(message.senderIsOwner ? { __openclaw: { senderIsOwner: true } } : {}),
},
});
}
};
const getFtsSessionManager: ManagerIndexFixture["getFtsSessionManager"] = async (params) => {
providerState.forceNoProvider = true;
setStateDir(path.join(workspace, params.stateDirName));
const cfg = createConfig({
provider: "none",
sources: ["memory", "sessions"],
sessionMemory: true,
minScore: 0,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
});
const manager = requireManager(await deps.getMemorySearchManager({ cfg, agentId: "main" }));
trackManager(manager);
resetManager(manager);
return manager.status().fts?.available ? manager : null;
};
beforeAll(async () => {
const rawRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-mem-fixtures-"),
);
root = await fs.realpath(rawRoot);
workspace = path.join(root, "workspace");
memory = path.join(workspace, "memory");
});
afterAll(async () => {
await Promise.all(Array.from(managers).map((manager) => manager.close()));
if (root) {
await fs.rm(root, { recursive: true, force: true });
}
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managers).map((manager) => manager.close()));
await deps.closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managers.clear();
restoreStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
providerState.embedBatchCalls = 0;
providerState.embeddedBatchTexts = [];
providerState.embedBatchInputCalls = 0;
providerState.providerRuntimeBatchCalls = [];
providerState.providerRuntimeBatchGate = null;
providerState.providerRuntimeBatchErrors = [];
providerState.providerRuntimeBatchFailuresRemaining = 0;
providerState.providerRuntimeActiveBatchCalls = 0;
providerState.providerRuntimeMaxActiveBatchCalls = 0;
providerState.providerCloseCalls = 0;
providerState.providerCloseFailuresRemaining = 0;
providerState.providerCloseFailure = new Error("provider close failed");
providerState.providerCreationFailure = null;
providerState.providerNullResult = null;
providerState.providerCloseGate = null;
providerState.providerInitGate = null;
providerState.providerCalls = [];
providerState.forceNoProvider = false;
rmSync(workspace, { recursive: true, force: true });
mkdirSync(memory, { recursive: true });
setStateDir(path.join(workspace, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memory, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
return {
paths: {
get root() {
return root;
},
get workspace() {
return workspace;
},
get memory() {
return memory;
},
},
provider,
createConfig,
requireManager,
trackManager,
resetManager,
getPersistentManager,
getFreshManager,
getFtsSessionManager,
seedSessionTranscript,
setStateDir,
restoreStateDir,
};
}
@@ -1,526 +1,29 @@
// Memory Core tests cover manager keyword retrieval behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
import { describe, expect, it } from "vitest";
import { createManagerIndexFixture } from "./manager-index.test-support.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function createLocalWorkerExitError(): Error {
return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
code: "LOCAL_EMBEDDING_WORKER_EXITED",
reason: "exit",
exitCode: 134,
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
async function seedMemoryIndexSessionTranscript(params: {
messages: Array<{
content: string;
role: "assistant" | "user";
senderIsOwner?: boolean;
timestamp: number | string;
}>;
sessionId: string;
sessionKey?: string;
}): Promise<void> {
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
const storePath = path.join(sessionsDir, "sessions.json");
const sessionKey = params.sessionKey ?? `agent:main:memory:${params.sessionId}`;
// Message timestamps are behavioral inputs; entry freshness only keeps the
// fixture out of real session-retention maintenance as wall time advances.
const updatedAt = Date.now();
await fs.mkdir(sessionsDir, { recursive: true });
await upsertSessionEntry({
agentId: "main",
sessionKey,
storePath,
entry: {
sessionId: params.sessionId,
updatedAt,
},
});
for (const message of params.messages) {
await appendSessionTranscriptMessageByIdentity({
agentId: "main",
sessionId: params.sessionId,
sessionKey,
storePath,
message: {
role: message.role,
timestamp: message.timestamp,
content: [{ type: "text", text: message.content }],
...(message.senderIsOwner ? { __openclaw: { senderIsOwner: true } } : {}),
},
});
}
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager;
}
async function getFtsSessionManager(params: {
stateDirName: string;
}): Promise<MemoryIndexManager | null> {
forceNoProvider = true;
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
provider: "none",
sources: ["memory", "sessions"],
sessionMemory: true,
minScore: 0,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager.status().fts?.available ? manager : null;
}
const { provider: providerFixture } = fixture;
const {
createConfig: createCfg,
getFtsSessionManager,
getPersistentManager,
requireManager,
resetManager: resetManagerForTest,
seedSessionTranscript: seedMemoryIndexSessionTranscript,
trackManager,
} = fixture;
it("builds FTS index and returns search results when no embedding provider is available", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
@@ -529,21 +32,21 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
path.join(fixture.paths.memory, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
await manager.sync({ reason: "test" });
const status = manager.status();
expect(status.chunks).toBeGreaterThan(0);
expect(embedBatchCalls).toBe(0);
expect(providerFixture.embedBatchCalls).toBe(0);
const results = await manager.search("Alpha");
expect(results.length).toBeGreaterThan(0);
@@ -553,36 +56,53 @@ describe("memory index", () => {
expect(noResults.length).toBe(0);
});
it("ranks an exact path stem ahead of a body match before applying the result limit", async () => {
forceNoProvider = true;
it.each([
{
name: "slug path stem",
config: { hybrid: { enabled: true } },
exactFile: "project-lantern.md",
bodyText: "Project lantern project lantern project lantern.",
query: "project-lantern",
expectedPath: "memory/project-lantern.md",
},
{
name: "dated path stem",
config: {},
exactFile: "2020-01-01.md",
bodyText: "2020 01 01 2020 01 01 2020 01 01",
query: "2020-01-01",
expectedPath: "memory/2020-01-01.md",
},
])("ranks an exact $name ahead of a body match", async (testCase) => {
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0.35,
hybrid: { enabled: true },
...testCase.config,
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
await fs.writeFile(path.join(memoryDir, "project-lantern.md"), "Unrelated exact-path body.");
await fs.writeFile(
path.join(memoryDir, "body-match.md"),
"Project lantern project lantern project lantern.",
path.join(fixture.paths.memory, testCase.exactFile),
"Unrelated exact-path body.",
);
await fs.writeFile(path.join(fixture.paths.memory, "body-match.md"), testCase.bodyText);
await manager.sync({ reason: "test" });
const results = await manager.search("project-lantern", { maxResults: 1 });
const results = await manager.search(testCase.query, { maxResults: 1 });
expect(results).toHaveLength(1);
expect(results[0]?.path).toContain("memory/project-lantern.md");
expect(results[0]?.path).toContain(testCase.expectedPath);
expect(results[0]?.score).toBe(1);
});
it("does not let fallback-term filenames consume the candidate cap", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0,
@@ -590,19 +110,19 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
for (let index = 0; index < 5; index += 1) {
const duplicateDir = path.join(memoryDir, `alpha-${index}`);
const duplicateDir = path.join(fixture.paths.memory, `alpha-${index}`);
await fs.mkdir(duplicateDir, { recursive: true });
await fs.writeFile(path.join(duplicateDir, "alpha.md"), "Unrelated path-only candidate.");
}
await fs.writeFile(
path.join(memoryDir, "body-match.md"),
path.join(fixture.paths.memory, "body-match.md"),
"Alpha alpha alpha alpha alpha strongest fallback body match.",
);
await manager.sync({ reason: "test" });
@@ -613,14 +133,17 @@ describe("memory index", () => {
});
it("bounds the merged six-term fallback candidate set", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const manager = await getPersistentManager(
createCfg({ provider: "none", minScore: 0, hybrid: { enabled: true } }),
);
const terms = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"];
for (const term of terms) {
for (let index = 0; index < 5; index += 1) {
await fs.writeFile(path.join(memoryDir, `${term}-${index}.md`), `${term} body ${index}`);
await fs.writeFile(
path.join(fixture.paths.memory, `${term}-${index}.md`),
`${term} body ${index}`,
);
}
}
await manager.sync({ reason: "test" });
@@ -632,12 +155,12 @@ describe("memory index", () => {
});
it("counts exact candidate headroom by distinct path instead of chunk", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const manager = await getPersistentManager(
createCfg({ provider: "none", minScore: 0, hybrid: { enabled: true } }),
);
for (let index = 0; index < 200; index += 1) {
const dir = path.join(memoryDir, index.toString().padStart(3, "0"));
const dir = path.join(fixture.paths.memory, index.toString().padStart(3, "0"));
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "foo.md"), `foo body ${index}`);
}
@@ -651,7 +174,7 @@ describe("memory index", () => {
});
it("uses body relevance within the same exact basename tier in FTS-only mode", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0,
@@ -659,14 +182,14 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
const weakDir = path.join(memoryDir, "a");
const strongDir = path.join(memoryDir, "z");
const weakDir = path.join(fixture.paths.memory, "a");
const strongDir = path.join(fixture.paths.memory, "z");
await fs.mkdir(weakDir, { recursive: true });
await fs.mkdir(strongDir, { recursive: true });
await fs.writeFile(path.join(weakDir, "foo.md"), "Unrelated weak body.");
@@ -680,9 +203,9 @@ describe("memory index", () => {
});
it("returns exact basename candidates with fixed FTS ranking", async () => {
forceNoProvider = true;
const staleDir = path.join(fixtureRoot, "decay-a-stale");
const freshDir = path.join(fixtureRoot, "decay-z-fresh");
providerFixture.forceNoProvider = true;
const staleDir = path.join(fixture.paths.root, "decay-a-stale");
const freshDir = path.join(fixture.paths.root, "decay-z-fresh");
await fs.mkdir(staleDir, { recursive: true });
await fs.mkdir(freshDir, { recursive: true });
const staleFooPath = path.join(staleDir, "foo.md");
@@ -704,7 +227,7 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
@@ -719,12 +242,12 @@ describe("memory index", () => {
});
it("applies the fixed FTS candidate cap to exact paths", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000);
const extraPaths: string[] = [];
for (let index = 0; index < 5; index += 1) {
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
const extraDir = path.join(fixtureRoot, `decay-cap-${suffix}`);
const extraDir = path.join(fixture.paths.root, `decay-cap-${suffix}`);
const filePath = path.join(extraDir, "foo.md");
await fs.mkdir(extraDir, { recursive: true });
const body = index < 4 ? "foo md stale content candidate." : "Unrelated fresh candidate.";
@@ -741,7 +264,7 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
@@ -758,7 +281,7 @@ describe("memory index", () => {
const extraPaths: string[] = [];
for (let index = 0; index < 5; index += 1) {
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
const extraDir = path.join(fixtureRoot, `hybrid-decay-cap-${suffix}`);
const extraDir = path.join(fixture.paths.root, `hybrid-decay-cap-${suffix}`);
const filePath = path.join(extraDir, "alpha.md");
await fs.mkdir(extraDir, { recursive: true });
const body = index === 4 ? "Alpha beta lower-similarity candidate." : "Alpha candidate.";
@@ -785,7 +308,7 @@ describe("memory index", () => {
const extraPaths: string[] = [];
for (let index = 0; index < 5; index += 1) {
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
const extraDir = path.join(fixtureRoot, `degraded-decay-cap-${suffix}`);
const extraDir = path.join(fixture.paths.root, `degraded-decay-cap-${suffix}`);
const filePath = path.join(extraDir, "beta.md");
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(filePath, "Beta equal content candidate.");
@@ -816,7 +339,7 @@ describe("memory index", () => {
throw new Error("Expected a test embedding provider");
}
provider.embedQuery = async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
};
degraded.markLocalEmbeddingProviderDegraded = () => {
degraded.provider = null;
@@ -828,7 +351,7 @@ describe("memory index", () => {
});
it("keeps body relevance for an exact basename beyond the exact candidate cap", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0,
@@ -836,13 +359,13 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
const duplicatesDir = path.join(memoryDir, "readme-dupes");
const duplicatesDir = path.join(fixture.paths.memory, "readme-dupes");
for (let index = 0; index < 205; index += 1) {
const duplicateDir = path.join(duplicatesDir, `a-${index.toString().padStart(3, "0")}`);
await fs.mkdir(duplicateDir, { recursive: true });
@@ -855,10 +378,13 @@ describe("memory index", () => {
"README md README md README md strongest body match.",
);
await fs.writeFile(
path.join(memoryDir, "readme-body-only.md"),
path.join(fixture.paths.memory, "readme-body-only.md"),
"README md body-only candidate.",
);
await fs.writeFile(path.join(memoryDir, "README.md.notes"), "Unrelated partial path.");
await fs.writeFile(
path.join(fixture.paths.memory, "README.md.notes"),
"Unrelated partial path.",
);
await manager.sync({ reason: "test" });
const results = await manager.search("README.md", { maxResults: 1, minScore: 0 });
@@ -868,7 +394,7 @@ describe("memory index", () => {
});
it("keeps boosted score ordering for non-exact FTS-only body matches", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0,
@@ -876,17 +402,17 @@ describe("memory index", () => {
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
trackManager(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
await fs.writeFile(
path.join(memoryDir, "project-memory-notes.md"),
path.join(fixture.paths.memory, "project-memory-notes.md"),
"Project memory notes covering workspace context and retrieval behavior.",
);
await fs.writeFile(path.join(memoryDir, "notes.md"), "Project memory context.");
await fs.writeFile(path.join(fixture.paths.memory, "notes.md"), "Project memory context.");
await manager.sync({ reason: "test" });
const results = await manager.search("project memory context", {
@@ -898,30 +424,6 @@ describe("memory index", () => {
expect(results[0]?.score).toBeLessThanOrEqual(1);
});
it("keeps an exact dated path ahead in FTS-only mode", async () => {
forceNoProvider = true;
const cfg = createCfg({
provider: "none",
minScore: 0.35,
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
if (!manager.status().fts?.available) {
return;
}
await fs.writeFile(path.join(memoryDir, "2020-01-01.md"), "Unrelated exact-path body.");
await fs.writeFile(path.join(memoryDir, "body-match.md"), "2020 01 01 2020 01 01 2020 01 01");
await manager.sync({ reason: "test" });
const results = await manager.search("2020-01-01", { maxResults: 1 });
expect(results).toHaveLength(1);
expect(results[0]?.path).toContain("memory/2020-01-01.md");
expect(results[0]?.score).toBe(1);
});
it("prefers exact session transcript hits in FTS-only mode", async () => {
try {
const manager = await getFtsSessionManager({
@@ -931,7 +433,7 @@ describe("memory index", () => {
return;
}
const memoryPath = path.join(workspaceDir, "MEMORY.md");
const memoryPath = path.join(fixture.paths.workspace, "MEMORY.md");
await fs.writeFile(memoryPath, "Project Nebula stale codename: ORBIT-9.\n", "utf8");
const staleAt = new Date("2020-01-01T00:00:00.000Z");
await fs.utimes(memoryPath, staleAt, staleAt);
@@ -966,7 +468,7 @@ describe("memory index", () => {
sessionKind: "interactive",
});
} finally {
restoreMemoryIndexStateDir();
fixture.restoreStateDir();
}
});
});
@@ -1,468 +1,16 @@
// Memory Core tests cover manager provider lifecycle fallback behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
import { describe, expect, it, vi } from "vitest";
import { createManagerIndexFixture } from "./manager-index.test-support.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function createLocalWorkerExitError(): Error {
return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
code: "LOCAL_EMBEDDING_WORKER_EXITED",
reason: "exit",
exitCode: 134,
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
managersForCleanup.add(manager);
return manager;
}
const { provider: providerFixture } = fixture;
const { createConfig: createCfg, getFreshManager, getPersistentManager } = fixture;
it("does not activate fallback during search when index identity is already mismatched", async () => {
const cfg = createCfg({
@@ -472,7 +20,7 @@ describe("memory index", () => {
const manager = await getPersistentManager(cfg);
await manager.sync({ reason: "test" });
const callsBeforeSearch = providerCalls.length;
const callsBeforeSearch = providerFixture.providerCalls.length;
(
manager as unknown as {
provider: {
@@ -487,7 +35,7 @@ describe("memory index", () => {
id: "local",
model: "mock-embed",
embedQuery: async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
},
embedBatch: async (texts: string[]) => texts.map(() => [1, 0, 0, 0]),
close: async () => {},
@@ -496,7 +44,7 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results).toStrictEqual([]);
expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
expect(providerFixture.providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
expect(
(
manager as unknown as {
@@ -536,10 +84,10 @@ describe("memory index", () => {
id: "mock",
model: "new-embed",
embedQuery: async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
},
embedBatch: async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
},
close: async () => {},
};
@@ -578,14 +126,14 @@ describe("memory index", () => {
id: "local",
model: "mock-embed",
embedQuery: async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
},
embedBatch: async () => {
throw createLocalWorkerExitError();
throw providerFixture.createLocalWorkerExitError();
},
close: async () => {},
};
const callsBeforeSearch = providerCalls.length;
const callsBeforeSearch = providerFixture.providerCalls.length;
await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
ok: false,
@@ -595,7 +143,9 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results.length).toBeGreaterThan(0);
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain("openai");
expect(
providerFixture.providerCalls.slice(callsBeforeSearch).map((call) => call.provider),
).toContain("openai");
expect(
(
manager as unknown as {
@@ -618,12 +168,7 @@ describe("memory index", () => {
fallback: "fallback-provider",
model: "new-embed",
});
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
const manager = await getRequiredMemoryIndexManager({
cfg,
agentId: "main",
purpose: "status",
});
const manager = await getFreshManager(cfg, "status");
try {
expect(manager.status().dirty).toBe(true);
@@ -671,12 +216,7 @@ describe("memory index", () => {
it("exposes already-created local runtime facts without probing embeddings", async () => {
const cfg = createCfg({});
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
const manager = await getRequiredMemoryIndexManager({
cfg,
agentId: "main",
purpose: "status",
});
const manager = await getFreshManager(cfg, "status");
try {
const getRuntimeFacts = vi.fn(() => ({
engine: "llama.cpp" as const,
@@ -726,7 +266,7 @@ describe("memory index", () => {
});
it("fails fast instead of searching FTS when an explicit provider is unavailable", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const cfg = createCfg({
provider: "openai",
@@ -741,7 +281,7 @@ describe("memory index", () => {
await expect(manager.sync({ reason: "test" })).rejects.toThrow(
/Memory sync unavailable: embedding provider "openai" is configured but unavailable\./,
);
forceNoProvider = false;
providerFixture.forceNoProvider = false;
await manager.sync({ reason: "test", force: true });
const results = await manager.search("Alpha");
expect(results.length).toBeGreaterThan(0);
@@ -1,476 +1,25 @@
// Memory Core tests cover manager provider lifecycle lease behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
import { describe, expect, it, vi } from "vitest";
import { createManagerIndexFixture } from "./manager-index.test-support.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function createLocalWorkerExitError(): Error {
return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
code: "LOCAL_EMBEDDING_WORKER_EXITED",
reason: "exit",
exitCode: 134,
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
managersForCleanup.add(manager);
return manager;
}
const { provider: providerFixture } = fixture;
const { createConfig: createCfg, getFreshManager, getPersistentManager, trackManager } = fixture;
it("keeps an active FTS-only generation stable while fallback activates", async () => {
const manager = await getFreshManager(
createCfg({ provider: "openai", fallback: "fallback-provider" }),
"cli",
);
managersForCleanup.add(manager);
trackManager(manager);
type IndexEntry = {
path: string;
absPath: string;
@@ -504,17 +53,17 @@ describe("memory index", () => {
}
fields.provider.id = "local";
fields.providerKey = fields.computeProviderKey();
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
fields.markLocalEmbeddingProviderDegraded(providerFixture.createLocalWorkerExitError());
await vi.waitFor(() => {
expect(fields.provider).toBeNull();
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
const createEntry = (name: string): IndexEntry => {
const content = `# Log\n${name} FTS-only generation.`;
return {
path: `memory/${name}.md`,
absPath: path.join(memoryDir, `${name}.md`),
absPath: path.join(fixture.paths.memory, `${name}.md`),
mtimeMs: Date.now(),
size: Buffer.byteLength(content),
hash: hashText(content),
@@ -586,14 +135,14 @@ describe("memory index", () => {
try {
await Promise.resolve();
expect(retirementSettled).toBe(false);
expect(providerCloseCalls).toBe(0);
expect(providerFixture.providerCloseCalls).toBe(0);
} finally {
releaseFirstQuery();
}
await expect(queryPromise).resolves.toEqual([1, 0, 0, 0]);
await retirementPromise;
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
it("uses the leased provider runtime after retirement starts", async () => {
@@ -642,13 +191,13 @@ describe("memory index", () => {
await expect(
fields.embedQueryWithRetry("alpha", undefined, provider, false, providerRuntime),
).rejects.toThrow("timed out");
expect(providerCloseCalls).toBe(0);
expect(providerFixture.providerCloseCalls).toBe(0);
} finally {
releaseProvider();
}
await retirementPromise;
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
it("waits for an admitted search before manager teardown", async () => {
@@ -690,14 +239,14 @@ describe("memory index", () => {
expect(closeSettled).toBe(false);
expect(fields.closing).toBe(true);
expect(fields.closed).toBe(false);
expect(providerCloseCalls).toBe(0);
expect(providerFixture.providerCloseCalls).toBe(0);
} finally {
releaseVectorSearch();
}
await expect(searchPromise).resolves.toBeDefined();
await closePromise;
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
it("waits for an admitted vector probe before manager teardown", async () => {
@@ -734,14 +283,14 @@ describe("memory index", () => {
try {
await Promise.resolve();
expect(closeSettled).toBe(false);
expect(providerCloseCalls).toBe(0);
expect(providerFixture.providerCloseCalls).toBe(0);
} finally {
releaseProbe();
}
await expect(probePromise).resolves.toBe(true);
await closePromise;
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
it("fails closed when fallback initialization fails for an explicit provider", async () => {
@@ -763,13 +312,13 @@ describe("memory index", () => {
fields.provider.embedQuery = async () => {
throw new Error("embedding provider failed");
};
providerCreationFailure = "fallback-provider";
providerFixture.providerCreationFailure = "fallback-provider";
await expect(manager.search("alpha")).rejects.toThrow(
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
);
providerCreationFailure = null;
providerFixture.providerCreationFailure = null;
await expect(manager.search("alpha")).resolves.toBeDefined();
});
@@ -792,17 +341,16 @@ describe("memory index", () => {
fields.provider.embedQuery = async () => {
throw new Error("embedding provider failed");
};
providerCreationFailure = "fallback-provider";
const callsBeforeSearch = providerCalls.length;
providerFixture.providerCreationFailure = "fallback-provider";
const callsBeforeSearch = providerFixture.providerCalls.length;
await expect(manager.search("alpha")).resolves.toBeDefined();
providerCreationFailure = null;
providerFixture.providerCreationFailure = null;
await expect(manager.search("alpha")).resolves.toBeDefined();
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toEqual([
"fallback-provider",
"openai",
]);
expect(
providerFixture.providerCalls.slice(callsBeforeSearch).map((call) => call.provider),
).toEqual(["fallback-provider", "openai"]);
expect(fields.provider?.id).toBe("mock");
});
@@ -823,13 +371,13 @@ describe("memory index", () => {
fields.provider.embedQuery = async () => {
throw new Error("embedding provider failed");
};
providerNullResult = "fallback-provider";
providerFixture.providerNullResult = "fallback-provider";
await expect(manager.search("alpha")).rejects.toThrow(
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
);
providerNullResult = null;
providerFixture.providerNullResult = null;
await expect(manager.search("alpha")).resolves.toBeDefined();
});
@@ -849,11 +397,11 @@ describe("memory index", () => {
fields.provider.embedQuery = async () => {
throw new Error("embedding provider failed");
};
providerNullResult = "fallback-provider";
providerFixture.providerNullResult = "fallback-provider";
await expect(manager.search("alpha")).resolves.toBeDefined();
providerNullResult = null;
providerFixture.providerNullResult = null;
await expect(manager.search("alpha")).resolves.toBeDefined();
expect(fields.provider?.id).toBe("mock");
});
@@ -883,16 +431,18 @@ describe("memory index", () => {
providerInitializationCalls += 1;
await ensureProviderInitialized();
};
providerCreationFailure = "fallback-provider";
providerFixture.providerCreationFailure = "fallback-provider";
let releaseProviderInit: () => void = () => {};
providerInitGate = new Promise<void>((resolve) => {
providerFixture.providerInitGate = new Promise<void>((resolve) => {
releaseProviderInit = resolve;
});
const callsBeforeSearch = providerCalls.length;
const callsBeforeSearch = providerFixture.providerCalls.length;
const firstSearch = manager.search("alpha");
await vi.waitFor(() =>
expect(providerCalls.some((call) => call.provider === "fallback-provider")).toBe(true),
expect(
providerFixture.providerCalls.some((call) => call.provider === "fallback-provider"),
).toBe(true),
);
const initializationCallsBeforeSecondSearch = providerInitializationCalls;
const secondSearch = manager.search("zebra");
@@ -914,12 +464,12 @@ describe("memory index", () => {
const results = await Promise.all([firstSearch, secondSearch]);
expect(results.every((result) => result.length > 0)).toBe(true);
expect(
providerCalls
providerFixture.providerCalls
.slice(callsBeforeSearch)
.filter((call) => call.provider === "fallback-provider"),
).toHaveLength(1);
} finally {
providerInitGate = null;
providerFixture.providerInitGate = null;
releaseProviderInit();
await Promise.allSettled([firstSearch, secondSearch]);
}
@@ -1,485 +1,40 @@
// Memory Core tests cover manager provider lifecycle availability behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
import { describe, expect, it, vi } from "vitest";
import { createManagerIndexFixture } from "./manager-index.test-support.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function createLocalWorkerExitError(): Error {
return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
code: "LOCAL_EMBEDDING_WORKER_EXITED",
reason: "exit",
exitCode: 134,
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
managersForCleanup.add(manager);
return manager;
}
const { provider: providerFixture } = fixture;
const {
createConfig: createCfg,
getFreshManager,
getPersistentManager,
requireManager,
trackManager,
} = fixture;
it("caches embedding probe readiness across transient status managers", async () => {
const cfg = createCfg({});
const first = requireManager(
await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }),
);
managersForCleanup.add(first);
trackManager(first);
await expect(first.probeEmbeddingAvailability()).resolves.toEqual({ ok: true });
expect(embedBatchCalls).toBe(1);
expect(providerFixture.embedBatchCalls).toBe(1);
await first.close();
const second = requireManager(
await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }),
);
managersForCleanup.add(second);
trackManager(second);
const cachedBeforeProbe = second.getCachedEmbeddingAvailability?.();
expect(cachedBeforeProbe?.ok).toBe(true);
@@ -500,7 +55,7 @@ describe("memory index", () => {
checkedAtMs: cachedBeforeProbe?.checkedAtMs,
cacheExpiresAtMs: cachedBeforeProbe?.cacheExpiresAtMs,
});
expect(embedBatchCalls).toBe(1);
expect(providerFixture.embedBatchCalls).toBe(1);
const cached = second.getCachedEmbeddingAvailability?.();
expect((cached?.cacheExpiresAtMs ?? 0) - (cached?.checkedAtMs ?? 0)).toBe(30_000);
@@ -534,7 +89,7 @@ describe("memory index", () => {
manager as unknown as {
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
}
).markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
).markLocalEmbeddingProviderDegraded(providerFixture.createLocalWorkerExitError());
expect(manager.getCachedEmbeddingAvailability()).toBeNull();
await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
@@ -549,7 +104,7 @@ describe("memory index", () => {
await manager.sync({ reason: "test" });
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const fields = manager as unknown as {
@@ -568,44 +123,44 @@ describe("memory index", () => {
throw new Error("Expected a test embedding provider");
}
fields.provider.id = "local";
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
fields.markLocalEmbeddingProviderDegraded(providerFixture.createLocalWorkerExitError());
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
const callsBeforeFallback = providerCalls.length;
const callsBeforeFallback = providerFixture.providerCalls.length;
const fallbackPromise = fields.activateFallbackProvider("local worker exited");
try {
await Promise.resolve();
expect(providerCalls).toHaveLength(callsBeforeFallback);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeFallback);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
await fallbackPromise;
}
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
"fallback-provider",
]);
expect(
providerFixture.providerCalls.slice(callsBeforeFallback).map((call) => call.provider),
).toEqual(["fallback-provider"]);
});
it("retries failed provider retirement before fallback initialization", async () => {
const cfg = createCfg({ fallback: "fallback-provider" });
const manager = await getPersistentManager(cfg);
await manager.sync({ reason: "test" });
providerCloseFailuresRemaining = 1;
providerFixture.providerCloseFailuresRemaining = 1;
const fields = manager as unknown as {
activateFallbackProvider: (reason: string) => Promise<boolean>;
};
const callsBeforeFallback = providerCalls.length;
const callsBeforeFallback = providerFixture.providerCalls.length;
await expect(fields.activateFallbackProvider("provider failed")).rejects.toThrow(
"provider close failed",
);
expect(providerCalls).toHaveLength(callsBeforeFallback);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeFallback);
await expect(fields.activateFallbackProvider("provider failed")).resolves.toBe(true);
expect(providerCloseCalls).toBe(2);
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
"fallback-provider",
]);
expect(providerFixture.providerCloseCalls).toBe(2);
expect(
providerFixture.providerCalls.slice(callsBeforeFallback).map((call) => call.provider),
).toEqual(["fallback-provider"]);
});
it("waits for provider shutdown before retry initialization", async () => {
@@ -614,7 +169,7 @@ describe("memory index", () => {
await manager.sync({ reason: "test" });
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
(
@@ -622,19 +177,21 @@ describe("memory index", () => {
resetProviderInitializationForRetry: () => void;
}
).resetProviderInitializationForRetry();
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
const callsBeforeProbe = providerCalls.length;
const callsBeforeProbe = providerFixture.providerCalls.length;
const probePromise = manager.probeEmbeddingAvailability();
try {
await Promise.resolve();
expect(providerCalls).toHaveLength(callsBeforeProbe);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeProbe);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
await probePromise;
}
expect(providerCalls.slice(callsBeforeProbe).map((call) => call.provider)).toEqual(["openai"]);
expect(
providerFixture.providerCalls.slice(callsBeforeProbe).map((call) => call.provider),
).toEqual(["openai"]);
});
it("waits for active provider shutdown before fallback initialization", async () => {
@@ -647,7 +204,7 @@ describe("memory index", () => {
await manager.sync({ reason: "test" });
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const fields = manager as unknown as {
@@ -662,11 +219,11 @@ describe("memory index", () => {
throw new Error("embedding provider failed");
};
const callsBeforeSearch = providerCalls.length;
const callsBeforeSearch = providerFixture.providerCalls.length;
const searchPromise = manager.search("alpha");
let concurrentSearch: ReturnType<typeof manager.search> = Promise.resolve([]);
try {
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
concurrentSearch = manager.search("zebra");
let concurrentSettled = false;
void concurrentSearch.then(
@@ -679,15 +236,15 @@ describe("memory index", () => {
);
await Promise.resolve();
expect(concurrentSettled).toBe(false);
expect(providerCalls).toHaveLength(callsBeforeSearch);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeSearch);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
await Promise.allSettled([searchPromise, concurrentSearch]);
}
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toEqual([
"fallback-provider",
]);
expect(
providerFixture.providerCalls.slice(callsBeforeSearch).map((call) => call.provider),
).toEqual(["fallback-provider"]);
await expect(concurrentSearch).resolves.toBeDefined();
});
@@ -701,7 +258,7 @@ describe("memory index", () => {
}),
"cli",
);
managersForCleanup.add(manager);
trackManager(manager);
const fields = manager as unknown as {
provider: {
id: string;
@@ -791,11 +348,11 @@ describe("memory index", () => {
return await ensureVectorReady(dimensions);
};
const callsBeforeFallback = providerCalls.length;
const callsBeforeFallback = providerFixture.providerCalls.length;
const firstIndexPromise = fields.indexFile(
{
path: "memory/generation-race-first.md",
absPath: path.join(memoryDir, "generation-race-first.md"),
absPath: path.join(fixture.paths.memory, "generation-race-first.md"),
mtimeMs: Date.now(),
size: Buffer.byteLength(firstContent),
hash: hashText(firstContent),
@@ -806,7 +363,7 @@ describe("memory index", () => {
const secondIndexPromise = fields.indexFile(
{
path: "memory/generation-race-second.md",
absPath: path.join(memoryDir, "generation-race-second.md"),
absPath: path.join(fixture.paths.memory, "generation-race-second.md"),
mtimeMs: Date.now(),
size: Buffer.byteLength(secondContent),
hash: hashText(secondContent),
@@ -821,18 +378,18 @@ describe("memory index", () => {
5_000,
"concurrent embeddings did not start",
);
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
fields.markLocalEmbeddingProviderDegraded(providerFixture.createLocalWorkerExitError());
await vi.waitFor(() => expect(fields.provider).toBeNull());
fallbackPromise = fields.activateFallbackProvider("local worker exited");
releaseFirstEmbedding();
await firstIndexPromise;
expect(providerCloseCalls).toBe(0);
expect(providerCalls).toHaveLength(callsBeforeFallback);
expect(providerFixture.providerCloseCalls).toBe(0);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeFallback);
releaseSecondEmbedding();
await fields.withTimeout(publicationStarted, 5_000, "publication did not start");
expect(providerCloseCalls).toBe(0);
expect(providerCalls).toHaveLength(callsBeforeFallback);
expect(providerFixture.providerCloseCalls).toBe(0);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeFallback);
releasePublication();
await secondIndexPromise;
@@ -848,9 +405,9 @@ describe("memory index", () => {
]);
}
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
"fallback-provider",
]);
expect(
providerFixture.providerCalls.slice(callsBeforeFallback).map((call) => call.provider),
).toEqual(["fallback-provider"]);
expect(
fields.db
.prepare("SELECT model FROM memory_index_chunks WHERE path = ?")
@@ -1,441 +1,36 @@
// Memory Core tests cover manager registry behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { describe, expect, it, vi } from "vitest";
import { createManagerIndexFixture } from "./manager-index.test-support.js";
import {
closeAllMemoryIndexManagers,
closeMemoryIndexManagersForAgent,
MemoryIndexManager as RuntimeMemoryIndexManager,
} from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
managersForCleanup.add(manager);
return manager;
}
const { provider: providerFixture } = fixture;
const { createConfig: createCfg, getFreshManager, requireManager, trackManager } = fixture;
it("waits for scoped manager close before initializing a replacement", async () => {
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const cfg = createCfg({
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
const closePromise = closeMemoryIndexManagersForAgent({ agentId: "main" });
const callsBeforeReplacement = providerCalls.length;
const callsBeforeReplacement = providerFixture.providerCalls.length;
const secondPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
requireManager(result),
);
@@ -456,38 +51,38 @@ describe("memory index", () => {
);
try {
await vi.waitFor(() => {
expect(providerCloseCalls).toBe(1);
expect(providerFixture.providerCloseCalls).toBe(1);
});
await Promise.resolve();
expect(secondSettled).toBe(false);
expect(providerCalls).toHaveLength(callsBeforeReplacement);
expect(providerFixture.providerCalls).toHaveLength(callsBeforeReplacement);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
await closePromise;
const second = await secondPromise;
const concurrentSecond = await concurrentSecondPromise;
await secondProbe;
managersForCleanup.add(second);
trackManager(second);
expect(second === first).toBe(false);
expect(concurrentSecond).toBe(second);
const third = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
managersForCleanup.add(third);
trackManager(third);
expect(third).toBe(second);
});
it("does not reuse a cached manager after direct close starts", async () => {
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const cfg = createCfg({
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
const closePromise = first.close();
@@ -504,17 +99,17 @@ describe("memory index", () => {
},
);
try {
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
await Promise.resolve();
expect(replacementSettled).toBe(false);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
await closePromise;
const replacement = await replacementPromise;
managersForCleanup.add(replacement);
trackManager(replacement);
expect(replacement === first).toBe(false);
});
@@ -524,10 +119,10 @@ describe("memory index", () => {
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg: firstCfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
@@ -535,22 +130,22 @@ describe("memory index", () => {
cfg: createCfg({ model: "second-model" }),
agentId: "main",
}).then((result) => requireManager(result));
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
const thirdPromise = getMemorySearchManager({
cfg: createCfg({ model: "third-model" }),
agentId: "main",
}).then((result) => requireManager(result));
try {
await Promise.resolve();
expect(providerCalls).toHaveLength(1);
expect(providerFixture.providerCalls).toHaveLength(1);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
const [second, third] = await Promise.all([secondPromise, thirdPromise]);
managersForCleanup.add(second);
managersForCleanup.add(third);
trackManager(second);
trackManager(third);
expect(second === first).toBe(false);
expect(third === second).toBe(false);
expect((second as unknown as { closed: boolean }).closed).toBe(true);
@@ -564,8 +159,8 @@ describe("memory index", () => {
if (!first || !second) {
throw new Error("Expected canonical memory index managers");
}
managersForCleanup.add(first);
managersForCleanup.add(second);
trackManager(first);
trackManager(second);
expect(second).toBe(first);
});
@@ -575,16 +170,16 @@ describe("memory index", () => {
if (!firstCfg.agents?.defaults || !secondCfg.agents?.defaults) {
throw new Error("Expected agent defaults");
}
firstCfg.agents.defaults.workspace = path.join(fixtureRoot, "workspace-a");
secondCfg.agents.defaults.workspace = path.join(fixtureRoot, "workspace-b");
firstCfg.agents.defaults.workspace = path.join(fixture.paths.root, "workspace-a");
secondCfg.agents.defaults.workspace = path.join(fixture.paths.root, "workspace-b");
const first = await RuntimeMemoryIndexManager.get({ cfg: firstCfg, agentId: "main" });
const second = await RuntimeMemoryIndexManager.get({ cfg: secondCfg, agentId: "main" });
if (!first || !second) {
throw new Error("Expected workspace memory index managers");
}
managersForCleanup.add(first);
managersForCleanup.add(second);
trackManager(first);
trackManager(second);
expect(second === first).toBe(false);
expect((first as unknown as { closed: boolean }).closed).toBe(true);
});
@@ -595,10 +190,10 @@ describe("memory index", () => {
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg: firstCfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
@@ -606,7 +201,7 @@ describe("memory index", () => {
cfg: createCfg({ model: "second-model" }),
agentId: "main",
});
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
const otherAgentPromise = getMemorySearchManager({
cfg: createCfg({ model: "other-model" }),
agentId: "other",
@@ -624,13 +219,13 @@ describe("memory index", () => {
await vi.waitFor(() => expect(otherAgentSettled).toBe(true));
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
const otherAgent = requireManager(await otherAgentPromise);
const replacement = requireManager(await replacementPromise);
managersForCleanup.add(otherAgent);
managersForCleanup.add(replacement);
trackManager(otherAgent);
trackManager(replacement);
expect((otherAgent as unknown as { closed: boolean }).closed).toBe(false);
});
@@ -642,10 +237,10 @@ describe("memory index", () => {
if (!first) {
throw new Error("Expected first memory index manager");
}
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
@@ -653,7 +248,7 @@ describe("memory index", () => {
cfg: createCfg({ model: "second-model" }),
agentId: "main",
});
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(1));
const globalClosePromise = closeAllMemoryIndexManagers();
let globalCloseSettled = false;
void globalClosePromise.then(
@@ -669,7 +264,7 @@ describe("memory index", () => {
expect(globalCloseSettled).toBe(false);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
const replacement = await replacementPromise;
@@ -677,7 +272,7 @@ describe("memory index", () => {
if (!replacement) {
throw new Error("Expected replacement memory index manager");
}
managersForCleanup.add(replacement);
trackManager(replacement);
expect((replacement as unknown as { closed: boolean }).closed).toBe(true);
});
@@ -686,33 +281,33 @@ describe("memory index", () => {
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
providerCloseFailuresRemaining = 2;
providerFixture.providerCloseFailuresRemaining = 2;
await expect(closeMemoryIndexManagersForAgent({ agentId: "main" })).rejects.toThrow(
"provider close failed",
);
expect(providerCloseCalls).toBe(2);
expect(providerFixture.providerCloseCalls).toBe(2);
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const callsBeforeReplacement = providerCalls.length;
const callsBeforeReplacement = providerFixture.providerCalls.length;
const replacementPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
requireManager(result),
);
try {
await vi.waitFor(() => expect(providerCloseCalls).toBe(3));
expect(providerCalls).toHaveLength(callsBeforeReplacement);
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(3));
expect(providerFixture.providerCalls).toHaveLength(callsBeforeReplacement);
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
const replacement = await replacementPromise;
managersForCleanup.add(replacement);
trackManager(replacement);
expect(replacement === first).toBe(false);
});
@@ -721,10 +316,10 @@ describe("memory index", () => {
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
managersForCleanup.add(first);
trackManager(first);
await first.probeEmbeddingAvailability();
providerCloseFailuresRemaining = 2;
providerCloseFailure = undefined;
providerFixture.providerCloseFailuresRemaining = 2;
providerFixture.providerCloseFailure = undefined;
let globalCloseRejected = false;
await closeAllMemorySearchManagers().then(
@@ -734,29 +329,29 @@ describe("memory index", () => {
},
);
expect(globalCloseRejected).toBe(true);
expect(providerCloseCalls).toBe(2);
expect(providerFixture.providerCloseCalls).toBe(2);
let releaseProviderClose: () => void = () => {};
providerCloseGate = new Promise<void>((resolve) => {
providerFixture.providerCloseGate = new Promise<void>((resolve) => {
releaseProviderClose = resolve;
});
const callsBeforeReplacement = providerCalls.length;
const callsBeforeReplacement = providerFixture.providerCalls.length;
const replacementPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
requireManager(result),
);
let concurrentGlobalClose: Promise<void> = Promise.resolve();
try {
await vi.waitFor(() => expect(providerCloseCalls).toBe(3));
expect(providerCalls).toHaveLength(callsBeforeReplacement);
await vi.waitFor(() => expect(providerFixture.providerCloseCalls).toBe(3));
expect(providerFixture.providerCalls).toHaveLength(callsBeforeReplacement);
concurrentGlobalClose = closeAllMemorySearchManagers();
} finally {
releaseProviderClose();
providerCloseGate = null;
providerFixture.providerCloseGate = null;
}
const replacement = await replacementPromise;
await concurrentGlobalClose;
managersForCleanup.add(replacement);
trackManager(replacement);
expect(replacement === first).toBe(false);
expect((replacement as unknown as { closed: boolean }).closed).toBe(false);
});
@@ -772,7 +367,7 @@ describe("memory index", () => {
acquireLocalService: firstAcquire,
}),
);
managersForCleanup.add(first);
trackManager(first);
const second = requireManager(
await getMemorySearchManager({
@@ -781,7 +376,7 @@ describe("memory index", () => {
acquireLocalService: secondAcquire,
}),
);
managersForCleanup.add(second);
trackManager(second);
const secondAgain = requireManager(
await getMemorySearchManager({
cfg,
@@ -795,7 +390,7 @@ describe("memory index", () => {
});
it("retries embedding provider close before releasing the manager", async () => {
providerCloseFailuresRemaining = 1;
providerFixture.providerCloseFailuresRemaining = 1;
const cfg = createCfg({
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
@@ -804,6 +399,6 @@ describe("memory index", () => {
await manager.probeEmbeddingAvailability();
await manager.close();
expect(providerCloseCalls).toBe(2);
expect(providerFixture.providerCloseCalls).toBe(2);
});
});
@@ -67,25 +67,23 @@ function isMemoryIndexIdentityDirty(
}
describe("memory reindex state", () => {
it("invalidates indexes written before path provenance classification was versioned", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({ meta: createMeta({ provenanceVersion: undefined }) }),
),
).toEqual({
status: "mismatched",
it.each([
{
name: "missing provenance version",
meta: { provenanceVersion: undefined },
reason: "index provenance classifier changed",
});
});
it("invalidates indexes written before curated entry chunking was versioned", () => {
},
{
name: "missing chunking version",
meta: { chunkingVersion: undefined },
reason: "index chunking implementation changed",
},
])("invalidates indexes with $name", ({ meta, reason }) => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({ meta: createMeta({ chunkingVersion: undefined }) }),
),
resolveMemoryIndexIdentityState(createIdentityParams({ meta: createMeta(meta) })),
).toEqual({
status: "mismatched",
reason: "index chunking implementation changed",
reason,
});
});
@@ -355,11 +353,14 @@ describe("memory reindex state", () => {
).toBe(false);
});
it("falls back to fts-only when provider.model is an empty string", () => {
it.each([
{ name: "empty model", model: "" },
{ name: "whitespace-only model", model: " " },
])("falls back to fts-only for $name", ({ model }) => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "openai", model: "" },
provider: { id: "openai", model },
meta: createMeta({ model: "fts-only" }),
}),
),
@@ -378,15 +379,4 @@ describe("memory reindex state", () => {
expect(state.reason).toContain("expected fts-only");
}
});
it("falls back to fts-only when provider.model is whitespace-only", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "openai", model: " " },
meta: createMeta({ model: "fts-only" }),
}),
),
).toEqual({ status: "valid" });
});
});
@@ -1,507 +1,32 @@
// Memory Core tests cover manager search orchestration behavior.
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import { describe, expect, it, vi } from "vitest";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
createManagerIndexFixture,
type ManagerIndexFixtureConfig,
} from "./manager-index.test-support.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
vi.setConfig({ testTimeout: 240_000 });
afterAll(() => {
vi.resetConfig();
});
let embedBatchCalls = 0;
let embeddedBatchTexts: string[] = [];
let embedBatchInputCalls = 0;
let providerRuntimeBatchCalls: string[][] = [];
let providerRuntimeBatchGate: Promise<void> | null = null;
let providerRuntimeBatchErrors: unknown[] = [];
let providerRuntimeBatchFailuresRemaining = 0;
let providerRuntimeActiveBatchCalls = 0;
let providerRuntimeMaxActiveBatchCalls = 0;
let providerCloseCalls = 0;
let providerCloseFailuresRemaining = 0;
let providerCloseFailure: unknown = new Error("provider close failed");
let providerCreationFailure: string | null = null;
let providerNullResult: string | null = null;
let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
canonicalModel: "hf:fixture/default-model.gguf",
cacheModel: "/fixture/cache/default-model.gguf",
}));
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embeddings.js")>();
const embedText = (text: string) => {
const lower = text.toLowerCase();
const alpha = lower.split("alpha").length - 1;
const beta = lower.split("beta").length - 1;
const image = lower.split("image").length - 1;
const audio = lower.split("audio").length - 1;
return [alpha, beta, image, audio];
};
return {
...actual,
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) =>
options.provider === identityAliasFixture.provider
? {
provider: {
id: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.canonicalModel,
},
aliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: identityAliasFixture.provider,
model: identityAliasFixture.cacheModel,
},
},
],
}
: undefined,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
await providerInitGate;
if (options.provider === providerCreationFailure) {
throw new Error(`provider creation failed: ${options.provider}`);
}
if (options.provider === providerNullResult) {
return {
provider: null,
requestedProvider: options.provider,
providerUnavailableReason: `provider unavailable: ${options.provider}`,
};
}
if (forceNoProvider) {
return {
provider: null,
requestedProvider: options.provider ?? "auto",
providerUnavailableReason: "No API key found for provider",
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "batch-test" ||
options.provider === "batch-wide-test" ||
options.provider === identityAliasFixture.provider ||
options.provider === "ollama"
? options.provider
: "mock";
const requestedModel = options.model ?? "mock-embed";
const model =
providerId === identityAliasFixture.provider &&
(requestedModel === identityAliasFixture.canonicalModel ||
requestedModel === identityAliasFixture.cacheModel)
? identityAliasFixture.canonicalModel
: requestedModel;
return {
requestedProvider: options.provider ?? "openai",
provider: {
id: providerId,
model,
close: async () => {
providerCloseCalls += 1;
await providerCloseGate;
if (providerCloseFailuresRemaining > 0) {
providerCloseFailuresRemaining -= 1;
throw providerCloseFailure;
}
},
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
embeddedBatchTexts.push(...texts);
return texts.map(embedText);
},
...(providerId === "gemini" || providerId === "fallback-provider"
? {
embedBatchInputs: async (
inputs: Array<{
text: string;
parts?: Array<
| { type: "text"; text: string }
| { type: "inline-data"; mimeType: string; data: string }
>;
}>,
) => {
embedBatchInputCalls += 1;
return inputs.map((input) => {
const inlineData = input.parts?.find((part) => part.type === "inline-data");
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
throw new Error("payload too large");
}
const mimeType =
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
if (mimeType?.startsWith("image/")) {
return [0, 0, 1, 0];
}
if (mimeType?.startsWith("audio/")) {
return [0, 0, 0, 1];
}
return embedText(input.text);
});
},
}
: {}),
},
...(providerId === identityAliasFixture.provider
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.canonicalModel,
},
indexIdentityAliases: [
{
model: identityAliasFixture.cacheModel,
cacheKeyData: {
provider: providerId,
model: identityAliasFixture.cacheModel,
},
},
],
},
}
: providerId === "batch-test" || providerId === "batch-wide-test"
? {
runtime: {
id: providerId,
...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}),
batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => {
providerRuntimeActiveBatchCalls += 1;
providerRuntimeMaxActiveBatchCalls = Math.max(
providerRuntimeMaxActiveBatchCalls,
providerRuntimeActiveBatchCalls,
);
try {
await providerRuntimeBatchGate;
providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text));
if (providerRuntimeBatchErrors.length > 0) {
throw providerRuntimeBatchErrors.shift();
}
if (providerRuntimeBatchFailuresRemaining > 0) {
providerRuntimeBatchFailuresRemaining -= 1;
throw new Error("provider runtime batch failed");
}
return batch.chunks.map((chunk) => embedText(chunk.text));
} finally {
providerRuntimeActiveBatchCalls -= 1;
}
},
},
}
: providerId === "gemini" || providerId === "fallback-provider"
? {
runtime: {
id: providerId,
cacheKeyData: {
provider: providerId,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
const { closeAllMemorySearchManagers, getMemorySearchManager } = await import("./index.js");
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
const managersForCleanup = new Set<MemoryIndexManager>();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
const fixture = createManagerIndexFixture({
getMemorySearchManager,
closeAllMemorySearchManagers,
});
const { provider: providerFixture } = fixture;
const {
createConfig: createCfg,
getFreshManager,
getFtsSessionManager,
getPersistentManager,
seedSessionTranscript: seedMemoryIndexSessionTranscript,
trackManager,
} = fixture;
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
embedBatchCalls = 0;
embeddedBatchTexts = [];
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];
providerRuntimeBatchGate = null;
providerRuntimeBatchErrors = [];
providerRuntimeBatchFailuresRemaining = 0;
providerRuntimeActiveBatchCalls = 0;
providerRuntimeMaxActiveBatchCalls = 0;
providerCloseCalls = 0;
providerCloseFailuresRemaining = 0;
providerCloseFailure = new Error("provider close failed");
providerCreationFailure = null;
providerNullResult = null;
providerCloseGate = null;
providerInitGate = null;
providerCalls = [];
forceNoProvider = false;
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
for (const table of [
"memory_index_sources",
"memory_index_chunks",
"memory_embedding_cache",
"memory_index_chunks_fts",
"memory_index_chunks_vec",
]) {
const existingTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table);
if (existingTable?.name === table) {
db.exec(`DELETE FROM ${table}`);
}
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
rememberAcrossConversations?: boolean;
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
batchEnabled?: boolean;
model?: string;
outputDimensionality?: number;
multimodal?: {
enabled?: boolean;
modalities?: Array<"image" | "audio" | "all">;
maxFileBytes?: number;
};
vectorEnabled?: boolean;
cacheEnabled?: boolean;
minScore?: number;
onSearch?: boolean;
hybrid?: {
enabled: boolean;
vectorWeight?: number;
textWeight?: number;
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: {
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
},
remote: params.batchEnabled
? {
batch: { enabled: true },
}
: undefined,
query: { minScore: params.minScore ?? 0 },
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
rememberAcrossConversations:
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
});
}
async function seedMemoryIndexSessionTranscript(params: {
messages: Array<{
content: string;
role: "assistant" | "user";
senderIsOwner?: boolean;
timestamp: number | string;
}>;
sessionId: string;
sessionKey?: string;
}): Promise<void> {
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
const storePath = path.join(sessionsDir, "sessions.json");
const sessionKey = params.sessionKey ?? `agent:main:memory:${params.sessionId}`;
// Message timestamps are behavioral inputs; entry freshness only keeps the
// fixture out of real session-retention maintenance as wall time advances.
const updatedAt = Date.now();
await fs.mkdir(sessionsDir, { recursive: true });
await upsertSessionEntry({
agentId: "main",
sessionKey,
storePath,
entry: {
sessionId: params.sessionId,
updatedAt,
},
});
for (const message of params.messages) {
await appendSessionTranscriptMessageByIdentity({
agentId: "main",
sessionId: params.sessionId,
sessionKey,
storePath,
message: {
role: message.role,
timestamp: message.timestamp,
content: [{ type: "text", text: message.content }],
...(message.senderIsOwner ? { __openclaw: { senderIsOwner: true } } : {}),
},
});
}
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing",
): MemoryIndexManager {
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager as unknown as MemoryIndexManager;
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
managersForCleanup.add(manager);
return manager;
}
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
async function expectHybridKeywordSearchFindsMemory(
cfg: Parameters<typeof getMemorySearchManager>[0]["cfg"],
) {
const manager = await getFreshManager(cfg);
try {
const status = manager.status();
@@ -518,31 +43,22 @@ describe("memory index", () => {
}
}
async function getFtsSessionManager(params: {
stateDirName: string;
}): Promise<MemoryIndexManager | null> {
forceNoProvider = true;
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
provider: "none",
sources: ["memory", "sessions"],
sessionMemory: true,
minScore: 0,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
});
const result = await getMemorySearchManager({ cfg, agentId: "main" });
const manager = requireManager(result);
managersForCleanup.add(manager);
resetManagerForTest(manager);
return manager.status().fts?.available ? manager : null;
}
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
await expectHybridKeywordSearchFindsMemory(
createCfg({
it.each([
{
name: "zero vector weight",
config: {
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
}),
);
} satisfies ManagerIndexFixtureConfig,
},
{
name: "minimum score exceeds text weight",
config: {
minScore: 0.35,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
} satisfies ManagerIndexFixtureConfig,
},
])("finds keyword matches via hybrid search when $name", async ({ config }) => {
await expectHybridKeywordSearchFindsMemory(createCfg(config));
});
it("retries transient query embedding transport failures during search", async () => {
@@ -627,15 +143,6 @@ describe("memory index", () => {
expect(queryCalls).toBe(3);
});
it("preserves keyword-only hybrid hits when minScore exceeds text weight", async () => {
await expectHybridKeywordSearchFindsMemory(
createCfg({
minScore: 0.35,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
}),
);
});
it("supplements thin strict FTS results for conversational queries", async () => {
const cases = [
{
@@ -654,8 +161,8 @@ describe("memory index", () => {
},
] as const;
for (const entry of cases) {
await fs.writeFile(path.join(memoryDir, entry.strictFile), entry.strictText);
await fs.writeFile(path.join(memoryDir, entry.recallFile), entry.recallText);
await fs.writeFile(path.join(fixture.paths.memory, entry.strictFile), entry.strictText);
await fs.writeFile(path.join(fixture.paths.memory, entry.recallFile), entry.recallText);
}
const manager = await getPersistentManager(
@@ -729,10 +236,13 @@ describe("memory index", () => {
}),
);
await fs.writeFile(
path.join(memoryDir, "body.md"),
path.join(fixture.paths.memory, "body.md"),
"Alpha gamma alpha gamma strongest fallback body match.",
);
await fs.writeFile(path.join(memoryDir, "alpha.md"), "Unrelated path-only candidate.");
await fs.writeFile(
path.join(fixture.paths.memory, "alpha.md"),
"Unrelated path-only candidate.",
);
await manager.sync({ reason: "test" });
const results = await manager.search("alpha gamma", { maxResults: 2, minScore: 0 });
@@ -769,13 +279,13 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
restoreMemoryIndexStateDir();
fixture.restoreStateDir();
}
});
it("keeps remember-only session transcripts out of ordinary manager searches", async () => {
forceNoProvider = true;
setMemoryIndexStateDir(path.join(workspaceDir, ".state-remember-search-sources"));
providerFixture.forceNoProvider = true;
fixture.setStateDir(path.join(fixture.paths.workspace, ".state-remember-search-sources"));
try {
const cfg = createCfg({
provider: "none",
@@ -784,7 +294,7 @@ describe("memory index", () => {
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
});
const manager = await getFreshManager(cfg);
managersForCleanup.add(manager);
trackManager(manager);
if (!manager.status().fts?.available) {
return;
}
@@ -811,7 +321,7 @@ describe("memory index", () => {
});
expect(trustedResults[0]?.source).toBe("sessions");
} finally {
restoreMemoryIndexStateDir();
fixture.restoreStateDir();
}
});
@@ -819,21 +329,21 @@ describe("memory index", () => {
const manager = await getPersistentManager(
createCfg({ provider: "required-provider", hybrid: { enabled: true } }),
);
providerCalls = [];
providerFixture.providerCalls = [];
await expect(manager.search(" \n\t ")).resolves.toStrictEqual([]);
expect(providerCalls).toHaveLength(0);
expect(providerFixture.providerCalls).toHaveLength(0);
});
it("waits for dirty sync before querying", async () => {
forceNoProvider = true;
providerFixture.forceNoProvider = true;
const manager = await getPersistentManager(
createCfg({ provider: "none", minScore: 0, onSearch: true, hybrid: { enabled: true } }),
);
await manager.sync({ reason: "test" });
await fs.writeFile(
path.join(memoryDir, "search-sync.md"),
path.join(fixture.paths.memory, "search-sync.md"),
"Current memory appears only after the dirty search sync.",
);
await vi.waitFor(() => expect(manager.status().dirty).toBe(true));
@@ -8,65 +8,70 @@ import {
} from "./manager-status-state.js";
describe("memory manager status state", () => {
it("keeps memory clean for status-only managers after prior indexing", () => {
expect(
resolveInitialMemoryDirty({
it.each([
{
name: "indexed status-only memory stays clean",
params: {
hasMemorySource: true,
statusOnly: true,
hasIndexedMeta: true,
}),
).toBe(false);
});
it("marks status-only managers dirty when no prior index metadata exists", () => {
expect(
resolveInitialMemoryDirty({
},
expected: false,
},
{
name: "missing metadata is dirty",
params: {
hasMemorySource: true,
statusOnly: true,
hasIndexedMeta: false,
}),
).toBe(true);
});
it("marks status-only managers dirty when index identity mismatches", () => {
expect(
resolveInitialMemoryDirty({
},
expected: true,
},
{
name: "identity mismatch is dirty",
params: {
hasMemorySource: false,
statusOnly: true,
hasIndexedMeta: true,
indexIdentityMismatched: true,
}),
).toBe(true);
},
expected: true,
},
])("resolves $name", ({ params, expected }) => {
expect(resolveInitialMemoryDirty(params)).toBe(expected);
});
it("reports the requested provider before provider initialization", () => {
expect(
resolveStatusProviderInfo({
it.each([
{
name: "requested provider before initialization",
params: {
provider: null,
providerInitialized: false,
requestedProvider: "openai",
configuredModel: "mock-embed",
}),
).toEqual({
provider: "openai",
model: "mock-embed",
searchMode: "hybrid",
});
});
it("reports fts-only mode when initialization finished without a provider", () => {
expect(
resolveStatusProviderInfo({
},
expected: {
provider: "openai",
model: "mock-embed",
searchMode: "hybrid" as const,
},
},
{
name: "FTS-only after providerless initialization",
params: {
provider: null,
providerInitialized: true,
requestedProvider: "openai",
configuredModel: "mock-embed",
}),
).toEqual({
provider: "none",
model: undefined,
searchMode: "fts-only",
});
},
expected: {
provider: "none",
model: undefined,
searchMode: "fts-only" as const,
},
},
])("reports $name", ({ params, expected }) => {
expect(resolveStatusProviderInfo(params)).toEqual(expected);
});
it("uses one aggregation query for status counts and source breakdowns", () => {
@@ -1,33 +0,0 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Memory Core helper module supports test manager helpers behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import type { MemoryIndexManager } from "./manager.js";
const ensureEmbeddingMocksLoaded = createLazyRuntimeModule(() =>
import("./embedding.test-mocks.js").then(() => undefined),
);
const loadGetMemorySearchManager = createLazyRuntimeModule(() =>
import("./index.js").then((mod) => mod.getMemorySearchManager),
);
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
agentId?: string;
purpose?: "default" | "status" | "cli";
}): Promise<MemoryIndexManager> {
await ensureEmbeddingMocksLoaded();
const getMemorySearchManager = await loadGetMemorySearchManager();
const result = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId ?? "main",
purpose: params.purpose,
});
if (!result.manager) {
throw new Error("manager missing");
}
if (!("sync" in result.manager) || typeof result.manager.sync !== "function") {
throw new Error("manager does not support sync");
}
return result.manager as unknown as MemoryIndexManager;
}