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

Closes #66265. Credit: Stan Shih (stantheman0128).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
This commit is contained in:
Po-Han Shih 2026-07-20 22:40:59 +08:00 committed by GitHub
parent c59ca46940
commit 3e6cead363
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 106 additions and 10 deletions

View File

@ -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')
})
})

View File

@ -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