fix(runtime): preserve provider status during refresh failures

This commit is contained in:
777genius 2026-05-30 18:44:31 +03:00
parent a06423a574
commit a7606032fc
8 changed files with 265 additions and 12 deletions

View file

@ -332,17 +332,27 @@ export function useCodexAccountSnapshot(options: {
[applySnapshot, electronMode, options.enabled]
);
const waitingForInitialRefresh =
electronMode &&
options.enabled &&
initialRefreshDelayMs > 0 &&
snapshot === null &&
!initialRefreshAttempted;
const effectiveLoading = loading || waitingForInitialRefresh;
const effectiveRateLimitsLoading =
rateLimitsLoading || (waitingForInitialRefresh && options.includeRateLimits === true);
return useMemo(
() => ({
snapshot,
loading,
rateLimitsLoading,
loading: effectiveLoading,
rateLimitsLoading: effectiveRateLimitsLoading,
error,
refresh,
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
logout: () => runAction(() => api.logoutCodexAccount()),
}),
[error, loading, rateLimitsLoading, refresh, runAction, snapshot]
[effectiveLoading, effectiveRateLimitsLoading, error, refresh, runAction, snapshot]
);
}

View file

@ -1,5 +1,6 @@
import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE } from '@shared/types/cliInstaller';
import { createLogger } from '@shared/utils/logger';
import {
createDefaultCliExtensionCapabilities,
@ -413,7 +414,7 @@ function createRuntimeStatusErrorProviderStatus(
return {
...createDefaultProviderStatus(providerId),
verificationState: 'error',
statusMessage: 'Provider status unavailable',
statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
detailMessage,
};
}

View file

@ -41,6 +41,11 @@ interface StoredApiKeyAccessOptions {
allowedStoredApiKeyEnvVarNames?: readonly string[];
}
interface CodexLaunchSnapshotRefreshOptions {
refreshRuntimeMissing?: boolean;
refreshBlockedLaunch?: boolean;
}
const PROVIDER_CAPABILITIES: Record<
CliProviderId,
Pick<CliProviderConnectionInfo, 'supportsOAuth' | 'supportsApiKey' | 'configurableAuthModes'>
@ -605,6 +610,7 @@ export class ProviderConnectionService {
const snapshot = await this.getCodexLaunchSnapshot(env, {
refreshRuntimeMissing: true,
refreshBlockedLaunch: true,
});
applyCodexRuntimeContextEnv(env, snapshot);
const readiness = evaluateCodexLaunchReadiness({
@ -687,6 +693,7 @@ export class ProviderConnectionService {
const snapshot = await this.getCodexLaunchSnapshot(env, {
refreshRuntimeMissing: true,
refreshBlockedLaunch: true,
});
applyCodexRuntimeContextEnv(env, snapshot);
const readiness = evaluateCodexLaunchReadiness({
@ -771,6 +778,7 @@ export class ProviderConnectionService {
const snapshot = await this.getCodexLaunchSnapshot(env, {
refreshRuntimeMissing: true,
refreshBlockedLaunch: true,
});
const runtimeEnv = { ...env };
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
@ -882,6 +890,7 @@ export class ProviderConnectionService {
const snapshot = await this.getCodexLaunchSnapshot(env, {
refreshRuntimeMissing: true,
refreshBlockedLaunch: true,
});
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
@ -1267,10 +1276,21 @@ export class ProviderConnectionService {
private async getCodexLaunchSnapshot(
env: NodeJS.ProcessEnv,
options?: { refreshRuntimeMissing?: boolean }
options?: CodexLaunchSnapshotRefreshOptions
): Promise<CodexAccountSnapshotDto> {
let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
apiKey: snapshot.apiKey,
appServerState: snapshot.appServerState,
appServerStatusMessage: snapshot.appServerStatusMessage,
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
});
const shouldRefresh =
(options?.refreshRuntimeMissing === true && snapshot.appServerState === 'runtime-missing') ||
(options?.refreshBlockedLaunch === true && !readiness.launchAllowed);
if (!shouldRefresh) {
return snapshot;
}
@ -1280,7 +1300,7 @@ export class ProviderConnectionService {
env
);
} catch {
// Keep the original runtime-missing snapshot so callers still report the concrete issue.
// Keep the original blocked snapshot so callers still report the concrete issue.
}
return snapshot;

View file

@ -4,7 +4,10 @@
import { api } from '@renderer/api';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import {
CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
} from '@shared/types/cliInstaller';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -195,6 +198,28 @@ function isOpenCodeRuntimeMissingSnapshot(provider: CliProviderStatus | undefine
);
}
function isProviderStatusUnavailableSnapshot(provider: CliProviderStatus | undefined): boolean {
return (
provider?.verificationState === 'error' &&
provider.statusMessage === CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE
);
}
function shouldKeepConnectedProviderDuringStatusUnavailable(
currentProvider: CliProviderStatus | undefined,
incomingProvider: CliProviderStatus | undefined
): boolean {
if (!currentProvider || !incomingProvider) {
return false;
}
return (
isHydratedMultimodelProviderStatus(currentProvider) &&
currentProvider.authenticated &&
isProviderStatusUnavailableSnapshot(incomingProvider)
);
}
function shouldPreserveCurrentProviderStatus(
currentProvider: CliProviderStatus | undefined,
incomingProvider: CliProviderStatus
@ -203,6 +228,10 @@ function shouldPreserveCurrentProviderStatus(
return false;
}
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) {
return true;
}
if (hasOpenCodeModels(currentProvider) && isOpenCodeRuntimeMissingSnapshot(incomingProvider)) {
return true;
}
@ -242,6 +271,10 @@ function mergePreservedHydratedProviderStatus(
incomingProvider: CliProviderStatus,
currentProvider: CliProviderStatus
): CliProviderStatus {
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) {
return currentProvider;
}
if (isDeferredMultimodelProviderStatus(incomingProvider)) {
return currentProvider;
}
@ -638,17 +671,23 @@ function createProviderStatusErrorSnapshot(params: {
backend: null,
} satisfies CliProviderStatus);
return {
const errorProvider: CliProviderStatus = {
...currentProvider,
providerId: params.providerId,
displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId),
authenticated: false,
authMethod: null,
verificationState: 'error',
modelCatalogRefreshState: 'error',
verificationState: 'error' as const,
modelCatalogRefreshState: 'error' as const,
statusMessage: params.message,
detailMessage: null,
};
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, errorProvider)) {
return currentProvider;
}
return errorProvider;
}
// =============================================================================

View file

@ -36,6 +36,7 @@ export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';
export const CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE = 'Provider status unavailable';
export interface CliProviderConnectionInfo {
supportsOAuth: boolean;

View file

@ -233,7 +233,7 @@ describe('useCodexAccountSnapshot', () => {
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('true');
await act(async () => {
vi.advanceTimersByTime(20_000);

View file

@ -1426,6 +1426,82 @@ describe('ProviderConnectionService', () => {
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
});
it('refreshes a stale blocked Codex snapshot before reporting an auth issue', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const staleMissingAuthSnapshot = createCodexSnapshot({
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage:
'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account.',
launchReadinessState: 'missing_auth',
managedAccount: null,
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
});
const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot());
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue(staleMissingAuthSnapshot),
refreshSnapshot,
});
const issue = await service.getConfiguredConnectionIssue({}, 'codex');
expect(issue).toBeNull();
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
});
it('does not refresh a stale Codex auth snapshot when launch env already provides an API key', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot());
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue(
createCodexSnapshot({
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage:
'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account.',
launchReadinessState: 'missing_auth',
managedAccount: null,
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
})
),
refreshSnapshot,
});
const issue = await service.getConfiguredConnectionIssue(
{
CODEX_API_KEY: 'native-key',
},
'codex'
);
expect(issue).toBeNull();
expect(refreshSnapshot).not.toHaveBeenCalled();
});
it('refreshes a runtime-missing Codex snapshot before mutating strict launch env', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');

View file

@ -65,6 +65,7 @@ import {
} from '@renderer/store/slices/cliInstallerSlice';
import {
CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
type CliProviderId,
} from '@shared/types/cliInstaller';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -614,6 +615,76 @@ describe('cliInstallerSlice', () => {
});
});
it('does not let a scoped runtime-status error overwrite a connected provider', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: true,
authMethod: 'oauth_token',
statusMessage: 'Connected via Anthropic subscription',
models: ['claude-sonnet-4-5'],
backend: { kind: 'anthropic', label: 'Anthropic' },
}),
]);
const incoming = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
models: [],
backend: null,
}),
]);
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
expect(merged.providers[0]).toBe(current.providers[0]);
expect(merged.authLoggedIn).toBe(true);
expect(merged.authMethod).toBe('oauth_token');
});
it('allows a real disconnected provider snapshot to replace a connected provider', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: true,
authMethod: 'oauth_token',
statusMessage: 'Connected via Anthropic subscription',
models: ['claude-sonnet-4-5'],
backend: { kind: 'anthropic', label: 'Anthropic' },
}),
]);
const incoming = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: false,
authMethod: null,
verificationState: 'verified',
statusMessage: null,
models: [],
backend: null,
}),
]);
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
expect(merged.providers[0]).toMatchObject({
authenticated: false,
authMethod: null,
verificationState: 'verified',
statusMessage: null,
});
expect(merged.authLoggedIn).toBe(false);
expect(merged.authMethod).toBeNull();
});
it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
@ -1470,6 +1541,41 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
});
it('keeps an already connected provider visible when a status refresh errors', async () => {
useStore.setState({
cliStatus: createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: true,
authMethod: 'oauth_token',
statusMessage: 'Connected via Anthropic subscription',
models: ['claude-sonnet-4-5'],
backend: { kind: 'anthropic', label: 'Anthropic' },
}),
]),
});
vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue(
new Error(CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE)
);
await useStore.getState().fetchCliProviderStatus('anthropic');
const provider = useStore
.getState()
.cliStatus?.providers.find((candidate) => candidate.providerId === 'anthropic');
expect(useStore.getState().cliStatusError).toBe(CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE);
expect(provider).toMatchObject({
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected via Anthropic subscription',
models: ['claude-sonnet-4-5'],
});
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(true);
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
});
it('ignores hidden Gemini provider failures without keeping global auth checking active', async () => {
useStore.setState({
cliStatus: createMultimodelStatus([