import { QueryClient } from '@tanstack/react-query' import { cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelInfo } from '@/hermes' import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session' import { useModelControls } from './use-model-controls' const setGlobalModel = vi.fn() const notifyError = vi.fn() vi.mock('@/hermes', () => ({ getGlobalModelInfo: vi.fn(), setGlobalModel: (...args: Parameters) => setGlobalModel(...args) })) vi.mock('@/i18n', () => ({ useI18n: () => ({ t: { desktop: { modelSwitchFailed: 'Model switch failed' } } }) })) vi.mock('@/store/notifications', () => ({ notifyError: (...args: Parameters) => notifyError(...args) })) type Controls = ReturnType function Harness({ activeSessionId, onReady, requestGateway }: { activeSessionId: string | null onReady: (controls: Controls) => void requestGateway: (method: string, params?: Record) => Promise }) { const controls = useModelControls({ activeSessionId, queryClient: new QueryClient(), requestGateway }) onReady(controls) return null } describe('useModelControls', () => { beforeEach(() => { $activeSessionId.set(null) setCurrentModel('') setCurrentProvider('') }) afterEach(() => { cleanup() vi.restoreAllMocks() $activeSessionId.set(null) setCurrentModel('') setCurrentProvider('') }) it('applies the global model when there is no active runtime session', async () => { vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) const { result } = renderHook(() => useModelControls({ activeSessionId: null, queryClient: new QueryClient(), requestGateway: vi.fn() }) ) await result.current.refreshCurrentModel() expect($currentModel.get()).toBe('openai/gpt-5.5') expect($currentProvider.get()).toBe('openai-codex') }) it('does not clobber the active session footer state with global model info', async () => { setCurrentModel('deepseek/deepseek-v4-pro') setCurrentProvider('deepseek') $activeSessionId.set('runtime-1') vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) const { result } = renderHook(() => useModelControls({ activeSessionId: 'runtime-1', queryClient: new QueryClient(), requestGateway: vi.fn() }) ) await result.current.refreshCurrentModel() expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro') expect($currentProvider.get()).toBe('deepseek') }) it('routes active-session picker changes through config.set with an explicit provider', async () => { const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) let controls!: Controls render( (controls = value)} requestGateway={requestGateway} /> ) await expect( controls.selectModel({ model: 'claude-sonnet-4.6', provider: 'anthropic' }) ).resolves.toBe(true) expect(requestGateway).toHaveBeenCalledWith('config.set', { session_id: 'session-1', key: 'model', value: 'claude-sonnet-4.6 --provider anthropic' }) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) it('stores a no-session pick as UI state with no gateway or global write', async () => { const requestGateway = vi.fn() let controls!: Controls render( (controls = value)} requestGateway={requestGateway} /> ) await expect( controls.selectModel({ model: 'claude-sonnet-4.6', provider: 'anthropic' }) ).resolves.toBe(true) // The pick is plain UI state; session.create ships it later. Nothing touches // the gateway or the profile default here. expect($currentModel.get()).toBe('claude-sonnet-4.6') expect($currentProvider.get()).toBe('anthropic') expect(requestGateway).not.toHaveBeenCalled() expect(setGlobalModel).not.toHaveBeenCalled() }) it('seeds an empty composer model from global but never clobbers a pick', async () => { vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) const { result } = renderHook(() => useModelControls({ activeSessionId: null, queryClient: new QueryClient(), requestGateway: vi.fn() }) ) // Empty → seeds the default. await result.current.refreshCurrentModel() expect($currentModel.get()).toBe('openai/gpt-5.5') // A user pick must survive the lifecycle refreshes that fire on boot / fresh // draft / session events. setCurrentModel('anthropic/claude-sonnet-4.6') setCurrentProvider('anthropic') await result.current.refreshCurrentModel() expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') // A profile swap forces a reseed to the new profile's default. await result.current.refreshCurrentModel(true) expect($currentModel.get()).toBe('openai/gpt-5.5') }) })