fix: prevent fallback persistence from clobbering user /models picks (#64471)

Merged via squash.

Prepared head SHA: b0a6add41f
Co-authored-by: hoyyeva <63033505+hoyyeva@users.noreply.github.com>
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Reviewed-by: @BruceMacD
This commit is contained in:
Eva H
2026-04-10 14:05:07 -07:00
committed by GitHub
parent a736b6eede
commit 3b13986214
5 changed files with 251 additions and 4 deletions

View File

@@ -140,6 +140,7 @@ Docs: https://docs.openclaw.ai
- Plugins/ACPX: wrap plugin tools on the MCP bridge with the shared `before_tool_call` handler so block and approval hooks fire consistently across all execution paths. (#63886) Thanks @eleqtrizit.
- Logging/security: redact Gmail watcher `--hook-token` values from startup logging and `logs.tail` output. (#62661) Thanks @eleqtrizit.
- Models/fallback: preserve `/models` selection across transient primary-model failures and config reloads so the fallback chain no longer permanently clobbers a user-chosen model. (#64471) Thanks @hoyyeva.
- Sandbox/security: auto-derive CDP source-range from Docker network gateway and refuse to start the socat relay without one, so peer containers cannot reach CDP unauthenticated. (#61404) Thanks @dims.

View File

@@ -829,7 +829,7 @@ export const registerTelegramHandlers = ({
// for reactions, we cannot determine if the reaction came from a topic, so block all
// reactions if requireTopic is enabled for this DM.
if (!isGroup) {
const requireTopic = (eventAuthContext.groupConfig as TelegramDirectConfig | undefined)
const requireTopic = (eventAuthContext.groupConfig)
?.requireTopic;
if (requireTopic === true) {
logVerbose(
@@ -1569,13 +1569,18 @@ export const registerTelegramHandlers = ({
// Directly set model override in session
try {
// Get session store path
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
// Use the fresh runtimeCfg (loaded at callback entry) so store path
// and default-model resolution stay consistent with the next
// inbound message. The outer `cfg` is a snapshot captured at
// handler-registration time and becomes stale after config reloads,
// which can cause the override to be written to the wrong store or
// incorrectly treated as the default model (clearing the override).
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
agentId: sessionState.agentId,
});
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
cfg: runtimeCfg,
agentId: sessionState.agentId,
});
const isDefaultSelection =

View File

@@ -1048,6 +1048,93 @@ describe("createTelegramBot", () => {
}
});
it("persists non-default model override using fresh config, not stale startup snapshot", async () => {
// Regression: the callback handler used the startup `cfg` snapshot for
// store path and default-model resolution. If the config was reloaded
// (e.g. default model changed) the override could be written to the wrong
// store or incorrectly cleared because `isDefaultSelection` was wrong.
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-fresh-cfg-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
// Startup config: default is openai/gpt-5.4
const startupConfig = {
agents: {
defaults: {
model: "openai/gpt-5.4",
models: {
"openai/gpt-5.4": {},
"anthropic/claude-opus-4-6": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
// Fresh config: default changed to anthropic/claude-opus-4-6
const freshConfig = {
...startupConfig,
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"openai/gpt-5.4": {},
"anthropic/claude-opus-4-6": {},
},
},
},
};
// Bot created with startup config; loadConfig now returns fresh config
loadConfig.mockReturnValue(freshConfig);
createTelegramBot({
token: "tok",
config: startupConfig,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
expect(callbackHandler).toBeDefined();
// User selects openai/gpt-5.4 — was default at startup but NOT default
// in fresh config. The override must be persisted.
await callbackHandler({
callbackQuery: {
id: "cbq-model-fresh-cfg-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 20,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
// Override must be persisted (not cleared) because openai/gpt-5.4 is
// NOT the default in the fresh config.
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
} finally {
await rm(storePath, { force: true });
}
});
it("rejects ambiguous compact model callbacks and returns provider list", async () => {
onSpy.mockClear();
replySpy.mockClear();

View File

@@ -1677,6 +1677,140 @@ describe("runAgentTurnWithFallback", () => {
expect(sessionStore.main.authProfileOverride).toBeUndefined();
});
it("does not persist fallback selection for legacy user overrides without modelOverrideSource", async () => {
// Regression: older persisted sessions can have a user-selected override
// (modelOverride set) but no modelOverrideSource field, because the field
// was added later. These legacy entries must still be protected from
// fallback overwrite, matching the backward-compat treatment in
// session-reset-service.
state.runWithModelFallbackMock.mockImplementation(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => ({
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,
// Legacy entry: override is set but the source field is missing.
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
// 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");
// Legacy user override must survive the fallback unchanged.
expect(sessionEntry.providerOverride).toBe("anthropic");
expect(sessionEntry.modelOverride).toBe("claude-opus-4-6");
expect(sessionEntry.modelOverrideSource).toBeUndefined();
});
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
// should NOT clobber it even when the primary model fails.
state.runWithModelFallbackMock.mockImplementation(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => ({
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,
// User explicitly selected this model via /models
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelOverrideSource: "user",
};
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");
// The user's /models selection must survive the fallback.
expect(sessionEntry.providerOverride).toBe("anthropic");
expect(sessionEntry.modelOverride).toBe("claude-opus-4-6");
expect(sessionEntry.modelOverrideSource).toBe("user");
});
it("keeps same-provider auth profile when fallback only changes model", async () => {
const applyFallbackCandidateSelectionToEntry =
await getApplyFallbackCandidateSelectionToEntry();

View File

@@ -647,6 +647,26 @@ export async function runAgentTurnWithFallback(params: {
return undefined;
}
// Don't overwrite a user-initiated model override (e.g. from /models or
// /model) with the fallback model. The user's explicit selection should
// survive transient primary-model failures so subsequent messages still
// target the model the user chose. Fallback persistence is only
// appropriate when the override was itself set by a previous fallback
// ("auto") or when there is no override yet.
//
// `modelOverrideSource` was added later, so older persisted sessions can
// carry a user-selected override without the source field. Treat any
// entry with a `modelOverride` but missing `modelOverrideSource` as legacy
// user state, matching the backward-compat treatment in
// session-reset-service.
const isUserModelOverride =
activeSessionEntry.modelOverrideSource === "user" ||
(activeSessionEntry.modelOverrideSource === undefined &&
Boolean(normalizeOptionalString(activeSessionEntry.modelOverride)));
if (isUserModelOverride) {
return undefined;
}
const previousState = snapshotFallbackSelectionState(activeSessionEntry);
const applied = applyFallbackCandidateSelectionToEntry({
entry: activeSessionEntry,