From 1f8fdf4ef0a8dce37f72af191aecc3bd755be05d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 08:50:09 -0700 Subject: [PATCH] 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 --- .../memory-core/src/memory/index.test.ts | 807 ++++-------------- .../src/memory/manager-async-state.test.ts | 30 +- .../src/memory/manager-index.test-support.ts | 604 +++++++++++++ .../memory/manager-keyword-retrieval.test.ts | 668 ++------------- ...anager-provider-lifecycle-fallback.test.ts | 506 +---------- .../manager-provider-lifecycle-leases.test.ts | 530 +----------- .../memory/manager-provider-lifecycle.test.ts | 569 ++---------- .../src/memory/manager-registry.test.ts | 541 ++---------- .../src/memory/manager-reindex-state.test.ts | 46 +- .../manager-search-orchestration.test.ts | 592 ++----------- .../src/memory/manager-status-state.test.ts | 83 +- .../src/memory/test-manager-helpers.ts | 33 - 12 files changed, 1191 insertions(+), 3818 deletions(-) create mode 100644 extensions/memory-core/src/memory/manager-index.test-support.ts delete mode 100644 extensions/memory-core/src/memory/test-manager-helpers.ts diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 0ffa607b5970..efaf3de65d49 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -1,10 +1,7 @@ // Memory Core tests cover index plugin behavior. -import { mkdirSync, rmSync } from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { hashText, INVALID_PROJECT_ANNOTATION_KEY, @@ -13,509 +10,41 @@ import { type MemorySyncParams, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; -import { deleteSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { deleteSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime"; import { closeOpenClawAgentDatabasesForTest, - closeOpenClawStateDatabaseForTest, openOpenClawAgentDatabase, } from "openclaw/plugin-sdk/sqlite-runtime-testing"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { - configureMemoryCoreDreamingStateForTests, - resetMemoryCoreDreamingStateForTests, -} from "../test-helpers.js"; -import "./test-runtime-mocks.js"; -import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js"; + createManagerIndexFixture, + type ManagerIndexFixture, +} from "./manager-index.test-support.js"; import type { MemoryIndexMeta } from "./manager-reindex-state.js"; import type { MemoryIndexManager } 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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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: ManagerIndexFixture = 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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 { - 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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose })); - managersForCleanup.add(manager); - return manager; - } + const { provider: providerFixture } = fixture; + const { + createConfig: createCfg, + getFreshManager, + getFtsSessionManager, + getPersistentManager, + seedSessionTranscript: seedMemoryIndexSessionTranscript, + trackManager, + } = fixture; function rewritePersistedProviderIdentity(manager: MemoryIndexManager, model: string): void { const providerKey = hashText( JSON.stringify({ - provider: identityAliasFixture.provider, + provider: providerFixture.identityAlias.provider, model, }), ); @@ -536,7 +65,7 @@ describe("memory index", () => { db.prepare("UPDATE memory_index_chunks SET model = ?").run(model); db.prepare( "UPDATE memory_embedding_cache SET model = ?, provider_key = ? WHERE provider = ?", - ).run(model, providerKey, identityAliasFixture.provider); + ).run(model, providerKey, providerFixture.identityAlias.provider); } it("does not prepare vector deletes after in-place reset drops a missing vector table", async () => { @@ -545,7 +74,7 @@ describe("memory index", () => { hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, }); const manager = await getFreshManager(cfg); - managersForCleanup.add(manager); + trackManager(manager); type VectorState = { available: boolean | null; dims?: number }; const vector = Reflect.get(manager, "vector") as VectorState; vector.available = true; @@ -559,25 +88,6 @@ describe("memory index", () => { ).resolves.toBeUndefined(); }); - async function getFtsSessionManager(params: { - stateDirName: string; - }): Promise { - 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("indexes memory files and searches", async () => { const cfg = createCfg({ hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, @@ -607,7 +117,7 @@ describe("memory index", () => { it("indexes trailing recall annotations only from curated memory files", async () => { await fs.writeFile( - path.join(workspaceDir, "MEMORY.md"), + path.join(fixture.paths.workspace, "MEMORY.md"), [ "# Curated entries", "", @@ -618,15 +128,15 @@ describe("memory index", () => { ].join("\n"), ); await fs.writeFile( - path.join(workspaceDir, "USER.md"), + path.join(fixture.paths.workspace, "USER.md"), "- Prefer concise replies. \n", ); await fs.writeFile( - path.join(memoryDir, "2026-01-12.md"), + path.join(fixture.paths.memory, "2026-01-12.md"), "- Daily note. \n", ); await fs.writeFile( - path.join(memoryDir, "2026-01-13.md"), + path.join(fixture.paths.memory, "2026-01-13.md"), [ "- Uppercase path. ", "- Lowercase path. ", @@ -704,8 +214,8 @@ describe("memory index", () => { originClass: "agent", }); expect(rows.every((row) => !row.text.includes(" \n`, ); @@ -788,7 +298,7 @@ describe("memory index", () => { it("keeps invalid project annotations scoped but unsatisfiable", async () => { await fs.writeFile( - path.join(workspaceDir, "MEMORY.md"), + path.join(fixture.paths.workspace, "MEMORY.md"), [ "- Invalid fact. ", "- Mixed fact. ", @@ -838,7 +348,7 @@ describe("memory index", () => { it("inherits entry-scoped annotations across oversized curated fragments", async () => { await fs.writeFile( - path.join(workspaceDir, "MEMORY.md"), + path.join(fixture.paths.workspace, "MEMORY.md"), [ "- Oversized alpha entry. ", ` ${"alpha-fragment-body ".repeat(400)}`, @@ -891,7 +401,7 @@ describe("memory index", () => { "- Beta entry. ", "- Global entry. ", ].join("\n"); - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), curatedContent); + await fs.writeFile(path.join(fixture.paths.workspace, "MEMORY.md"), curatedContent); const manager = await getFreshManager(createCfg({ provider: "none" })); try { @@ -1014,7 +524,10 @@ describe("memory index", () => { SELECT RAISE(FAIL, 'forced chunk publication failure'); END; `); - await fs.writeFile(path.join(memoryDir, "2026-01-12.md"), "# Log\nUpdated memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-12.md"), + "# Log\nUpdated memory line.", + ); Reflect.set(manager, "dirty", true); await expect(manager.sync({ reason: "test" })).rejects.toThrow( @@ -1037,8 +550,8 @@ describe("memory index", () => { }); it("reindexes memory tables in place without deleting unrelated agent rows", async () => { - const stateDir = path.join(workspaceDir, "managed-memory-state"); - setMemoryIndexStateDir(stateDir); + const stateDir = path.join(fixture.paths.workspace, "managed-memory-state"); + fixture.setStateDir(stateDir); const agentDbPath = resolveOpenClawAgentSqlitePath({ agentId: "main" }); const agentDb = openOpenClawAgentDatabase({ agentId: "main" }); agentDb.db @@ -1082,8 +595,14 @@ describe("memory index", () => { }); it("batches dirty memory chunks across files", async () => { - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); - await fs.writeFile(path.join(memoryDir, "2026-01-14.md"), "# Log\nGamma memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-14.md"), + "# Log\nGamma memory line.", + ); const cfg = createCfg({ provider: "batch-wide-test", batchEnabled: true, @@ -1092,8 +611,8 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(1); - expect(providerRuntimeBatchCalls[0]).toEqual([ + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(1); + expect(providerFixture.providerRuntimeBatchCalls[0]).toEqual([ "# Log\nAlpha memory line.\nZebra memory line.", "# Log\nBeta memory line.", "# Log\nGamma memory line.", @@ -1112,15 +631,18 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); - providerRuntimeBatchCalls = []; - providerRuntimeBatchFailuresRemaining = 1; - embedBatchCalls = 0; + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); + providerFixture.providerRuntimeBatchCalls = []; + providerFixture.providerRuntimeBatchFailuresRemaining = 1; + providerFixture.embedBatchCalls = 0; await manager.sync({ reason: "test", force: true }); - expect(providerRuntimeBatchCalls).toEqual([["# Log\nBeta memory line."]]); - expect(embedBatchCalls).toBe(1); + expect(providerFixture.providerRuntimeBatchCalls).toEqual([["# Log\nBeta memory line."]]); + expect(providerFixture.embedBatchCalls).toBe(1); const betaRow = ( manager as unknown as { db: { prepare: (sql: string) => { get: (...args: unknown[]) => unknown } }; @@ -1137,7 +659,7 @@ describe("memory index", () => { }); it("derives batch attempts locally instead of trusting provider error metadata", async () => { - providerRuntimeBatchErrors = [ + providerFixture.providerRuntimeBatchErrors = [ Object.assign(new Error("provider runtime batch failed"), { batchAttempts: Number.MAX_SAFE_INTEGER, }), @@ -1148,8 +670,8 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(1); - expect(embedBatchCalls).toBe(1); + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(1); + expect(providerFixture.embedBatchCalls).toBe(1); expect(manager.status().batch).toMatchObject({ enabled: true, failures: 1, @@ -1161,7 +683,7 @@ describe("memory index", () => { }); it("disables batch immediately when the provider reports it unavailable", async () => { - providerRuntimeBatchErrors = [ + providerFixture.providerRuntimeBatchErrors = [ Object.assign(new Error("provider batch unavailable"), { code: "embedding_batch_unavailable", }), @@ -1172,8 +694,8 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(1); - expect(embedBatchCalls).toBe(1); + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(1); + expect(providerFixture.embedBatchCalls).toBe(1); expect(manager.status().batch).toMatchObject({ enabled: false, failures: 2, @@ -1188,15 +710,18 @@ describe("memory index", () => { ["frozen errors", Object.freeze(new Error("provider runtime retry failed"))], ["primitive rejections", "provider runtime retry failed"], ])("preserves %s while recording both attempts", async (_kind, retryError) => { - providerRuntimeBatchErrors = [new Error("memory embeddings batch timed out"), retryError]; + providerFixture.providerRuntimeBatchErrors = [ + new Error("memory embeddings batch timed out"), + retryError, + ]; const manager = await getFreshManager( createCfg({ provider: "batch-wide-test", batchEnabled: true }), ); try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(2); - expect(embedBatchCalls).toBe(1); + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(2); + expect(providerFixture.embedBatchCalls).toBe(1); expect(manager.status().batch).toMatchObject({ enabled: false, failures: 2, @@ -1208,7 +733,7 @@ describe("memory index", () => { }); it("resets batch failures when a timeout retry recovers", async () => { - providerRuntimeBatchErrors = [new Error("provider runtime batch failed")]; + providerFixture.providerRuntimeBatchErrors = [new Error("provider runtime batch failed")]; const manager = await getFreshManager( createCfg({ provider: "batch-wide-test", batchEnabled: true }), ); @@ -1216,15 +741,18 @@ describe("memory index", () => { await manager.sync({ reason: "test" }); expect(manager.status().batch?.failures).toBe(1); - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); - providerRuntimeBatchCalls = []; - providerRuntimeBatchErrors = [new Error("memory embeddings batch timed out")]; - embedBatchCalls = 0; + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); + providerFixture.providerRuntimeBatchCalls = []; + providerFixture.providerRuntimeBatchErrors = [new Error("memory embeddings batch timed out")]; + providerFixture.embedBatchCalls = 0; await manager.sync({ reason: "test", force: true }); - expect(providerRuntimeBatchCalls).toHaveLength(2); - expect(embedBatchCalls).toBe(0); + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(2); + expect(providerFixture.embedBatchCalls).toBe(0); expect(manager.status().batch).toMatchObject({ enabled: true, failures: 0, @@ -1237,10 +765,13 @@ describe("memory index", () => { it("keeps split chunks from oversized files in one source-wide batch", async () => { await fs.writeFile( - path.join(memoryDir, "2026-01-13.md"), + path.join(fixture.paths.memory, "2026-01-13.md"), `# Log\n${"Long split memory line. ".repeat(1200)}`, ); - await fs.writeFile(path.join(memoryDir, "2026-01-14.md"), "# Log\nBeta memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-14.md"), + "# Log\nBeta memory line.", + ); const cfg = createCfg({ provider: "batch-wide-test", batchEnabled: true, @@ -1249,8 +780,8 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(1); - const combinedBatch = providerRuntimeBatchCalls[0] ?? []; + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(1); + const combinedBatch = providerFixture.providerRuntimeBatchCalls[0] ?? []; expect(combinedBatch.length).toBeGreaterThan(3); expect(combinedBatch.join("\n")).toContain("Long split memory line."); expect(combinedBatch).toContain("# Log\nBeta memory line."); @@ -1260,8 +791,14 @@ describe("memory index", () => { }); it("keeps custom batch runtimes per file without source-wide opt in", async () => { - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); - await fs.writeFile(path.join(memoryDir, "2026-01-14.md"), "# Log\nGamma memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-14.md"), + "# Log\nGamma memory line.", + ); const cfg = createCfg({ provider: "batch-test", batchEnabled: true, @@ -1270,9 +807,13 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(3); - expect(providerRuntimeBatchCalls.every((call) => call.length === 1)).toBe(true); - expect(providerRuntimeBatchCalls.map((call) => call[0] ?? "").toSorted()).toEqual( + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(3); + expect(providerFixture.providerRuntimeBatchCalls.every((call) => call.length === 1)).toBe( + true, + ); + expect( + providerFixture.providerRuntimeBatchCalls.map((call) => call[0] ?? "").toSorted(), + ).toEqual( [ "# Log\nAlpha memory line.\nZebra memory line.", "# Log\nBeta memory line.", @@ -1285,21 +826,29 @@ describe("memory index", () => { }); it("keeps custom batch runtimes concurrent without source-wide opt in", async () => { - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); - await fs.writeFile(path.join(memoryDir, "2026-01-14.md"), "# Log\nGamma memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-14.md"), + "# Log\nGamma memory line.", + ); const cfg = createCfg({ provider: "batch-test", batchEnabled: true, }); const manager = await getFreshManager(cfg); let releaseBatchGate: (() => void) | undefined; - providerRuntimeBatchGate = new Promise((resolve) => { + providerFixture.providerRuntimeBatchGate = new Promise((resolve) => { releaseBatchGate = resolve; }); const syncPromise = manager.sync({ reason: "test" }); let waitError: Error | undefined; try { - await vi.waitFor(() => expect(providerRuntimeMaxActiveBatchCalls).toBeGreaterThan(1)); + await vi.waitFor(() => + expect(providerFixture.providerRuntimeMaxActiveBatchCalls).toBeGreaterThan(1), + ); } catch (err) { waitError = err instanceof Error ? err : new Error(String(err)); } finally { @@ -1316,7 +865,7 @@ describe("memory index", () => { const batchFileLimit = 2048; for (let index = 0; index < batchFileLimit; index += 1) { await fs.writeFile( - path.join(memoryDir, `2026-02-${String(index + 1).padStart(4, "0")}.md`), + path.join(fixture.paths.memory, `2026-02-${String(index + 1).padStart(4, "0")}.md`), `# Log\nBounded memory line ${index}.`, ); } @@ -1328,17 +877,20 @@ describe("memory index", () => { try { await manager.sync({ reason: "test" }); - expect(providerRuntimeBatchCalls).toHaveLength(2); - expect(providerRuntimeBatchCalls[0]).toHaveLength(batchFileLimit); - expect(providerRuntimeBatchCalls[1]).toHaveLength(1); - expect(providerRuntimeBatchCalls.flat()).toHaveLength(batchFileLimit + 1); + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(2); + expect(providerFixture.providerRuntimeBatchCalls[0]).toHaveLength(batchFileLimit); + expect(providerFixture.providerRuntimeBatchCalls[1]).toHaveLength(1); + expect(providerFixture.providerRuntimeBatchCalls.flat()).toHaveLength(batchFileLimit + 1); } finally { await manager.close?.(); } }); it("batches forced memory and session indexing across files", async () => { - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); await seedMemoryIndexSessionTranscript({ sessionId: "session-alpha", messages: [ @@ -1369,8 +921,8 @@ describe("memory index", () => { try { await manager.sync({ reason: "cli", force: true }); - expect(providerRuntimeBatchCalls).toHaveLength(1); - const combinedBatch = providerRuntimeBatchCalls[0] ?? []; + expect(providerFixture.providerRuntimeBatchCalls).toHaveLength(1); + const combinedBatch = providerFixture.providerRuntimeBatchCalls[0] ?? []; expect(combinedBatch.slice(0, 2)).toEqual([ "# Log\nAlpha memory line.\nZebra memory line.", "# Log\nBeta memory line.", @@ -1403,21 +955,21 @@ describe("memory index", () => { status: "mismatched", reason: "index was built for model old-embed, expected new-embed", }); - embedBatchCalls = 0; + providerFixture.embedBatchCalls = 0; const results = await nextManager.search("alpha"); expect(results).toStrictEqual([]); - expect(embedBatchCalls).toBe(0); + expect(providerFixture.embedBatchCalls).toBe(0); expect(nextManager.status().dirty).toBe(true); await fs.writeFile( - path.join(memoryDir, "2026-01-12.md"), + path.join(fixture.paths.memory, "2026-01-12.md"), "# Log\nAlpha memory line changed.\nZebra memory line.", ); await nextManager.sync({ reason: "watch" }); - expect(embedBatchCalls).toBe(0); + expect(providerFixture.embedBatchCalls).toBe(0); const stillPausedResults = await nextManager.search("alpha"); expect(stillPausedResults).toStrictEqual([]); expect(nextManager.status().dirty).toBe(true); @@ -1433,20 +985,20 @@ describe("memory index", () => { it.each([ { direction: "HF to exact cache path", - indexedModel: identityAliasFixture.canonicalModel, - configuredModel: identityAliasFixture.cacheModel, + indexedModel: providerFixture.identityAlias.canonicalModel, + configuredModel: providerFixture.identityAlias.cacheModel, }, { direction: "exact cache path to HF", - indexedModel: identityAliasFixture.cacheModel, - configuredModel: identityAliasFixture.canonicalModel, + indexedModel: providerFixture.identityAlias.cacheModel, + configuredModel: providerFixture.identityAlias.canonicalModel, }, ])( "keeps $direction indexes and embedding caches usable", async ({ indexedModel, configuredModel }) => { const indexedCfg = createCfg({ - provider: identityAliasFixture.provider, - model: identityAliasFixture.canonicalModel, + provider: providerFixture.identityAlias.provider, + model: providerFixture.identityAlias.canonicalModel, cacheEnabled: true, vectorEnabled: false, onSearch: false, @@ -1454,14 +1006,14 @@ describe("memory index", () => { }); const indexedManager = await getFreshManager(indexedCfg); await indexedManager.sync({ reason: "test", force: true }); - if (indexedModel !== identityAliasFixture.canonicalModel) { + if (indexedModel !== providerFixture.identityAlias.canonicalModel) { rewritePersistedProviderIdentity(indexedManager, indexedModel); } await indexedManager.close?.(); - const embedsBeforeReuse = embedBatchCalls; + const embedsBeforeReuse = providerFixture.embedBatchCalls; const nextCfg = createCfg({ - provider: identityAliasFixture.provider, + provider: providerFixture.identityAlias.provider, model: configuredModel, cacheEnabled: true, vectorEnabled: false, @@ -1486,7 +1038,7 @@ describe("memory index", () => { await nextManager.sync({ reason: "test", force: true }); - expect(embedBatchCalls).toBe(embedsBeforeReuse); + expect(providerFixture.embedBatchCalls).toBe(embedsBeforeReuse); } finally { await nextManager.close?.(); } @@ -1560,11 +1112,14 @@ describe("memory index", () => { const cfg = createCfg({ hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, }); - await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "# Log\nBeta memory line."); + await fs.writeFile( + path.join(fixture.paths.memory, "2026-01-13.md"), + "# Log\nBeta memory line.", + ); const oldManager = await getFreshManager(cfg); await oldManager.sync({ reason: "test", force: true }); await oldManager.close?.(); - await fs.rm(path.join(memoryDir, "2026-01-12.md")); + await fs.rm(path.join(fixture.paths.memory, "2026-01-12.md")); const nextManager = await getFreshManager(cfg); try { @@ -1598,7 +1153,7 @@ describe("memory index", () => { await oldManager.sync({ reason: "test", force: true }); await oldManager.close?.(); - forceNoProvider = true; + providerFixture.forceNoProvider = true; const nextManager = await getFreshManager(oldCfg); try { const results = await nextManager.search("alpha"); @@ -1622,7 +1177,7 @@ describe("memory index", () => { await oldManager.sync({ reason: "test", force: true }); await oldManager.close?.(); - forceNoProvider = true; + providerFixture.forceNoProvider = true; const nextManager = await getFreshManager(oldCfg); try { const db = ( @@ -1653,7 +1208,7 @@ describe("memory index", () => { it("clears dirty after sessions-only identity reindex", async () => { try { - setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-only-reindex")); + fixture.setStateDir(path.join(fixture.paths.workspace, ".state-sessions-only-reindex")); await seedMemoryIndexSessionTranscript({ sessionId: "session-identity", messages: [ @@ -1692,13 +1247,13 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => { try { - setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-missing-meta")); + fixture.setStateDir(path.join(fixture.paths.workspace, ".state-sessions-missing-meta")); await seedMemoryIndexSessionTranscript({ sessionId: "session-missing-meta", messages: [ @@ -1737,7 +1292,7 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); @@ -2241,7 +1796,7 @@ describe("memory index", () => { it("keeps provider cutover vector search paused during targeted session sync", async () => { try { - setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover")); + fixture.setStateDir(path.join(fixture.paths.workspace, ".state-targeted-cutover")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl"); @@ -2283,11 +1838,11 @@ describe("memory index", () => { const nextManager = await getFreshManager(nextCfg); try { expect(nextManager.status().dirty).toBe(true); - embedBatchCalls = 0; + providerFixture.embedBatchCalls = 0; await nextManager.sync({ reason: "test", archiveFiles: [sessionFile] }); - expect(embedBatchCalls).toBe(0); + expect(providerFixture.embedBatchCalls).toBe(0); expect(nextManager.status().dirty).toBe(true); expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "mismatched", @@ -2299,13 +1854,13 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); it("preserves memory dirty events raised during session identity reindex", async () => { try { - setMemoryIndexStateDir(path.join(workspaceDir, ".state-dirty-during-session")); + fixture.setStateDir(path.join(fixture.paths.workspace, ".state-dirty-during-session")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); await fs.writeFile( @@ -2363,7 +1918,7 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); @@ -2374,12 +1929,12 @@ describe("memory index", () => { const manager = await getFreshManager(cfg); await manager.probeEmbeddingAvailability(); - expect(providerCloseCalls).toBe(0); + expect(providerFixture.providerCloseCalls).toBe(0); await manager.close(); await manager.close(); - expect(providerCloseCalls).toBe(1); + expect(providerFixture.providerCloseCalls).toBe(1); }); it("waits for pending sync before closing embedding providers", async () => { @@ -2397,7 +1952,7 @@ describe("memory index", () => { const concurrentClosePromise = manager.close(); try { await Promise.resolve(); - expect(providerCloseCalls).toBe(0); + expect(providerFixture.providerCloseCalls).toBe(0); let closeSettled = false; void closePromise.then(() => { @@ -2410,12 +1965,12 @@ describe("memory index", () => { resolveSync(); } await Promise.all([closePromise, concurrentClosePromise]); - expect(providerCloseCalls).toBe(1); + expect(providerFixture.providerCloseCalls).toBe(1); }); it("waits for sync that attaches after provider initialization before closing providers", async () => { let releaseProviderInit: () => void = () => {}; - providerInitGate = new Promise((resolve) => { + providerFixture.providerInitGate = new Promise((resolve) => { releaseProviderInit = resolve; }); const cfg = createCfg({ @@ -2449,7 +2004,7 @@ describe("memory index", () => { const syncPromise = manager.sync({ reason: "test" }); await vi.waitFor(() => { - expect(providerCalls).toHaveLength(1); + expect(providerFixture.providerCalls).toHaveLength(1); }); const closePromise = manager.close(); @@ -2458,21 +2013,21 @@ describe("memory index", () => { await syncStarted; await Promise.resolve(); - expect(providerCloseCalls).toBe(0); + expect(providerFixture.providerCloseCalls).toBe(0); } finally { releaseSync(); } await syncPromise; await closePromise; - expect(providerCloseCalls).toBe(1); + expect(providerFixture.providerCloseCalls).toBe(1); }); it("indexes multimodal files only from extra paths", async () => { - const mediaDir = path.join(workspaceDir, "media-memory"); + const mediaDir = path.join(fixture.paths.workspace, "media-memory"); await fs.mkdir(mediaDir, { recursive: true }); await fs.writeFile(path.join(mediaDir, "diagram.png"), Buffer.from("png")); await fs.writeFile(path.join(mediaDir, "meeting.wav"), Buffer.from("wav")); - await fs.writeFile(path.join(memoryDir, "default-diagram.png"), Buffer.from("png")); + await fs.writeFile(path.join(fixture.paths.memory, "default-diagram.png"), Buffer.from("png")); const cfg = createCfg({ provider: "gemini", @@ -2483,7 +2038,7 @@ describe("memory index", () => { const manager = await getPersistentManager(cfg); await manager.sync({ reason: "test" }); - expect(embedBatchInputCalls).toBeGreaterThan(0); + expect(providerFixture.embedBatchInputCalls).toBeGreaterThan(0); const db = Reflect.get(manager, "db") as DatabaseSync; const indexedMediaPaths = () => @@ -2562,7 +2117,7 @@ describe("memory index", () => { }); it("probes sqlite vector store availability without initializing embeddings", async () => { - forceNoProvider = true; + providerFixture.forceNoProvider = true; const cfg = createCfg({ vectorEnabled: true, }); @@ -2571,7 +2126,7 @@ describe("memory index", () => { const available = await manager.probeVectorStoreAvailability?.(); const status = manager.status(); - expect(providerCalls).toStrictEqual([]); + expect(providerFixture.providerCalls).toStrictEqual([]); expect(typeof status.vector?.storeAvailable).toBe("boolean"); expect(status.vector?.storeAvailable).toBe(available); expect(status.vector?.semanticAvailable).toBeUndefined(); @@ -2646,7 +2201,7 @@ describe("memory index", () => { await initialManager.close?.(); await fs.writeFile( - path.join(memoryDir, "2026-01-12.md"), + path.join(fixture.paths.memory, "2026-01-12.md"), "# Updated\n\nvector writes were disabled for this update\n", ); const disabledManager = await getFreshManager( @@ -2682,7 +2237,7 @@ describe("memory index", () => { }); it("keeps empty vector indexes clean after vector store probing", async () => { - await fs.rm(path.join(memoryDir, "2026-01-12.md")); + await fs.rm(path.join(fixture.paths.memory, "2026-01-12.md")); const legacyCfg = createCfg({ provider: "gemini", vectorEnabled: false, @@ -2731,11 +2286,11 @@ describe("memory index", () => { const manager = await getPersistentManager(cfg); await manager.sync({ reason: "test" }); - const beforeCalls = embedBatchCalls; + const beforeCalls = providerFixture.embedBatchCalls; (manager as unknown as { dirty: boolean }).dirty = true; await manager.sync({ reason: "test", force: true }); - expect(embedBatchCalls).toBe(beforeCalls); + expect(providerFixture.embedBatchCalls).toBe(beforeCalls); }); it("preserves trusted per-line provenance through session indexing", async () => { @@ -2772,7 +2327,7 @@ describe("memory index", () => { observedAt: Date.parse("2026-07-01T10:00:00.000Z"), }); } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); @@ -2781,7 +2336,7 @@ describe("memory index", () => { // must report dirty=true when session files exist without index rows. const cfg = createCfg({ sources: ["sessions"], sessionMemory: true }); const stateDirName = ".state-status-dirty-test"; - setMemoryIndexStateDir(path.join(workspaceDir, stateDirName)); + fixture.setStateDir(path.join(fixture.paths.workspace, stateDirName)); try { await seedMemoryIndexSessionTranscript({ sessionId: "status-dirty-test", @@ -2795,12 +2350,12 @@ describe("memory index", () => { }); const manager = await getFreshManager(cfg, "status"); - managersForCleanup.add(manager); + trackManager(manager); const result = manager.status(); expect(result.dirty).toBe(true); } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); @@ -2812,7 +2367,7 @@ describe("memory index", () => { minScore: 0, }); const stateDirName = ".state-status-stale-session-test"; - setMemoryIndexStateDir(path.join(workspaceDir, stateDirName)); + fixture.setStateDir(path.join(fixture.paths.workspace, stateDirName)); const sessionId = "status-stale-session-test"; const sessionKey = `agent:main:memory:${sessionId}`; const survivorId = "status-stale-session-survivor"; @@ -2843,7 +2398,7 @@ describe("memory index", () => { }); const initial = await getFreshManager(cfg, "cli"); - managersForCleanup.add(initial); + trackManager(initial); await initial.sync({ reason: "cli", force: true }); await expect( initial.search("ORBIT-DELETE-91", { minScore: 0, sources: ["sessions"] }), @@ -2852,7 +2407,7 @@ describe("memory index", () => { const agentDb = new DatabaseSync(resolveOpenClawAgentSqlitePath({ agentId: "main" })); agentDb.exec("DELETE FROM memory_embedding_cache"); agentDb.close(); - embedBatchCalls = 0; + providerFixture.embedBatchCalls = 0; await expect( deleteSessionEntry({ @@ -2865,11 +2420,11 @@ describe("memory index", () => { ).resolves.toBe(true); const statusManager = await getFreshManager(cfg, "status"); - managersForCleanup.add(statusManager); + trackManager(statusManager); expect(statusManager.status().dirty).toBe(true); await statusManager.sync({ reason: "cli" }); - expect(embedBatchCalls).toBe(0); + expect(providerFixture.embedBatchCalls).toBe(0); const deletedResults = await statusManager.search("ORBIT-DELETE-91", { minScore: 0, sources: ["sessions"], @@ -2884,7 +2439,7 @@ describe("memory index", () => { .get() as { count: number }; expect(sourceCount.count).toBe(1); } finally { - restoreMemoryIndexStateDir(); + fixture.restoreStateDir(); } }); }); diff --git a/extensions/memory-core/src/memory/manager-async-state.test.ts b/extensions/memory-core/src/memory/manager-async-state.test.ts index 27c92956b9b3..48c9414859ad 100644 --- a/extensions/memory-core/src/memory/manager-async-state.test.ts +++ b/extensions/memory-core/src/memory/manager-async-state.test.ts @@ -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 () => { diff --git a/extensions/memory-core/src/memory/manager-index.test-support.ts b/extensions/memory-core/src/memory/manager-index.test-support.ts new file mode 100644 index 000000000000..00fcf0051c14 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-index.test-support.ts @@ -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[0]["cfg"]; +type ManagerResult = Awaited>; + +export type ManagerIndexFixtureConfig = { + extraPaths?: string[]; + sources?: Array<"memory" | "sessions">; + sessionMemory?: boolean; + rememberAcrossConversations?: boolean; + provider?: string; + fallback?: "none" | "gemini" | "fallback-provider"; + providerAliases?: NonNullable["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 | null; + providerRuntimeBatchErrors: unknown[]; + providerRuntimeBatchFailuresRemaining: number; + providerRuntimeActiveBatchCalls: number; + providerRuntimeMaxActiveBatchCalls: number; + providerCloseCalls: number; + providerCloseFailuresRemaining: number; + providerCloseFailure: unknown; + providerCreationFailure: string | null; + providerNullResult: string | null; + providerCloseGate: Promise | null; + providerInitGate: Promise | 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; + getFreshManager: ( + cfg: ManagerConfig, + purpose?: "default" | "status" | "cli", + ) => Promise; + getFtsSessionManager: (params: { stateDirName: string }) => Promise; + seedSessionTranscript: (params: { + messages: Array<{ + content: string; + role: "assistant" | "user"; + senderIsOwner?: boolean; + timestamp: number | string; + }>; + sessionId: string; + sessionKey?: string; + }) => Promise; + setStateDir: (stateDir: string) => void; + restoreStateDir: () => void; +}; + +const providerState = vi.hoisted(() => ({ + embedBatchCalls: 0, + embeddedBatchTexts: [] as string[], + embedBatchInputCalls: 0, + providerRuntimeBatchCalls: [] as string[][], + providerRuntimeBatchGate: null as Promise | 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 | null, + providerInitGate: null as Promise | 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(); + 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; + }; + }, + ) => 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(); + + 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 }).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 => { + 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 => { + 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, + }; +} diff --git a/extensions/memory-core/src/memory/manager-keyword-retrieval.test.ts b/extensions/memory-core/src/memory/manager-keyword-retrieval.test.ts index 666961e93da2..270e19df5ef5 100644 --- a/extensions/memory-core/src/memory/manager-keyword-retrieval.test.ts +++ b/extensions/memory-core/src/memory/manager-keyword-retrieval.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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 { - 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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - 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(); } }); }); diff --git a/extensions/memory-core/src/memory/manager-provider-lifecycle-fallback.test.ts b/extensions/memory-core/src/memory/manager-provider-lifecycle-fallback.test.ts index e1200ac406f8..650ee44f9972 100644 --- a/extensions/memory-core/src/memory/manager-provider-lifecycle-fallback.test.ts +++ b/extensions/memory-core/src/memory/manager-provider-lifecycle-fallback.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - 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); diff --git a/extensions/memory-core/src/memory/manager-provider-lifecycle-leases.test.ts b/extensions/memory-core/src/memory/manager-provider-lifecycle-leases.test.ts index 1391f617ae12..d57bc2e7560a 100644 --- a/extensions/memory-core/src/memory/manager-provider-lifecycle-leases.test.ts +++ b/extensions/memory-core/src/memory/manager-provider-lifecycle-leases.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - 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((resolve) => { + providerFixture.providerInitGate = new Promise((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]); } diff --git a/extensions/memory-core/src/memory/manager-provider-lifecycle.test.ts b/extensions/memory-core/src/memory/manager-provider-lifecycle.test.ts index a9633ebe96f2..f78a46131637 100644 --- a/extensions/memory-core/src/memory/manager-provider-lifecycle.test.ts +++ b/extensions/memory-core/src/memory/manager-provider-lifecycle.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - 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((resolve) => { + providerFixture.providerCloseGate = new Promise((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; }; - 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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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 = 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 = ?") diff --git a/extensions/memory-core/src/memory/manager-registry.test.ts b/extensions/memory-core/src/memory/manager-registry.test.ts index a1dfdd602c8a..544c5a8b373c 100644 --- a/extensions/memory-core/src/memory/manager-registry.test.ts +++ b/extensions/memory-core/src/memory/manager-registry.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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>, - 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 { - 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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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((resolve) => { + providerFixture.providerCloseGate = new Promise((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 = 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); }); }); diff --git a/extensions/memory-core/src/memory/manager-reindex-state.test.ts b/extensions/memory-core/src/memory/manager-reindex-state.test.ts index 8450e61a2385..dd52824058bc 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.test.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.test.ts @@ -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" }); - }); }); diff --git a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts index 47087020c10c..fa30f37e58c8 100644 --- a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts +++ b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts @@ -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 | 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 | null = null; -let providerInitGate: Promise | 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(); - 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; - }; - }, - ) => 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(); - - 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 }).sessionsDirtyFiles.clear(); - } - - type TestCfg = Parameters[0]["cfg"]; - - function createCfg(params: { - extraPaths?: string[]; - sources?: Array<"memory" | "sessions">; - sessionMemory?: boolean; - rememberAcrossConversations?: boolean; - provider?: string; - fallback?: "none" | "gemini" | "fallback-provider"; - providerAliases?: NonNullable["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 { - 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>, - missingMessage = "manager missing", - ): MemoryIndexManager { - if (!result.manager) { - throw new Error(missingMessage); - } - return result.manager as unknown as MemoryIndexManager; - } - - async function getPersistentManager(cfg: TestCfg): Promise { - 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 { - const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose })); - managersForCleanup.add(manager); - return manager; - } - - async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) { + async function expectHybridKeywordSearchFindsMemory( + cfg: Parameters[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 { - 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)); diff --git a/extensions/memory-core/src/memory/manager-status-state.test.ts b/extensions/memory-core/src/memory/manager-status-state.test.ts index df3de14178fa..81393cd24021 100644 --- a/extensions/memory-core/src/memory/manager-status-state.test.ts +++ b/extensions/memory-core/src/memory/manager-status-state.test.ts @@ -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", () => { diff --git a/extensions/memory-core/src/memory/test-manager-helpers.ts b/extensions/memory-core/src/memory/test-manager-helpers.ts deleted file mode 100644 index 469d6f769493..000000000000 --- a/extensions/memory-core/src/memory/test-manager-helpers.ts +++ /dev/null @@ -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 { - 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; -}