diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f61e83cc15..e09ba4cd261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. - Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags. - Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist. +- Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant. - Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer. - Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship. - Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen. diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 34f8bab4a35..f3ab2623fdd 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -266,6 +266,7 @@ vi.mock("../utils/message-channel.js", () => ({ })); vi.mock("./agent-scope.js", () => ({ + hasSessionAutoModelFallbackProvenance: () => false, listAgentEntries: () => [], listAgentIds: () => ["default"], resolveAgentConfig: () => undefined, diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 4a9c069e78a..acaa8e7865e 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -39,6 +39,7 @@ import { createTrajectoryRuntimeRecorder } from "../trajectory/runtime.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { resolveAgentRuntimeConfig } from "./agent-runtime-config.js"; import { + hasSessionAutoModelFallbackProvenance, listAgentIds, resolveAgentDir, resolveEffectiveModelFallbacks, @@ -748,6 +749,8 @@ async function agentCommandInternal( let storedModelOverrideSource = hasStoredOverride ? sessionEntry?.modelOverrideSource : undefined; + const hasStoredAutoFallbackProvenance = + hasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry); const explicitProviderOverride = typeof opts.provider === "string" ? normalizeExplicitOverrideInput(opts.provider, "provider") @@ -1032,6 +1035,9 @@ async function agentCommandInternal( hasSessionModelOverride: hasExplicitRunOverride || Boolean(storedProviderOverride || storedModelOverride), modelOverrideSource: hasExplicitRunOverride ? "user" : storedModelOverrideSource, + hasAutoFallbackProvenance: hasExplicitRunOverride + ? false + : hasStoredAutoFallbackProvenance, }); let fallbackAttemptIndex = 0; diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 049ab55d8f4..d52ce4d4eba 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -324,6 +324,23 @@ describe("resolveAgentConfig", () => { hasSessionModelOverride: true, }), ).toStrictEqual([]); + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: true, + hasAutoFallbackProvenance: true, + }), + ).toEqual(["openai/gpt-5.4"]); + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: true, + modelOverrideSource: "user", + hasAutoFallbackProvenance: true, + }), + ).toStrictEqual([]); expect( resolveEffectiveModelFallbacks({ cfg: cfgNoOverride, diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 2b3f9bf5c8b..793a7651393 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; +export { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../config/types.agents-shared.js"; import type { AgentConfig } from "../config/types.agents.js"; @@ -271,12 +272,16 @@ export function resolveEffectiveModelFallbacks(params: { agentId: string; hasSessionModelOverride: boolean; modelOverrideSource?: "auto" | "user"; + hasAutoFallbackProvenance?: boolean; }): string[] | undefined { const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); if (!params.hasSessionModelOverride) { return agentFallbacksOverride; } - if (params.modelOverrideSource !== "auto") { + const canUseConfiguredFallbacks = + params.modelOverrideSource === "auto" || + (params.modelOverrideSource === undefined && params.hasAutoFallbackProvenance === true); + if (!canUseConfiguredFallbacks) { return []; } const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model); diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index edd75c7c59b..0276f244571 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -3959,8 +3959,8 @@ describe("runAgentTurnWithFallback", () => { }); const followupRun = createFollowupRun(); - followupRun.run.provider = "anthropic"; - followupRun.run.model = "claude-opus-4-6"; + followupRun.run.provider = "bailian"; + followupRun.run.model = "qwen3.6-plus"; const sessionEntry: SessionEntry = { sessionId: "session", @@ -4007,6 +4007,71 @@ describe("runAgentTurnWithFallback", () => { expect(sessionEntry.modelOverrideSource).toBeUndefined(); }); + it("persists fallback selection for recovered auto overrides without modelOverrideSource", async () => { + state.runWithModelFallbackMock.mockImplementation( + async (params: { run: (provider: string, model: string) => Promise }) => ({ + result: await params.run("openai-codex", "gpt-5.4"), + provider: "openai-codex", + model: "gpt-5.4", + attempts: [], + }), + ); + state.runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: {}, + }); + + const followupRun = createFollowupRun(); + followupRun.run.provider = "anthropic"; + followupRun.run.model = "claude-opus-4-6"; + + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 1, + compactionCount: 0, + providerOverride: "bailian", + modelOverride: "qwen3.6-plus", + modelOverrideFallbackOriginProvider: "minimax", + modelOverrideFallbackOriginModel: "MiniMax-M2.7", + // modelOverrideSource intentionally absent + }; + const sessionStore = { main: sessionEntry }; + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + commandBody: "hello", + followupRun, + sessionCtx: { + Provider: "telegram", + MessageSid: "msg", + } as unknown as TemplateContext, + opts: {}, + typingSignals: createMockTypingSignaler(), + blockReplyPipeline: null, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + applyReplyToMode: (payload) => payload, + shouldEmitToolResult: () => true, + shouldEmitToolOutput: () => false, + pendingToolTasks: new Set(), + resetSessionAfterCompactionFailure: async () => false, + resetSessionAfterRoleOrderingConflict: async () => false, + isHeartbeat: false, + sessionKey: "main", + getActiveSessionEntry: () => sessionEntry, + activeSessionStore: sessionStore, + resolvedVerboseLevel: "off", + }); + + expect(result.kind).toBe("success"); + expect(sessionEntry.providerOverride).toBe("openai-codex"); + expect(sessionEntry.modelOverride).toBe("gpt-5.4"); + expect(sessionEntry.modelOverrideSource).toBe("auto"); + expect(sessionEntry.modelOverrideFallbackOriginProvider).toBe("minimax"); + expect(sessionEntry.modelOverrideFallbackOriginModel).toBe("MiniMax-M2.7"); + }); + it("does not persist fallback selection when modelOverrideSource is user", async () => { // Regression: fallback persistence overwrote user-initiated /models // selections. When the user explicitly picked a model, the fallback diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 4e7e49a89f2..55dfceb5524 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -4,6 +4,7 @@ import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; +import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js"; import { buildOAuthRefreshFailureLoginCommand, classifyOAuthRefreshFailure, @@ -252,7 +253,11 @@ function resolveFallbackSelectionOrigin(params: { entry: SessionEntry; run: Foll provider: string; model: string; } { - if (params.entry.modelOverrideSource === "auto") { + if ( + params.entry.modelOverrideSource === "auto" || + (params.entry.modelOverrideSource === undefined && + hasSessionAutoModelFallbackProvenance(params.entry)) + ) { const persistedOriginProvider = normalizeOptionalString( params.entry.modelOverrideFallbackOriginProvider, ); @@ -1301,7 +1306,8 @@ export async function runAgentTurnWithFallback(params: { const isUserModelOverride = activeSessionEntry.modelOverrideSource === "user" || (activeSessionEntry.modelOverrideSource === undefined && - Boolean(normalizeOptionalString(activeSessionEntry.modelOverride))); + Boolean(normalizeOptionalString(activeSessionEntry.modelOverride)) && + !hasSessionAutoModelFallbackProvenance(activeSessionEntry)); if (isUserModelOverride) { return undefined; } diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index ae9ddb46749..97d619de0f6 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -31,17 +31,19 @@ export function resolveModelFallbackOptions( configOverride: FollowupRun["run"]["config"] = run.config, ) { const config = configOverride; + const fallbacksOverride = resolveEffectiveModelFallbacks({ + cfg: config, + agentId: run.agentId, + hasSessionModelOverride: run.hasSessionModelOverride === true, + modelOverrideSource: run.modelOverrideSource, + hasAutoFallbackProvenance: run.hasAutoFallbackProvenance === true, + }); return { cfg: config, provider: run.provider, model: run.model, agentDir: run.agentDir, - fallbacksOverride: resolveEffectiveModelFallbacks({ - cfg: config, - agentId: run.agentId, - hasSessionModelOverride: run.hasSessionModelOverride === true, - modelOverrideSource: run.modelOverrideSource, - }), + fallbacksOverride, }; } @@ -60,6 +62,7 @@ export function buildEmbeddedRunBaseParams(params: { agentId: params.run.agentId, hasSessionModelOverride: params.run.hasSessionModelOverride === true, modelOverrideSource: params.run.modelOverrideSource, + hasAutoFallbackProvenance: params.run.hasAutoFallbackProvenance === true, }); return { sessionFile: params.run.sessionFile, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 14c371fd750..f3a132c4cbb 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -73,6 +73,7 @@ describe("agent-runner-utils", () => { agentId: run.agentId, hasSessionModelOverride: true, modelOverrideSource: "user", + hasAutoFallbackProvenance: false, }); expect(resolved).toEqual({ cfg: run.config, @@ -83,6 +84,25 @@ describe("agent-runner-utils", () => { }); }); + it("passes through recovered auto fallback provenance for model fallback options", () => { + hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]); + const run = makeRun({ + hasSessionModelOverride: true, + hasAutoFallbackProvenance: true, + }); + + const resolved = resolveModelFallbackOptions(run); + + expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: run.agentId, + hasSessionModelOverride: true, + modelOverrideSource: undefined, + hasAutoFallbackProvenance: true, + }); + expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); + }); + it("passes through missing agentId for helper-based fallback resolution", () => { hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]); const run = makeRun({ agentId: undefined }); @@ -94,6 +114,7 @@ describe("agent-runner-utils", () => { agentId: undefined, hasSessionModelOverride: false, modelOverrideSource: undefined, + hasAutoFallbackProvenance: false, }); expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); }); @@ -135,6 +156,35 @@ describe("agent-runner-utils", () => { expect(resolved.runId).toBe("run-1"); }); + it("passes through recovered auto fallback provenance for embedded run params", () => { + hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]); + const run = makeRun({ + hasSessionModelOverride: true, + hasAutoFallbackProvenance: true, + }); + const authProfile = resolveProviderScopedAuthProfile({ + provider: "openai", + primaryProvider: "openai", + }); + + const resolved = buildEmbeddedRunBaseParams({ + run, + provider: "openai", + model: "gpt-4.1-mini", + runId: "run-1", + authProfile, + }); + + expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: run.agentId, + hasSessionModelOverride: true, + modelOverrideSource: undefined, + hasAutoFallbackProvenance: true, + }); + expect(resolved.modelFallbacksOverride).toEqual(["fallback-model"]); + }); + it("does not force final-tag enforcement for minimax providers", () => { const run = makeRun(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index bf2478ab500..92a8870230b 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import { + hasSessionAutoModelFallbackProvenance, hasConfiguredModelFallbacks, resolveAgentConfig, resolveSessionAgentId, @@ -186,7 +187,12 @@ function resolveConfiguredFallbackModel(params: { fallbackStateEntry?: SessionEntry; }): { provider: string; model: string; persistedAutoFallback: boolean } { const entry = params.fallbackStateEntry; - if (entry?.modelOverrideSource === "auto") { + const isAutoFallbackOverride = + entry?.modelOverrideSource === "auto" || + (entry !== undefined && + entry.modelOverrideSource === undefined && + hasSessionAutoModelFallbackProvenance(entry)); + if (isAutoFallbackOverride && entry !== undefined) { const originProvider = normalizeOptionalString(entry.modelOverrideFallbackOriginProvider); const originModel = normalizeOptionalString(entry.modelOverrideFallbackOriginModel); if (originProvider && originModel) { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index ba63e24251d..0624c97080c 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; @@ -965,6 +966,12 @@ export async function runPreparedReply( normalizeOptionalString(preparedSessionState.sessionEntry?.modelOverride) || normalizeOptionalString(preparedSessionState.sessionEntry?.providerOverride), ); + const runModelOverrideSource = runHasSessionModelOverride + ? preparedSessionState.sessionEntry?.modelOverrideSource + : undefined; + const runHasAutoFallbackProvenance = + runHasSessionModelOverride && + hasSessionAutoModelFallbackProvenance(preparedSessionState.sessionEntry); const followupRun = { prompt: queuedBody, transcriptPrompt: transcriptCommandBody, @@ -1018,9 +1025,8 @@ export async function runPreparedReply( provider, model, hasSessionModelOverride: runHasSessionModelOverride, - modelOverrideSource: runHasSessionModelOverride - ? preparedSessionState.sessionEntry?.modelOverrideSource - : undefined, + modelOverrideSource: runModelOverrideSource, + hasAutoFallbackProvenance: runHasAutoFallbackProvenance || undefined, authProfileId, authProfileIdSource, thinkLevel: resolvedThinkLevel, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index c3982709ae2..49d5c9d9dc1 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1068,6 +1068,28 @@ describe("createModelSelectionState auto-failover overrides", () => { expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7"); }); + it("keeps recovered heartbeat auto-failover override without modelOverrideSource", async () => { + const { state, sessionStore } = await resolveStateWithOverride({ + providerOverride: "openrouter", + modelOverride: "minimax/minimax-m2.7", + modelOverrideSource: undefined, + modelOverrideFallbackOriginProvider: "openai", + modelOverrideFallbackOriginModel: "gpt-4o", + primaryProvider: "openai", + primaryModel: "gpt-4o", + provider: "openrouter", + model: "minimax/minimax-m2.7", + isHeartbeat: true, + }); + + expect(state.provider).toBe("openrouter"); + expect(state.model).toBe("minimax/minimax-m2.7"); + expect(state.resetModelOverride).toBe(false); + expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7"); + expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined(); + }); + it("clears legacy heartbeat auto-failover override when no origin metadata exists", async () => { const { state, sessionStore } = await resolveStateWithOverride({ providerOverride: "openrouter", diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts index ee3994760f3..13031656216 100644 --- a/src/auto-reply/reply/queue/state.test.ts +++ b/src/auto-reply/reply/queue/state.test.ts @@ -67,7 +67,7 @@ describe("refreshQueuedFollowupSession", () => { const queuedRun: FollowupRun = { prompt: "queued message", enqueuedAt: Date.now(), - run: makeRun(), + run: { ...makeRun(), hasAutoFallbackProvenance: true }, }; queue.items.push(queuedRun); diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index eaea3474507..3dcdaa1fef2 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -125,10 +125,12 @@ export function refreshQueuedFollowupSession(params: { Boolean(params.previousSessionId) && Boolean(params.nextSessionId) && params.previousSessionId !== params.nextSessionId; - const shouldRewriteSelection = + const shouldRewriteModelSelection = typeof params.nextProvider === "string" || typeof params.nextModel === "string" || - Object.hasOwn(params, "nextModelOverrideSource") || + Object.hasOwn(params, "nextModelOverrideSource"); + const shouldRewriteSelection = + shouldRewriteModelSelection || Object.hasOwn(params, "nextAuthProfileId") || Object.hasOwn(params, "nextAuthProfileIdSource"); if (!shouldRewriteSession && !shouldRewriteSelection) { @@ -153,6 +155,9 @@ export function refreshQueuedFollowupSession(params: { if (typeof params.nextModel === "string") { run.model = params.nextModel; } + if (shouldRewriteModelSelection) { + delete run.hasAutoFallbackProvenance; + } if (Object.hasOwn(params, "nextModelOverrideSource")) { run.hasSessionModelOverride = Boolean(run.provider || run.model); run.modelOverrideSource = params.nextModelOverrideSource; diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 790619be274..13713a93392 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -87,6 +87,7 @@ export type FollowupRun = { model: string; hasSessionModelOverride?: boolean; modelOverrideSource?: "auto" | "user"; + hasAutoFallbackProvenance?: boolean; authProfileId?: string; authProfileIdSource?: "auto" | "user"; thinkLevel?: ThinkLevel; diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 3b7a49863da..6eb2bf17775 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2407,6 +2407,65 @@ describe("initSessionState preserves behavior overrides across /new and /reset", } }); + it("clears recovered auto fallback model overrides without modelOverrideSource on /new and /reset", async () => { + const storePath = await createStorePath("openclaw-reset-recovered-auto-fallback-"); + const sessionKey = "agent:main:telegram:direct:6761477233"; + const existingSessionId = "existing-session-recovered-auto-fallback"; + const autoOverrides = { + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideFallbackOriginProvider: "anthropic", + modelOverrideFallbackOriginModel: "claude-opus-4-6", + verboseLevel: "on", + } as const; + const cases = [ + { name: "new clears recovered auto fallback override", body: "/new" }, + { name: "reset clears recovered auto fallback override", body: "/reset" }, + ] as const; + + for (const testCase of cases) { + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { ...autoOverrides }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: testCase.body, + RawBody: testCase.body, + CommandBody: testCase.body, + From: "6761477233", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession, testCase.name).toBe(true); + expect(result.resetTriggered, testCase.name).toBe(true); + expect(result.sessionId, testCase.name).not.toBe(existingSessionId); + expect(result.sessionEntry.modelOverride, testCase.name).toBeUndefined(); + expect(result.sessionEntry.providerOverride, testCase.name).toBeUndefined(); + expect(result.sessionEntry.modelOverrideSource, testCase.name).toBeUndefined(); + expect( + result.sessionEntry.modelOverrideFallbackOriginProvider, + testCase.name, + ).toBeUndefined(); + expect(result.sessionEntry.modelOverrideFallbackOriginModel, testCase.name).toBeUndefined(); + expect(result.sessionEntry.verboseLevel, testCase.name).toBe(autoOverrides.verboseLevel); + } + }); + it("preserves spawned session ownership metadata across /new and /reset", async () => { const storePath = await createStorePath("openclaw-reset-spawned-metadata-"); const sessionKey = "subagent:owned-child"; diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index bac0173921d..d73d9473a24 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -1,3 +1,4 @@ +import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js"; import { modelKey, normalizeModelRef, @@ -91,7 +92,15 @@ export function isStaleHeartbeatAutoFallbackOverride(params: { if (params.storedOverride?.source !== "session") { return false; } - if (params.sessionEntry?.modelOverrideSource !== "auto") { + const entry = params.sessionEntry; + const recoveredAutoFallbackOverride = + entry !== undefined && + entry.modelOverrideSource === undefined && + hasSessionAutoModelFallbackProvenance(entry); + if (entry?.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) { + return false; + } + if (!entry) { return false; } @@ -106,8 +115,8 @@ export function isStaleHeartbeatAutoFallbackOverride(params: { const originKey = resolveModelRefKey({ defaultProvider: params.defaultProvider, - overrideProvider: params.sessionEntry.modelOverrideFallbackOriginProvider, - overrideModel: params.sessionEntry.modelOverrideFallbackOriginModel, + overrideProvider: entry.modelOverrideFallbackOriginProvider, + overrideModel: entry.modelOverrideFallbackOriginModel, }); if (originKey) { return originKey !== primaryKey; @@ -115,7 +124,7 @@ export function isStaleHeartbeatAutoFallbackOverride(params: { const noticeSelectedKey = resolveModelRefKey({ defaultProvider: params.defaultProvider, - overrideModel: normalizeOptionalString(params.sessionEntry.fallbackNoticeSelectedModel), + overrideModel: normalizeOptionalString(entry.fallbackNoticeSelectedModel), }); if (noticeSelectedKey) { return noticeSelectedKey !== primaryKey; diff --git a/src/config/sessions/model-override-provenance.ts b/src/config/sessions/model-override-provenance.ts new file mode 100644 index 00000000000..010f15860f0 --- /dev/null +++ b/src/config/sessions/model-override-provenance.ts @@ -0,0 +1,24 @@ +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { SessionEntry } from "./types.js"; + +export function hasSessionAutoModelFallbackProvenance( + entry: + | Pick< + SessionEntry, + | "providerOverride" + | "modelOverride" + | "modelOverrideFallbackOriginProvider" + | "modelOverrideFallbackOriginModel" + > + | undefined, +): boolean { + const hasActiveOverride = Boolean( + normalizeOptionalString(entry?.providerOverride) || + normalizeOptionalString(entry?.modelOverride), + ); + return Boolean( + hasActiveOverride && + normalizeOptionalString(entry?.modelOverrideFallbackOriginProvider) && + normalizeOptionalString(entry?.modelOverrideFallbackOriginModel), + ); +} diff --git a/src/config/sessions/reset-preserved-selection.ts b/src/config/sessions/reset-preserved-selection.ts index 9aa879e7526..4a17d7c703d 100644 --- a/src/config/sessions/reset-preserved-selection.ts +++ b/src/config/sessions/reset-preserved-selection.ts @@ -1,3 +1,4 @@ +import { hasSessionAutoModelFallbackProvenance } from "./model-override-provenance.js"; import type { SessionEntry } from "./types.js"; export type ResetPreservedSelectionState = Pick< @@ -30,9 +31,13 @@ export function resolveResetPreservedSelection(params: { } const preserved: Partial = {}; + const recoveredAutoFallbackOverride = + entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry); const preserveLegacyUserModelOverride = entry.modelOverrideSource === "user" || - (entry.modelOverrideSource === undefined && Boolean(entry.modelOverride)); + (entry.modelOverrideSource === undefined && + Boolean(entry.modelOverride) && + !recoveredAutoFallbackOverride); if (preserveLegacyUserModelOverride && entry.modelOverride) { preserved.providerOverride = entry.providerOverride; preserved.modelOverride = entry.modelOverride; diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 5596f17a34c..2def651bf11 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js"; +import { hasSessionAutoModelFallbackProvenance } from "../../config/sessions/model-override-provenance.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; import { evaluateSessionFreshness, @@ -59,7 +60,9 @@ function copySessionFields( } function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry): void { - if (entry.modelOverrideSource !== "auto") { + const recoveredAutoFallbackOverride = + entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry); + if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) { if (entry.modelOverride !== undefined) { target.modelOverride = entry.modelOverride; }