diff --git a/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx b/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx new file mode 100644 index 00000000..777a6cc8 --- /dev/null +++ b/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx @@ -0,0 +1,373 @@ +import React, { act } from 'react'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { createRoot } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import { create } from 'zustand'; + +/** + * Restoring a session must not invent an effort choice. + * + * The selection store keeps three states for a session's effort: an effort + * name, `null` for an explicit "Default", and `undefined` for no choice at all. + * Only the picker may write `null`. When a restore path writes it instead, the + * session latches onto "Default" — `null` outranks the agent and settings + * defaults by design — and the concrete effort the session's own history + * carries can never come back. These tests pin who writes what. + */ + +type VariantChoice = string | null | undefined; + +type UserModelChoice = { + id: string; + agent?: string; + providerID: string; + modelID: string; + variant?: string; +}; + +const PROVIDER_ID = 'openai'; +const MODEL_ID = 'gpt-5.5'; +const AGENT = 'build'; +const SESSION_ID = 'ses_restore'; + +const model = { + id: MODEL_ID, + name: MODEL_ID, + providerID: PROVIDER_ID, + variants: { low: {}, high: {} }, +}; +const provider = { id: PROVIDER_ID, name: PROVIDER_ID, models: [model] }; +const agent = { name: AGENT, mode: 'primary' as const }; + +let latestUserChoice: UserModelChoice | null = null; +let preserveManualOverride = false; + +/** Every effort written for the session, in order, including `undefined`. */ +const variantWrites: VariantChoice[] = []; +/** Every `(override, inherited)` pair pushed into the config store. */ +const overrideWrites: Array<{ override: VariantChoice; inherited: string | undefined }> = []; + +type ConfigState = { + providers: typeof provider[]; + agents: typeof agent[]; + modelsMetadata: Record; + currentProviderId: string; + currentModelId: string; + currentVariant: string | undefined; + currentVariantSelection: { override: VariantChoice; inherited: string | undefined }; + currentAgentName: string | undefined; + settingsDefaultVariant: string | undefined; + settingsDefaultAgent: string | undefined; + selectionSource: 'auto' | 'manual'; + setProvider: (providerId: string) => void; + setSelectedProvider: (providerId: string) => void; + setModel: (modelId: string) => void; + setAgent: (agentName: string) => void; + setCurrentVariant: (variant: string | undefined) => void; + setCurrentVariantOverride: (override: VariantChoice, inherited: string | undefined) => void; + getCurrentProvider: () => typeof provider; + getCurrentAgent: () => typeof agent; + getVisibleAgents: () => typeof agent[]; + getCurrentModelVariants: () => string[]; + getModelMetadata: () => undefined; +}; + +const useConfigStore = create((set) => ({ + providers: [provider], + agents: [agent], + modelsMetadata: {}, + currentProviderId: PROVIDER_ID, + currentModelId: MODEL_ID, + currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, + currentAgentName: AGENT, + settingsDefaultVariant: undefined, + settingsDefaultAgent: undefined, + selectionSource: 'auto', + setProvider: (providerId) => set({ currentProviderId: providerId }), + setSelectedProvider: () => undefined, + setModel: (modelId) => set({ currentModelId: modelId }), + setAgent: (agentName) => set({ currentAgentName: agentName }), + // Mirrors the real store, including its no-op guard: without that guard an + // unchanged write returns a fresh state object every render and the + // component's variant effects never settle. + setCurrentVariant: (variant) => { + useConfigStore.getState().setCurrentVariantOverride(undefined, variant); + }, + setCurrentVariantOverride: (override, inherited) => { + set((state) => { + const currentVariant = override === null ? undefined : override ?? inherited; + if ( + state.currentVariant === currentVariant + && state.currentVariantSelection.override === override + && state.currentVariantSelection.inherited === inherited + ) { + return state; + } + overrideWrites.push({ override, inherited }); + return { currentVariant, currentVariantSelection: { override, inherited } }; + }); + }, + getCurrentProvider: () => provider, + getCurrentAgent: () => agent, + getVisibleAgents: () => [agent], + getCurrentModelVariants: () => Object.keys(model.variants), + getModelMetadata: () => undefined, +})); + +type SelectionState = { + savedVariant: VariantChoice; + sessionAgentSelections: Map; + getSessionModelSelection: () => { providerId: string; modelId: string } | null; + getSessionAgentSelection: () => string | null; + getAgentModelForSession: () => { providerId: string; modelId: string } | null; + getAgentModelVariantForSession: () => VariantChoice; + saveSessionModelSelection: () => void; + saveSessionAgentSelection: () => void; + saveAgentModelForSession: () => void; + saveAgentModelVariantForSession: ( + sessionId: string, + agentName: string, + providerId: string, + modelId: string, + variant: VariantChoice, + ) => void; +}; + +const useSelectionStore = create((set, get) => ({ + savedVariant: undefined, + sessionAgentSelections: new Map([[SESSION_ID, AGENT]]), + getSessionModelSelection: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }), + getSessionAgentSelection: () => AGENT, + getAgentModelForSession: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }), + getAgentModelVariantForSession: () => get().savedVariant, + saveSessionModelSelection: () => undefined, + saveSessionAgentSelection: () => undefined, + saveAgentModelForSession: () => undefined, + saveAgentModelVariantForSession: (_sessionId, _agentName, _providerId, _modelId, variant) => { + variantWrites.push(variant); + set({ savedVariant: variant }); + }, +})); + +const useSessionUIStore = create(() => ({ + currentSessionId: SESSION_ID, + getDirectoryForSession: () => '/workspace/project', +})); + +const useUIStore = create(() => ({ + isMobile: false, + isModelSelectorOpen: false, + hiddenModels: [], + providerOrder: [], + shortcutOverrides: {}, + isFavoriteModel: () => false, + toggleFavoriteModel: () => undefined, + reorderFavoriteModel: () => undefined, + setProviderOrder: () => undefined, + setModelSelectorOpen: () => undefined, + setSettingsDialogOpen: () => undefined, + setSettingsPage: () => undefined, + addRecentAgent: () => undefined, + addRecentModel: () => undefined, + addRecentEffort: () => undefined, +})); + +const passthrough = ({ children }: React.PropsWithChildren) =>
{children}
; + +mock.module('@/lib/messages/userModelChoice', () => ({ + findLatestUserModelChoice: () => latestUserChoice, + shouldPreserveManualModelOverride: () => preserveManualOverride, +})); + +mock.module('@/stores/useConfigStore', () => ({ useConfigStore })); +mock.module('@/sync/selection-store', () => ({ useSelectionStore })); +mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore })); +mock.module('@/stores/useUIStore', () => ({ useUIStore })); +mock.module('@/stores/contextStore', () => ({ + useContextStore: (selector: (state: { hasHydrated: boolean }) => T): T => selector({ hasHydrated: true }), +})); + +mock.module('@/sync/sync-context', () => ({ + useSessionMessages: () => [], + useSessionRenderable: () => true, +})); +mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) })); +mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] })); + +mock.module('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuLabel: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuTrigger: passthrough, +})); +mock.module('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); +mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough })); +mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null })); +mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough })); +mock.module('@/components/ui/tooltip', () => ({ + Tooltip: passthrough, + TooltipContent: passthrough, + TooltipTrigger: passthrough, +})); +mock.module('@/components/icon/Icon', () => ({ Icon: () => null })); +mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null })); +mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false })); +mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) })); +mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false })); +mock.module('@/hooks/useOpenCodeReadiness', () => ({ + useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }), +})); +mock.module('@/lib/device', () => ({ useDeviceInfo: () => ({ isTouch: false }) })); +mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false })); +mock.module('@/lib/startupTrace', () => ({ markStartupTrace: () => undefined })); + +const { ModelControls } = await import('./ModelControls'); +const { I18nProvider } = await import('@/lib/i18n'); + +const DOM_GLOBAL_NAMES = [ + 'window', + 'document', + 'navigator', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLIFrameElement', + 'localStorage', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'IS_REACT_ACT_ENVIRONMENT', +] as const; + +const frameTimers = new Map>(); +let nextFrameHandle = 1; + +const installDom = () => { + const happyWindow = new Window({ url: 'http://localhost' }); + const previous = DOM_GLOBAL_NAMES.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ); + const values = { + window: happyWindow, + document: happyWindow.document, + navigator: happyWindow.navigator, + Node: happyWindow.Node, + Element: happyWindow.Element, + HTMLElement: happyWindow.HTMLElement, + HTMLIFrameElement: happyWindow.HTMLIFrameElement, + localStorage: happyWindow.localStorage, + // The component focuses the composer through rAF on several paths. + requestAnimationFrame: (callback: FrameRequestCallback) => { + const handle = nextFrameHandle++; + frameTimers.set(handle, happyWindow.setTimeout(() => { + frameTimers.delete(handle); + callback(0); + }, 0)); + return handle; + }, + cancelAnimationFrame: (handle: number) => { + const timer = frameTimers.get(handle); + if (timer === undefined) return; + frameTimers.delete(handle); + happyWindow.clearTimeout(timer); + }, + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const name of DOM_GLOBAL_NAMES) { + Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true }); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + return { + container, + restore: () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +const renderModelControls = async () => { + const dom = installDom(); + const root = createRoot(dom.container); + await act(async () => root.render( + + + , + )); + return { + dom, + cleanup: async () => { + await act(async () => root.unmount()); + dom.restore(); + }, + }; +}; + +describe('ModelControls effort restore', () => { + beforeEach(() => { + variantWrites.length = 0; + overrideWrites.length = 0; + latestUserChoice = null; + preserveManualOverride = false; + useSelectionStore.setState({ savedVariant: undefined }); + useConfigStore.setState({ + currentProviderId: PROVIDER_ID, + currentModelId: MODEL_ID, + currentAgentName: AGENT, + currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, + settingsDefaultVariant: undefined, + selectionSource: 'auto', + }); + }); + + test('restores the concrete effort the session history carries', async () => { + latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' }; + + const { cleanup } = await renderModelControls(); + try { + expect(variantWrites).toContain('low'); + expect(variantWrites).not.toContain(null); + expect(useSelectionStore.getState().savedVariant).toBe('low'); + expect(useConfigStore.getState().currentVariantSelection.override).toBe('low'); + } finally { + await cleanup(); + } + }); + + test('history without an effort records no choice instead of an explicit Default', async () => { + latestUserChoice = { id: 'msg-2', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID }; + + const { cleanup } = await renderModelControls(); + try { + expect(variantWrites).not.toContain(null); + expect(useSelectionStore.getState().savedVariant).toBe(undefined); + expect(useConfigStore.getState().currentVariantSelection.override).toBe(undefined); + } finally { + await cleanup(); + } + }); + + test('a preserved manual override keeps a recorded explicit Default', async () => { + latestUserChoice = { id: 'msg-3', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' }; + preserveManualOverride = true; + useSelectionStore.setState({ savedVariant: null }); + useConfigStore.setState({ selectionSource: 'manual' }); + + const { cleanup } = await renderModelControls(); + try { + expect(useSelectionStore.getState().savedVariant).toBeNull(); + expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 8a79e210..d5ba4cb2 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -292,7 +292,6 @@ export const GitView: React.FC = ({ isActive }) => { const isLogLoading = useGitLoadingLog(gitDirectory ?? null); const { setActiveDirectory, - fetchAll, ensureAll, fetchStatus, fetchBranches, @@ -308,7 +307,6 @@ export const GitView: React.FC = ({ isActive }) => { selectNestedRepo, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, - fetchAll: state.fetchAll, ensureAll: state.ensureAll, fetchStatus: state.fetchStatus, fetchBranches: state.fetchBranches,