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.