Skip to content

Commit 5d46e4d

Browse files
frankekntermtek
andauthored
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 cb0a752 ("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 <[email protected]>
1 parent 153e3ad commit 5d46e4d

8 files changed

Lines changed: 290 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020
- 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.
2121
- 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.
2222
- 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.
23+
- 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.
2324

2425
## 2026.4.8
2526

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,6 +1500,7 @@ describe("runAgentTurnWithFallback", () => {
15001500
});
15011501
expect(sessionEntry.providerOverride).toBe("openai-codex");
15021502
expect(sessionEntry.modelOverride).toBe("gpt-5.4");
1503+
expect(sessionEntry.modelOverrideSource).toBe("auto");
15031504
expect(sessionEntry.authProfileOverride).toBeUndefined();
15041505
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
15051506
expect(sessionStore.main.authProfileOverride).toBeUndefined();
@@ -1533,6 +1534,7 @@ describe("runAgentTurnWithFallback", () => {
15331534
updatedAt: 123,
15341535
providerOverride: "anthropic",
15351536
modelOverride: "claude-sonnet",
1537+
modelOverrideSource: "auto",
15361538
authProfileOverride: "anthropic:openclaw",
15371539
authProfileOverrideSource: "user",
15381540
});

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ type FallbackSelectionState = Pick<
113113
SessionEntry,
114114
| "providerOverride"
115115
| "modelOverride"
116+
| "modelOverrideSource"
116117
| "authProfileOverride"
117118
| "authProfileOverrideSource"
118119
| "authProfileOverrideCompactionCount"
@@ -121,6 +122,7 @@ type FallbackSelectionState = Pick<
121122
const FALLBACK_SELECTION_STATE_KEYS = [
122123
"providerOverride",
123124
"modelOverride",
125+
"modelOverrideSource",
124126
"authProfileOverride",
125127
"authProfileOverrideSource",
126128
"authProfileOverrideCompactionCount",
@@ -144,6 +146,12 @@ function setFallbackSelectionStateField(
144146
return true;
145147
}
146148
return false;
149+
case "modelOverrideSource":
150+
if (entry.modelOverrideSource !== value) {
151+
entry.modelOverrideSource = value as SessionEntry["modelOverrideSource"];
152+
return true;
153+
}
154+
return false;
147155
case "authProfileOverride":
148156
if (entry.authProfileOverride !== value) {
149157
entry.authProfileOverride = value as SessionEntry["authProfileOverride"];
@@ -170,6 +178,7 @@ function snapshotFallbackSelectionState(entry: SessionEntry): FallbackSelectionS
170178
return {
171179
providerOverride: entry.providerOverride,
172180
modelOverride: entry.modelOverride,
181+
modelOverrideSource: entry.modelOverrideSource,
173182
authProfileOverride: entry.authProfileOverride,
174183
authProfileOverrideSource: entry.authProfileOverrideSource,
175184
authProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount,
@@ -185,6 +194,7 @@ function buildFallbackSelectionState(params: {
185194
return {
186195
providerOverride: params.provider,
187196
modelOverride: params.model,
197+
modelOverrideSource: "auto",
188198
authProfileOverride: params.authProfileId,
189199
authProfileOverrideSource: params.authProfileId ? params.authProfileIdSource : undefined,
190200
authProfileOverrideCompactionCount: undefined,

src/config/sessions/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ export type SessionEntry = {
162162
responseUsage?: "on" | "off" | "tokens" | "full";
163163
providerOverride?: string;
164164
modelOverride?: string;
165+
/**
166+
* Tracks whether the persisted model override came from an explicit user
167+
* action (`/model`, `sessions.patch`) or from a temporary runtime fallback.
168+
* Resets only preserve user-driven overrides.
169+
*/
170+
modelOverrideSource?: "auto" | "user";
165171
authProfileOverride?: string;
166172
authProfileOverrideSource?: "auto" | "user";
167173
authProfileOverrideCompactionCount?: number;

src/gateway/server.sessions.gateway-server-sessions-a.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,182 @@ describe("gateway server sessions", () => {
15661566
ws.close();
15671567
});
15681568

1569+
test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => {
1570+
const { storePath } = await createSessionStoreDir();
1571+
testState.agentConfig = {
1572+
model: {
1573+
primary: "openai/gpt-test-a",
1574+
},
1575+
};
1576+
1577+
await writeSessionStore({
1578+
entries: {
1579+
main: {
1580+
sessionId: "sess-explicit-model-override",
1581+
updatedAt: Date.now(),
1582+
providerOverride: "anthropic",
1583+
modelOverride: "claude-opus-4-1",
1584+
modelProvider: "openai",
1585+
model: "gpt-test-a",
1586+
},
1587+
},
1588+
});
1589+
1590+
const { ws } = await openClient();
1591+
const reset = await rpcReq<{
1592+
ok: true;
1593+
key: string;
1594+
entry: {
1595+
providerOverride?: string;
1596+
modelOverride?: string;
1597+
modelOverrideSource?: string;
1598+
modelProvider?: string;
1599+
model?: string;
1600+
};
1601+
}>(ws, "sessions.reset", { key: "main" });
1602+
1603+
expect(reset.ok).toBe(true);
1604+
expect(reset.payload?.entry.providerOverride).toBe("anthropic");
1605+
expect(reset.payload?.entry.modelOverride).toBe("claude-opus-4-1");
1606+
expect(reset.payload?.entry.modelOverrideSource).toBe("user");
1607+
expect(reset.payload?.entry.modelProvider).toBe("anthropic");
1608+
expect(reset.payload?.entry.model).toBe("claude-opus-4-1");
1609+
1610+
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
1611+
string,
1612+
{
1613+
providerOverride?: string;
1614+
modelOverride?: string;
1615+
modelOverrideSource?: string;
1616+
modelProvider?: string;
1617+
model?: string;
1618+
}
1619+
>;
1620+
expect(store["agent:main:main"]?.providerOverride).toBe("anthropic");
1621+
expect(store["agent:main:main"]?.modelOverride).toBe("claude-opus-4-1");
1622+
expect(store["agent:main:main"]?.modelOverrideSource).toBe("user");
1623+
expect(store["agent:main:main"]?.modelProvider).toBe("anthropic");
1624+
expect(store["agent:main:main"]?.model).toBe("claude-opus-4-1");
1625+
1626+
ws.close();
1627+
});
1628+
1629+
test("sessions.reset clears fallback-pinned model overrides and restores the selected model", async () => {
1630+
const { storePath } = await createSessionStoreDir();
1631+
testState.agentConfig = {
1632+
model: {
1633+
primary: "openai/gpt-test-a",
1634+
},
1635+
};
1636+
1637+
await writeSessionStore({
1638+
entries: {
1639+
main: {
1640+
sessionId: "sess-fallback-model-override",
1641+
updatedAt: Date.now(),
1642+
providerOverride: "anthropic",
1643+
modelOverride: "claude-opus-4-1",
1644+
modelOverrideSource: "auto",
1645+
fallbackNoticeSelectedModel: "openai/gpt-test-a",
1646+
fallbackNoticeActiveModel: "anthropic/claude-opus-4-1",
1647+
fallbackNoticeReason: "rate limit",
1648+
},
1649+
},
1650+
});
1651+
1652+
const { ws } = await openClient();
1653+
const reset = await rpcReq<{
1654+
ok: true;
1655+
key: string;
1656+
entry: {
1657+
providerOverride?: string;
1658+
modelOverride?: string;
1659+
modelProvider?: string;
1660+
model?: string;
1661+
};
1662+
}>(ws, "sessions.reset", { key: "main" });
1663+
1664+
expect(reset.ok).toBe(true);
1665+
expect(reset.payload?.entry.providerOverride).toBeUndefined();
1666+
expect(reset.payload?.entry.modelOverride).toBeUndefined();
1667+
expect(reset.payload?.entry.modelProvider).toBe("openai");
1668+
expect(reset.payload?.entry.model).toBe("gpt-test-a");
1669+
1670+
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
1671+
string,
1672+
{
1673+
providerOverride?: string;
1674+
modelOverride?: string;
1675+
modelProvider?: string;
1676+
model?: string;
1677+
}
1678+
>;
1679+
expect(store["agent:main:main"]?.providerOverride).toBeUndefined();
1680+
expect(store["agent:main:main"]?.modelOverride).toBeUndefined();
1681+
expect(store["agent:main:main"]?.modelProvider).toBe("openai");
1682+
expect(store["agent:main:main"]?.model).toBe("gpt-test-a");
1683+
1684+
ws.close();
1685+
});
1686+
1687+
test("sessions.reset follows the updated default after an auto fallback pinned an older default", async () => {
1688+
const { storePath } = await createSessionStoreDir();
1689+
testState.agentConfig = {
1690+
model: {
1691+
primary: "openai/gpt-test-c",
1692+
},
1693+
};
1694+
1695+
await writeSessionStore({
1696+
entries: {
1697+
main: {
1698+
sessionId: "sess-fallback-stale-default",
1699+
updatedAt: Date.now(),
1700+
providerOverride: "anthropic",
1701+
modelOverride: "claude-opus-4-1",
1702+
modelOverrideSource: "auto",
1703+
fallbackNoticeSelectedModel: "openai/gpt-test-a",
1704+
fallbackNoticeActiveModel: "anthropic/claude-opus-4-1",
1705+
fallbackNoticeReason: "rate limit",
1706+
},
1707+
},
1708+
});
1709+
1710+
const { ws } = await openClient();
1711+
const reset = await rpcReq<{
1712+
ok: true;
1713+
key: string;
1714+
entry: {
1715+
providerOverride?: string;
1716+
modelOverride?: string;
1717+
modelProvider?: string;
1718+
model?: string;
1719+
};
1720+
}>(ws, "sessions.reset", { key: "main" });
1721+
1722+
expect(reset.ok).toBe(true);
1723+
expect(reset.payload?.entry.providerOverride).toBeUndefined();
1724+
expect(reset.payload?.entry.modelOverride).toBeUndefined();
1725+
expect(reset.payload?.entry.modelProvider).toBe("openai");
1726+
expect(reset.payload?.entry.model).toBe("gpt-test-c");
1727+
1728+
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
1729+
string,
1730+
{
1731+
providerOverride?: string;
1732+
modelOverride?: string;
1733+
modelProvider?: string;
1734+
model?: string;
1735+
}
1736+
>;
1737+
expect(store["agent:main:main"]?.providerOverride).toBeUndefined();
1738+
expect(store["agent:main:main"]?.modelOverride).toBeUndefined();
1739+
expect(store["agent:main:main"]?.modelProvider).toBe("openai");
1740+
expect(store["agent:main:main"]?.model).toBe("gpt-test-c");
1741+
1742+
ws.close();
1743+
});
1744+
15691745
test("sessions.reset preserves spawned session ownership metadata", async () => {
15701746
const { storePath } = await createSessionStoreDir();
15711747
const customSessionFile = path.join(
@@ -1595,6 +1771,7 @@ describe("gateway server sessions", () => {
15951771
ttsAuto: "always",
15961772
providerOverride: "anthropic",
15971773
modelOverride: "claude-opus-4-1",
1774+
modelOverrideSource: "user",
15981775
authProfileOverride: "work",
15991776
authProfileOverrideSource: "user",
16001777
authProfileOverrideCompactionCount: 7,

src/gateway/session-reset-service.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,48 @@ function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined
6161
};
6262
}
6363

64+
type ResetPreservedSelectionState = Pick<
65+
SessionEntry,
66+
| "providerOverride"
67+
| "modelOverride"
68+
| "modelOverrideSource"
69+
| "authProfileOverride"
70+
| "authProfileOverrideSource"
71+
| "authProfileOverrideCompactionCount"
72+
>;
73+
74+
function resolveResetPreservedSelection(params: {
75+
entry?: SessionEntry;
76+
}): Partial<ResetPreservedSelectionState> {
77+
const { entry } = params;
78+
if (!entry) {
79+
return {};
80+
}
81+
82+
const preserved: Partial<ResetPreservedSelectionState> = {};
83+
// `modelOverrideSource` is new. Older persisted sessions can still carry
84+
// user-selected overrides without the source field, so treat an absent
85+
// source as legacy user state during reset and backfill it forward.
86+
const preserveLegacyUserModelOverride =
87+
entry.modelOverrideSource === "user" ||
88+
(entry.modelOverrideSource === undefined && Boolean(entry.modelOverride));
89+
if (preserveLegacyUserModelOverride && entry.modelOverride) {
90+
preserved.providerOverride = entry.providerOverride;
91+
preserved.modelOverride = entry.modelOverride;
92+
preserved.modelOverrideSource = "user";
93+
}
94+
95+
if (entry.authProfileOverrideSource === "user" && entry.authProfileOverride) {
96+
preserved.authProfileOverride = entry.authProfileOverride;
97+
preserved.authProfileOverrideSource = entry.authProfileOverrideSource;
98+
if (entry.authProfileOverrideCompactionCount !== undefined) {
99+
preserved.authProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount;
100+
}
101+
}
102+
103+
return preserved;
104+
}
105+
64106
export function archiveSessionTranscriptsForSession(params: {
65107
sessionId: string | undefined;
66108
storePath: string;
@@ -507,9 +549,21 @@ export async function performGatewaySessionReset(params: {
507549
});
508550
const currentEntry = store[primaryKey];
509551
resetSourceEntry = currentEntry ? { ...currentEntry } : undefined;
510-
const resetEntry = stripRuntimeModelState(currentEntry);
511552
const parsed = parseAgentSessionKey(primaryKey);
512553
const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
554+
const resetPreservedSelection = resolveResetPreservedSelection({
555+
entry: currentEntry,
556+
});
557+
const resetEntry = {
558+
...stripRuntimeModelState(currentEntry),
559+
providerOverride: undefined,
560+
modelOverride: undefined,
561+
modelOverrideSource: undefined,
562+
authProfileOverride: undefined,
563+
authProfileOverrideSource: undefined,
564+
authProfileOverrideCompactionCount: undefined,
565+
...resetPreservedSelection,
566+
};
513567
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
514568
oldSessionId = currentEntry?.sessionId;
515569
oldSessionFile = currentEntry?.sessionFile;
@@ -540,11 +594,9 @@ export async function performGatewaySessionReset(params: {
540594
execAsk: currentEntry?.execAsk,
541595
execNode: currentEntry?.execNode,
542596
responseUsage: currentEntry?.responseUsage,
543-
providerOverride: currentEntry?.providerOverride,
544-
modelOverride: currentEntry?.modelOverride,
545-
authProfileOverride: currentEntry?.authProfileOverride,
546-
authProfileOverrideSource: currentEntry?.authProfileOverrideSource,
547-
authProfileOverrideCompactionCount: currentEntry?.authProfileOverrideCompactionCount,
597+
// Resets should keep the user's explicit selection, but clear any
598+
// temporary fallback model that was pinned during the previous run.
599+
...resetPreservedSelection,
548600
groupActivation: currentEntry?.groupActivation,
549601
groupActivationNeedsSystemIntro: currentEntry?.groupActivationNeedsSystemIntro,
550602
chatType: currentEntry?.chatType,

0 commit comments

Comments
 (0)