{"repo":"NousResearch/hermes-agent","public_id":"53510","display_label":"Desktop composer ignores config.yaml provider on startup","summary":"Multiple bug reports and fix attempts address a desktop issue where the composer model and provider are not correctly loaded from config.yaml at startup, particularly when OAuth credentials or API keys are present. Several pull requests iterate on refreshing the default-derived composer model to resolve this.","subsystem":"apps/desktop","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65896},"resolution":{"state":"partially_resolved","by":"pr-65896"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T19:23:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22476190476190475},"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":53510,"title":"Bug: Hermes Desktop ignores config.yaml provider when OAuth credentials exist","url":null,"state":"closed"},{"kind":"issue","number":58498,"title":"[Bug]: Hermes Desktop ignores OpenAI Codex provider and routes GPT-5.5 requests through Nous Portal (CLI works correctly)","url":null,"state":"closed"},{"kind":"issue","number":65418,"title":"Desktop model picker: ignores config.yaml provider, always uses ollama-cloud when OLLAMA_API_KEY is set","url":null,"state":"closed"},{"kind":"pr","number":58527,"title":"fix(desktop): refresh default-derived composer model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65896","number":65896},{"kind":"pr","value":"#62486","number":62486},{"kind":"pr","value":"#58498","number":58498}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":60002,"title":"fix(desktop): sync composer model/provider from config.yaml on startup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58527","number":58527},{"kind":"pr","value":"#58527","number":58527},{"kind":"pr","value":"#62486","number":62486},{"kind":"pr","value":"#62486","number":62486},{"kind":"pr","value":"#59979","number":59979},{"kind":"pr","value":"#58498","number":58498}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":62486,"title":"fix(desktop): refresh default-derived composer model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65896","number":65896}],"category":"implemented_elsewhere","implication":"superseded"},"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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}