@@ -262,7 +262,7 @@ vi.mock("../config/sessions.js", () => ({
262262vi . 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
348348vi . 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
381438vi . 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