diff --git a/src/agents/live-model-switch.test.ts b/src/agents/live-model-switch.test.ts index 81414782c99..a75361652a5 100644 --- a/src/agents/live-model-switch.test.ts +++ b/src/agents/live-model-switch.test.ts @@ -350,6 +350,39 @@ describe("live model switch", () => { expect(result).toBeUndefined(); }); + it("clears the stale liveModelSwitchPending flag when models already match", async () => { + const sessionEntry = { + liveModelSwitchPending: true, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + }; + state.loadSessionStoreMock.mockReturnValue({ main: sessionEntry }); + state.updateSessionStoreMock.mockImplementation( + async (_path: string, updater: (store: Record) => void) => { + const store: Record = { main: sessionEntry }; + updater(store); + }, + ); + + const { shouldSwitchToLiveModel } = await loadModule(); + + const result = shouldSwitchToLiveModel({ + cfg: { session: { store: "/tmp/custom-store.json" } }, + sessionKey: "main", + agentId: "reply", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + currentProvider: "anthropic", + currentModel: "claude-opus-4-6", + }); + + expect(result).toBeUndefined(); + // Give the fire-and-forget clearLiveModelSwitchPending a tick to resolve + await new Promise((r) => setTimeout(r, 10)); + expect(state.updateSessionStoreMock).toHaveBeenCalledTimes(1); + expect(sessionEntry).not.toHaveProperty("liveModelSwitchPending"); + }); + it("returns undefined when sessionKey is missing", async () => { const { shouldSwitchToLiveModel } = await loadModule(); diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts index 13a338b9ca2..3b9a63a4763 100644 --- a/src/agents/live-model-switch.ts +++ b/src/agents/live-model-switch.ts @@ -119,6 +119,17 @@ export function shouldTrackPersistedLiveSessionModelSelection( * `liveModelSwitchPending` flag is `true` AND the persisted selection differs * from the currently running model; otherwise returns `undefined`. * + * When the flag is set but the current model already matches the persisted + * selection (e.g. the switch was applied as an override and the current + * attempt is already using the new model), the flag is consumed (cleared) + * eagerly to prevent it from persisting as stale state. + * + * **Deferral semantics:** The caller in `run.ts` only acts on the returned + * selection when `canRestartForLiveSwitch` is `true`. If the run cannot + * restart (e.g. a tool call is in progress), the flag intentionally remains + * set so the switch fires on the next clean retry opportunity — even if that + * falls into a subsequent user turn. + * * This replaces the previous approach that used an in-memory map * (`consumeEmbeddedRunModelSwitch`) which could not distinguish between * user-initiated `/model` switches and system-initiated fallback rotations. @@ -164,6 +175,14 @@ export function shouldSwitchToLiveModel(params: { persisted, ) ) { + // Current model already matches the persisted selection — the switch has + // effectively been applied. Clear the stale flag so subsequent fallback + // iterations don't re-evaluate it. + void clearLiveModelSwitchPending({ + cfg, + sessionKey, + agentId: params.agentId, + }); return undefined; } return persisted ?? undefined;