diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index d3434d3104f..2a47755ce84 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { clearMemoryPluginState, registerMemoryPromptSection } from "../plugins/memory-state.js"; @@ -705,26 +705,58 @@ describe("Default engine selection", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Invalid engine fallback", () => { - it("includes the requested id and available ids in unknown-engine errors", async () => { - // Ensure at least legacy is registered so we see it in the available list + beforeEach(() => { registerLegacyContextEngine(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to default engine when requested engine is not registered", async () => { + const engine = await resolveContextEngine(configWithSlot("does-not-exist")); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does-not-exist")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("falling back to default engine"), + ); + }); + + it("throws when the default engine itself is not registered", async () => { + // Access the process-global registry via the well-known symbol and clear it + // so even the default engine is missing. + const registryState = (globalThis as Record)[ + Symbol.for("openclaw.contextEngineRegistryState") + ] as { engines: Map }; + const snapshot = new Map(registryState.engines); + registryState.engines.clear(); try { - await resolveContextEngine(configWithSlot("does-not-exist")); - // Should not reach here - expect.unreachable("Expected resolveContextEngine to throw"); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - expect(message).toContain("does-not-exist"); - expect(message).toContain("not registered"); - // Should mention available engines - expect(message).toMatch(/Available engines:/); - // At least "legacy" should be listed as available - expect(message).toContain("legacy"); + await expect(resolveContextEngine()).rejects.toThrow("not registered"); + } finally { + // Restore so other tests are not affected. + for (const [key, value] of snapshot) { + registryState.engines.set(key, value); + } } }); - it("rejects resolved engines that omit info metadata", async () => { + it("falls back to default engine when factory throws", async () => { + const engineId = `factory-throw-${Date.now().toString(36)}`; + registerContextEngine(engineId, () => { + throw new Error("plugin version mismatch"); + }); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("plugin version mismatch")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("falling back to default engine"), + ); + }); + + it("falls back to default engine when resolved engine omits info metadata", async () => { const engineId = `invalid-info-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -742,36 +774,12 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.`, - ); + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing info")); }); - it("rejects resolved engines that omit required info fields", async () => { - const engineId = `invalid-info-fields-${Date.now().toString(36)}`; - registerContextEngine( - engineId, - () => - ({ - info: { id: engineId }, - async ingest() { - return { ingested: false }; - }, - async assemble({ messages }: { messages: AgentMessage[] }) { - return { messages, estimatedTokens: 0 }; - }, - async compact() { - return { ok: true, compacted: false }; - }, - }) as unknown as ContextEngine, - ); - - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.name.`, - ); - }); - - it("rejects resolved engines whose info.id mismatches the registered id", async () => { + it("falls back to default engine when info.id mismatches the registered id", async () => { const engineId = `mismatched-info-id-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -790,12 +798,14 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: info.id must match registered id "${engineId}".`, + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(`info.id must match registered id "${engineId}"`), ); }); - it("rejects resolved engines that omit required lifecycle methods", async () => { + it("falls back to default engine when resolved engine omits lifecycle methods", async () => { const engineId = `invalid-methods-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -808,8 +818,10 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing assemble(), missing compact().`, + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("missing assemble(), missing compact()"), ); }); }); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index f2d65373f9f..47b2d4074e6 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import type { ContextEngine } from "./types.js"; /** @@ -449,7 +450,9 @@ function describeResolvedContextEngineContractError( * 1. `config.plugins.slots.contextEngine` (explicit slot override) * 2. Default slot value ("legacy") * - * Throws if the resolved engine id has no registered factory. + * Non-default engines that fail (unregistered, factory throw, or contract + * violation) are logged and silently replaced by the default engine. + * Throws only when the default engine itself cannot be resolved. */ export async function resolveContextEngine(config?: OpenClawConfig): Promise { const slotValue = config?.plugins?.slots?.contextEngine; @@ -458,19 +461,71 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { + const defaultEntry = getContextEngineRegistryState().engines.get(defaultEngineId); + if (!defaultEntry) { + throw new Error( + `Context engine fallback failed: default engine "${defaultEngineId}" is not registered. ` + + `Available engines: ${listContextEngineIds().join(", ") || "(none)"}`, + ); + } + const engine = await defaultEntry.factory(); + const contractError = describeResolvedContextEngineContractError(defaultEngineId, engine); + if (contractError) { + throw new Error(contractError); + } + return wrapContextEngineWithSessionKeyCompat(engine); +}