{"repo":"NousResearch/hermes-agent","public_id":"65335","display_label":"Desktop composer model selection reverting or not switching mid-session","summary":"Most members describe the Desktop app's model/provider selection not persisting or not being changeable in an existing session. Several point to session.info gateway events overwriting the composer's choice, and a couple of the items appear to be fix attempts. Reports cover different session contexts (historical, stopped, resumed Telegram), so root causes may not be identical.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":66603,"merged_at":"2026-07-20T14:40:59+00:00"},"resolution":{"state":"partially_resolved","by":"pr-66603"},"freshness":{"state":"verified","last_verified_at":"2026-07-20T14:40:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2053333333333333},"fix_edges":[{"issue_number":66265,"pr_number":66603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T14:40:59+00:00"}],"members":[{"kind":"issue","number":65335,"title":"Desktop: Model selector instantly reverts when resuming Telegram session from sidebar","url":null,"state":"closed"},{"kind":"issue","number":65359,"title":"[Bug]: Desktop cannot switch models in a stopped historical session even though the composer accepts messages","url":null,"state":"closed"},{"kind":"issue","number":65590,"title":"Desktop: can't switch model/provider mid-session (regression ~v2026.7.7)","url":null,"state":"open"},{"kind":"issue","number":66265,"title":"Desktop: composer model selection overridden by session.info gateway event on every poll","url":null,"state":"closed"},{"kind":"pr","number":66603,"title":"fix(desktop): stop session.info from overwriting composer model","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx","apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts"],"fix_hunks":[{"file":"apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx","hunks":["@@ -0,0 +1,99 @@\n+import { QueryClient } from '@tanstack/react-query'\n+import { act, cleanup, render, waitFor } from '@testing-library/react'\n+import { useEffect, useRef } from 'react'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import type { ClientSessionState } from '@/app/types'\n+import { createClientSessionState } from '@/lib/chat-runtime'\n+import {\n+  $currentModel,\n+  $currentProvider,\n+  setCurrentModel,\n+  setCurrentModelSource,\n+  setCurrentProvider\n+} from '@/store/session'\n+import type { RpcEvent } from '@/types/hermes'\n+\n+import { useMessageStream } from './index'\n+\n+let handleEvent: ((event: RpcEvent) => void) | null = null\n+\n+function Harness({ activeSessionId }: { activeSessionId: string | null }) {\n+  const sessionIdRef = useRef<string | null>(activeSessionId)\n+  const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())\n+  const queryClientRef = useRef(new QueryClient())\n+\n+  sessionIdRef.current = activeSessionId\n+\n+  const stream = useMessageStream({\n+    activeSessionIdRef: sessionIdRef,\n+    hydrateFromStoredSession: vi.fn(async () => undefined),\n+    queryClient: queryClientRef.current,\n+    refreshHermesConfig: vi.fn(async () => undefined),\n+    refreshSessions: vi.fn(async () => undefined),\n+    sessionStateByRuntimeIdRef,\n+    updateSessionState: (sessionId, updater) => {\n+      const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()\n+      const next = updater(current)\n+      sessionStateByRuntimeIdRef.current.set(sessionId, next)\n+\n+      return next\n+    }\n+  })\n+\n+  useEffect(() => {\n+    handleEvent = stream.handleGatewayEvent\n+  }, [stream.handleGatewayEvent])\n+\n+  return null\n+}\n+\n+async function mountStream(activeSessionId: string | null) {\n+  render(<Harness activeSessionId={activeSessionId} />)\n+  await waitFor(() => expect(handleEvent).not.toBeNull())\n+}\n+\n+describe('session.info does not clobber composer model selection', () => {\n+  beforeEach(() => {\n+    handleEvent = null\n+    setCurrentModel('deepseek-v4-flash')\n+    setCurrentProvider('deepseek')\n+    setCurrentModelSource('manual')\n+  })\n+\n+  afterEach(() => {\n+    cleanup()\n+    vi.restoreAllMocks()\n+    setCurrentModel('')\n+    setCurrentProvider('')\n+    setCurrentModelSource('')\n+  })\n+\n+  it('keeps a sticky manual pick when a global session.info carries the profile default', async () => {\n+    await mountStream(null)\n+\n+    act(() =>\n+      handleEvent!({\n+        payload: { model: 'deepseek-chat', provider: 'deepseek' },\n+        type: 'session.info'\n+      })\n+    )\n+\n+    expect($currentModel.get()).toBe('deepseek-v4-flash')\n+    expect($currentProvider.get()).toBe('deepseek')\n+  })\n+\n+  it('keeps the composer pick when an unscoped session.info arrives with no live session', async () => {\n+    await mountStream(null)\n+\n+    act(() =>\n+      handleEvent!({\n+        payload: { cwd: '/tmp/project', model: 'deepseek-chat', provider: 'deepseek' },\n+        type: 'session.info'\n+      })\n+    )\n+\n+    expect($currentModel.get()).toBe('deepseek-v4-flash')\n+    expect($currentProvider.get()).toBe('deepseek')\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts","hunks":["@@ -32,9 +32,7 @@ import {\n   setCurrentBranch,\n   setCurrentCwd,\n   setCurrentFastMode,\n-  setCurrentModel,\n   setCurrentPersonality,\n-  setCurrentProvider,\n   setCurrentReasoningEffort,\n   setCurrentServiceTier,\n   setCurrentUsage,","@@ -219,13 +217,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n         }\n \n         if (apply) {\n-          if (modelChanged) {\n-            setCurrentModel(payload!.model || '')\n-          }\n-\n-          if (providerChanged) {\n-            setCurrentProvider(payload!.provider || '')\n-          }\n+          // Do not call setCurrentModel / setCurrentProvider here. Composer\n+          // model/provider is sticky UI state (localStorage + manual picks).\n+          // Periodic session.info heartbeats often carry the profile default\n+          // (or a stale session model) and would silently revert the dropdown.\n+          // Active-session model/provider still flows through the session state\n+          // cache via updateSessionState → syncRuntimeMetadataToView below.\n \n           if (typeof payload?.cwd === 'string') {\n             // The active session's agent can relocate itself (new repo/worktree","@@ -284,7 +281,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n \n         // The running→busy transition must reach EVERY session, not just the\n         // active one. The `apply` gate above correctly scopes view-only side\n-        // effects (setCurrentModel, setCurrentCwd, etc.) to the focused chat,\n+        // effects (setCurrentCwd, etc.) to the focused chat,\n         // but the per-session busy state is what drives the sidebar working\n         // indicator — a background session's turn start/finish must update\n         // its dot without the user opening it. updateSessionState only\n"]}]},{"kind":"pr","number":102467,"title":"fix: Desktop: can't switch model/provider mid-session (regression ~v2026.7.7) (#65590)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#102467","number":102467},{"kind":"pr","value":"#65590","number":65590}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}