mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 21:21:10 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user