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 { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||||
|
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -321,7 +322,11 @@ function formatRuntimeAuthSummary(
|
||||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||||
visibleProviders: readonly CliProviderStatus[]
|
visibleProviders: readonly CliProviderStatus[]
|
||||||
): string | null {
|
): string | null {
|
||||||
if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
|
if (isMultimodelRuntimeStatus(cliStatus)) {
|
||||||
|
if (visibleProviders.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
visibleProviders.every(
|
visibleProviders.every(
|
||||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||||
|
|
@ -351,7 +356,7 @@ function isCheckingMultimodelStatus(
|
||||||
visibleProviders: readonly CliProviderStatus[]
|
visibleProviders: readonly CliProviderStatus[]
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
cliStatus.flavor === 'agent_teams_orchestrator' &&
|
isMultimodelRuntimeStatus(cliStatus) &&
|
||||||
visibleProviders.length > 0 &&
|
visibleProviders.length > 0 &&
|
||||||
visibleProviders.every(
|
visibleProviders.every(
|
||||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ import {
|
||||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
formatCliExtensionCapabilityStatus,
|
||||||
|
getVisibleMultimodelProviders,
|
||||||
|
isMultimodelRuntimeStatus,
|
||||||
|
} from '@renderer/utils/multimodelProviderVisibility';
|
||||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
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 cliStatusBanner = useMemo(() => {
|
||||||
const providers = cliStatus?.providers ?? [];
|
const providers = cliStatus?.providers ?? [];
|
||||||
const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini');
|
const visibleProviders = getVisibleMultimodelProviders(providers);
|
||||||
const isMultimodel =
|
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
|
||||||
cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0;
|
|
||||||
|
|
||||||
if (cliStatusLoading || cliStatus === null) {
|
if (cliStatusLoading || cliStatus === null) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -260,57 +264,64 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
{visibleProviders.length > 0 && (
|
||||||
{visibleProviders.map((provider) => {
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
const statusTone = provider.authenticated
|
{visibleProviders.map((provider) => {
|
||||||
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
const statusTone = provider.authenticated
|
||||||
: provider.supported
|
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
||||||
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
: provider.supported
|
||||||
: 'border-border bg-surface-raised text-text-muted';
|
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
||||||
const statusLabel = provider.authenticated
|
: 'border-border bg-surface-raised text-text-muted';
|
||||||
? 'Connected'
|
const statusLabel = provider.authenticated
|
||||||
: provider.supported
|
? 'Connected'
|
||||||
? 'Needs setup'
|
: provider.supported
|
||||||
: 'Unsupported';
|
? 'Needs setup'
|
||||||
const pluginStatus = provider.capabilities.extensions.plugins.status;
|
: 'Unsupported';
|
||||||
|
const pluginStatus = provider.capabilities.extensions.plugins.status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider.providerId}
|
key={provider.providerId}
|
||||||
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="inline-flex items-center gap-2 text-sm font-medium">
|
<p className="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
<ProviderBrandLogo
|
<ProviderBrandLogo
|
||||||
providerId={provider.providerId}
|
providerId={provider.providerId}
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>{provider.displayName}</span>
|
<span>{provider.displayName}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-text-muted">
|
<p className="truncate text-[11px] text-text-muted">
|
||||||
{provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'}
|
{provider.statusMessage ??
|
||||||
</p>
|
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>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0">
|
|
||||||
{statusLabel}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
);
|
||||||
<Badge variant="secondary">
|
})}
|
||||||
Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'}
|
</div>
|
||||||
</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: {
|
connection: {
|
||||||
supportsOAuth: true,
|
supportsOAuth: true,
|
||||||
supportsApiKey: 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',
|
configuredAuthMode: 'api_key',
|
||||||
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
|
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
|
||||||
apiKeyBetaEnabled: providerId === 'codex' ? true : undefined,
|
apiKeyBetaEnabled: providerId === 'codex' ? true : undefined,
|
||||||
|
|
@ -181,9 +182,7 @@ function createApiKeyMisconfiguredProvider(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApiKeyModeProviderIssue(
|
function createApiKeyModeProviderIssue(providerId: 'anthropic' | 'codex'): Record<string, unknown> {
|
||||||
providerId: 'anthropic' | 'codex'
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
return {
|
||||||
...createApiKeyMisconfiguredProvider(providerId),
|
...createApiKeyMisconfiguredProvider(providerId),
|
||||||
statusMessage:
|
statusMessage:
|
||||||
|
|
@ -191,8 +190,8 @@ function createApiKeyModeProviderIssue(
|
||||||
? 'Anthropic API key was rejected by the runtime.'
|
? 'Anthropic API key was rejected by the runtime.'
|
||||||
: 'OpenAI API key was rejected by the runtime.',
|
: 'OpenAI API key was rejected by the runtime.',
|
||||||
connection: {
|
connection: {
|
||||||
...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record<string, unknown> })
|
...(createApiKeyMisconfiguredProvider(providerId) as { connection: Record<string, unknown> })
|
||||||
.connection),
|
.connection,
|
||||||
apiKeyConfigured: true,
|
apiKeyConfigured: true,
|
||||||
apiKeySource: 'stored',
|
apiKeySource: 'stored',
|
||||||
apiKeySourceLabel:
|
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 () => {
|
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
storeState.fetchCliProviderStatus = vi.fn(() =>
|
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
||||||
Promise.reject(new Error('refresh failed'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
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 () => {
|
it('shows a degraded runtime warning when a binary is found but the health check fails', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
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 () => {
|
it('preserves settings runtime backend refresh errors for the manage dialog', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
storeState.fetchCliProviderStatus = vi.fn(() =>
|
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
||||||
Promise.reject(new Error('refresh failed'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
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('Already logged in?');
|
||||||
expect(host.textContent).not.toContain('Login');
|
expect(host.textContent).not.toContain('Login');
|
||||||
|
|
||||||
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
const manageButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
(button) => button.textContent?.includes('Manage Providers')
|
button.textContent?.includes('Manage Providers')
|
||||||
);
|
);
|
||||||
expect(manageButton).not.toBeUndefined();
|
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