mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
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 <termtek@ubuntu.tail2b72cd.ts.net>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ResetPreservedSelectionState> {
|
||||
const { entry } = params;
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const preserved: Partial<ResetPreservedSelectionState> = {};
|
||||
// `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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user