From 3e6cead36367ab9488d49481122347fff3430b12 Mon Sep 17 00:00:00 2001 From: Po-Han Shih <79733497+stantheman0128@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:40:59 +0800 Subject: [PATCH] fix(desktop): stop session.info from overwriting composer model (#66603) Closes #66265. Credit: Stan Shih (stantheman0128). Co-authored-by: Cursor Co-authored-by: Austin Pickett --- .../composer-model-event.test.tsx | 99 +++++++++++++++++++ .../hooks/use-message-stream/gateway-event.ts | 17 ++-- 2 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/composer-model-event.test.tsx new file mode 100644 index 0000000000000..7d8e4f69aa2bf --- /dev/null +++ b/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(activeSessionId) + const sessionStateByRuntimeIdRef = useRef(new Map()) + 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() + 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') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 3b2c15b1e1fe7..35dc67a8e79b0 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/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