mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
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:
committed by
GitHub
parent
0f4640a887
commit
1f8fdf4ef0
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;
|
||||
}
|
||||
Reference in New Issue
Block a user