





















@@ -12,6 +12,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
1212import "./test-runtime-mocks.js";
1313import type { MemoryIndexManager } from "./index.js";
1414import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
15+import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
1516import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
1617import {
1718DEFAULT_LOCAL_MODEL,
@@ -34,6 +35,14 @@ let providerCloseGate: Promise<void> | null = null;
3435let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
3536let forceNoProvider = false;
363738+function createLocalWorkerExitError(): Error {
39+return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), {
40+code: LOCAL_EMBEDDING_WORKER_ERROR_CODES.exited,
41+reason: "exit",
42+exitCode: 134,
43+});
44+}
45+3746vi.mock("./embeddings.js", () => {
3847const embedText = (text: string) => {
3948const lower = text.toLowerCase();
@@ -44,6 +53,10 @@ vi.mock("./embeddings.js", () => {
4453return [alpha, beta, image, audio];
4554};
4655return {
56+resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
57+providerId === "gemini" || providerId === "fallback-provider"
58+ ? `${providerId}-embed`
59+ : fallbackSourceModel,
4760createEmbeddingProvider: async (options: {
4861provider?: string;
4962model?: string;
@@ -61,7 +74,10 @@ vi.mock("./embeddings.js", () => {
6174providerUnavailableReason: "No API key found for provider",
6275};
6376}
64-const providerId = options.provider === "gemini" ? "gemini" : "mock";
77+const providerId =
78+options.provider === "gemini" || options.provider === "fallback-provider"
79+ ? options.provider
80+ : "mock";
6581const model = options.model ?? "mock-embed";
6682return {
6783requestedProvider: options.provider ?? "openai",
@@ -81,7 +97,7 @@ vi.mock("./embeddings.js", () => {
8197embedBatchCalls += 1;
8298return texts.map(embedText);
8399},
84- ...(providerId === "gemini"
100+ ...(providerId === "gemini" || providerId === "fallback-provider"
85101 ? {
86102embedBatchInputs: async (
87103inputs: Array<{
@@ -112,12 +128,12 @@ vi.mock("./embeddings.js", () => {
112128}
113129 : {}),
114130},
115- ...(providerId === "gemini"
131+ ...(providerId === "gemini" || providerId === "fallback-provider"
116132 ? {
117133runtime: {
118-id: "gemini",
134+id: providerId,
119135cacheKeyData: {
120-provider: "gemini",
136+provider: providerId,
121137baseUrl: "https://generativelanguage.googleapis.com/v1beta",
122138 model,
123139outputDimensionality: options.outputDimensionality,
@@ -242,7 +258,8 @@ describe("memory index", () => {
242258extraPaths?: string[];
243259sources?: Array<"memory" | "sessions">;
244260sessionMemory?: boolean;
245-provider?: "openai" | "gemini";
261+provider?: "openai" | "gemini" | "fallback-provider";
262+fallback?: "none" | "gemini" | "fallback-provider";
246263model?: string;
247264outputDimensionality?: number;
248265multimodal?: {
@@ -263,6 +280,7 @@ describe("memory index", () => {
263280memorySearch: {
264281provider: params.provider ?? "openai",
265282model: params.model ?? "mock-embed",
283+fallback: params.fallback,
266284outputDimensionality: params.outputDimensionality,
267285store: { path: params.storePath, vector: { enabled: params.vectorEnabled ?? false } },
268286// Perf: keep test indexes to a single chunk to reduce sqlite work.
@@ -577,6 +595,144 @@ describe("memory index", () => {
577595);
578596});
579597598+it("clears cached embedding probe readiness when local embeddings degrade", async () => {
599+const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-degraded.sqlite") });
600+const manager = await getPersistentManager(cfg);
601+602+await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({ ok: true });
603+expect(manager.getCachedEmbeddingAvailability()?.ok).toBe(true);
604+(
605+manager as unknown as {
606+provider: {
607+id: string;
608+model: string;
609+embedQuery: (text: string) => Promise<number[]>;
610+embedBatch: (texts: string[]) => Promise<number[][]>;
611+close: () => Promise<void>;
612+};
613+}
614+).provider = {
615+id: "local",
616+model: "local-model",
617+embedQuery: async () => [1, 0],
618+embedBatch: async (texts: string[]) => texts.map(() => [1, 0]),
619+close: async () => {},
620+};
621+622+(
623+manager as unknown as {
624+markLocalEmbeddingProviderDegraded: (err: unknown) => void;
625+}
626+).markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
627+628+expect(manager.getCachedEmbeddingAvailability()).toBeNull();
629+await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
630+ok: false,
631+error: expect.stringContaining("Local embeddings degraded"),
632+});
633+});
634+635+it("activates configured fallback when local embeddings degrade during search", async () => {
636+const cfg = createCfg({
637+storePath: path.join(workspaceDir, "index-search-degraded-fallback.sqlite"),
638+fallback: "fallback-provider",
639+hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
640+});
641+const manager = await getPersistentManager(cfg);
642+643+await manager.sync({ reason: "test" });
644+const callsBeforeSearch = providerCalls.length;
645+(
646+manager as unknown as {
647+provider: {
648+id: string;
649+model: string;
650+embedQuery: () => Promise<number[]>;
651+embedBatch: (texts: string[]) => Promise<number[][]>;
652+close: () => Promise<void>;
653+};
654+}
655+).provider = {
656+id: "local",
657+model: "mock-embed",
658+embedQuery: async () => {
659+throw createLocalWorkerExitError();
660+},
661+embedBatch: async (texts: string[]) => texts.map(() => [1, 0, 0, 0]),
662+close: async () => {},
663+};
664+665+const results = await manager.search("alpha");
666+667+expect(results.length).toBeGreaterThan(0);
668+const resultKeys = results.map(
669+(result) => `${result.source}:${result.path}:${result.startLine}:${result.endLine}`,
670+);
671+expect(new Set(resultKeys).size).toBe(resultKeys.length);
672+expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
673+"fallback-provider",
674+);
675+expect(
676+(
677+manager as unknown as {
678+provider: { id: string } | null;
679+}
680+).provider?.id,
681+).toBe("fallback-provider");
682+});
683+684+it("activates configured fallback after probe-time local degradation", async () => {
685+const cfg = createCfg({
686+storePath: path.join(workspaceDir, "index-probe-degraded-fallback.sqlite"),
687+fallback: "fallback-provider",
688+hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
689+});
690+const manager = await getPersistentManager(cfg);
691+692+await manager.sync({ reason: "test" });
693+(
694+manager as unknown as {
695+provider: {
696+id: string;
697+model: string;
698+embedQuery: () => Promise<number[]>;
699+embedBatch: () => Promise<number[][]>;
700+close: () => Promise<void>;
701+};
702+}
703+).provider = {
704+id: "local",
705+model: "mock-embed",
706+embedQuery: async () => {
707+throw createLocalWorkerExitError();
708+},
709+embedBatch: async () => {
710+throw createLocalWorkerExitError();
711+},
712+close: async () => {},
713+};
714+const callsBeforeSearch = providerCalls.length;
715+716+await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
717+ok: false,
718+error: expect.stringContaining("Local embedding worker exited"),
719+});
720+721+const results = await manager.search("alpha");
722+723+expect(results.length).toBeGreaterThan(0);
724+expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
725+"fallback-provider",
726+);
727+expect(
728+(
729+manager as unknown as {
730+provider: { id: string } | null;
731+}
732+).provider?.id,
733+).toBe("fallback-provider");
734+});
735+580736it("streams embedding cache rows during safe reindex", async () => {
581737vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
582738type EmbeddingCacheRow = {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。