diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 53a6bb17..004869a8 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -88,6 +88,14 @@ interface UnifiedRuntimeStatusResponse { selectable?: boolean; recommended?: boolean; available?: boolean; + state?: + | 'ready' + | 'locked' + | 'disabled' + | 'authentication-required' + | 'runtime-missing' + | 'degraded'; + audience?: 'general' | 'internal'; statusMessage?: string | null; detailMessage?: string | null; }[]; @@ -270,6 +278,8 @@ export class ClaudeMultimodelBridgeService { selectable: backend.selectable !== false, recommended: backend.recommended === true, available: backend.available === true, + state: backend.state ?? undefined, + audience: backend.audience ?? undefined, statusMessage: backend.statusMessage ?? null, detailMessage: backend.detailMessage ?? null, })) ?? [], diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx index abc21660..7128ddb3 100644 --- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx @@ -20,6 +20,39 @@ interface Props { onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void; } +export function getProviderRuntimeBackendStateLabel( + option: NonNullable[number] +): string | null { + switch (option.state) { + case 'ready': + return null; + case 'locked': + return 'Locked'; + case 'disabled': + return 'Disabled'; + case 'authentication-required': + return 'Auth required'; + case 'runtime-missing': + return 'Runtime missing'; + case 'degraded': + return 'Degraded'; + default: + if (!option.available) { + return 'Unavailable'; + } + if (option.selectable === false) { + return 'Locked'; + } + return null; + } +} + +export function getProviderRuntimeBackendAudienceLabel( + option: NonNullable[number] +): string | null { + return option.audience === 'internal' ? 'Internal' : null; +} + export function getOptionDisplayLabel( option: NonNullable[number], resolvedOption: NonNullable[number] | null @@ -44,8 +77,18 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? ''; const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0]; const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null; + const parts = [getOptionDisplayLabel(selectedOption, resolvedOption)]; + const audienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption); + const stateLabel = getProviderRuntimeBackendStateLabel(selectedOption); - return getOptionDisplayLabel(selectedOption, resolvedOption); + if (audienceLabel) { + parts.push(audienceLabel.toLowerCase()); + } + if (stateLabel) { + parts.push(stateLabel.toLowerCase()); + } + + return parts.join(' - '); } export const ProviderRuntimeBackendSelector = ({ @@ -62,6 +105,8 @@ export const ProviderRuntimeBackendSelector = ({ const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0]; const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null; const selectedLabel = getOptionDisplayLabel(selectedOption, resolvedOption); + const selectedStateLabel = getProviderRuntimeBackendStateLabel(selectedOption); + const selectedAudienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption); return (
@@ -120,25 +165,40 @@ export const ProviderRuntimeBackendSelector = ({ Recommended ) : null} - {!option.available ? ( + {getProviderRuntimeBackendAudienceLabel(option) ? ( - Unavailable + {getProviderRuntimeBackendAudienceLabel(option)} - ) : option.selectable === false ? ( + ) : null} + {getProviderRuntimeBackendStateLabel(option) ? ( - Locked + {getProviderRuntimeBackendStateLabel(option)} ) : null}
@@ -173,7 +233,18 @@ export const ProviderRuntimeBackendSelector = ({ Recommended ) : null} - {!selectedOption.available ? ( + {selectedAudienceLabel ? ( + + {selectedAudienceLabel} + + ) : null} + {!selectedStateLabel && !selectedOption.available ? ( @@ -192,18 +263,24 @@ export const ProviderRuntimeBackendSelector = ({ - ) : selectedOption.selectable === false ? ( + ) : selectedStateLabel ? ( - Locked + {selectedStateLabel} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index d4be245c..73b011e6 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -46,12 +46,43 @@ export function getProvisioningProviderBackendSummary( const options = provider.availableBackends ?? []; const optionById = new Map(options.map((option) => [option.id, option.label])); const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId; + const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null; - if (effectiveBackendId) { - return optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId; + const baseSummary = effectiveBackendId + ? (optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId) + : (provider.backend?.label ?? null); + + if (!baseSummary) { + return null; } - return provider.backend?.label ?? null; + const suffixes: string[] = []; + if (effectiveOption?.audience === 'internal') { + suffixes.push('internal'); + } + if (effectiveOption?.state && effectiveOption.state !== 'ready') { + switch (effectiveOption.state) { + case 'locked': + suffixes.push('locked'); + break; + case 'disabled': + suffixes.push('disabled'); + break; + case 'authentication-required': + suffixes.push('auth required'); + break; + case 'runtime-missing': + suffixes.push('runtime missing'); + break; + case 'degraded': + suffixes.push('degraded'); + break; + default: + break; + } + } + + return suffixes.length > 0 ? `${baseSummary} - ${suffixes.join(', ')}` : baseSummary; } export function updateProviderCheck( diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 9ccbb235..a1bee176 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -45,6 +45,14 @@ export interface CliProviderBackendOption { selectable: boolean; recommended: boolean; available: boolean; + state?: + | 'ready' + | 'locked' + | 'disabled' + | 'authentication-required' + | 'runtime-missing' + | 'degraded'; + audience?: 'general' | 'internal'; statusMessage?: string | null; detailMessage?: string | null; } diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index b38f5f56..fc2d55dd 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -346,6 +346,8 @@ describe('ClaudeMultimodelBridgeService', () => { selectable: false, recommended: false, available: true, + state: 'locked', + audience: 'internal', statusMessage: 'Experimental native lane', detailMessage: 'Phase 0 keeps the lane locked behind rollout policy.', }, @@ -422,6 +424,8 @@ describe('ClaudeMultimodelBridgeService', () => { id: 'codex-native', selectable: false, available: true, + state: 'locked', + audience: 'internal', statusMessage: 'Experimental native lane', }), ], @@ -439,4 +443,67 @@ describe('ClaudeMultimodelBridgeService', () => { expect(getProviderConnectionModeSummary(codex!)).toBeNull(); expect(getProviderCurrentRuntimeSummary(codex!)).toBeNull(); }); + + it('preserves codex-native internal unlock readiness from runtime status payloads', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + codex: { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + canLoginFromUi: false, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + selectable: true, + recommended: false, + available: true, + state: 'ready', + audience: 'internal', + statusMessage: 'Ready for internal use', + detailMessage: 'Internal rollout only.', + }, + ], + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported', ownership: 'shared', reason: 'Phase 1' }, + mcp: { status: 'unsupported', ownership: 'shared', reason: 'Phase 1' }, + skills: { status: 'unsupported', ownership: 'shared', reason: 'Phase 1' }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + authMethodDetail: 'api_key', + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const codex = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex'); + + expect(codex.availableBackends?.find((backend) => backend.id === 'codex-native')).toMatchObject({ + id: 'codex-native', + selectable: true, + available: true, + state: 'ready', + audience: 'internal', + statusMessage: 'Ready for internal use', + }); + }); }); diff --git a/test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts b/test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts new file mode 100644 index 00000000..b4e75290 --- /dev/null +++ b/test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + getProviderRuntimeBackendAudienceLabel, + getProviderRuntimeBackendStateLabel, + getProviderRuntimeBackendSummary, +} from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; + +import type { CliProviderStatus } from '@shared/types'; + +function createCodexProvider( + overrides?: Partial +): CliProviderStatus { + return { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + statusMessage: 'Connected', + models: ['gpt-5-codex'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + availableBackends: [ + { + id: 'auto', + label: 'Auto', + description: 'Automatically choose the best backend.', + selectable: true, + recommended: true, + available: true, + state: 'ready', + audience: 'general', + }, + { + id: 'codex-native', + label: 'Codex native', + description: 'Use the local codex exec JSON seam.', + selectable: false, + recommended: false, + available: true, + state: 'locked', + audience: 'internal', + statusMessage: 'Ready but locked', + detailMessage: 'Internal rollout only.', + }, + ], + externalRuntimeDiagnostics: [], + backend: { + kind: 'codex-native', + label: 'Codex native', + }, + connection: null, + ...overrides, + }; +} + +describe('ProviderRuntimeBackendSelector helpers', () => { + it('exposes explicit internal-audience and locked-state labels', () => { + const provider = createCodexProvider(); + const option = provider.availableBackends?.find((backend) => backend.id === 'codex-native'); + + expect(option).toBeDefined(); + expect(getProviderRuntimeBackendAudienceLabel(option!)).toBe('Internal'); + expect(getProviderRuntimeBackendStateLabel(option!)).toBe('Locked'); + }); + + it('builds a runtime summary that keeps internal locked truth visible', () => { + const provider = createCodexProvider(); + + expect(getProviderRuntimeBackendSummary(provider)).toBe( + 'Codex native - internal - locked' + ); + }); + + it('shows auth-required state for degraded internal native rollout', () => { + const provider = createCodexProvider({ + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use the local codex exec JSON seam.', + selectable: false, + recommended: false, + available: false, + state: 'authentication-required', + audience: 'internal', + statusMessage: 'Authentication required', + detailMessage: 'Set CODEX_API_KEY.', + }, + ], + }); + const option = provider.availableBackends?.[0]; + + expect(getProviderRuntimeBackendAudienceLabel(option!)).toBe('Internal'); + expect(getProviderRuntimeBackendStateLabel(option!)).toBe('Auth required'); + expect(getProviderRuntimeBackendSummary(provider)).toBe( + 'Codex native - internal - auth required' + ); + }); +}); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 931753f9..02674a81 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getPrimaryProvisioningFailureDetail, + getProvisioningProviderBackendSummary, ProvisioningProviderStatusList, createInitialProviderChecks, } from '@renderer/components/team/dialogs/ProvisioningProviderStatusList'; @@ -160,4 +161,30 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('keeps internal native rollout state visible in provisioning backend summaries', () => { + expect( + getProvisioningProviderBackendSummary({ + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + backend: { + kind: 'codex-native', + label: 'Codex native', + }, + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use codex exec JSON mode.', + selectable: false, + recommended: false, + available: true, + state: 'locked', + audience: 'internal', + statusMessage: 'Ready but locked', + }, + ], + }) + ).toBe('Codex native - internal, locked'); + }); });