Desktop model selector sync with config defaults

NousResearch/hermes-agent · cluster 50013 · apps/desktop

Data as of

2 open items. Fix merged in #65896. Check it covers your case.

The short answer

2open issues
0open PRs
10items grouped
Known?
Yes. 10 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #65896. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
2 issues and 0 PRs.

These issues and fixes concern the desktop (and some TUI) model selector failing to stay in sync with config.yaml profile defaults — including stale localStorage caching, sticky overrides lacking visual disclosure, and new sessions inheriting the wrong model. Several fixes and feature additions address these related behaviors.

Open issues2

  • Issue#50013[Feature]: Desktop composer must stay in sync with profile default model changes (config.yaml)
  • Issue#50821Desktop app model selector uses cached localStorage instead of config.yaml for new sessions

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #65896 fix(desktop): refresh default-derived composer model (supersedes #62486)

    merged to main

    files: apps/desktop/src/app/contrib/wiring.tsx, apps/desktop/src/app/session/hooks/use-model-controls.test.tsx, apps/desktop/src/app/session/hooks/use-model-controls.ts, apps/desktop/src/store/session.ts

    Show the diff
    • apps/desktop/src/app/contrib/wiring.tsx
      @@ -51,6 +51,7 @@ import {
         setCurrentBranch,
         setCurrentCwd,
         setCurrentModel,
      +  setCurrentModelSource,
         setCurrentProvider,
         setMessages
       } from '@/store/session'
      @@ -898,6 +899,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
                   onMainModelChanged={(provider, model) => {
                     setCurrentProvider(provider)
                     setCurrentModel(model)
      +              setCurrentModelSource('default')
                     updateModelOptionsCache(provider, model, true)
                     void refreshCurrentModel()
                     void queryClient.invalidateQueries({ queryKey: ['model-options'] })
    • apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
      @@ -3,7 +3,15 @@ import { cleanup, render, renderHook } from '@testing-library/react'
       import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { getGlobalModelInfo } from '@/hermes'
      -import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
      +import {
      +  $activeSessionId,
      +  $currentModel,
      +  $currentProvider,
      +  getCurrentModelSource,
      +  setCurrentModel,
      +  setCurrentModelSource,
      +  setCurrentProvider
      +} from '@/store/session'
       
       import { useModelControls } from './use-model-controls'
       
      @@ -52,6 +60,7 @@ describe('useModelControls', () => {
         beforeEach(() => {
           $activeSessionId.set(null)
           setCurrentModel('')
      +    setCurrentModelSource('')
           setCurrentProvider('')
         })
       
      @@ -60,6 +69,7 @@ describe('useModelControls', () => {
           vi.restoreAllMocks()
           $activeSessionId.set(null)
           setCurrentModel('')
      +    setCurrentModelSource('')
           setCurrentProvider('')
         })
       
      @@ -80,6 +90,7 @@ describe('useModelControls', () => {
       
           expect($currentModel.get()).toBe('openai/gpt-5.5')
           expect($currentProvider.get()).toBe('openai-codex')
      +    expect(getCurrentModelSource()).toBe('default')
         })
       
         it('does not clobber the active session footer state with global model info', async () => {
      @@ -164,6 +175,7 @@ describe('useModelControls', () => {
           // the gateway or the profile default here.
           expect($currentModel.get()).toBe('claude-sonnet-4.6')
           expect($currentProvider.get()).toBe('anthropic')
      +    expect(getCurrentModelSource()).toBe('manual')
           expect(requestGateway).not.toHaveBeenCalled()
           expect(setGlobalModel).not.toHaveBeenCalled()
         })
      @@ -185,6 +197,7 @@ describe('useModelControls', () => {
           // A user pick must survive the lifecycle refreshes that fire on boot / fresh
           // draft / session events.
           setCurrentModel('anthropic/claude-sonnet-4.6')
      +    setCurrentModelSource('manual')
           setCurrentProvider('anthropic')
           await result.current.refreshCurrentModel()
           expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
      @@ -193,4 +206,27 @@ describe('useModelControls', () => {
           await result.current.refreshCurrentModel(true)
           expect($currentModel.get()).toBe('openai/gpt-5.5')
         })
      +
      +  it('refreshes legacy/default-derived composer state from the profile default', async () => {
      +    setCurrentModel('openai/gpt-5.5')
      +    setCurrentProvider('nous')
      +    setCurrentModelSource('')
      +    vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'gpt-5.5', provider: 'openai-codex' })
      +
      +    const { result } = renderHook(() =>
      +      useModelControls({
      +        queryClient: new QueryClient(),
      +        requestGateway: vi.fn()
      +      })
      +    )
      +
      +    expect(getCurrentModelSource()).toBe('')
      +
      +    await result.current.refreshCurrentModel()
      +
      +    expect(getGlobalModelInfo).toHaveBeenCalled()
      +    expect($currentModel.get()).toBe('gpt-5.5')
      +    expect($currentProvider.get()).toBe('openai-codex')
      +    expect(getCurrentModelSource()).toBe('default')
      +  })
       })
    • apps/desktop/src/app/session/hooks/use-model-controls.ts
      @@ -4,7 +4,15 @@ import { useCallback } from 'react'
       import { getGlobalModelInfo } from '@/hermes'
       import { useI18n } from '@/i18n'
       import { notifyError } from '@/store/notifications'
      -import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
      +import {
      +  $activeSessionId,
      +  $currentModel,
      +  $currentProvider,
      +  getCurrentModelSource,
      +  setCurrentModel,
      +  setCurrentModelSource,
      +  setCurrentProvider
      +} from '@/store/session'
       import type { ModelOptionsResponse } from '@/types/hermes'
       
       interface ModelSelection {
      @@ -50,13 +58,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
               return
             }
       
      -      if (!force && $currentModel.get()) {
      +      if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') {
               return
             }
       
             const result = await getGlobalModelInfo()
       
      -      if ($activeSessionId.get() || (!force && $currentModel.get())) {
      +      if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) {
               return
             }
       
      @@ -67,6 +75,10 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
             if (typeof result.provider === 'string') {
               setCurrentProvider(result.provider)
             }
      +
      +      if (typeof result.model === 'string' || typeof result.provider === 'string') {
      +        setCurrentModelSource('default')
      +      }
           } catch {
             // The delayed session.info event still updates this once the agent is ready.
           }
      @@ -85,11 +97,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
             // rather than leave the UI showing a model the backend never selected.
             const prevModel = $currentModel.get()
             const prevProvider = $currentProvider.get()
      +      const prevSource = getCurrentModelSource()
       
             const liveSessionId = $activeSessionId.get()
       
             setCurrentModel(selection.model)
             setCurrentProvider(selection.provider)
      +      setCurrentModelSource('manual')
             updateModelOptionsCache(selection.provider, selection.model, !liveSessionId)
       
             // No live session yet: the pick is pure UI state. session.create reads
      @@ -111,6 +125,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
             } catch (err) {
               setCurrentModel(prevModel)
               setCurrentProvider(prevProvider)
      +        setCurrentModelSource(prevSource)
               updateModelOptionsCache(prevProvider, prevModel, !liveSessionId)
               notifyError(err, copy.modelSwitchFailed)
       
    • apps/desktop/src/store/session.ts
      @@ -8,6 +8,7 @@ import { persistBoolean, persistString, storedBoolean, storedString } from '@/li
       import type { SessionInfo, UsageStats } from '@/types/hermes'
       
       type Updater<T> = T | ((current: T) => T)
      +export type ComposerModelSource = '' | 'default' | 'manual'
       
       const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
       
      @@ -18,6 +19,7 @@ const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
       // that profile's default, while within a profile new chats keep your last pick.
       const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
       const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
      +const COMPOSER_MODEL_SOURCE_KEY = 'hermes.desktop.composer.model-source'
       const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
       const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
       
      @@ -347,6 +349,16 @@ export const setCurrentProvider = (next: Updater<string>) => {
         persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
       }
       
      +export const getCurrentModelSource = (): ComposerModelSource => {
      +  const source = storedString(COMPOSER_MODEL_SOURCE_KEY)
      +
      +  return source === 'default' || source === 'manual' ? source : ''
      +}
      +
      +export const setCurrentModelSource = (source: ComposerModelSource) => {
      +  persistString(COMPOSER_MODEL_SOURCE_KEY, source || null)
      +}
      +
       export const setCurrentReasoningEffort = (next: Updater<string>) => {
         updateAtom($currentReasoningEffort, next)
         persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)

3 PRs closed without merging

From the close comments: done elsewhere 2 · other 1

  • #62072 fix(desktop): disclose sticky model overrides · other → #58527, #62486
  • #67207 fix(desktop): reseed new chats from profile defaults · done elsewhere → #65300, #58498, #65896, #67291
  • #67291 fix(desktop): reseed new chats from profile defaults, guarded against concurrent picks (supersedes #67207) · done elsewhere → #65896, #58498
Closed issues · 4
  • Closed#62055Desktop: composer model picker silently overrides Settings default — unexpected model & billing
  • Closed#65300Hermes Desktop new session ignores config.yaml default and reuses sticky composer model
  • Closed#65877Desktop model pill shows global default instead of session override
  • Closed#68342[Bug] Desktop model selector sticky manual override silently diverges from config.yaml default — no visual indicator
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#65896
Closed issues
4
Attempts
3 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.