refactor(core): dedupe shared config and runtime helpers

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:03 +00:00
parent 544ffbcf7b
commit 04892ee230
68 changed files with 1966 additions and 2018 deletions

View File

@@ -0,0 +1,23 @@
type BatchOutputErrorLike = {
error?: { message?: string };
response?: {
body?: {
error?: { message?: string };
};
};
};
export function extractBatchErrorMessage(lines: BatchOutputErrorLike[]): string | undefined {
const first = lines.find((line) => line.error?.message || line.response?.body?.error);
return (
first?.error?.message ??
(typeof first?.response?.body?.error?.message === "string"
? first?.response?.body?.error?.message
: undefined)
);
}
export function formatUnavailableBatchError(err: unknown): string | undefined {
const message = err instanceof Error ? err.message : String(err);
return message ? `error file unavailable: ${message}` : undefined;
}

View File

@@ -1,4 +1,5 @@
import type { OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
import { postJsonWithRetry } from "./batch-http.js";
import { applyEmbeddingBatchOutputLine } from "./batch-output.js";
import { buildBatchHeaders, normalizeBatchBaseUrl, splitBatchRequests } from "./batch-utils.js";
@@ -133,16 +134,9 @@ async function readOpenAiBatchError(params: {
fileId: params.errorFileId,
});
const lines = parseOpenAiBatchOutput(content);
const first = lines.find((line) => line.error?.message || line.response?.body?.error);
const message =
first?.error?.message ??
(typeof first?.response?.body?.error?.message === "string"
? first?.response?.body?.error?.message
: undefined);
return message;
return extractBatchErrorMessage(lines);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return message ? `error file unavailable: ${message}` : undefined;
return formatUnavailableBatchError(err);
}
}

View File

@@ -1,6 +1,7 @@
import { createInterface } from "node:readline";
import { Readable } from "node:stream";
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
import { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
import { postJsonWithRetry } from "./batch-http.js";
import { applyEmbeddingBatchOutputLine } from "./batch-output.js";
import { buildBatchHeaders, normalizeBatchBaseUrl, splitBatchRequests } from "./batch-utils.js";
@@ -128,16 +129,9 @@ async function readVoyageBatchError(params: {
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as VoyageBatchOutputLine);
const first = lines.find((line) => line.error?.message || line.response?.body?.error);
const message =
first?.error?.message ??
(typeof first?.response?.body?.error?.message === "string"
? first?.response?.body?.error?.message
: undefined);
return message;
return extractBatchErrorMessage(lines);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return message ? `error file unavailable: ${message}` : undefined;
return formatUnavailableBatchError(err);
}
}

View File

@@ -1,4 +1,5 @@
import { vi } from "vitest";
import "./test-runtime-mocks.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
@@ -24,18 +25,6 @@ export function resetEmbeddingMocks(): void {
hoisted.embedQuery.mockImplementation(async () => [0, 1, 0]);
}
// Unit tests: avoid importing the real chokidar implementation (native fsevents, etc.).
vi.mock("chokidar", () => ({
default: {
watch: () => ({ on: () => {}, close: async () => {} }),
},
watch: () => ({ on: () => {}, close: async () => {} }),
}));
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: async () => ({
requestedProvider: "openai",

View File

@@ -328,30 +328,42 @@ describe("local embedding normalization", () => {
vi.unstubAllGlobals();
});
it("normalizes local embeddings to magnitude ~1.0", async () => {
const unnormalizedVector = [2.35, 3.45, 0.63, 4.3, 1.2, 5.1, 2.8, 3.9];
const resolveModelFileMock = vi.fn(async () => "/fake/model.gguf");
importNodeLlamaCppMock.mockResolvedValue({
getLlama: async () => ({
loadModel: vi.fn().mockResolvedValue({
createEmbeddingContext: vi.fn().mockResolvedValue({
getEmbeddingFor: vi.fn().mockResolvedValue({
vector: new Float32Array(unnormalizedVector),
}),
}),
}),
}),
resolveModelFile: resolveModelFileMock,
LlamaLogLevel: { error: 0 },
});
const result = await createEmbeddingProvider({
async function createLocalProviderForTest() {
return createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "",
fallback: "none",
});
}
function mockSingleLocalEmbeddingVector(
vector: number[],
resolveModelFile: (modelPath: string, modelDirectory?: string) => Promise<string> = async () =>
"/fake/model.gguf",
): void {
importNodeLlamaCppMock.mockResolvedValue({
getLlama: async () => ({
loadModel: vi.fn().mockResolvedValue({
createEmbeddingContext: vi.fn().mockResolvedValue({
getEmbeddingFor: vi.fn().mockResolvedValue({
vector: new Float32Array(vector),
}),
}),
}),
}),
resolveModelFile,
LlamaLogLevel: { error: 0 },
});
}
it("normalizes local embeddings to magnitude ~1.0", async () => {
const unnormalizedVector = [2.35, 3.45, 0.63, 4.3, 1.2, 5.1, 2.8, 3.9];
const resolveModelFileMock = vi.fn(async () => "/fake/model.gguf");
mockSingleLocalEmbeddingVector(unnormalizedVector, resolveModelFileMock);
const result = await createLocalProviderForTest();
const embedding = await result.provider.embedQuery("test query");
@@ -364,26 +376,9 @@ describe("local embedding normalization", () => {
it("handles zero vector without division by zero", async () => {
const zeroVector = [0, 0, 0, 0];
importNodeLlamaCppMock.mockResolvedValue({
getLlama: async () => ({
loadModel: vi.fn().mockResolvedValue({
createEmbeddingContext: vi.fn().mockResolvedValue({
getEmbeddingFor: vi.fn().mockResolvedValue({
vector: new Float32Array(zeroVector),
}),
}),
}),
}),
resolveModelFile: async () => "/fake/model.gguf",
LlamaLogLevel: { error: 0 },
});
mockSingleLocalEmbeddingVector(zeroVector);
const result = await createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "",
fallback: "none",
});
const result = await createLocalProviderForTest();
const embedding = await result.provider.embedQuery("test");
@@ -394,26 +389,9 @@ describe("local embedding normalization", () => {
it("sanitizes non-finite values before normalization", async () => {
const nonFiniteVector = [1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
importNodeLlamaCppMock.mockResolvedValue({
getLlama: async () => ({
loadModel: vi.fn().mockResolvedValue({
createEmbeddingContext: vi.fn().mockResolvedValue({
getEmbeddingFor: vi.fn().mockResolvedValue({
vector: new Float32Array(nonFiniteVector),
}),
}),
}),
}),
resolveModelFile: async () => "/fake/model.gguf",
LlamaLogLevel: { error: 0 },
});
mockSingleLocalEmbeddingVector(nonFiniteVector);
const result = await createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "",
fallback: "none",
});
const result = await createLocalProviderForTest();
const embedding = await result.provider.embedQuery("test");
@@ -444,12 +422,7 @@ describe("local embedding normalization", () => {
LlamaLogLevel: { error: 0 },
});
const result = await createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "",
fallback: "none",
});
const result = await createLocalProviderForTest();
const embeddings = await result.provider.embedBatch(["text1", "text2", "text3"]);

View File

@@ -3,21 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import "./test-runtime-mocks.js";
let embedBatchCalls = 0;
// Unit tests: avoid importing the real chokidar implementation (native fsevents, etc.).
vi.mock("chokidar", () => ({
default: {
watch: () => ({ on: () => {}, close: async () => {} }),
},
watch: () => ({ on: () => {}, close: async () => {} }),
}));
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
vi.mock("./embeddings.js", () => {
const embedText = (text: string) => {
const lower = text.toLowerCase();

View File

@@ -3,25 +3,17 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
const embedBatch = vi.fn(async () => []);
const embedQuery = vi.fn(async () => [0.2, 0.2, 0.2]);
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "openai",
model: "text-embedding-3-small",
createEmbeddingProvider: async () =>
createOpenAIEmbeddingProviderMock({
embedQuery,
embedBatch,
},
openAi: {
baseUrl: "https://api.openai.com/v1",
headers: { Authorization: "Bearer test", "Content-Type": "application/json" },
model: "text-embedding-3-small",
},
}),
}),
}));
describe("memory search async sync", () => {

View File

@@ -2,48 +2,19 @@ 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 { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import type { MemoryIndexManager } from "./index.js";
import { getEmbedBatchMock, resetEmbeddingMocks } from "./embedding.test-mocks.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
let shouldFail = false;
vi.mock("chokidar", () => ({
default: {
watch: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
},
}));
vi.mock("./embeddings.js", () => {
return {
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "mock",
model: "mock-embed",
embedQuery: async () => [1, 0, 0],
embedBatch: async (texts: string[]) => {
if (shouldFail) {
throw new Error("embedding failure");
}
return texts.map((_, index) => [index + 1, 0, 0]);
},
},
}),
};
});
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
let caseId = 0;
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
const embedBatch = getEmbedBatchMock();
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-atomic-"));
@@ -51,7 +22,14 @@ describe("memory manager atomic reindex", () => {
beforeEach(async () => {
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
resetEmbeddingMocks();
shouldFail = false;
embedBatch.mockImplementation(async (texts: string[]) => {
if (shouldFail) {
throw new Error("embedding failure");
}
return texts.map((_, index) => [index + 1, 0, 0]);
});
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(workspaceDir, { recursive: true });
indexPath = path.join(workspaceDir, "index.sqlite");
@@ -92,12 +70,7 @@ describe("memory manager atomic reindex", () => {
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) {
throw new Error("manager missing");
}
manager = result.manager;
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
await manager.sync({ force: true });
const beforeStatus = manager.status();

View File

@@ -3,37 +3,18 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
import "./test-runtime-mocks.js";
const embedBatch = vi.fn(async () => []);
const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]);
// Unit tests: avoid importing the real chokidar implementation (native fsevents, etc.).
vi.mock("chokidar", () => ({
default: {
watch: () => ({ on: () => {}, close: async () => {} }),
},
watch: () => ({ on: () => {}, close: async () => {} }),
}));
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "openai",
model: "text-embedding-3-small",
createEmbeddingProvider: async () =>
createOpenAIEmbeddingProviderMock({
embedQuery,
embedBatch,
},
openAi: {
baseUrl: "https://api.openai.com/v1",
headers: { Authorization: "Bearer test", "Content-Type": "application/json" },
model: "text-embedding-3-small",
},
}),
}),
}));
describe("memory indexing with OpenAI batches", () => {

View File

@@ -2,40 +2,22 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
vi.mock("chokidar", () => ({
default: {
watch: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
},
}));
vi.mock("./embeddings.js", () => {
return {
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "mock",
model: "mock-embed",
embedQuery: async () => [0, 0, 0],
embedBatch: async () => {
throw new Error("openai embeddings failed: 400 bad request");
},
},
}),
};
});
import type { MemoryIndexManager } from "./index.js";
import { getEmbedBatchMock, resetEmbeddingMocks } from "./embedding.test-mocks.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
describe("memory manager sync failures", () => {
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
const embedBatch = getEmbedBatchMock();
beforeEach(async () => {
vi.useFakeTimers();
resetEmbeddingMocks();
embedBatch.mockImplementation(async () => {
throw new Error("openai embeddings failed: 400 bad request");
});
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"));
@@ -73,12 +55,7 @@ describe("memory manager sync failures", () => {
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) {
throw new Error("manager missing");
}
manager = result.manager;
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
const syncSpy = vi.spyOn(manager, "sync");
// Call the internal scheduler directly; it uses fire-and-forget sync.

View File

@@ -70,6 +70,31 @@ vi.mock("./manager.js", () => ({
import { QmdMemoryManager } from "./qmd-manager.js";
import { getMemorySearchManager } from "./search-manager.js";
type SearchManagerResult = Awaited<ReturnType<typeof getMemorySearchManager>>;
type SearchManager = NonNullable<SearchManagerResult["manager"]>;
function createQmdCfg(agentId: string) {
return {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
}
function requireManager(result: SearchManagerResult): SearchManager {
expect(result.manager).toBeTruthy();
if (!result.manager) {
throw new Error("manager missing");
}
return result.manager;
}
async function createFailedQmdSearchHarness(params: { agentId: string; errorMessage: string }) {
const cfg = createQmdCfg(params.agentId);
mockPrimary.search.mockRejectedValueOnce(new Error(params.errorMessage));
const first = await getMemorySearchManager({ cfg, agentId: params.agentId });
return { cfg, manager: requireManager(first), firstResult: first };
}
beforeEach(() => {
mockPrimary.search.mockClear();
mockPrimary.readFile.mockClear();
@@ -92,10 +117,7 @@ beforeEach(() => {
describe("getMemorySearchManager caching", () => {
it("reuses the same QMD manager instance for repeated calls", async () => {
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: "main", default: true, workspace: "/tmp/workspace" }] },
} as const;
const cfg = createQmdCfg("main");
const first = await getMemorySearchManager({ cfg, agentId: "main" });
const second = await getMemorySearchManager({ cfg, agentId: "main" });
@@ -107,24 +129,21 @@ describe("getMemorySearchManager caching", () => {
it("evicts failed qmd wrapper so next call retries qmd", async () => {
const retryAgentId = "retry-agent";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: retryAgentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
const {
cfg,
manager: firstManager,
firstResult: first,
} = await createFailedQmdSearchHarness({
agentId: retryAgentId,
errorMessage: "qmd query failed",
});
mockPrimary.search.mockRejectedValueOnce(new Error("qmd query failed"));
const first = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(first.manager).toBeTruthy();
if (!first.manager) {
throw new Error("manager missing");
}
const fallbackResults = await first.manager.search("hello");
const fallbackResults = await firstManager.search("hello");
expect(fallbackResults).toHaveLength(1);
expect(fallbackResults[0]?.path).toBe("MEMORY.md");
const second = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(second.manager).toBeTruthy();
requireManager(second);
expect(second.manager).not.toBe(first.manager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
@@ -132,16 +151,13 @@ describe("getMemorySearchManager caching", () => {
it("does not cache status-only qmd managers", async () => {
const agentId = "status-agent";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
const cfg = createQmdCfg(agentId);
const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
expect(first.manager).toBeTruthy();
expect(second.manager).toBeTruthy();
requireManager(first);
requireManager(second);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -158,53 +174,36 @@ describe("getMemorySearchManager caching", () => {
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {
const retryAgentId = "retry-agent-close";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: retryAgentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
mockPrimary.search.mockRejectedValueOnce(new Error("qmd query failed"));
const first = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(first.manager).toBeTruthy();
if (!first.manager) {
throw new Error("manager missing");
}
await first.manager.search("hello");
const {
cfg,
manager: firstManager,
firstResult: first,
} = await createFailedQmdSearchHarness({
agentId: retryAgentId,
errorMessage: "qmd query failed",
});
await firstManager.search("hello");
const second = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(second.manager).toBeTruthy();
if (!second.manager) {
throw new Error("manager missing");
}
const secondManager = requireManager(second);
expect(second.manager).not.toBe(first.manager);
await first.manager.close?.();
await firstManager.close?.();
const third = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(third.manager).toBe(second.manager);
expect(third.manager).toBe(secondManager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
});
it("falls back to builtin search when qmd fails with sqlite busy", async () => {
const retryAgentId = "retry-agent-busy";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: retryAgentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
const { manager: firstManager } = await createFailedQmdSearchHarness({
agentId: retryAgentId,
errorMessage: "qmd index busy while reading results: SQLITE_BUSY: database is locked",
});
mockPrimary.search.mockRejectedValueOnce(
new Error("qmd index busy while reading results: SQLITE_BUSY: database is locked"),
);
const first = await getMemorySearchManager({ cfg, agentId: retryAgentId });
expect(first.manager).toBeTruthy();
if (!first.manager) {
throw new Error("manager missing");
}
const results = await first.manager.search("hello");
const results = await firstManager.search("hello");
expect(results).toHaveLength(1);
expect(results[0]?.path).toBe("MEMORY.md");
expect(fallbackSearch).toHaveBeenCalledTimes(1);
@@ -212,19 +211,12 @@ describe("getMemorySearchManager caching", () => {
it("keeps original qmd error when fallback manager initialization fails", async () => {
const retryAgentId = "retry-agent-no-fallback-auth";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: retryAgentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
mockPrimary.search.mockRejectedValueOnce(new Error("qmd query failed"));
const { manager: firstManager } = await createFailedQmdSearchHarness({
agentId: retryAgentId,
errorMessage: "qmd query failed",
});
mockMemoryIndexGet.mockRejectedValueOnce(new Error("No API key found for provider openai"));
const first = await getMemorySearchManager({ cfg, agentId: retryAgentId });
if (!first.manager) {
throw new Error("manager missing");
}
await expect(first.manager.search("hello")).rejects.toThrow("qmd query failed");
await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed");
});
});

View File

@@ -0,0 +1,19 @@
export function createOpenAIEmbeddingProviderMock(params: {
embedQuery: (input: string) => Promise<number[]>;
embedBatch: (input: string[]) => Promise<number[][]>;
}) {
return {
requestedProvider: "openai",
provider: {
id: "openai",
model: "text-embedding-3-small",
embedQuery: params.embedQuery,
embedBatch: params.embedBatch,
},
openAi: {
baseUrl: "https://api.openai.com/v1",
headers: { Authorization: "Bearer test", "Content-Type": "application/json" },
model: "text-embedding-3-small",
},
};
}

View File

@@ -0,0 +1,19 @@
import type { OpenClawConfig } from "../config/config.js";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
agentId?: string;
}): Promise<MemoryIndexManager> {
const result = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId ?? "main",
});
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;
}

View File

@@ -0,0 +1,13 @@
import { vi } from "vitest";
// Unit tests: avoid importing the real chokidar implementation (native fsevents, etc.).
vi.mock("chokidar", () => ({
default: {
watch: () => ({ on: () => {}, close: async () => {} }),
},
watch: () => ({ on: () => {}, close: async () => {} }),
}));
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));