{"repo":"NousResearch/hermes-agent","public_id":"50013","display_label":"Desktop model selector sync with config defaults","summary":"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.","subsystem":"apps/desktop","counts":{"members":10,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65896},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-16T19:23:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1608888888888889},"fix_edges":[{"issue_number":58498,"pr_number":65896,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":63835,"pr_number":65896,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":65300,"pr_number":65896,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":50013,"title":"[Feature]: Desktop composer must stay in sync with profile default model changes (config.yaml)","url":null,"state":"open"},{"kind":"issue","number":50821,"title":"Desktop app model selector uses cached localStorage instead of config.yaml for new sessions","url":null,"state":"open"},{"kind":"issue","number":62055,"title":"Desktop: composer model picker silently overrides Settings default — unexpected model & billing","url":null,"state":"closed"},{"kind":"issue","number":65300,"title":"Hermes Desktop new session ignores config.yaml default and reuses sticky composer model","url":null,"state":"closed"},{"kind":"issue","number":65877,"title":"Desktop model pill shows global default instead of session override","url":null,"state":"closed"},{"kind":"issue","number":68342,"title":"[Bug] Desktop model selector sticky manual override silently diverges from config.yaml default — no visual indicator","url":null,"state":"closed"},{"kind":"pr","number":62072,"title":"fix(desktop): disclose sticky model overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58527","number":58527},{"kind":"pr","value":"#62486","number":62486},{"kind":"pr","value":"#58527","number":58527}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":65896,"title":"fix(desktop): refresh default-derived composer model (supersedes #62486)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/desktop/src/app/contrib/wiring.tsx","hunks":["@@ -51,6 +51,7 @@ import {\n   setCurrentBranch,\n   setCurrentCwd,\n   setCurrentModel,\n+  setCurrentModelSource,\n   setCurrentProvider,\n   setMessages\n } from '@/store/session'","@@ -898,6 +899,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n             onMainModelChanged={(provider, model) => {\n               setCurrentProvider(provider)\n               setCurrentModel(model)\n+              setCurrentModelSource('default')\n               updateModelOptionsCache(provider, model, true)\n               void refreshCurrentModel()\n               void queryClient.invalidateQueries({ queryKey: ['model-options'] })"]},{"file":"apps/desktop/src/app/session/hooks/use-model-controls.test.tsx","hunks":["@@ -3,7 +3,15 @@ import { cleanup, render, renderHook } from '@testing-library/react'\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { getGlobalModelInfo } from '@/hermes'\n-import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'\n+import {\n+  $activeSessionId,\n+  $currentModel,\n+  $currentProvider,\n+  getCurrentModelSource,\n+  setCurrentModel,\n+  setCurrentModelSource,\n+  setCurrentProvider\n+} from '@/store/session'\n \n import { useModelControls } from './use-model-controls'\n ","@@ -52,6 +60,7 @@ describe('useModelControls', () => {\n   beforeEach(() => {\n     $activeSessionId.set(null)\n     setCurrentModel('')\n+    setCurrentModelSource('')\n     setCurrentProvider('')\n   })\n ","@@ -60,6 +69,7 @@ describe('useModelControls', () => {\n     vi.restoreAllMocks()\n     $activeSessionId.set(null)\n     setCurrentModel('')\n+    setCurrentModelSource('')\n     setCurrentProvider('')\n   })\n ","@@ -80,6 +90,7 @@ describe('useModelControls', () => {\n \n     expect($currentModel.get()).toBe('openai/gpt-5.5')\n     expect($currentProvider.get()).toBe('openai-codex')\n+    expect(getCurrentModelSource()).toBe('default')\n   })\n \n   it('does not clobber the active session footer state with global model info', async () => {","@@ -164,6 +175,7 @@ describe('useModelControls', () => {\n     // the gateway or the profile default here.\n     expect($currentModel.get()).toBe('claude-sonnet-4.6')\n     expect($currentProvider.get()).toBe('anthropic')\n+    expect(getCurrentModelSource()).toBe('manual')\n     expect(requestGateway).not.toHaveBeenCalled()\n     expect(setGlobalModel).not.toHaveBeenCalled()\n   })","@@ -185,6 +197,7 @@ describe('useModelControls', () => {\n     // A user pick must survive the lifecycle refreshes that fire on boot / fresh\n     // draft / session events.\n     setCurrentModel('anthropic/claude-sonnet-4.6')\n+    setCurrentModelSource('manual')\n     setCurrentProvider('anthropic')\n     await result.current.refreshCurrentModel()\n     expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')","@@ -193,4 +206,27 @@ describe('useModelControls', () => {\n     await result.current.refreshCurrentModel(true)\n     expect($currentModel.get()).toBe('openai/gpt-5.5')\n   })\n+\n+  it('refreshes legacy/default-derived composer state from the profile default', async () => {\n+    setCurrentModel('openai/gpt-5.5')\n+    setCurrentProvider('nous')\n+    setCurrentModelSource('')\n+    vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'gpt-5.5', provider: 'openai-codex' })\n+\n+    const { result } = renderHook(() =>\n+      useModelControls({\n+        queryClient: new QueryClient(),\n+        requestGateway: vi.fn()\n+      })\n+    )\n+\n+    expect(getCurrentModelSource()).toBe('')\n+\n+    await result.current.refreshCurrentModel()\n+\n+    expect(getGlobalModelInfo).toHaveBeenCalled()\n+    expect($currentModel.get()).toBe('gpt-5.5')\n+    expect($currentProvider.get()).toBe('openai-codex')\n+    expect(getCurrentModelSource()).toBe('default')\n+  })\n })"]},{"file":"apps/desktop/src/app/session/hooks/use-model-controls.ts","hunks":["@@ -4,7 +4,15 @@ import { useCallback } from 'react'\n import { getGlobalModelInfo } from '@/hermes'\n import { useI18n } from '@/i18n'\n import { notifyError } from '@/store/notifications'\n-import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'\n+import {\n+  $activeSessionId,\n+  $currentModel,\n+  $currentProvider,\n+  getCurrentModelSource,\n+  setCurrentModel,\n+  setCurrentModelSource,\n+  setCurrentProvider\n+} from '@/store/session'\n import type { ModelOptionsResponse } from '@/types/hermes'\n \n interface ModelSelection {","@@ -50,13 +58,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO\n         return\n       }\n \n-      if (!force && $currentModel.get()) {\n+      if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') {\n         return\n       }\n \n       const result = await getGlobalModelInfo()\n \n-      if ($activeSessionId.get() || (!force && $currentModel.get())) {\n+      if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) {\n         return\n       }\n ","@@ -67,6 +75,10 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO\n       if (typeof result.provider === 'string') {\n         setCurrentProvider(result.provider)\n       }\n+\n+      if (typeof result.model === 'string' || typeof result.provider === 'string') {\n+        setCurrentModelSource('default')\n+      }\n     } catch {\n       // The delayed session.info event still updates this once the agent is ready.\n     }","@@ -85,11 +97,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO\n       // rather than leave the UI showing a model the backend never selected.\n       const prevModel = $currentModel.get()\n       const prevProvider = $currentProvider.get()\n+      const prevSource = getCurrentModelSource()\n \n       const liveSessionId = $activeSessionId.get()\n \n       setCurrentModel(selection.model)\n       setCurrentProvider(selection.provider)\n+      setCurrentModelSource('manual')\n       updateModelOptionsCache(selection.provider, selection.model, !liveSessionId)\n \n       // No live session yet: the pick is pure UI state. session.create reads","@@ -111,6 +125,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO\n       } catch (err) {\n         setCurrentModel(prevModel)\n         setCurrentProvider(prevProvider)\n+        setCurrentModelSource(prevSource)\n         updateModelOptionsCache(prevProvider, prevModel, !liveSessionId)\n         notifyError(err, copy.modelSwitchFailed)\n "]},{"file":"apps/desktop/src/store/session.ts","hunks":["@@ -8,6 +8,7 @@ import { persistBoolean, persistString, storedBoolean, storedString } from '@/li\n import type { SessionInfo, UsageStats } from '@/types/hermes'\n \n type Updater<T> = T | ((current: T) => T)\n+export type ComposerModelSource = '' | 'default' | 'manual'\n \n const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'\n ","@@ -18,6 +19,7 @@ const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'\n // that profile's default, while within a profile new chats keep your last pick.\n const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'\n const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'\n+const COMPOSER_MODEL_SOURCE_KEY = 'hermes.desktop.composer.model-source'\n const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'\n const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'\n ","@@ -347,6 +349,16 @@ export const setCurrentProvider = (next: Updater<string>) => {\n   persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)\n }\n \n+export const getCurrentModelSource = (): ComposerModelSource => {\n+  const source = storedString(COMPOSER_MODEL_SOURCE_KEY)\n+\n+  return source === 'default' || source === 'manual' ? source : ''\n+}\n+\n+export const setCurrentModelSource = (source: ComposerModelSource) => {\n+  persistString(COMPOSER_MODEL_SOURCE_KEY, source || null)\n+}\n+\n export const setCurrentReasoningEffort = (next: Updater<string>) => {\n   updateAtom($currentReasoningEffort, next)\n   persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)\n"]}]},{"kind":"pr","number":67207,"title":"fix(desktop): reseed new chats from profile defaults","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65300","number":65300},{"kind":"pr","value":"#58498","number":58498},{"kind":"pr","value":"#65896","number":65896},{"kind":"pr","value":"#67291","number":67291}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":67291,"title":"fix(desktop): reseed new chats from profile defaults, guarded against concurrent picks (supersedes #67207)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65896","number":65896},{"kind":"pr","value":"#58498","number":58498},{"kind":"pr","value":"#65896","number":65896}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}