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

@@ -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,