Skip to content

Commit c575388

Browse files
fix(agent): clear stale fallback origins in commands
1 parent b63a5e3 commit c575388

4 files changed

Lines changed: 195 additions & 37 deletions

File tree

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ const state = vi.hoisted(() => ({
3737
),
3838
resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined),
3939
hasLegacyAutoFallbackWithoutOriginMock: vi.fn((_entry: unknown) => false),
40+
isStaleAutoFallbackOriginOverrideMock: vi.fn((_params: unknown) => false),
4041
resolveAutoFallbackPrimaryProbeMock: vi.fn((_params: unknown) => undefined as unknown),
4142
resolveChannelModelOverrideMock: vi.fn((_params: unknown) => null as unknown),
43+
applyModelOverrideToSessionEntryMock: vi.fn((_params: unknown) => ({ updated: false })),
4244
assertLifecycleCurrentMock: vi.fn(),
4345
emitAgentEventMock: vi.fn(),
4446
registerAgentRunContextMock: vi.fn(),
@@ -346,7 +348,8 @@ vi.mock("../sessions/level-overrides.js", () => ({
346348
}));
347349

348350
vi.mock("../sessions/model-overrides.js", () => ({
349-
applyModelOverrideToSessionEntry: () => ({ updated: false }),
351+
applyModelOverrideToSessionEntry: (params: unknown) =>
352+
state.applyModelOverrideToSessionEntryMock(params),
350353
repairProviderWrappedModelOverride: () => ({ updated: false }),
351354
}));
352355

@@ -384,6 +387,8 @@ vi.mock("./agent-scope.js", () => ({
384387
hasLegacyAutoFallbackWithoutOrigin: (entry: unknown) =>
385388
state.hasLegacyAutoFallbackWithoutOriginMock(entry),
386389
hasSessionAutoModelFallbackProvenance: () => false,
390+
isStaleAutoFallbackOriginOverride: (params: unknown) =>
391+
state.isStaleAutoFallbackOriginOverrideMock(params),
387392
listAgentEntries: () => [],
388393
listAgentIds: () => ["default"],
389394
markAutoFallbackPrimaryProbe: vi.fn(),
@@ -957,7 +962,9 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
957962
state.resolveAgentSkillsFilterMock.mockReturnValue(undefined);
958963
state.loadManifestModelCatalogMock.mockReturnValue([]);
959964
state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(false);
965+
state.isStaleAutoFallbackOriginOverrideMock.mockReturnValue(false);
960966
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue(undefined);
967+
state.applyModelOverrideToSessionEntryMock.mockReturnValue({ updated: false });
961968
state.resolveChannelModelOverrideMock.mockImplementation((params: unknown) => {
962969
const input = params as {
963970
cfg?: { channels?: { modelByChannel?: Record<string, Record<string, string>> } };
@@ -1521,6 +1528,88 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
15211528
expect(fallbackParams.model).toBe("channel-model");
15221529
});
15231530

1531+
it("clears stale auto fallback origin overrides before agent-command model selection", async () => {
1532+
setupSingleAttemptFallback();
1533+
state.isStaleAutoFallbackOriginOverrideMock.mockReturnValue(true);
1534+
state.applyModelOverrideToSessionEntryMock.mockImplementation((params: unknown) => {
1535+
const entry = (params as { entry: SessionEntry }).entry;
1536+
delete entry.providerOverride;
1537+
delete entry.modelOverride;
1538+
delete entry.modelOverrideSource;
1539+
delete entry.modelOverrideFallbackOriginProvider;
1540+
delete entry.modelOverrideFallbackOriginModel;
1541+
entry.updatedAt = 2;
1542+
return { updated: true };
1543+
});
1544+
state.runtimeConfigMock = {
1545+
agents: {
1546+
defaults: {
1547+
model: "anthropic/claude-opus-4-8",
1548+
},
1549+
},
1550+
};
1551+
const sessionEntry: SessionEntry = {
1552+
sessionId: "session-1",
1553+
updatedAt: 1,
1554+
providerOverride: "anthropic",
1555+
modelOverride: "claude-opus-4-7",
1556+
modelOverrideSource: "auto",
1557+
modelOverrideFallbackOriginProvider: "anthropic",
1558+
modelOverrideFallbackOriginModel: "claude-haiku-4-5",
1559+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
1560+
};
1561+
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": sessionEntry };
1562+
state.sessionEntryMock = sessionEntry;
1563+
state.sessionStoreMock = sessionStore;
1564+
state.storePathMock = "/tmp/openclaw-session-store.json";
1565+
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-opus-4-8"));
1566+
1567+
await runBasicAgentCommand();
1568+
1569+
const staleOriginParams = requireRecord(
1570+
mockCallArg(state.isStaleAutoFallbackOriginOverrideMock),
1571+
"stale origin params",
1572+
);
1573+
expect(staleOriginParams.entry).toEqual(expect.objectContaining({ sessionId: "session-1" }));
1574+
expectRecordFields(staleOriginParams, {
1575+
primaryProvider: "anthropic",
1576+
primaryModel: "claude-opus-4-8",
1577+
});
1578+
const applyOverrideParams = requireRecord(
1579+
mockCallArg(state.applyModelOverrideToSessionEntryMock),
1580+
"apply model override params",
1581+
);
1582+
expect(applyOverrideParams.entry).toEqual(expect.objectContaining({ sessionId: "session-1" }));
1583+
expectRecordFields(applyOverrideParams, {
1584+
preserveAuthProfileOverride: true,
1585+
});
1586+
expectRecordFields(applyOverrideParams.selection, {
1587+
provider: "anthropic",
1588+
model: "claude-opus-4-8",
1589+
isDefault: true,
1590+
});
1591+
const cleanupPersist = state.persistSessionEntryMock.mock.calls
1592+
.map(
1593+
([params]) => params as { entry?: SessionEntry; sessionKey?: string; storePath?: string },
1594+
)
1595+
.find((params) => params.entry?.updatedAt === 2);
1596+
expect(cleanupPersist).toEqual(
1597+
expect.objectContaining({
1598+
sessionKey: "agent:main:main",
1599+
storePath: "/tmp/openclaw-session-store.json",
1600+
}),
1601+
);
1602+
expect(cleanupPersist?.entry?.providerOverride).toBeUndefined();
1603+
expect(cleanupPersist?.entry?.modelOverride).toBeUndefined();
1604+
expect(cleanupPersist?.entry?.modelOverrideSource).toBeUndefined();
1605+
expect(cleanupPersist?.entry?.modelOverrideFallbackOriginProvider).toBeUndefined();
1606+
expect(cleanupPersist?.entry?.modelOverrideFallbackOriginModel).toBeUndefined();
1607+
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
1608+
expect(fallbackParams.provider).toBe("anthropic");
1609+
expect(fallbackParams.model).toBe("claude-opus-4-8");
1610+
expect(state.resolveAutoFallbackPrimaryProbeMock).not.toHaveBeenCalled();
1611+
});
1612+
15241613
it("probes the channel primary when a session is pinned to an auto fallback", async () => {
15251614
setupSingleAttemptFallback();
15261615
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue({

src/agents/agent-command.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
entryMatchesAutoFallbackPrimaryProbe,
7474
hasLegacyAutoFallbackWithoutOrigin,
7575
hasSessionAutoModelFallbackProvenance,
76+
isStaleAutoFallbackOriginOverride,
7677
listAgentIds,
7778
markAutoFallbackPrimaryProbe,
7879
resolveAutoFallbackPrimaryProbe,
@@ -1374,7 +1375,7 @@ async function agentCommandInternal(
13741375
}
13751376
}
13761377

1377-
const storedProviderOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
1378+
let storedProviderOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
13781379
? undefined
13791380
: sessionEntry?.providerOverride?.trim();
13801381
let storedModelOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
@@ -1416,6 +1417,41 @@ async function agentCommandInternal(
14161417
: null;
14171418
const primaryProvider = normalizedChannelOverride?.provider ?? defaultProvider;
14181419
const primaryModel = normalizedChannelOverride?.model ?? defaultModel;
1420+
const staleAutoFallbackOriginOverride =
1421+
!hasExplicitRunOverride &&
1422+
!hasLegacyAutoFallbackOverrideWithoutOrigin &&
1423+
hasStoredOverride &&
1424+
isStaleAutoFallbackOriginOverride({
1425+
entry: sessionEntry,
1426+
primaryProvider,
1427+
primaryModel,
1428+
});
1429+
if (
1430+
staleAutoFallbackOriginOverride &&
1431+
sessionEntry &&
1432+
sessionStore &&
1433+
sessionKey &&
1434+
!suppressVisibleSessionEffects
1435+
) {
1436+
const { updated } = applyModelOverrideToSessionEntry({
1437+
entry: sessionEntry,
1438+
selection: { provider: primaryProvider, model: primaryModel, isDefault: true },
1439+
preserveAuthProfileOverride: true,
1440+
});
1441+
if (updated) {
1442+
storedModelOverrideSource = undefined;
1443+
await persistSessionEntry({
1444+
sessionStore,
1445+
sessionKey,
1446+
storePath,
1447+
entry: sessionEntry,
1448+
});
1449+
}
1450+
}
1451+
if (staleAutoFallbackOriginOverride) {
1452+
storedProviderOverride = undefined;
1453+
storedModelOverride = undefined;
1454+
}
14191455
const hasEffectiveStoredOverride = Boolean(storedProviderOverride || storedModelOverride);
14201456
if (normalizedChannelOverride && !hasEffectiveStoredOverride) {
14211457
provider = normalizedChannelOverride.provider;
@@ -1435,14 +1471,15 @@ async function agentCommandInternal(
14351471
model = normalizedStored.model;
14361472
}
14371473
}
1438-
const autoFallbackPrimaryProbe = !hasExplicitRunOverride
1439-
? resolveAutoFallbackPrimaryProbe({
1440-
entry: sessionEntry,
1441-
sessionKey,
1442-
primaryProvider,
1443-
primaryModel,
1444-
})
1445-
: undefined;
1474+
const autoFallbackPrimaryProbe =
1475+
!hasExplicitRunOverride && !staleAutoFallbackOriginOverride
1476+
? resolveAutoFallbackPrimaryProbe({
1477+
entry: sessionEntry,
1478+
sessionKey,
1479+
primaryProvider,
1480+
primaryModel,
1481+
})
1482+
: undefined;
14461483
let autoFallbackPrimaryProbeSessionEntry: SessionEntry | undefined;
14471484
if (autoFallbackPrimaryProbe && sessionEntry) {
14481485
provider = autoFallbackPrimaryProbe.provider;

src/agents/agent-scope.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
resolveAgentWorkspaceDir,
3131
resolveDefaultAgentId,
3232
} from "./agent-scope-config.js";
33+
import { modelKey, normalizeModelRef } from "./model-selection-normalize.js";
3334
export {
3435
listAgentEntries,
3536
listAgentIds,
@@ -118,6 +119,56 @@ export function hasLegacyAutoFallbackWithoutOrigin(
118119
);
119120
}
120121

122+
function resolveModelRefKey(params: {
123+
provider?: string | null;
124+
model?: string | null;
125+
}): string | null {
126+
const provider = normalizeOptionalString(params.provider);
127+
const model = normalizeOptionalString(params.model);
128+
if (!provider || !model) {
129+
return null;
130+
}
131+
const normalized = normalizeModelRef(provider, model);
132+
return modelKey(normalized.provider, normalized.model);
133+
}
134+
135+
/** Detects auto-fallback pins whose recorded primary origin no longer matches the current primary. */
136+
export function isStaleAutoFallbackOriginOverride(params: {
137+
entry:
138+
| Pick<
139+
SessionEntry,
140+
| "modelOverrideSource"
141+
| "modelOverrideFallbackOriginProvider"
142+
| "modelOverrideFallbackOriginModel"
143+
>
144+
| null
145+
| undefined;
146+
primaryProvider?: string | null;
147+
primaryModel?: string | null;
148+
}): boolean {
149+
const entry = params.entry;
150+
if (!entry) {
151+
return false;
152+
}
153+
const recoveredAutoFallbackOverride =
154+
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
155+
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
156+
return false;
157+
}
158+
const primaryKey = resolveModelRefKey({
159+
provider: params.primaryProvider,
160+
model: params.primaryModel,
161+
});
162+
if (!primaryKey) {
163+
return false;
164+
}
165+
const originKey = resolveModelRefKey({
166+
provider: entry.modelOverrideFallbackOriginProvider,
167+
model: entry.modelOverrideFallbackOriginModel,
168+
});
169+
return originKey !== null && originKey !== primaryKey;
170+
}
171+
121172
export function resolveAutoFallbackPrimaryProbe(params: {
122173
entry:
123174
| Pick<

src/auto-reply/reply/stored-model-override.ts

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Persists and resolves per-session model override choices.
22
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
3-
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
3+
import {
4+
hasSessionAutoModelFallbackProvenance,
5+
isStaleAutoFallbackOriginOverride as isStaleAutoFallbackOriginSessionEntry,
6+
} from "../../agents/agent-scope.js";
47
import {
58
modelKey,
69
normalizeModelRef,
@@ -155,31 +158,9 @@ export function isStaleAutoFallbackOriginOverride(params: {
155158
if (params.storedOverride?.source !== "session") {
156159
return false;
157160
}
158-
const entry = params.sessionEntry;
159-
const recoveredAutoFallbackOverride =
160-
entry !== undefined &&
161-
entry.modelOverrideSource === undefined &&
162-
hasSessionAutoModelFallbackProvenance(entry);
163-
if (entry?.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
164-
return false;
165-
}
166-
if (!entry) {
167-
return false;
168-
}
169-
170-
const primaryKey = resolveModelRefKey({
171-
defaultProvider: params.defaultProvider,
172-
overrideProvider: params.primaryProvider ?? params.defaultProvider,
173-
overrideModel: params.primaryModel ?? params.defaultModel,
174-
});
175-
if (!primaryKey) {
176-
return false;
177-
}
178-
179-
const originKey = resolveModelRefKey({
180-
defaultProvider: params.defaultProvider,
181-
overrideProvider: entry.modelOverrideFallbackOriginProvider,
182-
overrideModel: entry.modelOverrideFallbackOriginModel,
161+
return isStaleAutoFallbackOriginSessionEntry({
162+
entry: params.sessionEntry,
163+
primaryProvider: params.primaryProvider ?? params.defaultProvider,
164+
primaryModel: params.primaryModel ?? params.defaultModel,
183165
});
184-
return originKey !== null && originKey !== primaryKey;
185166
}

0 commit comments

Comments
 (0)