fix(extensions): harden hidden provider runtime handling
This commit is contained in:
parent
12f6f90701
commit
3446ef0100
5 changed files with 218 additions and 67 deletions
|
|
@ -33,6 +33,7 @@ import { useStore } from '@renderer/store';
|
|||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -321,7 +322,11 @@ function formatRuntimeAuthSummary(
|
|||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||
visibleProviders: readonly CliProviderStatus[]
|
||||
): string | null {
|
||||
if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
|
||||
if (isMultimodelRuntimeStatus(cliStatus)) {
|
||||
if (visibleProviders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
visibleProviders.every(
|
||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||
|
|
@ -351,7 +356,7 @@ function isCheckingMultimodelStatus(
|
|||
visibleProviders: readonly CliProviderStatus[]
|
||||
): boolean {
|
||||
return (
|
||||
cliStatus.flavor === 'agent_teams_orchestrator' &&
|
||||
isMultimodelRuntimeStatus(cliStatus) &&
|
||||
visibleProviders.length > 0 &&
|
||||
visibleProviders.every(
|
||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
formatCliExtensionCapabilityStatus,
|
||||
getVisibleMultimodelProviders,
|
||||
isMultimodelRuntimeStatus,
|
||||
} from '@renderer/utils/multimodelProviderVisibility';
|
||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
|
|
@ -178,9 +183,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
const providers = cliStatus?.providers ?? [];
|
||||
const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini');
|
||||
const isMultimodel =
|
||||
cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0;
|
||||
const visibleProviders = getVisibleMultimodelProviders(providers);
|
||||
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
|
||||
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
return (
|
||||
|
|
@ -260,57 +264,64 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const statusTone = provider.authenticated
|
||||
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
||||
: provider.supported
|
||||
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
||||
: 'border-border bg-surface-raised text-text-muted';
|
||||
const statusLabel = provider.authenticated
|
||||
? 'Connected'
|
||||
: provider.supported
|
||||
? 'Needs setup'
|
||||
: 'Unsupported';
|
||||
const pluginStatus = provider.capabilities.extensions.plugins.status;
|
||||
{visibleProviders.length > 0 && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const statusTone = provider.authenticated
|
||||
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
||||
: provider.supported
|
||||
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
||||
: 'border-border bg-surface-raised text-text-muted';
|
||||
const statusLabel = provider.authenticated
|
||||
? 'Connected'
|
||||
: provider.supported
|
||||
? 'Needs setup'
|
||||
: 'Unsupported';
|
||||
const pluginStatus = provider.capabilities.extensions.plugins.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<ProviderBrandLogo
|
||||
providerId={provider.providerId}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span>{provider.displayName}</span>
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-text-muted">
|
||||
{provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'}
|
||||
</p>
|
||||
return (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<ProviderBrandLogo
|
||||
providerId={provider.providerId}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span>{provider.displayName}</span>
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-text-muted">
|
||||
{provider.statusMessage ??
|
||||
provider.backend?.label ??
|
||||
'Ready to configure'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
||||
<Badge variant="secondary">
|
||||
Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
MCP:{' '}
|
||||
{formatCliExtensionCapabilityStatus(
|
||||
provider.capabilities.extensions.mcp.status
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
Skills: {provider.capabilities.extensions.skills.ownership}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
||||
<Badge variant="secondary">
|
||||
Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
MCP: {provider.capabilities.extensions.mcp.status}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
Skills: {provider.capabilities.extensions.skills.ownership}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
32
src/renderer/utils/multimodelProviderVisibility.ts
Normal file
32
src/renderer/utils/multimodelProviderVisibility.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { filterMainScreenCliProviders } from './geminiUiFreeze';
|
||||
|
||||
import type {
|
||||
CliExtensionCapability,
|
||||
CliInstallationStatus,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
export function getVisibleMultimodelProviders(
|
||||
providers: readonly CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
return filterMainScreenCliProviders(providers);
|
||||
}
|
||||
|
||||
export function isMultimodelRuntimeStatus(
|
||||
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'providers'> | null | undefined
|
||||
): boolean {
|
||||
return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function formatCliExtensionCapabilityStatus(
|
||||
status: CliExtensionCapability['status']
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'supported':
|
||||
return 'supported';
|
||||
case 'read-only':
|
||||
return 'read-only';
|
||||
default:
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +170,8 @@ function createApiKeyMisconfiguredProvider(
|
|||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'],
|
||||
configurableAuthModes:
|
||||
providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'],
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
|
||||
apiKeyBetaEnabled: providerId === 'codex' ? true : undefined,
|
||||
|
|
@ -181,9 +182,7 @@ function createApiKeyMisconfiguredProvider(
|
|||
};
|
||||
}
|
||||
|
||||
function createApiKeyModeProviderIssue(
|
||||
providerId: 'anthropic' | 'codex'
|
||||
): Record<string, unknown> {
|
||||
function createApiKeyModeProviderIssue(providerId: 'anthropic' | 'codex'): Record<string, unknown> {
|
||||
return {
|
||||
...createApiKeyMisconfiguredProvider(providerId),
|
||||
statusMessage:
|
||||
|
|
@ -191,8 +190,8 @@ function createApiKeyModeProviderIssue(
|
|||
? 'Anthropic API key was rejected by the runtime.'
|
||||
: 'OpenAI API key was rejected by the runtime.',
|
||||
connection: {
|
||||
...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record<string, unknown> })
|
||||
.connection),
|
||||
...(createApiKeyMisconfiguredProvider(providerId) as { connection: Record<string, unknown> })
|
||||
.connection,
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
apiKeySourceLabel:
|
||||
|
|
@ -287,9 +286,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.fetchCliProviderStatus = vi.fn(() =>
|
||||
Promise.reject(new Error('refresh failed'))
|
||||
);
|
||||
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -345,6 +342,49 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'cli_oauth_personal',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Resolved to CLI SDK',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Authenticated');
|
||||
expect(host.textContent).not.toContain('Providers:');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a degraded runtime warning when a binary is found but the health check fails', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
@ -413,9 +453,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
it('preserves settings runtime backend refresh errors for the manage dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.fetchCliProviderStatus = vi.fn(() =>
|
||||
Promise.reject(new Error('refresh failed'))
|
||||
);
|
||||
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -490,8 +528,8 @@ describe('CLI status visibility during completed install state', () => {
|
|||
expect(host.textContent).not.toContain('Already logged in?');
|
||||
expect(host.textContent).not.toContain('Login');
|
||||
|
||||
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Manage Providers')
|
||||
const manageButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Manage Providers')
|
||||
);
|
||||
expect(manageButton).not.toBeUndefined();
|
||||
|
||||
|
|
|
|||
65
test/renderer/utils/multimodelProviderVisibility.test.ts
Normal file
65
test/renderer/utils/multimodelProviderVisibility.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatCliExtensionCapabilityStatus,
|
||||
getVisibleMultimodelProviders,
|
||||
isMultimodelRuntimeStatus,
|
||||
} from '@renderer/utils/multimodelProviderVisibility';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderStatus } from '@shared/types';
|
||||
|
||||
function createProvider(providerId: CliProviderStatus['providerId']): CliProviderStatus {
|
||||
return {
|
||||
providerId,
|
||||
displayName:
|
||||
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
models: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('multimodelProviderVisibility', () => {
|
||||
it('keeps multimodel runtime detection true even when all visible provider cards are hidden', () => {
|
||||
const cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [createProvider('gemini')],
|
||||
} satisfies Pick<CliInstallationStatus, 'flavor' | 'providers'>;
|
||||
|
||||
expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true);
|
||||
expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters Gemini from the visible provider cards while keeping supported providers', () => {
|
||||
const providers = [
|
||||
createProvider('anthropic'),
|
||||
createProvider('codex'),
|
||||
createProvider('gemini'),
|
||||
];
|
||||
|
||||
expect(getVisibleMultimodelProviders(providers).map((provider) => provider.providerId)).toEqual(
|
||||
['anthropic', 'codex']
|
||||
);
|
||||
});
|
||||
|
||||
it('formats capability statuses without collapsing read-only into a vague limited label', () => {
|
||||
expect(formatCliExtensionCapabilityStatus('supported')).toBe('supported');
|
||||
expect(formatCliExtensionCapabilityStatus('read-only')).toBe('read-only');
|
||||
expect(formatCliExtensionCapabilityStatus('unsupported')).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue