import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; vi.mock('@renderer/components/ui/tabs', () => { let currentValue = ''; let currentOnValueChange: ((value: string) => void) | null = null; return { Tabs: ({ children, value, onValueChange, }: { children: React.ReactNode; value: string; onValueChange?: (value: string) => void; }) => { currentValue = value; currentOnValueChange = onValueChange ?? null; return React.createElement('div', { 'data-tabs-value': value }, children); }, TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), TabsTrigger: ({ children, value, disabled, title, }: { children: React.ReactNode; value: string; disabled?: boolean; title?: string; }) => React.createElement( 'button', { type: 'button', disabled, title, 'data-state': currentValue === value ? 'active' : 'inactive', onClick: () => { if (!disabled) { currentOnValueChange?.(value); } }, }, children ), }; }); const storeState = { cliStatus: null as unknown, cliStatusLoading: false, appConfig: { general: { multimodelEnabled: true } }, fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), }; const codexAccountHookState = { snapshot: null as CodexAccountSnapshotDto | null, loading: false, error: null as string | null, refresh: vi.fn(() => Promise.resolve(undefined)), startChatgptLogin: vi.fn(() => Promise.resolve(true)), cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), logout: vi.fn(() => Promise.resolve(true)), }; vi.mock('@renderer/store', () => ({ useStore: (selector: (state: unknown) => unknown) => selector(storeState), })); vi.mock('@features/codex-account/renderer', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useCodexAccountSnapshot: () => codexAccountHookState, }; }); const useVirtualizerMock = vi.fn( (options: { count: number }) => ({ getVirtualItems: () => Array.from({ length: Math.min(options.count, 9) }, (_, index) => ({ index, key: index, start: index * 92, size: 92, })), getTotalSize: () => options.count * 92, measureElement: () => undefined, }) as const ); vi.mock('@tanstack/react-virtual', () => ({ useVirtualizer: (options: { count: number }) => useVirtualizerMock(options), })); import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { document.body.innerHTML = ''; storeState.cliStatus = null; storeState.cliStatusLoading = false; storeState.fetchCliProviderStatus.mockClear(); codexAccountHookState.snapshot = null; codexAccountHookState.loading = false; codexAccountHookState.error = null; codexAccountHookState.refresh.mockClear(); codexAccountHookState.startChatgptLogin.mockClear(); codexAccountHookState.cancelChatgptLogin.mockClear(); codexAccountHookState.logout.mockClear(); useVirtualizerMock.mockClear(); }); it('shows only Default while Codex runtime models are still loading', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('Default'); expect(host.textContent).not.toContain('5.1 Codex Mini'); expect(host.textContent).not.toContain('5.3 Codex Spark'); const defaultButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.trim().startsWith('Default') ); expect(defaultButton?.getAttribute('title')).toBe( 'Uses the runtime default for the selected provider.' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('normalizes a stale disabled selection back to default', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: 'gpt-5.1-codex-mini', onValueChange, }) ); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith(''); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('normalizes a stale 5.3 Codex Spark selection back to default', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: 'gpt-5.3-codex-spark', onValueChange, }) ); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith(''); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.4', 'gpt-5.3-codex'], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: 'gpt-5.2-codex', onValueChange, }) ); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith(''); expect(host.textContent).toContain('5.4'); expect(host.textContent).toContain('5.3 Codex'); const disabledCodexButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.2 Codex') ); expect(disabledCodexButton).not.toBeNull(); expect(disabledCodexButton?.getAttribute('aria-disabled')).toBe('true'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('labels, sorts, and filters OpenCode models with real Agent Teams E2E recommendations', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { flavor: 'agent_teams_orchestrator', providers: [ { providerId: 'opencode', authMethod: 'api_key', backend: { kind: 'opencode-cli', label: 'OpenCode CLI', endpointLabel: 'opencode', }, authenticated: true, supported: true, capabilities: { teamLaunch: true, }, models: [ 'openrouter/openai/gpt-oss-20b:free', 'openrouter/qwen/qwen3-coder-plus', 'opencode/big-pickle', 'opencode/minimax-m2.5-free', 'openrouter/openai/gpt-oss-120b:free', 'openrouter/mistralai/codestral-2508', 'openrouter/anthropic/claude-sonnet-4.6', ], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('anthropic/claude-sonnet-4.6'); expect(host.textContent).toContain('Tested'); expect(host.textContent).toContain('mistralai/codestral-2508'); expect(host.textContent).toContain('Tested'); expect(host.textContent).toContain('minimax-m2.5-free'); expect(host.textContent).toContain('Tested with limits'); expect(host.textContent).toContain('openai/gpt-oss-120b:free'); expect(host.textContent).toContain('big-pickle'); expect(host.textContent).toContain('qwen/qwen3-coder-plus'); expect(host.textContent).toContain('Not verified in OpenCode'); expect(host.textContent).toContain('openai/gpt-oss-20b:free'); expect(host.textContent).toContain('Not recommended'); const groupLabels = Array.from( host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4') ).map((heading) => heading.textContent ?? ''); expect(groupLabels).toContain('OpenCode'); expect(groupLabels).toContain('OpenRouter'); const buttonTexts = Array.from(host.querySelectorAll('button')).map( (button) => button.textContent ?? '' ); const sonnetIndex = buttonTexts.findIndex((text) => text.includes('anthropic/claude-sonnet-4.6') ); const testedIndex = buttonTexts.findIndex((text) => text.includes('mistralai/codestral-2508')); const recommendedIndex = buttonTexts.findIndex((text) => text.includes('big-pickle')); const limitedIndex = buttonTexts.findIndex((text) => text.includes('minimax-m2.5-free')); const notRecommendedIndex = buttonTexts.findIndex((text) => text.includes('openai/gpt-oss-20b:free') ); const unavailableIndex = buttonTexts.findIndex((text) => text.includes('qwen/qwen3-coder-plus') ); expect(sonnetIndex).toBeGreaterThanOrEqual(0); expect(recommendedIndex).toBeGreaterThanOrEqual(0); expect(limitedIndex).toBeGreaterThanOrEqual(0); expect(testedIndex).toBeGreaterThanOrEqual(0); expect(limitedIndex).toBeGreaterThan(recommendedIndex); expect(testedIndex).toBeGreaterThan(recommendedIndex); expect(unavailableIndex).toBeGreaterThan(limitedIndex); expect(notRecommendedIndex).toBeGreaterThan(unavailableIndex); expect(host.textContent).toContain('Recommended only'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const models = Array.from( { length: 160 }, (_, index) => `openrouter/test/model-${String(index).padStart(3, '0')}` ); storeState.cliStatus = { flavor: 'agent_teams_orchestrator', providers: [ { providerId: 'opencode', authMethod: 'opencode_managed', backend: { kind: 'opencode-cli', label: 'OpenCode CLI', endpointLabel: 'opencode', }, authenticated: true, supported: true, capabilities: { teamLaunch: true, }, models, modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const virtualizerOptions = useVirtualizerMock.mock.calls.at(-1)?.[0] as | { count: number } | undefined; expect(virtualizerOptions?.count).toBeGreaterThan(80); expect(host.textContent).toContain('OpenRouter'); expect(host.textContent).toContain('test/model-000'); expect(host.textContent).not.toContain('test/model-159'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows short-lived OpenCode preflight failures as unavailable model tiles', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { flavor: 'agent_teams_orchestrator', providers: [ { providerId: 'opencode', authMethod: 'opencode_managed', backend: { kind: 'opencode-cli', label: 'OpenCode CLI', endpointLabel: 'opencode', }, authenticated: true, supported: true, capabilities: { teamLaunch: true, }, models: ['openai/gpt-5.4', 'opencode/big-pickle'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange, modelUnavailableReasonByValue: { 'openai/gpt-5.4': 'OpenCode provider authentication failed', }, }) ); await Promise.resolve(); }); const unavailableButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('GPT-5.4') ); expect(unavailableButton).not.toBeNull(); expect(unavailableButton?.getAttribute('aria-disabled')).toBe('true'); expect(unavailableButton?.textContent).toContain('Unavailable'); expect(unavailableButton?.getAttribute('title')).toContain( 'OpenCode provider authentication failed' ); await act(async () => { unavailableButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows short-lived OpenCode preflight notes as selectable advisory tiles', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { flavor: 'agent_teams_orchestrator', providers: [ { providerId: 'opencode', authMethod: 'opencode_managed', backend: { kind: 'opencode-cli', label: 'OpenCode CLI', endpointLabel: 'opencode', }, authenticated: true, supported: true, capabilities: { teamLaunch: true, }, models: ['openai/gpt-5.4', 'opencode/big-pickle'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange, modelAdvisoryReasonByValue: { 'opencode/big-pickle': 'big-pickle - ping not confirmed', }, }) ); await Promise.resolve(); }); const issueButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('big-pickle') ); expect(issueButton).not.toBeNull(); expect(issueButton?.getAttribute('aria-disabled')).toBe('false'); expect(issueButton?.textContent).toContain('Ping not confirmed'); expect(issueButton?.className).toContain('border-amber-300/35'); expect(issueButton?.className).not.toContain('border-red-500'); expect(issueButton?.getAttribute('title')).toContain('ping not confirmed'); await act(async () => { issueButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith('opencode/big-pickle'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('dynamically disables OpenCode openai routes when OpenAI auth is invalid', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { flavor: 'agent_teams_orchestrator', providers: [ { providerId: 'opencode', authMethod: 'opencode_managed', backend: { kind: 'opencode-cli', label: 'OpenCode CLI', endpointLabel: 'opencode', }, authenticated: true, supported: true, capabilities: { teamLaunch: true, }, statusMessage: 'OpenAI token invalid', detailMessage: 'OpenAI token refresh failed: 401', models: ['openai/gpt-5.4', 'opencode/big-pickle'], availableBackends: [ { id: 'openai', label: 'OpenAI', description: 'OpenAI route', selectable: false, recommended: false, available: false, state: 'authentication-required', statusMessage: 'Authentication required', detailMessage: 'Token refresh failed: 401', }, ], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); const openAiButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('GPT-5.4') ); const bigPickleButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('big-pickle') ); expect(openAiButton).not.toBeNull(); expect(openAiButton?.getAttribute('aria-disabled')).toBe('true'); expect(openAiButton?.textContent).toContain('Unavailable'); expect(bigPickleButton).not.toBeNull(); expect(bigPickleButton?.getAttribute('aria-disabled')).toBe('false'); await act(async () => { openAiButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('constrains long runtime model lists so the selector scrolls', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: [ 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2', 'gpt-5.1-codex', 'gpt-5.1-codex-mini', 'gpt-5', 'gpt-4.1', ], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const modelGrid = host.querySelector( '[data-testid="team-model-selector-model-grid"]' ); expect(modelGrid).toBeTruthy(); expect(modelGrid?.style.maxHeight).toBe('400px'); expect(modelGrid?.className).toContain('overflow-y-auto'); const searchInput = host.querySelector( '[data-testid="team-model-selector-model-search"]' ); expect(searchInput).toBeTruthy(); await act(async () => { const setValue = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set; setValue?.call(searchInput, '5.3'); searchInput?.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); }); expect(host.textContent).toContain('5.3 Codex'); expect(host.textContent).not.toContain('5.4 Mini'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps the runtime-reported Codex model list visible during a background refresh', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.4', 'gpt-5.3-codex'], }, ], }; storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('5.4'); expect(host.textContent).toContain('5.3 Codex'); expect(host.textContent).not.toContain('Explicit models load from the current runtime'); expect(host.querySelector('[data-testid="team-model-selector-model-search"]')).toBeNull(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows 5.2 Codex as a disabled tile when the runtime still reports it', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.4', 'gpt-5.2-codex'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.2 Codex') ); expect(disabledButton).not.toBeNull(); expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); expect(disabledButton?.textContent).toContain('Disabled'); expect(disabledButton?.getAttribute('title')).toContain( 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.' ); await act(async () => { disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps known disabled Codex tiles visible when the runtime omits them', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); const disabledButtons = ['5.3 Codex Spark', '5.2 Codex', '5.1 Codex Mini'].map((label) => { const button = Array.from(host.querySelectorAll('button')).find((candidate) => candidate.textContent?.includes(label) ); expect(button, `${label} should stay visible as a disabled option`).not.toBeNull(); expect(button?.getAttribute('aria-disabled')).toBe('true'); expect(button?.textContent).toContain('Disabled'); expect(button?.getAttribute('title')).toContain('Temporarily disabled for team agents'); return button; }); const activeButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.2') ); expect(activeButton?.textContent).toContain('Recommended'); expect(activeButton?.getAttribute('aria-disabled')).toBe('false'); await act(async () => { disabledButtons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps 5.1 Codex Max selectable on the native Codex path', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', authMethod: 'api_key', backend: { kind: 'codex-native', label: 'Codex native', endpointLabel: 'codex exec --json', }, models: ['gpt-5.4', 'gpt-5.1-codex-max'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); const button = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.1 Codex Max') ); expect(button).not.toBeNull(); expect(button?.getAttribute('aria-disabled')).toBe('false'); expect(button?.textContent).not.toContain('Disabled'); await act(async () => { button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith('gpt-5.1-codex-max'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('disables 5.1 Codex Max when the live Codex snapshot says ChatGPT account mode', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', authMethod: null, backend: null, models: ['gpt-5.4', 'gpt-5.1-codex-max'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; codexAccountHookState.snapshot = { preferredAuthMode: 'chatgpt', effectiveAuthMode: 'chatgpt', launchAllowed: true, launchIssueMessage: null, launchReadinessState: 'ready_chatgpt', appServerState: 'healthy', appServerStatusMessage: null, managedAccount: { type: 'chatgpt', email: 'user@example.com', planType: 'pro', }, apiKey: { available: false, source: null, sourceLabel: null, }, requiresOpenaiAuth: false, localAccountArtifactsPresent: false, login: { status: 'idle', error: null, startedAt: null, }, rateLimits: null, updatedAt: new Date().toISOString(), }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('5.4'); const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.1 Codex Max') ); expect(disabledButton).not.toBeNull(); expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); expect(disabledButton?.textContent).toContain('Disabled'); expect(disabledButton?.getAttribute('title')).toContain( 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps runtime model buttons selectable without starting automatic model probes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.4', 'gpt-5.4-mini'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled(); const gpt54Button = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.4') ); expect(gpt54Button?.getAttribute('aria-disabled')).toBe('false'); await act(async () => { gpt54Button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith('gpt-5.4'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('highlights the specific model tile when preflight found a model issue', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'codex', models: ['gpt-5.4', 'gpt-5.2-codex'], modelVerificationState: 'idle', modelAvailability: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'codex', onProviderChange: () => undefined, value: 'gpt-5.2-codex', onValueChange: () => undefined, modelIssueReasonByValue: { 'gpt-5.2-codex': 'Not available on this Codex native runtime', }, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('Issue'); const issueButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('5.2 Codex') ); expect(issueButton?.className).toContain('border-red-500/40'); expect(issueButton?.getAttribute('title')).toBe('Not available on this Codex native runtime'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps the curated Anthropic picker surface while showing runtime-backed labels', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'anthropic', models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], modelCatalog: { schemaVersion: 1, providerId: 'anthropic', source: 'anthropic-models-api', status: 'ready', fetchedAt: '2026-04-21T00:00:00.000Z', staleAt: '2026-04-21T00:10:00.000Z', defaultModelId: 'opus[1m]', defaultLaunchModel: 'opus[1m]', models: [ { id: 'opus', launchModel: 'opus', displayName: 'Opus 4.8', hidden: false, supportedReasoningEfforts: ['low', 'medium', 'high'], defaultReasoningEffort: null, inputModalities: ['text', 'image'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'anthropic-models-api', badgeLabel: 'Opus 4.8', }, { id: 'opus[1m]', launchModel: 'opus[1m]', displayName: 'Opus 4.8 (1M)', hidden: true, supportedReasoningEfforts: ['low', 'medium', 'high'], defaultReasoningEffort: null, inputModalities: ['text', 'image'], supportsPersonality: false, isDefault: true, upgrade: false, source: 'anthropic-models-api', }, { id: 'claude-opus-4-6', launchModel: 'claude-opus-4-6', displayName: 'Opus 4.6', hidden: false, supportedReasoningEfforts: ['low', 'medium', 'high'], defaultReasoningEffort: null, inputModalities: ['text', 'image'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'anthropic-models-api', badgeLabel: 'Opus 4.6', }, { id: 'sonnet', launchModel: 'sonnet', displayName: 'Sonnet 4.7', hidden: false, supportedReasoningEfforts: ['low', 'medium', 'high'], defaultReasoningEffort: null, inputModalities: ['text', 'image'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'anthropic-models-api', badgeLabel: 'Sonnet 4.7', }, { id: 'haiku', launchModel: 'haiku', displayName: 'Haiku 4.6', hidden: false, supportedReasoningEfforts: [], defaultReasoningEffort: null, inputModalities: ['text', 'image'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'anthropic-models-api', badgeLabel: 'Haiku 4.6', }, ], diagnostics: { configReadState: 'ready', appServerState: 'healthy', message: null, code: null, }, }, runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'anthropic-models-api', }, reasoningEffort: { supported: true, values: ['low', 'medium', 'high'], configPassthrough: false, }, }, }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const modelButtons = Array.from(host.querySelectorAll('button')).map( (button) => button.textContent?.trim() ?? '' ); const hasModelButtonStartingWith = (label: string): boolean => modelButtons.some((text) => text.startsWith(label)); expect(hasModelButtonStartingWith('Default')).toBe(true); expect(hasModelButtonStartingWith('Opus 4.8')).toBe(true); expect(hasModelButtonStartingWith('Opus 4.6')).toBe(true); expect(hasModelButtonStartingWith('Sonnet 4.7')).toBe(true); expect(hasModelButtonStartingWith('Haiku 4.6')).toBe(true); expect(hasModelButtonStartingWith('Opus 4.8 (1M)')).toBe(false); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows OpenCode as readiness-gated and keeps it non-selectable', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onProviderChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange, value: '', onValueChange: () => undefined, disableGeminiOption: true, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('OpenCode'); expect(host.textContent).not.toContain('Gemini in development'); const buttons = Array.from(host.querySelectorAll('button')); const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode')); expect(openCodeButton).not.toBeNull(); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toContain( 'OpenCode runtime status is still loading.' ); await act(async () => { openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onProviderChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses backend OpenCode readiness detail as the disabled reason', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, statusMessage: 'OpenCode team launch is gated', detailMessage: 'OpenCode runtime store needs recovery', capabilities: { teamLaunch: false }, models: [], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('OpenCode') ); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toContain( 'OpenCode runtime store needs recovery' ); expect(openCodeButton?.textContent).toContain('Gate'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses role-specific provider disabled copy before OpenCode readiness gating', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, detailMessage: null, statusMessage: null, capabilities: { teamLaunch: true, }, models: ['openrouter/minimax/minimax-m2.5-free'], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onProviderChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange, value: '', onValueChange: () => undefined, providerDisabledReasonById: { opencode: 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.', }, providerDisabledBadgeLabelById: { opencode: 'team only', }, }) ); await Promise.resolve(); }); const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('OpenCode') ); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toBe( 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.' ); expect(openCodeButton?.textContent).toContain('team only'); await act(async () => { openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onProviderChange).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps ready OpenCode selectable when no role-specific disable is provided', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, detailMessage: null, statusMessage: null, capabilities: { teamLaunch: true, }, models: ['openrouter/minimax/minimax-m2.5-free'], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onProviderChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('OpenCode') ); expect(openCodeButton?.hasAttribute('disabled')).toBe(false); await act(async () => { openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onProviderChange).toHaveBeenCalledWith('opencode'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('switches providers through tabs instead of a dropdown', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onProviderChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'anthropic', onProviderChange, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const buttons = Array.from(host.querySelectorAll('button')); const codexTab = buttons.find((button) => button.textContent?.trim() === 'Codex'); expect(codexTab).not.toBeNull(); expect(host.textContent).toContain('Anthropic'); expect(host.textContent).toContain('Codex'); await act(async () => { codexTab?.click(); await Promise.resolve(); }); expect(onProviderChange).toHaveBeenCalledWith('codex'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('renders OpenCode source groups and keeps raw model ids on selection', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, detailMessage: null, statusMessage: null, capabilities: { teamLaunch: true, }, models: ['openai/gpt-5.4', 'openrouter/moonshotai/kimi-k2'], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onValueChange = vi.fn(); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('GPT-5.4'); expect(host.textContent).toContain('OpenAI'); expect(host.textContent).toContain('moonshotai/kimi-k2'); expect(host.textContent).toContain('OpenRouter'); const openRouterButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('moonshotai/kimi-k2') ); expect(openRouterButton).toBeTruthy(); await act(async () => { openRouterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onValueChange).toHaveBeenCalledWith('openrouter/moonshotai/kimi-k2'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('renders OpenCode free badges and tiny model pricing from runtime catalog metadata', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, detailMessage: null, statusMessage: null, capabilities: { teamLaunch: true, }, models: ['opencode/big-pickle', 'opencode/minimax-m2.7'], modelCatalog: { schemaVersion: 1, providerId: 'opencode', source: 'app-server', status: 'ready', fetchedAt: '2026-05-13T00:00:00.000Z', staleAt: '2026-05-13T00:10:00.000Z', defaultModelId: null, defaultLaunchModel: null, models: [ { id: 'opencode/big-pickle', launchModel: 'opencode/big-pickle', displayName: 'big-pickle', hidden: false, supportedReasoningEfforts: [], defaultReasoningEffort: null, inputModalities: ['text'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'app-server', metadata: { cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, context: 200000, limits: null, free: true, }, }, { id: 'opencode/minimax-m2.7', launchModel: 'opencode/minimax-m2.7', displayName: 'minimax-m2.7', hidden: false, supportedReasoningEfforts: [], defaultReasoningEffort: null, inputModalities: ['text'], supportsPersonality: false, isDefault: false, upgrade: false, source: 'app-server', metadata: { cost: { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, context: 200000, limits: null, free: false, }, }, ], diagnostics: { configReadState: 'ready', appServerState: 'healthy', message: null, code: null, }, }, }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('in Free · out Free / 1M'); expect(host.textContent).toContain('in $0.30 · out $1.20 / 1M'); expect(host.textContent).toContain('Free'); const pricingRows = Array.from( host.querySelectorAll('[data-testid="team-model-selector-model-pricing"]') ); expect(pricingRows).toHaveLength(2); expect(pricingRows[0]?.className).toContain('text-[9px]'); expect(pricingRows[1]?.getAttribute('title')).toContain('Cache write: $0.375 per 1M tokens'); const freeBadges = host.querySelectorAll( '[data-testid="team-model-selector-model-free-badge"]' ); expect(freeBadges).toHaveLength(1); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('filters OpenCode model groups by selected source providers', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ { providerId: 'opencode', supported: true, authenticated: true, detailMessage: null, statusMessage: null, capabilities: { teamLaunch: true, }, models: ['openai/gpt-5.4', 'openrouter/moonshotai/kimi-k2', 'opencode/big-pickle'], }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(TeamModelSelector, { providerId: 'opencode', onProviderChange: () => undefined, value: '', onValueChange: () => undefined, }) ); await Promise.resolve(); }); const filterButton = host.querySelector( '[data-testid="team-model-selector-opencode-provider-filter"]' ); expect(filterButton).toBeTruthy(); await act(async () => { filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); const openRouterCheckbox = document.body.querySelector( '[aria-label="Filter OpenRouter"]' ); expect(openRouterCheckbox).toBeTruthy(); await act(async () => { openRouterCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(host.textContent).toContain('moonshotai/kimi-k2'); expect(host.textContent).toContain('OpenRouter'); expect(host.textContent).not.toContain('GPT-5.4'); expect(host.textContent).not.toContain('OpenAI'); expect(host.textContent).not.toContain('big-pickle'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });