{"repo":"NousResearch/hermes-agent","public_id":"38901","display_label":"Session switching causes stale model display state","summary":"Multiple bugs and fixes address model name/state not updating correctly in the status bar, header, and sidebar when switching between sessions. Includes both desktop and TUI fixes, plus a related inline model picker feature.","subsystem":"apps/desktop","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":43702},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-11T14:05:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.21714285714285714},"fix_edges":[{"issue_number":38901,"pr_number":43702,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":41987,"pr_number":43977,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":38901,"title":"[Bug]: Desktop model display stays stale when switching between sessions with different models","url":null,"state":"closed"},{"kind":"issue","number":40725,"title":"[UI Bug] Status bar model name not refreshing on session switch — shows stale model from previous session","url":null,"state":"closed"},{"kind":"issue","number":41987,"title":"[Desktop] Status bar model selector shows wrong model when switching between conversations","url":null,"state":"closed"},{"kind":"pr","number":39361,"title":"fix(desktop): sync model header when switching cached sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6de3963e37698b0789bdec5a08761bd85ff4502f","number":0},{"kind":"pr","value":"#43702","number":43702},{"kind":"pr","value":"#40163","number":40163}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40748,"title":"fix(tui): filter session.info events by session ID to prevent stale model on switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d682f320b35a13084371a541a835e1d988c982b8","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42005,"title":"fix: sync chat sidebar model badge with resumed sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e372803554be5027be3da23090c4ddf36e255a67","number":0},{"kind":"pr","value":"#41987","number":41987},{"kind":"pr","value":"#43977","number":43977}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43702,"title":"fix(desktop): keep model runtime state per session","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/desktop-controller.tsx","apps/desktop/src/app/session/hooks/use-message-stream.ts","apps/desktop/src/app/session/hooks/use-model-controls.test.tsx","apps/desktop/src/app/session/hooks/use-model-controls.ts","apps/desktop/src/app/session/hooks/use-session-actions.ts","apps/desktop/src/app/session/hooks/use-session-state-cache.ts","apps/desktop/src/app/shell/model-menu-panel.tsx","apps/desktop/src/app/types.ts","apps/desktop/src/lib/chat-runtime.ts","apps/desktop/src/lib/model-status-label.test.ts","scripts/release.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"apps/desktop/src/app/desktop-controller.tsx","hunks":["@@ -743,6 +743,13 @@ export function DesktopController() {\n     }\n   }, [gatewayState, refreshCronJobs])\n \n+  useEffect(() => {\n+    if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {\n+      void refreshCurrentModel()\n+      void refreshHermesConfig()\n+    }\n+  }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])\n+\n   useRouteResume({\n     activeSessionId,\n     activeSessionIdRef,"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream.ts","hunks":["@@ -633,14 +633,21 @@ export function useMessageStream({\n         const runningChanged = typeof payload?.running === 'boolean'\n \n         if (apply) {\n-          const runtimeInfo: { branch?: string; cwd?: string } = {}\n+          const runtimeInfo: Partial<\n+            Pick<\n+              ClientSessionState,\n+              'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'\n+            >\n+          > = {}\n \n           if (modelChanged) {\n             setCurrentModel(payload!.model || '')\n+            runtimeInfo.model = payload!.model || ''\n           }\n \n           if (providerChanged) {\n             setCurrentProvider(payload!.provider || '')\n+            runtimeInfo.provider = payload!.provider || ''\n           }\n \n           if (typeof payload?.cwd === 'string') {","@@ -653,32 +660,32 @@ export function useMessageStream({\n             runtimeInfo.branch = payload.branch\n           }\n \n-          if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {\n-            updateSessionState(sessionId, state => ({\n-              ...state,\n-              branch: runtimeInfo.branch ?? state.branch,\n-              cwd: runtimeInfo.cwd ?? state.cwd\n-            }))\n-          }\n-\n           if (typeof payload?.personality === 'string') {\n             setCurrentPersonality(normalizePersonalityValue(payload.personality))\n           }\n \n           if (typeof payload?.reasoning_effort === 'string') {\n             setCurrentReasoningEffort(payload.reasoning_effort)\n+            runtimeInfo.reasoningEffort = payload.reasoning_effort\n           }\n \n           if (typeof payload?.service_tier === 'string') {\n             setCurrentServiceTier(payload.service_tier)\n+            runtimeInfo.serviceTier = payload.service_tier\n           }\n \n           if (typeof payload?.fast === 'boolean') {\n             setCurrentFastMode(payload.fast)\n+            runtimeInfo.fast = payload.fast\n           }\n \n           if (typeof payload?.yolo === 'boolean') {\n             setYoloActive(payload.yolo)\n+            runtimeInfo.yolo = payload.yolo\n+          }\n+\n+          if (sessionId && Object.keys(runtimeInfo).length > 0) {\n+            updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))\n           }\n \n           if (runningChanged && sessionId) {"]},{"file":"apps/desktop/src/app/session/hooks/use-model-controls.test.tsx","hunks":["@@ -0,0 +1,77 @@\n+import { renderHook } from '@testing-library/react'\n+import { QueryClient } from '@tanstack/react-query'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { getGlobalModelInfo } from '@/hermes'\n+import {\n+  $activeSessionId,\n+  $currentModel,\n+  $currentProvider,\n+  setCurrentModel,\n+  setCurrentProvider\n+} from '@/store/session'\n+\n+import { useModelControls } from './use-model-controls'\n+\n+vi.mock('@/hermes', () => ({\n+  getGlobalModelInfo: vi.fn(),\n+  setGlobalModel: vi.fn()\n+}))\n+\n+describe('useModelControls.refreshCurrentModel', () => {\n+  beforeEach(() => {\n+    $activeSessionId.set(null)\n+    setCurrentModel('')\n+    setCurrentProvider('')\n+  })\n+\n+  afterEach(() => {\n+    vi.restoreAllMocks()\n+    $activeSessionId.set(null)\n+    setCurrentModel('')\n+    setCurrentProvider('')\n+  })\n+\n+  it('applies the global model when there is no active runtime session', async () => {\n+    vi.mocked(getGlobalModelInfo).mockResolvedValue({\n+      model: 'openai/gpt-5.5',\n+      provider: 'openai-codex'\n+    })\n+\n+    const { result } = renderHook(() =>\n+      useModelControls({\n+        activeSessionId: null,\n+        queryClient: new QueryClient(),\n+        requestGateway: vi.fn()\n+      })\n+    )\n+\n+    await result.current.refreshCurrentModel()\n+\n+    expect($currentModel.get()).toBe('openai/gpt-5.5')\n+    expect($currentProvider.get()).toBe('openai-codex')\n+  })\n+\n+  it('does not clobber the active session footer state with global model info', async () => {\n+    setCurrentModel('deepseek/deepseek-v4-pro')\n+    setCurrentProvider('deepseek')\n+    $activeSessionId.set('runtime-1')\n+    vi.mocked(getGlobalModelInfo).mockResolvedValue({\n+      model: 'openai/gpt-5.5',\n+      provider: 'openai-codex'\n+    })\n+\n+    const { result } = renderHook(() =>\n+      useModelControls({\n+        activeSessionId: 'runtime-1',\n+        queryClient: new QueryClient(),\n+        requestGateway: vi.fn()\n+      })\n+    )\n+\n+    await result.current.refreshCurrentModel()\n+\n+    expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')\n+    expect($currentProvider.get()).toBe('deepseek')\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-model-controls.ts","hunks":["@@ -4,7 +4,13 @@ import { useCallback } from 'react'\n import { getGlobalModelInfo, setGlobalModel } from '@/hermes'\n import { useI18n } from '@/i18n'\n import { notifyError } from '@/store/notifications'\n-import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'\n+import {\n+  $activeSessionId,\n+  $currentModel,\n+  $currentProvider,\n+  setCurrentModel,\n+  setCurrentProvider\n+} from '@/store/session'\n import type { ModelOptionsResponse } from '@/types/hermes'\n \n interface ModelSelection {","@@ -39,6 +45,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway\n     try {\n       const result = await getGlobalModelInfo()\n \n+      // A resumed/live session owns the footer model state. Global config\n+      // refreshes (gateway boot, profile swap, settings save) must not clobber\n+      // the active chat's runtime model/provider in the status bar.\n+      if ($activeSessionId.get()) {\n+        return\n+      }\n+\n       if (typeof result.model === 'string') {\n         setCurrentModel(result.model)\n       }"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.ts","hunks":["@@ -19,7 +19,6 @@ import {\n   $messages,\n   $sessions,\n   $yoloActive,\n-  getRememberedWorkspaceCwd,\n   workspaceCwdForNewSession,\n   sessionPinId,\n   setActiveSessionId,","@@ -211,14 +210,16 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {\n   setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))\n }\n \n-function applyRuntimeInfo(\n-  info: SessionCreateResponse['info'] | undefined\n-): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {\n+function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<\n+  Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>\n+> | null {\n   if (!info) {\n     return null\n   }\n \n-  const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}\n+  const sessionState: Partial<\n+    Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>\n+  > = {}\n \n   reportBackendContract(info.desktop_contract)\n ","@@ -228,10 +229,12 @@ function applyRuntimeInfo(\n \n   if (info.model) {\n     setCurrentModel(info.model)\n+    sessionState.model = info.model\n   }\n \n   if (info.provider) {\n     setCurrentProvider(info.provider)\n+    sessionState.provider = info.provider\n   }\n \n   if (info.cwd) {","@@ -250,18 +253,22 @@ function applyRuntimeInfo(\n \n   if (typeof info.reasoning_effort === 'string') {\n     setCurrentReasoningEffort(info.reasoning_effort)\n+    sessionState.reasoningEffort = info.reasoning_effort\n   }\n \n   if (typeof info.service_tier === 'string') {\n     setCurrentServiceTier(info.service_tier)\n+    sessionState.serviceTier = info.service_tier\n   }\n \n   if (typeof info.fast === 'boolean') {\n     setCurrentFastMode(info.fast)\n+    sessionState.fast = info.fast\n   }\n \n   if (typeof info.yolo === 'boolean') {\n     setYoloActive(info.yolo)\n+    sessionState.yolo = info.yolo\n   }\n \n   if (info.usage) {","@@ -314,6 +321,12 @@ export function useSessionActions({\n       setTurnStartedAt(null)\n       // New chats start in the configured default project dir when set,\n       // otherwise the sticky last-used workspace (PR #37586).\n+      setCurrentModel('')\n+      setCurrentProvider('')\n+      setCurrentReasoningEffort('')\n+      setCurrentServiceTier('')\n+      setCurrentFastMode(false)\n+      setYoloActive(false)\n       setCurrentCwd(workspaceCwdForNewSession())\n       setCurrentBranch('')\n       clearComposerDraft()"]},{"file":"apps/desktop/src/app/session/hooks/use-session-state-cache.ts","hunks":["@@ -5,7 +5,20 @@ import type { ChatMessage } from '@/lib/chat-messages'\n import { preserveLocalAssistantErrors } from '@/lib/chat-messages'\n import { createClientSessionState } from '@/lib/chat-runtime'\n import { setMutableRef } from '@/lib/mutable-ref'\n-import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'\n+import {\n+  $busy,\n+  $messages,\n+  noteSessionActivity,\n+  setCurrentFastMode,\n+  setCurrentModel,\n+  setCurrentProvider,\n+  setCurrentReasoningEffort,\n+  setCurrentServiceTier,\n+  setSessionAttention,\n+  setSessionWorking,\n+  setTurnStartedAt,\n+  setYoloActive\n+} from '@/store/session'\n \n import type { ClientSessionState } from '../../types'\n ","@@ -124,6 +137,12 @@ export function useSessionStateCache({\n       setMessages(nextMessages)\n     }\n \n+    setCurrentModel(pending.state.model)\n+    setCurrentProvider(pending.state.provider)\n+    setCurrentReasoningEffort(pending.state.reasoningEffort)\n+    setCurrentServiceTier(pending.state.serviceTier)\n+    setCurrentFastMode(pending.state.fast)\n+    setYoloActive(pending.state.yolo)\n     setBusy(pending.state.busy)\n     setMutableRef(busyRef, pending.state.busy)\n     setAwaitingResponse(pending.state.awaitingResponse)"]},{"file":"apps/desktop/src/app/shell/model-menu-panel.tsx","hunks":["@@ -162,8 +162,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n                   currentFastMode\n                 )\n \n-                // Grayed text: active row shows live state (Fast + effort);\n-                // others show a fast-capability hint.\n+                // Grayed text is live session state only. Do not label inactive\n+                // rows as \"Fast\" just because they have a fast-capable sibling:\n+                // that makes an off Fast toggle look like it is already on.\n                 const meta = isCurrent\n                   ? [\n                       fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,","@@ -171,9 +172,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n                     ]\n                       .filter(Boolean)\n                       .join(' ')\n-                  : caps?.fast || family.fastId\n-                    ? copy.fast\n-                    : ''\n+                  : ''\n \n                 // Every row is a hover-Edit submenu trigger. Activating it\n                 // (pointer or keyboard) switches to the family's base model;"]},{"file":"apps/desktop/src/app/types.ts","hunks":["@@ -103,6 +103,12 @@ export interface ClientSessionState {\n   messages: ChatMessage[]\n   branch: string\n   cwd: string\n+  model: string\n+  provider: string\n+  reasoningEffort: string\n+  serviceTier: string\n+  fast: boolean\n+  yolo: boolean\n   busy: boolean\n   awaitingResponse: boolean\n   streamId: string | null"]},{"file":"apps/desktop/src/lib/chat-runtime.ts","hunks":["@@ -40,6 +40,12 @@ export function createClientSessionState(\n     messages,\n     branch: '',\n     cwd: '',\n+    model: '',\n+    provider: '',\n+    reasoningEffort: '',\n+    serviceTier: '',\n+    fast: false,\n+    yolo: false,\n     busy: false,\n     awaitingResponse: false,\n     streamId: null,"]},{"file":"apps/desktop/src/lib/model-status-label.test.ts","hunks":["@@ -5,6 +5,8 @@ import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from '\n describe('model-status-label', () => {\n   it('formats display names consistently', () => {\n     expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')\n+    expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5')\n+    expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro')\n     expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')\n   })\n "]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"pinguarmy\",\n     \"[redacted:email]\": \"barronlroth\",\n     \"[redacted:email]\": \"OndrejDrapalik\",\n     \"[redacted:email]\": \"tomekpanek\","]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -878,6 +878,97 @@ def get_messages_as_conversation(self, target, include_ancestors=False):\n     assert captured[\"history_calls\"] == [(\"tip\", False), (\"tip\", True)]\n \n \n+def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):\n+    captured = {}\n+\n+    class FakeDB:\n+        def get_session(self, target):\n+            return {\n+                \"id\": target,\n+                \"model\": \"gpt-5.4\",\n+                \"billing_provider\": \"openai-codex\",\n+                \"model_config\": '{\"reasoning_config\":{\"enabled\":true,\"effort\":\"high\"},\"service_tier\":\"priority\",\"base_url\":\"https://custom.example/v1\",\"api_mode\":\"chat_completions\"}',\n+            }\n+\n+        def reopen_session(self, target):\n+            pass\n+\n+        def get_messages_as_conversation(self, target, include_ancestors=False):\n+            return [{\"role\": \"user\", \"content\": \"hello\"}]\n+\n+    def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):\n+        captured.update(kwargs)\n+        return types.SimpleNamespace(model=\"gpt-5.4\", provider=\"openai-codex\")\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: FakeDB())\n+    monkeypatch.setattr(server, \"_enable_gateway_prompts\", lambda: None)\n+    monkeypatch.setattr(server, \"_set_session_context\", lambda target: [])\n+    monkeypatch.setattr(server, \"_clear_session_context\", lambda tokens: None)\n+    monkeypatch.setattr(server, \"_make_agent\", fake_make_agent)\n+    monkeypatch.setattr(server, \"_session_info\", lambda agent, *a: {\"model\": agent.model, \"provider\": agent.provider})\n+\n+    def fake_init_session(sid, key, agent, history, cols=80):\n+        server._sessions[sid] = {\"agent\": agent, \"session_key\": key}\n+\n+    monkeypatch.setattr(server, \"_init_session\", fake_init_session)\n+\n+    resp = server.handle_request(\n+        {\"id\": \"1\", \"method\": \"session.resume\", \"params\": {\"session_id\": \"stored-session\"}}\n+    )\n+\n+    assert resp[\"result\"][\"info\"] == {\"model\": \"gpt-5.4\", \"provider\": \"openai-codex\"}\n+    assert captured[\"model_override\"] == {\n+        \"model\": \"gpt-5.4\",\n+        \"provider\": \"openai-codex\",\n+        \"base_url\": \"https://custom.example/v1\",\n+        \"api_mode\": \"chat_completions\",\n+    }\n+    assert captured[\"provider_override\"] == \"openai-codex\"\n+    assert captured[\"reasoning_config_override\"] == {\"enabled\": True, \"effort\": \"high\"}\n+    assert captured[\"service_tier_override\"] == \"priority\"\n+    runtime_sid = resp[\"result\"][\"session_id\"]\n+    assert server._sessions[runtime_sid][\"model_override\"] == captured[\"model_override\"]\n+\n+\n+def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):\n+    updates = {}\n+\n+    class FakeDB:\n+        def get_session(self, session_id):\n+            assert session_id == \"stored-session\"\n+            return {\"model_config\": '{\"_branched_from\":\"root\"}'}\n+\n+        def update_session_meta(self, session_id, model_config_json, model=None):\n+            updates[\"meta\"] = (session_id, json.loads(model_config_json), model)\n+\n+    agent = types.SimpleNamespace(\n+        model=\"gpt-5.4\",\n+        provider=\"openai-codex\",\n+        base_url=\"https://custom.example/v1\",\n+        api_mode=\"chat_completions\",\n+        reasoning_config={\"enabled\": True, \"effort\": \"high\"},\n+        service_tier=\"priority\",\n+        _session_db=FakeDB(),\n+    )\n+\n+    server._persist_live_session_runtime({\"agent\": agent, \"session_key\": \"stored-session\"})\n+\n+    assert \"model\" not in updates\n+    assert updates[\"meta\"] == (\n+        \"stored-session\",\n+        {\n+            \"_branched_from\": \"root\",\n+            \"model\": \"gpt-5.4\",\n+            \"provider\": \"openai-codex\",\n+            \"base_url\": \"https://custom.example/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"reasoning_config\": {\"enabled\": True, \"effort\": \"high\"},\n+            \"service_tier\": \"priority\",\n+        },\n+        \"gpt-5.4\",\n+    )\n+\n+\n def test_status_callback_emits_kind_and_text():\n     with patch(\"tui_gateway.server._emit\") as emit:\n         cb = server._agent_cbs(\"sid\")[\"status_callback\"]","@@ -3593,8 +3684,9 @@ def test_session_info_includes_mcp_servers(monkeypatch):\n     fake_mod.get_mcp_status = lambda: fake_status\n     monkeypatch.setitem(sys.modules, \"tools.mcp_tool\", fake_mod)\n \n-    info = server._session_info(types.SimpleNamespace(tools=[], model=\"\"))\n+    info = server._session_info(types.SimpleNamespace(tools=[], model=\"\", provider=\"openai-codex\"))\n \n+    assert info[\"provider\"] == \"openai-codex\"\n     assert info[\"mcp_servers\"] == fake_status\n \n ","@@ -5983,6 +6075,45 @@ def test_make_agent_defaults_to_90(monkeypatch):\n     assert mock_agent.call_args.kwargs[\"max_iterations\"] == 90\n \n \n+def test_make_agent_uses_session_runtime_overrides(monkeypatch):\n+    _setup_make_agent_mocks(monkeypatch, {})\n+    resolved = {}\n+\n+    def fake_resolve_runtime_provider(requested=None, target_model=None):\n+        resolved[\"requested\"] = requested\n+        resolved[\"target_model\"] = target_model\n+        return {\n+            \"provider\": requested,\n+            \"base_url\": None,\n+            \"api_key\": None,\n+            \"api_mode\": None,\n+            \"command\": None,\n+            \"args\": None,\n+            \"credential_pool\": None,\n+        }\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+        fake_resolve_runtime_provider,\n+    )\n+\n+    with patch(\"run_agent.AIAgent\") as mock_agent:\n+        server._make_agent(\n+            \"sid1\",\n+            \"key1\",\n+            model_override=\"gpt-5.4\",\n+            provider_override=\"openai-codex\",\n+            reasoning_config_override={\"enabled\": True, \"effort\": \"high\"},\n+            service_tier_override=\"priority\",\n+        )\n+\n+    assert resolved == {\"requested\": \"openai-codex\", \"target_model\": \"gpt-5.4\"}\n+    assert mock_agent.call_args.kwargs[\"model\"] == \"gpt-5.4\"\n+    assert mock_agent.call_args.kwargs[\"provider\"] == \"openai-codex\"\n+    assert mock_agent.call_args.kwargs[\"reasoning_config\"] == {\"enabled\": True, \"effort\": \"high\"}\n+    assert mock_agent.call_args.kwargs[\"service_tier\"] == \"priority\"\n+\n+\n def test_make_agent_handles_null_agent_config(monkeypatch):\n     _setup_make_agent_mocks(monkeypatch, {\"agent\": None, \"max_turns\": 80})\n "]},{"file":"tui_gateway/server.py","hunks":["@@ -1409,6 +1409,131 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:\n     return model, None\n \n \n+def _stored_session_runtime_overrides(row: dict | None) -> dict:\n+    \"\"\"Return runtime fields persisted with a stored session.\n+\n+    ``session.resume`` is a session-scoped operation: reopening an older chat\n+    must restore the model/provider/reasoning state that chat actually used,\n+    not whatever global model the user most recently selected in another chat.\n+    The durable session row stores the model directly, the billing provider in\n+    ``billing_provider``, and richer runtime knobs in JSON ``model_config``.\n+    \"\"\"\n+    if not row:\n+        return {}\n+\n+    raw_config = row.get(\"model_config\")\n+    model_config: dict = {}\n+    if isinstance(raw_config, dict):\n+        model_config = raw_config\n+    elif isinstance(raw_config, str) and raw_config.strip():\n+        try:\n+            parsed = json.loads(raw_config)\n+            if isinstance(parsed, dict):\n+                model_config = parsed\n+        except Exception:\n+            logger.debug(\"failed to parse stored session model_config\", exc_info=True)\n+\n+    overrides: dict = {}\n+    model = str(row.get(\"model\") or model_config.get(\"model\") or \"\").strip()\n+    provider = str(\n+        model_config.get(\"provider\")\n+        or model_config.get(\"billing_provider\")\n+        or row.get(\"billing_provider\")\n+        or \"\"\n+    ).strip()\n+    base_url = str(model_config.get(\"base_url\") or \"\").strip()\n+    api_mode = str(model_config.get(\"api_mode\") or \"\").strip()\n+    reasoning_config = model_config.get(\"reasoning_config\")\n+    service_tier = str(model_config.get(\"service_tier\") or \"\").strip()\n+\n+    if model:\n+        # Use the same dict-shaped override that live /model switches use so a\n+        # DB-restored session can preserve custom endpoint metadata across both\n+        # initial resume and later rebuilds (/new). Deliberately do not persist\n+        # or restore raw api_key here; endpoint credentials should continue to\n+        # come from config/env/provider resolution rather than the session DB.\n+        overrides[\"model_override\"] = {\n+            \"model\": model,\n+            \"provider\": provider or None,\n+            \"base_url\": base_url or None,\n+            \"api_mode\": api_mode or None,\n+        }\n+    if provider:\n+        overrides[\"provider_override\"] = provider\n+    if isinstance(reasoning_config, dict):\n+        overrides[\"reasoning_config_override\"] = reasoning_config\n+    if service_tier:\n+        overrides[\"service_tier_override\"] = service_tier\n+\n+    return overrides\n+\n+\n+def _runtime_model_config(agent, existing: dict | None = None) -> dict:\n+    config = dict(existing or {})\n+    model = str(getattr(agent, \"model\", \"\") or \"\").strip()\n+    provider = str(getattr(agent, \"provider\", \"\") or \"\").strip()\n+    base_url = str(getattr(agent, \"base_url\", \"\") or \"\").strip()\n+    api_mode = str(getattr(agent, \"api_mode\", \"\") or \"\").strip()\n+    reasoning_config = getattr(agent, \"reasoning_config\", None)\n+    service_tier = getattr(agent, \"service_tier\", None)\n+\n+    if model:\n+        config[\"model\"] = model\n+    if provider:\n+        config[\"provider\"] = provider\n+    if base_url:\n+        config[\"base_url\"] = base_url\n+    else:\n+        config.pop(\"base_url\", None)\n+    if api_mode:\n+        config[\"api_mode\"] = api_mode\n+    else:\n+        config.pop(\"api_mode\", None)\n+    if isinstance(reasoning_config, dict):\n+        config[\"reasoning_config\"] = reasoning_config\n+    else:\n+        config.pop(\"reasoning_config\", None)\n+    if service_tier:\n+        config[\"service_tier\"] = service_tier\n+    else:\n+        config.pop(\"service_tier\", None)\n+\n+    return config\n+\n+\n+def _persist_live_session_runtime(session: dict | None) -> None:\n+    \"\"\"Persist active session runtime so future resumes restore the same footer.\"\"\"\n+    if not session:\n+        return\n+    agent = session.get(\"agent\")\n+    session_key = str(session.get(\"session_key\") or \"\").strip()\n+    if agent is None or not session_key:\n+        return\n+\n+    db = getattr(agent, \"_session_db\", None) or _get_db()\n+    if db is None:\n+        return\n+\n+    try:\n+        row = db.get_session(session_key) or {}\n+        raw_config = row.get(\"model_config\")\n+        existing_config = {}\n+        if isinstance(raw_config, dict):\n+            existing_config = raw_config\n+        elif isinstance(raw_config, str) and raw_config.strip():\n+            parsed = json.loads(raw_config)\n+            if isinstance(parsed, dict):\n+                existing_config = parsed\n+        model_config = _runtime_model_config(agent, existing_config)\n+        model = str(getattr(agent, \"model\", \"\") or \"\").strip()\n+        if hasattr(db, \"update_session_meta\"):\n+            db.update_session_meta(session_key, json.dumps(model_config), model or None)\n+        elif model and hasattr(db, \"update_session_model\"):\n+            db.update_session_model(session_key, model)\n+    except Exception:\n+        logger.debug(\"failed to persist live session runtime\", exc_info=True)\n+\n+\n def _write_config_key(key_path: str, value):\n     cfg = _load_cfg()\n     current = cfg","@@ -1789,6 +1914,7 @@ def _apply_model_switch(\n             api_mode=result.api_mode,\n         )\n         _restart_slash_worker(sid, session)\n+        _persist_live_session_runtime(session)\n         _emit(\"session.info\", sid, _session_info(agent, session))\n \n     # Record the switch as a PER-SESSION override so a later rebuild of THIS","@@ -2104,6 +2230,7 @@ def _session_info(agent, session: dict | None = None) -> dict:\n         yolo = False\n     info: dict = {\n         \"model\": getattr(agent, \"model\", \"\"),\n+        \"provider\": getattr(agent, \"provider\", \"\"),\n         \"reasoning_effort\": reasoning_effort,\n         \"service_tier\": service_tier,\n         \"fast\": service_tier == \"priority\",","@@ -2891,7 +3018,10 @@ def _make_agent(\n     key: str,\n     session_id: str | None = None,\n     session_db=None,\n-    model_override: dict | None = None,\n+    model_override: dict | str | None = None,\n+    provider_override: str | None = None,\n+    reasoning_config_override: dict | None = None,\n+    service_tier_override: str | None = None,\n ):\n     from run_agent import AIAgent\n     from hermes_cli.runtime_provider import resolve_runtime_provider","@@ -2927,12 +3057,11 @@ def _make_agent(\n                 part for part in (system_prompt, skills_prompt) if part\n             ).strip()\n     # Prefer a per-session model override (set by a prior in-session /model\n-    # switch) over global config/env resolution. This keeps a rebuilt session\n-    # (/new, resume) on the model the user picked FOR THIS SESSION, without\n-    # reading process-global env vars that another session may have changed.\n-    if model_override and model_override.get(\"model\"):\n+    # switch) over global config/env resolution. Resume-time stored sessions may\n+    # also pass scalar model/provider/runtime knobs from the persisted DB row.\n+    if isinstance(model_override, dict) and model_override.get(\"model\"):\n         model = str(model_override.get(\"model\") or \"\")\n-        requested_provider = model_override.get(\"provider\") or None\n+        requested_provider = model_override.get(\"provider\") or provider_override or None\n         override_base_url = model_override.get(\"base_url\")\n         override_api_key = [redacted:secret-assignment]\"api_key\")\n         override_api_mode = model_override.get(\"api_mode\")","@@ -2951,6 +3080,10 @@ def _make_agent(\n             runtime[\"api_mode\"] = override_api_mode\n     else:\n         model, requested_provider = _resolve_startup_runtime()\n+        if isinstance(model_override, str) and model_override:\n+            model = model_override\n+        if provider_override:\n+            requested_provider = provider_override\n         runtime = resolve_runtime_provider(\n             requested=requested_provider,\n             target_model=model or None,","@@ -2971,8 +3104,16 @@ def _make_agent(\n         # display detail).  See cli.py PR (decoupling fix) for the matching\n         # change on the classic CLI side.\n         verbose_logging=False,\n-        reasoning_config=_load_reasoning_config(),\n-        service_tier=_load_service_tier(),\n+        reasoning_config=(\n+            reasoning_config_override\n+            if reasoning_config_override is not None\n+            else _load_reasoning_config()\n+        ),\n+        service_tier=(\n+            service_tier_override\n+            if service_tier_override is not None\n+            else _load_service_tier()\n+        ),\n         enabled_toolsets=_load_enabled_toolsets(),\n         platform=\"tui\",\n         session_id=session_id or key,","@@ -3660,8 +3801,17 @@ def _(rid, params: dict) -> dict:\n         try:\n             # Pass the profile's db so the agent persists turns to the right\n             # state.db; home override is active here so config/skills/model\n-            # resolve to the profile too.\n-            agent = _make_agent(sid, target, session_id=target, session_db=db)\n+            # resolve to the profile too. Runtime identity is restored from the\n+            # stored session row so switching chats does not inherit whatever\n+            # global model another chat last selected.\n+            stored_runtime_overrides = _stored_session_runtime_overrides(found)\n+            agent = _make_agent(\n+                sid,\n+                target,\n+                session_id=target,\n+                session_db=db,\n+                **stored_runtime_overrides,\n+            )\n         finally:\n             _clear_session_context(tokens)\n     except Exception as e:","@@ -3698,6 +3848,10 @@ def _(rid, params: dict) -> dict:\n         try:\n             _init_session(sid, target, agent, history, cols=cols)\n             if sid in _sessions:\n+                if stored_runtime_overrides.get(\"model_override\") is not None:\n+                    _sessions[sid][\"model_override\"] = stored_runtime_overrides[\n+                        \"model_override\"\n+                    ]\n                 _sessions[sid][\"display_history_prefix\"] = display_history_prefix\n                 # Remember the profile home so each turn re-binds HERMES_HOME (the\n                 # agent persists to its own db, but mid-turn home reads — memory,","@@ -6309,6 +6463,7 @@ def _(rid, params: dict) -> dict:\n             if nv == \"fast\":\n                 current_overrides.update(overrides)\n             agent.request_overrides = current_overrides\n+            _persist_live_session_runtime(session)\n             _emit(\n                 \"session.info\",\n                 params.get(\"session_id\", \"\"),","@@ -6475,6 +6630,12 @@ def _resolve_toggle(current: bool) -> bool:\n             _write_config_key(\"agent.reasoning_effort\", arg)\n             if session and session.get(\"agent\") is not None:\n                 session[\"agent\"].reasoning_config = parsed\n+                _persist_live_session_runtime(session)\n+                _emit(\n+                    \"session.info\",\n+                    params.get(\"session_id\", \"\"),\n+                    _session_info(session[\"agent\"], session),\n+                )\n             return _ok(rid, {\"key\": key, \"value\": arg})\n         except Exception as e:\n             return _err(rid, 5001, str(e))\n"]}]},{"kind":"pr","number":43977,"title":"fix(desktop): refresh session model metadata on switch","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/session/hooks/use-message-stream.ts","apps/desktop/src/app/session/hooks/use-session-actions.ts","apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx","apps/desktop/src/app/session/hooks/use-session-state-cache.ts","apps/desktop/src/app/types.ts","apps/desktop/src/lib/chat-runtime.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}