# NousResearch/hermes-agent · Desktop model selector sync with config defaults

## Open now

- 2 issues, 0 PRs open on GitHub.
- #50013: \[Feature\]: Desktop composer must stay in sync with profile default model changes (config.yaml)
- #50821: Desktop app model selector uses cached localStorage instead of config.yaml for new sessions

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.

## TL;DR

- 10 member(s): 2 open issue(s), 4 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #65896. Verify it addresses your case.
- **Recommended:** read PR #65896 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #62055: Desktop: composer model picker silently overrides Settings default — unexpected model & billing
- #65300: Hermes Desktop new session ignores config.yaml default and reuses sticky composer model
- #65877: Desktop model pill shows global default instead of session override
- #68342: \[Bug\] Desktop model selector sticky manual override silently diverges from config.yaml default — no visual indicator

## Prior & competing PRs

- **#65896: fix(desktop): refresh default-derived composer model (supersedes #62486)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `apps/desktop/src/app/contrib/wiring.tsx`

    ```diff
    @@ -51,6 +51,7 @@ import {
       setCurrentBranch,
       setCurrentCwd,
       setCurrentModel,
    +  setCurrentModelSource,
       setCurrentProvider,
       setMessages
     } from '@/store/session'
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -52,6 +60,7 @@ describe('useModelControls', () => {
       beforeEach(() => {
         $activeSessionId.set(null)
         setCurrentModel('')
    +    setCurrentModelSource('')
         setCurrentProvider('')
       })
     
    ```

    ```diff
    @@ -60,6 +69,7 @@ describe('useModelControls', () => {
         vi.restoreAllMocks()
         $activeSessionId.set(null)
         setCurrentModel('')
    +    setCurrentModelSource('')
         setCurrentProvider('')
       })
     
    ```

    ```diff
    @@ -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 () => {
    ```

    ```diff
    @@ -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()
       })
    ```

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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
           }
     
    ```

    ```diff
    @@ -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.
         }
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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)
    ```
- **#62072: fix(desktop): disclose sticky model overrides**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#58527`, `#62486`, `#58527`
- **#67207: fix(desktop): reseed new chats from profile defaults**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#65300`, `#58498`, `#65896`, `#67291`
- **#67291: fix(desktop): reseed new chats from profile defaults, guarded against concurrent picks (supersedes #67207)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#65896`, `#58498`, `#65896`

## Fix status

- Merged to main in PR #65896 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `apps/desktop`
