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:
parent
c59ca46940
commit
3e6cead363
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue