diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 694fd3a7a71..1f06beb9c13 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -529,6 +529,122 @@ describe("createModelSelectionState respects session model override", () => { }); }); +describe("createModelSelectionState auto-failover override self-healing", () => { + const defaultProvider = "mac-studio"; + const defaultModel = "MiniMax-M2.7-MLX"; + const sessionKey = "agent:main:telegram:direct:1"; + + async function resolveStateWithOverride(params: { + providerOverride: string; + modelOverride: string; + modelOverrideSource: "auto" | "user" | undefined; + }) { + const cfg = {} as OpenClawConfig; + const sessionEntry = makeEntry({ + providerOverride: params.providerOverride, + modelOverride: params.modelOverride, + modelOverrideSource: params.modelOverrideSource, + }); + const sessionStore = { [sessionKey]: sessionEntry }; + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + return { state, sessionEntry, sessionStore }; + } + + it("clears auto-failover override and retries the configured primary", async () => { + const { state, sessionStore } = await resolveStateWithOverride({ + providerOverride: "openrouter", + modelOverride: "minimax/minimax-m2.7", + modelOverrideSource: "auto", + }); + + // Provider/model should revert to the configured primary, not the fallback. + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe(defaultModel); + // The auto override should be cleared from session state. + expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined(); + expect(state.resetModelOverride).toBe(true); + }); + + it("preserves a user-selected override across turns", async () => { + const { state, sessionStore } = await resolveStateWithOverride({ + providerOverride: "openrouter", + modelOverride: "minimax/minimax-m2.7", + modelOverrideSource: "user", + }); + + // User-selected override must persist. + expect(state.provider).toBe("openrouter"); + expect(state.model).toBe("minimax/minimax-m2.7"); + expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7"); + expect(state.resetModelOverride).toBe(false); + }); + + it("preserves a legacy override with no modelOverrideSource (treated as user)", async () => { + // Sessions persisted before modelOverrideSource was introduced lack the field. + // Backward-compat rule: missing source + present override = user selection. + const { state, sessionStore } = await resolveStateWithOverride({ + providerOverride: "openrouter", + modelOverride: "minimax/minimax-m2.7", + modelOverrideSource: undefined, + }); + + expect(state.provider).toBe("openrouter"); + expect(state.model).toBe("minimax/minimax-m2.7"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7"); + expect(state.resetModelOverride).toBe(false); + }); + + it("does not touch an auto-failover override inherited from a parent session", async () => { + // Auto clearing only applies to a direct session override, not one inherited + // from a parent. The parent's own session state is managed separately. + const cfg = {} as OpenClawConfig; + const parentKey = "agent:main:telegram:direct:1"; + const childKey = "agent:main:telegram:direct:1:thread:99"; + const parentEntry = makeEntry({ + providerOverride: "openrouter", + modelOverride: "minimax/minimax-m2.7", + modelOverrideSource: "auto", + }); + const childEntry = makeEntry(); // no override of its own + const sessionStore = { [parentKey]: parentEntry, [childKey]: childEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry: childEntry, + sessionStore, + sessionKey: childKey, + parentSessionKey: parentKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + // Parent auto-override is applied to the child (it has no direct override). + expect(state.provider).toBe("openrouter"); + expect(state.model).toBe("minimax/minimax-m2.7"); + // Parent session entry is not modified by the child's selection logic. + expect(sessionStore[parentKey]?.providerOverride).toBe("openrouter"); + expect(state.resetModelOverride).toBe(false); + }); +}); + describe("createModelSelectionState resolveDefaultReasoningLevel", () => { it("returns on when catalog model has reasoning true", async () => { const { loadModelCatalog } = await import("../../agents/model-catalog.runtime.js"); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 0f38f243154..a90f6095703 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -375,7 +375,32 @@ export async function createModelSelectionState(params: { // was resolved. Heartbeat runs without heartbeat.model should still inherit // the regular session/parent model override behavior. const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true; - if (storedOverride?.model && !skipStoredOverride) { + + // Auto-failover overrides are transient: on the next turn, retry the configured + // primary so the session self-heals when the primary recovers. The fallback loop + // in runWithModelFallback will re-set the override if the primary is still down. + // User-selected overrides (/model command) are preserved across turns. + const isAutoSessionOverride = + storedOverride?.source === "session" && sessionEntry?.modelOverrideSource === "auto"; + if (isAutoSessionOverride && sessionEntry && sessionStore && sessionKey && !resetModelOverride) { + const { updated } = applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, + }); + if (updated) { + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await ( + await loadSessionStoreRuntime() + ).updateSessionStore(storePath, (store) => { + store[sessionKey] = sessionEntry; + }); + } + resetModelOverride = true; + } + } + + if (storedOverride?.model && !skipStoredOverride && !isAutoSessionOverride) { const normalizedStoredOverride = normalizeModelRef( storedOverride.provider || defaultProvider, storedOverride.model,