From 5d46e4dc4f458d3ef2097c881eab3a78f2abee2b Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Thu, 9 Apr 2026 00:31:05 +0800 Subject: [PATCH] fix(gateway): clear auto-fallback model override on session reset (#63155) * fix(gateway): clear auto-fallback model override on session reset When `persistFallbackCandidateSelection()` writes a fallback provider override with `authProfileOverrideSource: "auto"`, the override was incorrectly preserved across `/reset` and `/new` commands. This caused sessions to keep using the fallback provider even after the user changed the agent config primary provider, because the session store override takes precedence over the config default. Now the override fields (`providerOverride`, `modelOverride`, `authProfileOverride`, `authProfileOverrideSource`, `authProfileOverrideCompactionCount`) are only carried forward when `authProfileOverrideSource === "user"` (i.e. explicit `/model` command). System-driven overrides are dropped on reset so the session picks up the current config default. Introduced in cb0a752156 ("fix: preserve reset session behavior config") * fix(gateway): preserve explicit reset model selection * fix(gateway): track reset model override source * fix(gateway): preserve legacy reset model overrides * docs(changelog): add session reset merge note --------- Co-authored-by: termtek --- CHANGELOG.md | 1 + .../reply/agent-runner-execution.test.ts | 2 + .../reply/agent-runner-execution.ts | 10 + src/config/sessions/types.ts | 6 + ...sessions.gateway-server-sessions-a.test.ts | 177 ++++++++++++++++++ src/gateway/session-reset-service.ts | 64 ++++++- src/sessions/model-overrides.test.ts | 28 ++- src/sessions/model-overrides.ts | 10 + 8 files changed, 290 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75979760eb4..a7fed652c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Reply/doctor: resolve reply-run SecretRefs before preflight helpers touch config, surface gateway OAuth reauth failures to users, and make `openclaw doctor` call out exact reauth commands. - Android/pairing: clear stale setup-code auth on new QR scans, bootstrap operator and node sessions from fresh pairing, prefer stored device tokens after bootstrap handoff, and pause pairing auto-retry while the app is backgrounded so scan-once Android pairing recovers reliably again. (#63199) Thanks @obviyus. - Auto-reply/NO_REPLY: strip glued leading `NO_REPLY` tokens before reply normalization and ACP-visible streaming so silent sentinel text no longer leaks into user-visible replies while preserving substantive `NO_REPLY ...` text. Thanks @frankekn. +- Gateway/sessions: clear auto-fallback-pinned model overrides on `/reset` and `/new` while still preserving explicit user model selections, including legacy sessions created before override-source tracking existed. (#63155) Thanks @frankekn. ## 2026.4.8 diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 2aa1bcc5175..eb0cb19c319 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1500,6 +1500,7 @@ describe("runAgentTurnWithFallback", () => { }); expect(sessionEntry.providerOverride).toBe("openai-codex"); expect(sessionEntry.modelOverride).toBe("gpt-5.4"); + expect(sessionEntry.modelOverrideSource).toBe("auto"); expect(sessionEntry.authProfileOverride).toBeUndefined(); expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); expect(sessionStore.main.authProfileOverride).toBeUndefined(); @@ -1533,6 +1534,7 @@ describe("runAgentTurnWithFallback", () => { updatedAt: 123, providerOverride: "anthropic", modelOverride: "claude-sonnet", + modelOverrideSource: "auto", authProfileOverride: "anthropic:openclaw", authProfileOverrideSource: "user", }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 5c105df90bc..166214b717e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -113,6 +113,7 @@ type FallbackSelectionState = Pick< SessionEntry, | "providerOverride" | "modelOverride" + | "modelOverrideSource" | "authProfileOverride" | "authProfileOverrideSource" | "authProfileOverrideCompactionCount" @@ -121,6 +122,7 @@ type FallbackSelectionState = Pick< const FALLBACK_SELECTION_STATE_KEYS = [ "providerOverride", "modelOverride", + "modelOverrideSource", "authProfileOverride", "authProfileOverrideSource", "authProfileOverrideCompactionCount", @@ -144,6 +146,12 @@ function setFallbackSelectionStateField( return true; } return false; + case "modelOverrideSource": + if (entry.modelOverrideSource !== value) { + entry.modelOverrideSource = value as SessionEntry["modelOverrideSource"]; + return true; + } + return false; case "authProfileOverride": if (entry.authProfileOverride !== value) { entry.authProfileOverride = value as SessionEntry["authProfileOverride"]; @@ -170,6 +178,7 @@ function snapshotFallbackSelectionState(entry: SessionEntry): FallbackSelectionS return { providerOverride: entry.providerOverride, modelOverride: entry.modelOverride, + modelOverrideSource: entry.modelOverrideSource, authProfileOverride: entry.authProfileOverride, authProfileOverrideSource: entry.authProfileOverrideSource, authProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount, @@ -185,6 +194,7 @@ function buildFallbackSelectionState(params: { return { providerOverride: params.provider, modelOverride: params.model, + modelOverrideSource: "auto", authProfileOverride: params.authProfileId, authProfileOverrideSource: params.authProfileId ? params.authProfileIdSource : undefined, authProfileOverrideCompactionCount: undefined, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 140f8f05aac..ae26dbb4596 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -162,6 +162,12 @@ export type SessionEntry = { responseUsage?: "on" | "off" | "tokens" | "full"; providerOverride?: string; modelOverride?: string; + /** + * Tracks whether the persisted model override came from an explicit user + * action (`/model`, `sessions.patch`) or from a temporary runtime fallback. + * Resets only preserve user-driven overrides. + */ + modelOverrideSource?: "auto" | "user"; authProfileOverride?: string; authProfileOverrideSource?: "auto" | "user"; authProfileOverrideCompactionCount?: number; diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 7a5504da1a5..2581cca15a1 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -1566,6 +1566,182 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => { + const { storePath } = await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-a", + }, + }; + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-explicit-model-override", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-1", + modelProvider: "openai", + model: "gpt-test-a", + }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ + ok: true; + key: string; + entry: { + providerOverride?: string; + modelOverride?: string; + modelOverrideSource?: string; + modelProvider?: string; + model?: string; + }; + }>(ws, "sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry.providerOverride).toBe("anthropic"); + expect(reset.payload?.entry.modelOverride).toBe("claude-opus-4-1"); + expect(reset.payload?.entry.modelOverrideSource).toBe("user"); + expect(reset.payload?.entry.modelProvider).toBe("anthropic"); + expect(reset.payload?.entry.model).toBe("claude-opus-4-1"); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { + providerOverride?: string; + modelOverride?: string; + modelOverrideSource?: string; + modelProvider?: string; + model?: string; + } + >; + expect(store["agent:main:main"]?.providerOverride).toBe("anthropic"); + expect(store["agent:main:main"]?.modelOverride).toBe("claude-opus-4-1"); + expect(store["agent:main:main"]?.modelOverrideSource).toBe("user"); + expect(store["agent:main:main"]?.modelProvider).toBe("anthropic"); + expect(store["agent:main:main"]?.model).toBe("claude-opus-4-1"); + + ws.close(); + }); + + test("sessions.reset clears fallback-pinned model overrides and restores the selected model", async () => { + const { storePath } = await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-a", + }, + }; + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-fallback-model-override", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-1", + modelOverrideSource: "auto", + fallbackNoticeSelectedModel: "openai/gpt-test-a", + fallbackNoticeActiveModel: "anthropic/claude-opus-4-1", + fallbackNoticeReason: "rate limit", + }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ + ok: true; + key: string; + entry: { + providerOverride?: string; + modelOverride?: string; + modelProvider?: string; + model?: string; + }; + }>(ws, "sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry.providerOverride).toBeUndefined(); + expect(reset.payload?.entry.modelOverride).toBeUndefined(); + expect(reset.payload?.entry.modelProvider).toBe("openai"); + expect(reset.payload?.entry.model).toBe("gpt-test-a"); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { + providerOverride?: string; + modelOverride?: string; + modelProvider?: string; + model?: string; + } + >; + expect(store["agent:main:main"]?.providerOverride).toBeUndefined(); + expect(store["agent:main:main"]?.modelOverride).toBeUndefined(); + expect(store["agent:main:main"]?.modelProvider).toBe("openai"); + expect(store["agent:main:main"]?.model).toBe("gpt-test-a"); + + ws.close(); + }); + + test("sessions.reset follows the updated default after an auto fallback pinned an older default", async () => { + const { storePath } = await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-c", + }, + }; + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-fallback-stale-default", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-1", + modelOverrideSource: "auto", + fallbackNoticeSelectedModel: "openai/gpt-test-a", + fallbackNoticeActiveModel: "anthropic/claude-opus-4-1", + fallbackNoticeReason: "rate limit", + }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ + ok: true; + key: string; + entry: { + providerOverride?: string; + modelOverride?: string; + modelProvider?: string; + model?: string; + }; + }>(ws, "sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry.providerOverride).toBeUndefined(); + expect(reset.payload?.entry.modelOverride).toBeUndefined(); + expect(reset.payload?.entry.modelProvider).toBe("openai"); + expect(reset.payload?.entry.model).toBe("gpt-test-c"); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { + providerOverride?: string; + modelOverride?: string; + modelProvider?: string; + model?: string; + } + >; + expect(store["agent:main:main"]?.providerOverride).toBeUndefined(); + expect(store["agent:main:main"]?.modelOverride).toBeUndefined(); + expect(store["agent:main:main"]?.modelProvider).toBe("openai"); + expect(store["agent:main:main"]?.model).toBe("gpt-test-c"); + + ws.close(); + }); + test("sessions.reset preserves spawned session ownership metadata", async () => { const { storePath } = await createSessionStoreDir(); const customSessionFile = path.join( @@ -1595,6 +1771,7 @@ describe("gateway server sessions", () => { ttsAuto: "always", providerOverride: "anthropic", modelOverride: "claude-opus-4-1", + modelOverrideSource: "user", authProfileOverride: "work", authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 7, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 8d5036402a5..144016dbaa9 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -61,6 +61,48 @@ function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined }; } +type ResetPreservedSelectionState = Pick< + SessionEntry, + | "providerOverride" + | "modelOverride" + | "modelOverrideSource" + | "authProfileOverride" + | "authProfileOverrideSource" + | "authProfileOverrideCompactionCount" +>; + +function resolveResetPreservedSelection(params: { + entry?: SessionEntry; +}): Partial { + const { entry } = params; + if (!entry) { + return {}; + } + + const preserved: Partial = {}; + // `modelOverrideSource` is new. Older persisted sessions can still carry + // user-selected overrides without the source field, so treat an absent + // source as legacy user state during reset and backfill it forward. + const preserveLegacyUserModelOverride = + entry.modelOverrideSource === "user" || + (entry.modelOverrideSource === undefined && Boolean(entry.modelOverride)); + if (preserveLegacyUserModelOverride && entry.modelOverride) { + preserved.providerOverride = entry.providerOverride; + preserved.modelOverride = entry.modelOverride; + preserved.modelOverrideSource = "user"; + } + + if (entry.authProfileOverrideSource === "user" && entry.authProfileOverride) { + preserved.authProfileOverride = entry.authProfileOverride; + preserved.authProfileOverrideSource = entry.authProfileOverrideSource; + if (entry.authProfileOverrideCompactionCount !== undefined) { + preserved.authProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; + } + } + + return preserved; +} + export function archiveSessionTranscriptsForSession(params: { sessionId: string | undefined; storePath: string; @@ -507,9 +549,21 @@ export async function performGatewaySessionReset(params: { }); const currentEntry = store[primaryKey]; resetSourceEntry = currentEntry ? { ...currentEntry } : undefined; - const resetEntry = stripRuntimeModelState(currentEntry); const parsed = parseAgentSessionKey(primaryKey); const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); + const resetPreservedSelection = resolveResetPreservedSelection({ + entry: currentEntry, + }); + const resetEntry = { + ...stripRuntimeModelState(currentEntry), + providerOverride: undefined, + modelOverride: undefined, + modelOverrideSource: undefined, + authProfileOverride: undefined, + authProfileOverrideSource: undefined, + authProfileOverrideCompactionCount: undefined, + ...resetPreservedSelection, + }; const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId); oldSessionId = currentEntry?.sessionId; oldSessionFile = currentEntry?.sessionFile; @@ -540,11 +594,9 @@ export async function performGatewaySessionReset(params: { execAsk: currentEntry?.execAsk, execNode: currentEntry?.execNode, responseUsage: currentEntry?.responseUsage, - providerOverride: currentEntry?.providerOverride, - modelOverride: currentEntry?.modelOverride, - authProfileOverride: currentEntry?.authProfileOverride, - authProfileOverrideSource: currentEntry?.authProfileOverrideSource, - authProfileOverrideCompactionCount: currentEntry?.authProfileOverrideCompactionCount, + // Resets should keep the user's explicit selection, but clear any + // temporary fallback model that was pinned during the previous run. + ...resetPreservedSelection, groupActivation: currentEntry?.groupActivation, groupActivationNeedsSystemIntro: currentEntry?.groupActivationNeedsSystemIntro, chatType: currentEntry?.chatType, diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index d9215a294ff..9a0fefdec52 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -44,6 +44,7 @@ describe("applyModelOverrideToSessionEntry", () => { expect(entry.fallbackNoticeSelectedModel).toBeUndefined(); expect(entry.fallbackNoticeActiveModel).toBeUndefined(); expect(entry.fallbackNoticeReason).toBeUndefined(); + expect(entry.modelOverrideSource).toBe("user"); }); it("clears stale runtime model fields even when override selection is unchanged", () => { @@ -85,11 +86,12 @@ describe("applyModelOverrideToSessionEntry", () => { }, }); - expect(result.updated).toBe(false); + expect(result.updated).toBe(true); expect(entry.modelProvider).toBe("openai"); expect(entry.model).toBe("gpt-5.4"); + expect(entry.modelOverrideSource).toBe("user"); expect(entry.contextTokens).toBe(200_000); - expect(entry.updatedAt).toBe(before); + expect((entry.updatedAt ?? 0) >= before).toBe(true); }); it("clears stale contextTokens when switching back to the default model", () => { @@ -114,10 +116,32 @@ describe("applyModelOverrideToSessionEntry", () => { expect(result.updated).toBe(true); expect(entry.providerOverride).toBeUndefined(); expect(entry.modelOverride).toBeUndefined(); + expect(entry.modelOverrideSource).toBeUndefined(); expect(entry.contextTokens).toBeUndefined(); expect((entry.updatedAt ?? 0) > before).toBe(true); }); + it("marks non-default overrides with the provided source", () => { + const entry: SessionEntry = { + sessionId: "sess-5a", + updatedAt: Date.now() - 5_000, + }; + + const result = applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + selectionSource: "auto", + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBe("anthropic"); + expect(entry.modelOverride).toBe("claude-sonnet-4-6"); + expect(entry.modelOverrideSource).toBe("auto"); + }); + it("sets liveModelSwitchPending only when explicitly requested", () => { const entry: SessionEntry = { sessionId: "sess-5", diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 1e2c29c1640..5bd6ccb1d51 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -12,10 +12,12 @@ export function applyModelOverrideToSessionEntry(params: { selection: ModelOverrideSelection; profileOverride?: string; profileOverrideSource?: "auto" | "user"; + selectionSource?: "auto" | "user"; markLiveSwitchPending?: boolean; }): { updated: boolean } { const { entry, selection, profileOverride } = params; const profileOverrideSource = params.profileOverrideSource ?? "user"; + const selectionSource = params.selectionSource ?? "user"; let updated = false; let selectionUpdated = false; @@ -30,6 +32,10 @@ export function applyModelOverrideToSessionEntry(params: { updated = true; selectionUpdated = true; } + if (entry.modelOverrideSource) { + delete entry.modelOverrideSource; + updated = true; + } } else { if (entry.providerOverride !== selection.provider) { entry.providerOverride = selection.provider; @@ -41,6 +47,10 @@ export function applyModelOverrideToSessionEntry(params: { updated = true; selectionUpdated = true; } + if (entry.modelOverrideSource !== selectionSource) { + entry.modelOverrideSource = selectionSource; + updated = true; + } } // Model overrides supersede previously recorded runtime model identity.