mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 19:10:21 +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:
@@ -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