NousResearch/hermes-agent

Desktop composer model selection reverting or not switching mid-session

Cluster 65335 · apps/desktop. Data as of

1 open item. Fix merged in #66603 on 20 Jul 2026. Check it covers your case.

The short answer

1open issue
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #66603. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #66603. That is separate from the fix event.
Still open
1 issue and 0 PRs.

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.

Open issues1

  • Issue#65590Desktop: can't switch model/provider mid-session (regression ~v2026.7.7)

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #66603 fix(desktop): stop session.info from overwriting composer model

    merged to main

    files: 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

    Show the diff
    • apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx
      @@ -0,0 +1,99 @@
      +import { QueryClient } from '@tanstack/react-query'
      +import { act, cleanup, render, waitFor } from '@testing-library/react'
      +import { useEffect, useRef } from 'react'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
      +
      +import type { ClientSessionState } from '@/app/types'
      +import { createClientSessionState } from '@/lib/chat-runtime'
      +import {
      +  $currentModel,
      +  $currentProvider,
      +  setCurrentModel,
      +  setCurrentModelSource,
      +  setCurrentProvider
      +} from '@/store/session'
      +import type { RpcEvent } from '@/types/hermes'
      +
      +import { useMessageStream } from './index'
      +
      +let handleEvent: ((event: RpcEvent) => void) | null = null
      +
      +function Harness({ activeSessionId }: { activeSessionId: string | null }) {
      +  const sessionIdRef = useRef<string | null>(activeSessionId)
      +  const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
      +  const queryClientRef = useRef(new QueryClient())
      +
      +  sessionIdRef.current = activeSessionId
      +
      +  const stream = useMessageStream({
      +    activeSessionIdRef: sessionIdRef,
      +    hydrateFromStoredSession: vi.fn(async () => undefined),
      +    queryClient: queryClientRef.current,
      +    refreshHermesConfig: vi.fn(async () => undefined),
      +    refreshSessions: vi.fn(async () => undefined),
      +    sessionStateByRuntimeIdRef,
      +    updateSessionState: (sessionId, updater) => {
      +      const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
      +      const next = updater(current)
      +      sessionStateByRuntimeIdRef.current.set(sessionId, next)
      +
      +      return next
      +    }
      +  })
      +
      +  useEffect(() => {
      +    handleEvent = stream.handleGatewayEvent
      +  }, [stream.handleGatewayEvent])
      +
      +  return null
      +}
      +
      +async function mountStream(activeSessionId: string | null) {
      +  render(<Harness activeSessionId={activeSessionId} />)
      +  await waitFor(() => expect(handleEvent).not.toBeNull())
      +}
      +
      +describe('session.info does not clobber composer model selection', () => {
      +  beforeEach(() => {
      +    handleEvent = null
      +    setCurrentModel('deepseek-v4-flash')
      +    setCurrentProvider('deepseek')
      +    setCurrentModelSource('manual')
      +  })
      +
      +  afterEach(() => {
      +    cleanup()
      +    vi.restoreAllMocks()
      +    setCurrentModel('')
      +    setCurrentProvider('')
      +    setCurrentModelSource('')
      +  })
      +
      +  it('keeps a sticky manual pick when a global session.info carries the profile default', async () => {
      +    await mountStream(null)
      +
      +    act(() =>
      +      handleEvent!({
      +        payload: { model: 'deepseek-chat', provider: 'deepseek' },
      +        type: 'session.info'
      +      })
      +    )
      +
      +    expect($currentModel.get()).toBe('deepseek-v4-flash')
      +    expect($currentProvider.get()).toBe('deepseek')
      +  })
      +
      +  it('keeps the composer pick when an unscoped session.info arrives with no live session', async () => {
      +    await mountStream(null)
      +
      +    act(() =>
      +      handleEvent!({
      +        payload: { cwd: '/tmp/project', model: 'deepseek-chat', provider: 'deepseek' },
      +        type: 'session.info'
      +      })
      +    )
      +
      +    expect($currentModel.get()).toBe('deepseek-v4-flash')
      +    expect($currentProvider.get()).toBe('deepseek')
      +  })
      +})
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
      @@ -32,9 +32,7 @@ import {
         setCurrentBranch,
         setCurrentCwd,
         setCurrentFastMode,
      -  setCurrentModel,
         setCurrentPersonality,
      -  setCurrentProvider,
         setCurrentReasoningEffort,
         setCurrentServiceTier,
         setCurrentUsage,
      @@ -219,13 +217,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
               }
       
               if (apply) {
      -          if (modelChanged) {
      -            setCurrentModel(payload!.model || '')
      -          }
      -
      -          if (providerChanged) {
      -            setCurrentProvider(payload!.provider || '')
      -          }
      +          // Do not call setCurrentModel / setCurrentProvider here. Composer
      +          // model/provider is sticky UI state (localStorage + manual picks).
      +          // Periodic session.info heartbeats often carry the profile default
      +          // (or a stale session model) and would silently revert the dropdown.
      +          // Active-session model/provider still flows through the session state
      +          // cache via updateSessionState → syncRuntimeMetadataToView below.
       
                 if (typeof payload?.cwd === 'string') {
                   // The active session's agent can relocate itself (new repo/worktree
      @@ -284,7 +281,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
       
               // The running→busy transition must reach EVERY session, not just the
               // active one. The `apply` gate above correctly scopes view-only side
      -        // effects (setCurrentModel, setCurrentCwd, etc.) to the focused chat,
      +        // effects (setCurrentCwd, etc.) to the focused chat,
               // but the per-session busy state is what drives the sidebar working
               // indicator — a background session's turn start/finish must update
               // its dot without the user opening it. updateSessionState only

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #102467 fix: Desktop: can't switch model/provider mid-session (regression ~v2026.7.7) (#65590) · superseded by another PR → #102467, #65590
Closed issues · 3
  • Closed#65335Desktop: Model selector instantly reverts when resuming Telegram session from sidebar
  • Closed#65359[Bug]: Desktop cannot switch models in a stopped historical session even though the composer accepts messages
  • Closed#66265Desktop: composer model selection overridden by session.info gateway event on every poll
How this cluster was grouped
  • Link evidence (partly structural links): 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.21: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#66603
Closed issues
3
Attempts
1 PR closed without merging
Fix link checked
20 Jul 2026

For your agent

The same facts, typed.