fix(runtime): gate codex install prompt on runtime status

This commit is contained in:
777genius 2026-05-25 14:52:50 +03:00
parent 8f4a4dd502
commit 53dec55b1d
6 changed files with 280 additions and 22 deletions

View file

@ -24,6 +24,10 @@ import {
CodexLoginLinkCopyButton,
CodexLoginUserCodeBadge,
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
import {
isCodexProviderRuntimeMissing,
shouldOfferCodexRuntimeInstall,
} from '@renderer/components/runtime/codexRuntimeInstallAction';
import {
formatProviderStatusText,
getProviderConnectionModeSummary,
@ -606,30 +610,12 @@ function shouldShowCodexInstallAction(
showSkeleton: boolean,
codexRuntimeStatus: CodexRuntimeStatus | null
): boolean {
const codexNativeBackend = provider.availableBackends?.find(
(backend) => backend.id === 'codex-native'
);
const runtimeMissingText = [
provider.statusMessage,
provider.detailMessage,
codexNativeBackend?.statusMessage,
codexNativeBackend?.detailMessage,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const runtimeMissing =
provider.verificationState === 'error' &&
(codexNativeBackend?.state === 'runtime-missing' ||
runtimeMissingText.includes('codex cli not found') ||
runtimeMissingText.includes('runtime missing'));
return (
provider.providerId === 'codex' &&
!showSkeleton &&
!provider.authenticated &&
runtimeMissing &&
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed')
isCodexProviderRuntimeMissing(provider) &&
shouldOfferCodexRuntimeInstall(codexRuntimeStatus)
);
}
@ -1368,6 +1354,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
bootstrapCliStatus,
fetchCliStatus,
fetchCliProviderStatus,
fetchCodexRuntimeStatus,
invalidateCliStatus,
installCli,
installOpenCodeRuntime,
@ -1455,6 +1442,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
[loadingCliStatus, visibleCliProviders]
);
const renderCliStatus = effectiveCliStatus;
useEffect(() => {
if (!isElectron || codexRuntimeStatus || codexRuntimeStatusLoading) {
return;
}
if (visibleCliProviders.some(isCodexProviderRuntimeMissing)) {
void fetchCodexRuntimeStatus();
}
}, [
codexRuntimeStatus,
codexRuntimeStatusLoading,
fetchCodexRuntimeStatus,
isElectron,
visibleCliProviders,
]);
const shouldPollAnthropicSubscriptionLimits = useMemo(() => {
if (
!renderCliStatus?.installed ||

View file

@ -47,6 +47,10 @@ import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useStore } from '@renderer/store';
import { AlertTriangle, Download, Key, Link2, Loader2, Save, Trash2 } from 'lucide-react';
import {
isCodexProviderRuntimeMissing,
shouldOfferCodexRuntimeInstall,
} from './codexRuntimeInstallAction';
import {
formatProviderAuthMethodLabelForProvider,
formatProviderAuthModeLabelForProvider,
@ -1056,8 +1060,8 @@ export const ProviderRuntimeSettingsDialog = ({
const showCodexRuntimeInstallAction =
selectedProvider?.providerId === 'codex' &&
typeof onInstallCodexRuntime === 'function' &&
codexConnection?.appServerState === 'runtime-missing' &&
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed');
isCodexProviderRuntimeMissing(selectedProvider) &&
shouldOfferCodexRuntimeInstall(codexRuntimeStatus);
const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving;
const anthropicFastModeCapability =
selectedProvider?.providerId === 'anthropic'

View file

@ -0,0 +1,47 @@
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
import type { CliProviderStatus } from '@shared/types';
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
export function isCodexProviderRuntimeMissing(provider: CliProviderStatus): boolean {
if (provider.providerId !== 'codex') {
return false;
}
const codexNativeBackend = provider.availableBackends?.find(
(backend) => backend.id === CODEX_NATIVE_BACKEND_ID
);
const runtimeMissingText = [
provider.statusMessage,
provider.detailMessage,
codexNativeBackend?.statusMessage,
codexNativeBackend?.detailMessage,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return (
provider.connection?.codex?.appServerState === 'runtime-missing' ||
codexNativeBackend?.state === 'runtime-missing' ||
(provider.verificationState === 'error' &&
(runtimeMissingText.includes('codex cli not found') ||
runtimeMissingText.includes('runtime missing')))
);
}
export function shouldOfferCodexRuntimeInstall(
codexRuntimeStatus: CodexRuntimeStatus | null | undefined
): boolean {
if (!codexRuntimeStatus || codexRuntimeStatus.installed) {
return false;
}
return (
codexRuntimeStatus.source === 'missing' ||
codexRuntimeStatus.state === 'failed' ||
codexRuntimeStatus.state === 'checking' ||
codexRuntimeStatus.state === 'downloading' ||
codexRuntimeStatus.state === 'installing'
);
}

View file

@ -15,6 +15,7 @@ import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { isCodexProviderRuntimeMissing } from '@renderer/components/runtime/codexRuntimeInstallAction';
import {
formatProviderStatusText,
getProviderConnectionModeSummary,
@ -148,6 +149,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
bootstrapCliStatus,
fetchCliStatus,
fetchCliProviderStatus,
fetchCodexRuntimeStatus,
installCodexRuntime,
installCli,
isBusy,
@ -226,6 +228,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}
}, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
useEffect(() => {
if (!isElectron || codexRuntimeStatus || codexRuntimeStatusLoading) {
return;
}
if (visibleEffectiveProviders.some(isCodexProviderRuntimeMissing)) {
void fetchCodexRuntimeStatus();
}
}, [
codexRuntimeStatus,
codexRuntimeStatusLoading,
fetchCodexRuntimeStatus,
isElectron,
visibleEffectiveProviders,
]);
const handleInstall = useCallback(() => {
installCli();
}, [installCli]);

View file

@ -1755,6 +1755,110 @@ describe('ProviderRuntimeSettingsDialog', () => {
expect(icon?.className).toContain('shrink-0');
});
it('does not offer Codex runtime install before installer status is known', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onInstallCodexRuntime = vi.fn();
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
authenticated: false,
authMethod: null,
codex: {
appServerState: 'runtime-missing',
appServerStatusMessage: 'Codex CLI not found',
launchAllowed: false,
launchIssueMessage: 'Codex CLI not found',
launchReadinessState: 'runtime_missing',
},
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use the local codex exec JSON seam.',
selectable: false,
recommended: true,
available: false,
state: 'runtime-missing',
audience: 'general',
statusMessage: 'Codex CLI not found',
},
],
}),
],
initialProviderId: 'codex',
codexRuntimeStatus: null,
onInstallCodexRuntime,
onSelectBackend: vi.fn(),
onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('Install Codex CLI');
expect(onInstallCodexRuntime).not.toHaveBeenCalled();
});
it('offers Codex runtime install after installer status confirms the runtime is missing', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
authenticated: false,
authMethod: null,
codex: {
appServerState: 'runtime-missing',
appServerStatusMessage: 'Codex CLI not found',
launchAllowed: false,
launchIssueMessage: 'Codex CLI not found',
launchReadinessState: 'runtime_missing',
},
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use the local codex exec JSON seam.',
selectable: false,
recommended: true,
available: false,
state: 'runtime-missing',
audience: 'general',
statusMessage: 'Codex CLI not found',
},
],
}),
],
initialProviderId: 'codex',
codexRuntimeStatus: {
installed: false,
source: 'missing',
state: 'idle',
},
onInstallCodexRuntime: vi.fn(),
onSelectBackend: vi.fn(),
onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Install Codex CLI');
});
it('keeps the API key form open and shows an error when delete fails', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -0,0 +1,81 @@
import {
isCodexProviderRuntimeMissing,
shouldOfferCodexRuntimeInstall,
} from '@renderer/components/runtime/codexRuntimeInstallAction';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { describe, expect, it } from 'vitest';
import type { CliProviderStatus } from '@shared/types';
function createCodexProvider(overrides?: Partial<CliProviderStatus>): CliProviderStatus {
return {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: 'Codex CLI not found',
models: [],
modelAvailability: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: false,
recommended: true,
available: false,
state: 'runtime-missing',
audience: 'general',
statusMessage: 'Codex CLI not found',
},
],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
...overrides,
};
}
describe('codexRuntimeInstallAction', () => {
it('recognizes provider runtime-missing snapshots', () => {
expect(isCodexProviderRuntimeMissing(createCodexProvider())).toBe(true);
});
it('does not offer install before installer status is loaded', () => {
expect(shouldOfferCodexRuntimeInstall(null)).toBe(false);
});
it('offers install for confirmed missing or failed runtime status only', () => {
expect(
shouldOfferCodexRuntimeInstall({
installed: false,
source: 'missing',
state: 'idle',
})
).toBe(true);
expect(
shouldOfferCodexRuntimeInstall({
installed: false,
source: 'app-managed',
state: 'failed',
})
).toBe(true);
expect(
shouldOfferCodexRuntimeInstall({
installed: true,
source: 'path',
state: 'ready',
})
).toBe(false);
});
});