Skip to content

Commit bc3d129

Browse files
Fix stale auto-fallback origin selection
1 parent 57e8c50 commit bc3d129

10 files changed

Lines changed: 846 additions & 21 deletions

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

Lines changed: 358 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ vi.mock("../config/sessions.js", () => ({
262262
vi.mock("../config/sessions/transcript-resolve.runtime.js", () => ({
263263
resolveSessionTranscriptFile: async () => ({
264264
sessionFile: "/tmp/session.jsonl",
265-
sessionEntry: { sessionId: "session-1", updatedAt: Date.now() },
265+
sessionEntry: state.sessionEntryMock ?? { sessionId: "session-1", updatedAt: Date.now() },
266266
}),
267267
}));
268268

@@ -346,7 +346,64 @@ vi.mock("../sessions/level-overrides.js", () => ({
346346
}));
347347

348348
vi.mock("../sessions/model-overrides.js", () => ({
349-
applyModelOverrideToSessionEntry: () => ({ updated: false }),
349+
applyModelOverrideToSessionEntry: ({
350+
entry,
351+
selection,
352+
profileOverride,
353+
preserveAuthProfileOverride,
354+
}: {
355+
entry: SessionEntry;
356+
selection: { provider: string; model: string; isDefault?: boolean };
357+
profileOverride?: string;
358+
preserveAuthProfileOverride?: boolean;
359+
}) => {
360+
let updated = false;
361+
if (selection.isDefault) {
362+
for (const key of [
363+
"providerOverride",
364+
"modelOverride",
365+
"modelOverrideSource",
366+
"modelOverrideFallbackOriginProvider",
367+
"modelOverrideFallbackOriginModel",
368+
] as const) {
369+
if (entry[key] !== undefined) {
370+
delete entry[key];
371+
updated = true;
372+
}
373+
}
374+
} else {
375+
if (entry.providerOverride !== selection.provider) {
376+
entry.providerOverride = selection.provider;
377+
updated = true;
378+
}
379+
if (entry.modelOverride !== selection.model) {
380+
entry.modelOverride = selection.model;
381+
updated = true;
382+
}
383+
}
384+
if (profileOverride) {
385+
if (entry.authProfileOverride !== profileOverride) {
386+
entry.authProfileOverride = profileOverride;
387+
updated = true;
388+
}
389+
if (entry.authProfileOverrideSource !== "user") {
390+
entry.authProfileOverrideSource = "user";
391+
updated = true;
392+
}
393+
} else if (!preserveAuthProfileOverride) {
394+
for (const key of [
395+
"authProfileOverride",
396+
"authProfileOverrideSource",
397+
"authProfileOverrideCompactionCount",
398+
] as const) {
399+
if (entry[key] !== undefined) {
400+
delete entry[key];
401+
updated = true;
402+
}
403+
}
404+
}
405+
return { updated };
406+
},
350407
repairProviderWrappedModelOverride: () => ({ updated: false }),
351408
}));
352409

@@ -379,7 +436,25 @@ vi.mock("../utils/message-channel.js", () => ({
379436
}));
380437

381438
vi.mock("./agent-scope.js", () => ({
382-
clearAutoFallbackPrimaryProbeSelection: vi.fn(),
439+
clearAutoFallbackPrimaryProbeSelection: vi.fn((entry: SessionEntry) => {
440+
delete entry.providerOverride;
441+
delete entry.modelOverride;
442+
delete entry.modelOverrideSource;
443+
delete entry.modelOverrideFallbackOriginProvider;
444+
delete entry.modelOverrideFallbackOriginModel;
445+
if (
446+
entry.authProfileOverrideSource === "auto" ||
447+
(entry.authProfileOverrideSource === undefined &&
448+
entry.authProfileOverrideCompactionCount !== undefined)
449+
) {
450+
delete entry.authProfileOverride;
451+
delete entry.authProfileOverrideSource;
452+
delete entry.authProfileOverrideCompactionCount;
453+
}
454+
delete entry.fallbackNoticeSelectedModel;
455+
delete entry.fallbackNoticeActiveModel;
456+
delete entry.fallbackNoticeReason;
457+
}),
383458
entryMatchesAutoFallbackPrimaryProbe: () => true,
384459
hasLegacyAutoFallbackWithoutOrigin: (entry: unknown) =>
385460
state.hasLegacyAutoFallbackWithoutOriginMock(entry),
@@ -644,6 +719,28 @@ vi.mock("./model-selection.js", () => {
644719
: { provider: defaultProvider, model: raw },
645720
};
646721
},
722+
resolvePersistedOverrideModelRef: ({
723+
defaultProvider,
724+
overrideProvider,
725+
overrideModel,
726+
}: {
727+
defaultProvider: string;
728+
overrideProvider?: string;
729+
overrideModel?: string;
730+
}) => {
731+
const model = overrideModel?.trim();
732+
if (!model) {
733+
return null;
734+
}
735+
const provider = overrideProvider?.trim();
736+
if (provider) {
737+
return { provider, model };
738+
}
739+
const slash = model.indexOf("/");
740+
return slash > 0
741+
? { provider: model.slice(0, slash), model: model.slice(slash + 1) }
742+
: { provider: defaultProvider, model };
743+
},
647744
resolveConfiguredModelRef: ({ cfg }: { cfg?: unknown }) => {
648745
const raw = (cfg as { agents?: { defaults?: { model?: string | { primary?: string } } } })
649746
?.agents?.defaults?.model;
@@ -1573,6 +1670,264 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
15731670
expect(fallbackParams.model).toBe("channel-model");
15741671
});
15751672

1673+
it("prepares stale auto-fallback origin pins as primary attempts without fallback auth", async () => {
1674+
setupSingleAttemptFallback();
1675+
state.runtimeConfigMock = {
1676+
agents: {
1677+
defaults: {
1678+
model: "anthropic/default-model",
1679+
models: {
1680+
"anthropic/default-model": {},
1681+
"anthropic/fallback-model": {},
1682+
"openai/channel-model": {},
1683+
},
1684+
},
1685+
},
1686+
channels: {
1687+
modelByChannel: {
1688+
discord: {
1689+
"channel-123": "openai/channel-model",
1690+
},
1691+
},
1692+
},
1693+
};
1694+
const sessionEntry: SessionEntry = {
1695+
sessionId: "session-1",
1696+
updatedAt: 1,
1697+
channel: "discord",
1698+
groupId: "channel-123",
1699+
providerOverride: "anthropic",
1700+
modelOverride: "fallback-model",
1701+
modelOverrideSource: "auto",
1702+
modelOverrideFallbackOriginProvider: "openai",
1703+
modelOverrideFallbackOriginModel: "old-channel-model",
1704+
authProfileOverride: "anthropic:fallback",
1705+
authProfileOverrideSource: "auto",
1706+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
1707+
};
1708+
state.sessionEntryMock = sessionEntry;
1709+
state.sessionStoreMock = { "agent:main:main": sessionEntry };
1710+
state.storePathMock = "/tmp/openclaw-sessions.json";
1711+
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model"));
1712+
1713+
await runBasicAgentCommand();
1714+
1715+
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
1716+
expect(fallbackParams.provider).toBe("openai");
1717+
expect(fallbackParams.model).toBe("channel-model");
1718+
expectRecordFields(mockCallArg(state.resolveEffectiveModelFallbacksMock), {
1719+
hasSessionModelOverride: false,
1720+
hasAutoFallbackProvenance: false,
1721+
});
1722+
const attemptParams = requireRecord(mockCallArg(state.runAgentAttemptMock), "attempt params");
1723+
const attemptEntry = requireRecord(attemptParams.sessionEntry, "attempt session entry");
1724+
expect(attemptEntry.providerOverride).toBeUndefined();
1725+
expect(attemptEntry.modelOverride).toBeUndefined();
1726+
expect(attemptEntry.modelOverrideSource).toBeUndefined();
1727+
expect(attemptEntry.authProfileOverride).toBeUndefined();
1728+
expect(attemptEntry.authProfileOverrideSource).toBeUndefined();
1729+
const cleanupWrite = state.persistSessionEntryMock.mock.calls
1730+
.map((call) => call[0] as { entry?: SessionEntry; shouldPersist?: unknown })
1731+
.find(
1732+
(params) =>
1733+
params.shouldPersist &&
1734+
params.entry?.providerOverride === undefined &&
1735+
params.entry?.modelOverride === undefined &&
1736+
params.entry?.authProfileOverride === undefined,
1737+
);
1738+
expect(cleanupWrite).toBeDefined();
1739+
});
1740+
1741+
it("preserves a direct stale auto-fallback pin that changes before cleanup persists", async () => {
1742+
setupSingleAttemptFallback();
1743+
state.runtimeConfigMock = {
1744+
agents: {
1745+
defaults: {
1746+
model: "anthropic/default-model",
1747+
models: {
1748+
"anthropic/default-model": {},
1749+
"anthropic/fallback-model": {},
1750+
"openai/channel-model": {},
1751+
},
1752+
},
1753+
},
1754+
channels: {
1755+
modelByChannel: {
1756+
discord: {
1757+
"channel-123": "openai/channel-model",
1758+
},
1759+
},
1760+
},
1761+
};
1762+
const sessionEntry: SessionEntry = {
1763+
sessionId: "session-1",
1764+
updatedAt: 1,
1765+
channel: "discord",
1766+
groupId: "channel-123",
1767+
providerOverride: "anthropic",
1768+
modelOverride: "fallback-model",
1769+
modelOverrideSource: "auto",
1770+
modelOverrideFallbackOriginProvider: "openai",
1771+
modelOverrideFallbackOriginModel: "old-channel-model",
1772+
authProfileOverride: "anthropic:fallback",
1773+
authProfileOverrideSource: "auto",
1774+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
1775+
};
1776+
state.sessionEntryMock = sessionEntry;
1777+
state.sessionStoreMock = { "agent:main:main": sessionEntry };
1778+
state.storePathMock = "/tmp/openclaw-sessions.json";
1779+
state.runAgentAttemptMock.mockImplementation(async () => {
1780+
const store = state.sessionStoreMock as Record<string, SessionEntry>;
1781+
store["agent:main:main"] = {
1782+
...store["agent:main:main"],
1783+
providerOverride: "anthropic",
1784+
modelOverride: "newer-fallback-model",
1785+
modelOverrideSource: "auto",
1786+
modelOverrideFallbackOriginProvider: "openai",
1787+
modelOverrideFallbackOriginModel: "older-channel-model",
1788+
authProfileOverride: "anthropic:newer-fallback",
1789+
authProfileOverrideSource: "auto",
1790+
updatedAt: 2,
1791+
};
1792+
return makeSuccessResult("openai", "channel-model");
1793+
});
1794+
1795+
await runBasicAgentCommand();
1796+
1797+
const cleanupWrite = state.persistSessionEntryMock.mock.calls
1798+
.map((call) => call[0] as { entry?: SessionEntry; shouldPersist?: unknown })
1799+
.find(
1800+
(params) =>
1801+
params.shouldPersist &&
1802+
params.entry?.providerOverride === undefined &&
1803+
params.entry?.modelOverride === undefined &&
1804+
params.entry?.authProfileOverride === undefined,
1805+
);
1806+
expect(cleanupWrite).toBeDefined();
1807+
const stored = (state.sessionStoreMock as Record<string, SessionEntry>)["agent:main:main"];
1808+
expect(stored?.providerOverride).toBe("anthropic");
1809+
expect(stored?.modelOverride).toBe("newer-fallback-model");
1810+
expect(stored?.modelOverrideSource).toBe("auto");
1811+
expect(stored?.modelOverrideFallbackOriginProvider).toBe("openai");
1812+
expect(stored?.modelOverrideFallbackOriginModel).toBe("older-channel-model");
1813+
expect(stored?.authProfileOverride).toBe("anthropic:newer-fallback");
1814+
expect(stored?.authProfileOverrideSource).toBe("auto");
1815+
});
1816+
1817+
it("does not persist stale auto-fallback origin cleanup when preserving session model state", async () => {
1818+
setupSingleAttemptFallback();
1819+
state.runtimeConfigMock = {
1820+
agents: {
1821+
defaults: {
1822+
model: "openai/primary-model",
1823+
models: {
1824+
"openai/primary-model": {},
1825+
"anthropic/fallback-model": {},
1826+
},
1827+
},
1828+
},
1829+
};
1830+
const sessionEntry: SessionEntry = {
1831+
sessionId: "session-1",
1832+
updatedAt: 1,
1833+
providerOverride: "anthropic",
1834+
modelOverride: "fallback-model",
1835+
modelOverrideSource: "auto",
1836+
modelOverrideFallbackOriginProvider: "openai",
1837+
modelOverrideFallbackOriginModel: "old-primary-model",
1838+
authProfileOverride: "anthropic:fallback",
1839+
authProfileOverrideSource: "auto",
1840+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
1841+
};
1842+
state.sessionEntryMock = sessionEntry;
1843+
state.sessionStoreMock = { "agent:main:main": sessionEntry };
1844+
state.storePathMock = "/tmp/openclaw-sessions.json";
1845+
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "primary-model"));
1846+
1847+
await agentCommand({
1848+
message: "hello",
1849+
to: "+1234567890",
1850+
preserveUserFacingSessionModelState: true,
1851+
skipInitialSessionTouch: true,
1852+
});
1853+
1854+
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
1855+
expect(fallbackParams.provider).toBe("openai");
1856+
expect(fallbackParams.model).toBe("primary-model");
1857+
const attemptParams = requireRecord(mockCallArg(state.runAgentAttemptMock), "attempt params");
1858+
const attemptEntry = requireRecord(attemptParams.sessionEntry, "attempt session entry");
1859+
expect(attemptEntry.authProfileOverride).toBeUndefined();
1860+
const stored = (state.sessionStoreMock as Record<string, SessionEntry>)["agent:main:main"];
1861+
expect(stored?.providerOverride).toBe("anthropic");
1862+
expect(stored?.modelOverride).toBe("fallback-model");
1863+
expect(stored?.modelOverrideSource).toBe("auto");
1864+
expect(stored?.authProfileOverride).toBe("anthropic:fallback");
1865+
});
1866+
1867+
it("strips user-sourced incompatible stale-origin auth only from preserved direct attempts", async () => {
1868+
setupSingleAttemptFallback();
1869+
state.runtimeConfigMock = {
1870+
agents: {
1871+
defaults: {
1872+
model: "openai/primary-model",
1873+
models: {
1874+
"openai/primary-model": {},
1875+
"anthropic/fallback-model": {},
1876+
},
1877+
},
1878+
},
1879+
};
1880+
state.authProfileStoreMock = {
1881+
profiles: {
1882+
"anthropic:fallback": {
1883+
type: "api_key",
1884+
provider: "anthropic",
1885+
key: "sk-test",
1886+
},
1887+
},
1888+
};
1889+
const sessionEntry: SessionEntry = {
1890+
sessionId: "session-1",
1891+
updatedAt: 1,
1892+
providerOverride: "anthropic",
1893+
modelOverride: "fallback-model",
1894+
modelOverrideSource: "auto",
1895+
modelOverrideFallbackOriginProvider: "openai",
1896+
modelOverrideFallbackOriginModel: "old-primary-model",
1897+
authProfileOverride: "anthropic:fallback",
1898+
authProfileOverrideSource: "user",
1899+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
1900+
};
1901+
state.sessionEntryMock = sessionEntry;
1902+
state.sessionStoreMock = { "agent:main:main": sessionEntry };
1903+
state.storePathMock = "/tmp/openclaw-sessions.json";
1904+
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "primary-model"));
1905+
1906+
await agentCommand({
1907+
message: "hello",
1908+
to: "+1234567890",
1909+
preserveUserFacingSessionModelState: true,
1910+
skipInitialSessionTouch: true,
1911+
});
1912+
1913+
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
1914+
expect(fallbackParams.provider).toBe("openai");
1915+
expect(fallbackParams.model).toBe("primary-model");
1916+
const attemptParams = requireRecord(mockCallArg(state.runAgentAttemptMock), "attempt params");
1917+
const attemptEntry = requireRecord(attemptParams.sessionEntry, "attempt session entry");
1918+
expect(attemptEntry.providerOverride).toBeUndefined();
1919+
expect(attemptEntry.modelOverride).toBeUndefined();
1920+
expect(attemptEntry.authProfileOverride).toBeUndefined();
1921+
expect(attemptEntry.authProfileOverrideSource).toBeUndefined();
1922+
expect(state.clearSessionAuthProfileOverrideMock).not.toHaveBeenCalled();
1923+
const stored = (state.sessionStoreMock as Record<string, SessionEntry>)["agent:main:main"];
1924+
expect(stored?.providerOverride).toBe("anthropic");
1925+
expect(stored?.modelOverride).toBe("fallback-model");
1926+
expect(stored?.modelOverrideSource).toBe("auto");
1927+
expect(stored?.authProfileOverride).toBe("anthropic:fallback");
1928+
expect(stored?.authProfileOverrideSource).toBe("user");
1929+
});
1930+
15761931
it("uses current threaded session key for parent channel model overrides", async () => {
15771932
setupSingleAttemptFallback();
15781933
state.runtimeConfigMock = {

0 commit comments

Comments
 (0)