diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 4703c86033d..fabf16cc755 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -4,19 +4,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "./index.js"; +import { + clearMemoryEmbeddingProviders as clearRegistry, + registerMemoryEmbeddingProvider as registerAdapter, +} from "../../../../src/plugins/memory-embedding-providers.js"; import "./test-runtime-mocks.js"; +import type { MemoryIndexManager } from "./index.js"; +import { getMemorySearchManager, closeAllMemorySearchManagers } from "./index.js"; import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"; -type MemoryIndexModule = typeof import("./index.js"); -type MemoryEmbeddingProvidersModule = - typeof import("../../../../src/plugins/memory-embedding-providers.js"); - -let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; -let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; -let clearRegistry: MemoryEmbeddingProvidersModule["clearMemoryEmbeddingProviders"]; -let registerAdapter: MemoryEmbeddingProvidersModule["registerMemoryEmbeddingProvider"]; - let embedBatchCalls = 0; let embedBatchInputCalls = 0; let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = []; @@ -120,7 +116,6 @@ describe("memory index", () => { let indexMainPath = ""; let indexExtraPath = ""; let indexMultimodalPath = ""; - let indexStatusPath = ""; let indexSourceChangePath = ""; let indexModelPath = ""; let indexFtsOnlyPath = ""; @@ -145,13 +140,6 @@ describe("memory index", () => { const managersForCleanup = new Set(); beforeAll(async () => { - vi.resetModules(); - await import("./test-runtime-mocks.js"); - ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); - ({ - clearMemoryEmbeddingProviders: clearRegistry, - registerMemoryEmbeddingProvider: registerAdapter, - } = await import("../../../../src/plugins/memory-embedding-providers.js")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-")); workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); @@ -160,7 +148,6 @@ describe("memory index", () => { indexVectorPath = path.join(workspaceDir, "index-vector.sqlite"); indexExtraPath = path.join(workspaceDir, "index-extra.sqlite"); indexMultimodalPath = path.join(workspaceDir, "index-multimodal.sqlite"); - indexStatusPath = path.join(workspaceDir, "index-status.sqlite"); indexSourceChangePath = path.join(workspaceDir, "index-source-change.sqlite"); indexModelPath = path.join(workspaceDir, "index-model-change.sqlite"); indexFtsOnlyPath = path.join(workspaceDir, "index-fts-only.sqlite"); @@ -432,52 +419,6 @@ describe("memory index", () => { await manager.close?.(); }); - it("keeps dirty false in status-only manager after prior indexing", async () => { - const cfg = createCfg({ storePath: indexStatusPath }); - - const first = await getMemorySearchManager({ cfg, agentId: "main" }); - const firstManager = requireManager(first); - await firstManager.sync?.({ reason: "test" }); - await firstManager.close?.(); - const providerCallsBeforeStatus = providerCalls.length; - - const statusOnly = await getMemorySearchManager({ - cfg, - agentId: "main", - purpose: "status", - }); - const statusManager = requireManager(statusOnly, "status manager missing"); - const status = statusManager.status(); - expect(status.dirty).toBe(false); - expect(status.provider).toBe("openai"); - expect(providerCalls).toHaveLength(providerCallsBeforeStatus); - await statusManager.close?.(); - }); - - it("does not cache builtin status-only managers across repeated requests", async () => { - const cfg = createCfg({ - storePath: path.join(workspaceDir, `index-status-${randomUUID()}.sqlite`), - }); - - const first = await getMemorySearchManager({ - cfg, - agentId: "main", - purpose: "status", - }); - const second = await getMemorySearchManager({ - cfg, - agentId: "main", - purpose: "status", - }); - - const firstManager = requireManager(first, "first status manager missing"); - const secondManager = requireManager(second, "second status manager missing"); - expect(secondManager).not.toBe(firstManager); - - await firstManager.close?.(); - await secondManager.close?.(); - }); - it("reindexes sessions when source config adds sessions to an existing index", async () => { const stateDir = sourceChangeStateDir; const sessionDir = path.join(stateDir, "agents", "main", "sessions"); diff --git a/extensions/memory-core/src/memory/manager-status-state.test.ts b/extensions/memory-core/src/memory/manager-status-state.test.ts new file mode 100644 index 00000000000..33c0e86c73c --- /dev/null +++ b/extensions/memory-core/src/memory/manager-status-state.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { resolveInitialMemoryDirty, resolveStatusProviderInfo } from "./manager-status-state.js"; + +describe("memory manager status state", () => { + it("keeps memory clean for status-only managers after prior indexing", () => { + expect( + resolveInitialMemoryDirty({ + hasMemorySource: true, + statusOnly: true, + hasIndexedMeta: true, + }), + ).toBe(false); + }); + + it("marks status-only managers dirty when no prior index metadata exists", () => { + expect( + resolveInitialMemoryDirty({ + hasMemorySource: true, + statusOnly: true, + hasIndexedMeta: false, + }), + ).toBe(true); + }); + + it("reports the requested provider before provider initialization", () => { + expect( + resolveStatusProviderInfo({ + 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({ + provider: null, + providerInitialized: true, + requestedProvider: "openai", + configuredModel: "mock-embed", + }), + ).toEqual({ + provider: "none", + model: undefined, + searchMode: "fts-only", + }); + }); +}); diff --git a/extensions/memory-core/src/memory/manager-status-state.ts b/extensions/memory-core/src/memory/manager-status-state.ts new file mode 100644 index 00000000000..20e36fd646c --- /dev/null +++ b/extensions/memory-core/src/memory/manager-status-state.ts @@ -0,0 +1,43 @@ +type StatusProvider = { + id: string; + model: string; +}; + +export function resolveInitialMemoryDirty(params: { + hasMemorySource: boolean; + statusOnly: boolean; + hasIndexedMeta: boolean; +}): boolean { + return params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true); +} + +export function resolveStatusProviderInfo(params: { + provider: StatusProvider | null; + providerInitialized: boolean; + requestedProvider: string; + configuredModel?: string; +}): { + provider: string; + model?: string; + searchMode: "hybrid" | "fts-only"; +} { + if (params.provider) { + return { + provider: params.provider.id, + model: params.provider.model, + searchMode: "hybrid", + }; + } + if (params.providerInitialized) { + return { + provider: "none", + model: undefined, + searchMode: "fts-only", + }; + } + return { + provider: params.requestedProvider, + model: params.configuredModel || undefined, + searchMode: "hybrid", + }; +} diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index e4632c95883..73c62501de5 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -35,6 +35,7 @@ import { } from "./manager-cache.js"; import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js"; import { searchKeyword, searchVector } from "./manager-search.js"; +import { resolveInitialMemoryDirty, resolveStatusProviderInfo } from "./manager-status-state.js"; const SNIPPET_MAX_CHARS = 700; const VECTOR_TABLE = "chunks_vec"; const FTS_TABLE = "chunks_fts"; @@ -330,7 +331,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.ensureSessionListener(); this.ensureIntervalSync(); } - this.dirty = this.sources.has("memory") && (statusOnly ? !meta : true); + this.dirty = resolveInitialMemoryDirty({ + hasMemorySource: this.sources.has("memory"), + statusOnly, + hasIndexedMeta: Boolean(meta), + }); this.batch = this.resolveBatchConfig(); } @@ -815,12 +820,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem }; })(); - const searchMode = this.provider || !this.providerInitialized ? "hybrid" : "fts-only"; - const providerInfo = this.provider - ? { provider: this.provider.id, model: this.provider.model } - : this.providerInitialized - ? { provider: "none", model: undefined } - : { provider: this.requestedProvider, model: this.settings.model || undefined }; + const providerInfo = resolveStatusProviderInfo({ + provider: this.provider, + providerInitialized: this.providerInitialized, + requestedProvider: this.requestedProvider, + configuredModel: this.settings.model || undefined, + }); return { backend: "builtin", @@ -874,7 +879,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem lastProvider: this.batchFailureLastProvider, }, custom: { - searchMode, + searchMode: providerInfo.searchMode, providerUnavailableReason: this.providerUnavailableReason, readonlyRecovery: { attempts: this.readonlyRecoveryAttempts, diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 1dc42d5e86d..790e191c81b 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -133,6 +133,29 @@ function createQmdCfg(agentId: string): OpenClawConfig { }; } +function createBuiltinCfg(agentId: string): OpenClawConfig { + return { + agents: { + defaults: { + workspace: "/tmp/workspace", + memorySearch: { + provider: "openai", + model: "text-embedding-3-small", + store: { + path: "/tmp/index.sqlite", + vector: { enabled: false }, + }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + sources: ["memory"], + experimental: { sessionMemory: false }, + }, + }, + list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }], + }, + } as OpenClawConfig; +} + function requireManager(result: SearchManagerResult): SearchManager { expect(result.manager).toBeTruthy(); if (!result.manager) { @@ -269,6 +292,39 @@ describe("getMemorySearchManager caching", () => { expect(mockPrimary.close).toHaveBeenCalledTimes(2); }); + it("does not cache builtin managers for status-only requests", async () => { + const agentId = "builtin-status-agent"; + const cfg = createBuiltinCfg(agentId); + const firstBuiltinManager = createManagerMock({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + }); + const secondBuiltinManager = createManagerMock({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + }); + mockMemoryIndexGet + .mockResolvedValueOnce(firstBuiltinManager) + .mockResolvedValueOnce(secondBuiltinManager); + + const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + + expect(first.manager).toBe(firstBuiltinManager); + expect(second.manager).toBe(secondBuiltinManager); + expect(second.manager).not.toBe(first.manager); + expect(mockMemoryIndexGet).toHaveBeenCalledTimes(2); + + await first.manager?.close?.(); + await second.manager?.close?.(); + expect(firstBuiltinManager.close).toHaveBeenCalledTimes(1); + expect(secondBuiltinManager.close).toHaveBeenCalledTimes(1); + }); + it("reports real qmd index counts for status-only requests", async () => { const agentId = "status-counts-agent"; const cfg = createQmdCfg(agentId);