diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 85c26aa4..44b7ca31 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -353,6 +353,26 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat }; } +function createPendingProviderStatus(providerId: CliProviderId): CliProviderStatus { + return { + ...createDefaultProviderStatus(providerId), + statusMessage: 'Checking...', + }; +} + +function createRuntimeStatusErrorProviderStatus( + providerId: CliProviderId, + error: unknown +): CliProviderStatus { + const message = error instanceof Error ? error.message : String(error); + return { + ...createDefaultProviderStatus(providerId), + verificationState: 'error', + statusMessage: 'Provider status unavailable', + detailMessage: message, + }; +} + function mapRuntimeExtensionCapabilities( providerId: CliProviderId, capabilities?: RuntimeExtensionCapabilitiesResponse @@ -668,6 +688,97 @@ export class ClaudeMultimodelBridgeService { return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); } + private buildProviderStatusesSnapshot( + providers: Map + ): CliProviderStatus[] { + return ORDERED_PROVIDER_IDS.map( + (providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId) + ); + } + + private async getProviderStatusFromRuntimeStatusCommand( + binaryPath: string, + providerId: CliProviderId, + env: NodeJS.ProcessEnv, + connectionIssues: Partial> + ): Promise { + const { stdout } = await execCli( + binaryPath, + ['runtime', 'status', '--json', '--provider', providerId], + { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + return providerConnectionService.enrichProviderStatus( + this.applyConnectionIssue( + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), + connectionIssues + ) + ); + } + + private async getProviderStatusFromScopedRuntimeStatus( + binaryPath: string, + providerId: CliProviderId + ): Promise { + const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId); + return this.getProviderStatusFromRuntimeStatusCommand( + binaryPath, + providerId, + env, + connectionIssues + ); + } + + private async getProviderStatusesFromScopedRuntimeStatus( + binaryPath: string, + onUpdate?: (providers: CliProviderStatus[]) => void + ): Promise { + const providers = new Map( + ORDERED_PROVIDER_IDS.map((providerId) => [ + providerId, + createPendingProviderStatus(providerId), + ]) + ); + const failures: { providerId: CliProviderId; error: unknown }[] = []; + + await Promise.all( + ORDERED_PROVIDER_IDS.map(async (providerId) => { + try { + providers.set( + providerId, + await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId) + ); + onUpdate?.(this.buildProviderStatusesSnapshot(providers)); + } catch (error) { + failures.push({ providerId, error }); + } + }) + ); + + if (failures.length === 0) { + return this.buildProviderStatusesSnapshot(providers); + } + + if (failures.length === ORDERED_PROVIDER_IDS.length) { + return null; + } + + logger.warn( + `Provider-scoped runtime status failed for ${failures + .map(({ providerId }) => providerId) + .join(', ')}; using partial provider statuses` + ); + + for (const { providerId, error } of failures) { + providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error)); + } + onUpdate?.(this.buildProviderStatusesSnapshot(providers)); + return this.buildProviderStatusesSnapshot(providers); + } + private async getOpenCodeVerifySnapshot( binaryPath: string ): Promise { @@ -761,24 +872,9 @@ export class ClaudeMultimodelBridgeService { providerId: CliProviderId ): Promise { await resolveInteractiveShellEnv(); - const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { - const { stdout } = await execCli( - binaryPath, - ['runtime', 'status', '--json', '--provider', providerId], - { - timeout: PROVIDER_STATUS_TIMEOUT_MS, - env, - } - ); - const parsed = extractJsonObject(stdout); - return providerConnectionService.enrichProviderStatus( - this.applyConnectionIssue( - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), - connectionIssues - ) - ); + return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId); } catch (error) { if (!this.isUnifiedRuntimeUnsupported(error)) { logger.warn( @@ -937,6 +1033,20 @@ export class ClaudeMultimodelBridgeService { onUpdate?: (providers: CliProviderStatus[]) => void ): Promise { await resolveInteractiveShellEnv(); + + try { + const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate); + if (providers) { + return providers; + } + } catch (error) { + logger.warn( + `Provider-scoped runtime status unavailable, falling back to full probe: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 0b9424ff..75751dbc 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -19,9 +19,9 @@ import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; import { createCliInstallerSlice, - getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, + reconcileMultimodelProviderLoading, } from './slices/cliInstallerSlice'; import { createConfigSlice } from './slices/configSlice'; import { createConnectionSlice } from './slices/connectionSlice'; @@ -1485,20 +1485,14 @@ export function initializeNotificationListeners(): () => void { state.cliStatus, progress.status! ); - const incompleteProviderIds = getIncompleteMultimodelProviderIds(nextStatus); modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus); return { cliStatus: nextStatus, - cliProviderStatusLoading: - incompleteProviderIds.length > 0 - ? { - ...state.cliProviderStatusLoading, - ...Object.fromEntries( - incompleteProviderIds.map((providerId) => [providerId, true]) - ), - } - : state.cliProviderStatusLoading, + cliProviderStatusLoading: reconcileMultimodelProviderLoading( + nextStatus, + state.cliProviderStatusLoading + ), }; }); for (const providerId of modelOnlyFallbackProviderIds) { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index b52440d5..0cad9079 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -125,6 +125,24 @@ export function getModelOnlyFallbackProviderIds( .map((provider) => provider.providerId); } +export function reconcileMultimodelProviderLoading( + status: CliInstallationStatus | null, + currentLoading: Partial> +): Partial> { + if (status?.flavor !== 'agent_teams_orchestrator' || !status.installed) { + return {}; + } + + const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status)); + return status.providers.reduce>>( + (nextLoading, provider) => ({ + ...nextLoading, + [provider.providerId]: incompleteProviderIds.has(provider.providerId), + }), + { ...currentLoading } + ); +} + export function mergeCliStatusPreservingHydratedProviders( current: CliInstallationStatus | null, incoming: CliInstallationStatus diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 0bbc6195..5c38cb3b 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -233,6 +233,108 @@ describe('ClaudeMultimodelBridgeService', () => { }); }); + it('loads all providers with parallel provider-scoped runtime status probes', async () => { + const providerPayloads = { + anthropic: { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + models: ['claude-sonnet-4-5'], + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'anthropic', label: 'Anthropic' }, + }, + codex: { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + canLoginFromUi: false, + models: ['gpt-5-codex'], + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'codex-native', label: 'Codex native' }, + }, + gemini: { + supported: true, + authenticated: false, + verificationState: 'unknown', + canLoginFromUi: true, + statusMessage: 'No Gemini runtime backend is ready', + models: ['gemini-2.5-pro'], + capabilities: { teamLaunch: true, oneShot: true }, + }, + opencode: { + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + canLoginFromUi: false, + models: ['openai/gpt-5.4-mini'], + capabilities: { teamLaunch: true, oneShot: false }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }, + } as const; + + execCliMock.mockImplementation((_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1; + const providerId = + providerArgIndex >= 0 && Array.isArray(args) + ? (args[providerArgIndex + 1] as keyof typeof providerPayloads) + : null; + + if ( + normalizedArgs.startsWith('runtime status --json --provider ') && + providerId && + providerPayloads[providerId] + ) { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 2, + providers: { + [providerId]: providerPayloads[providerId], + }, + }), + stderr: '', + exitCode: 0, + }); + } + + return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + const onUpdate = vi.fn(); + + const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate); + + expect(execCliMock).toHaveBeenCalledTimes(4); + expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual( + expect.arrayContaining([ + 'runtime status --json --provider anthropic', + 'runtime status --json --provider codex', + 'runtime status --json --provider gemini', + 'runtime status --json --provider opencode', + ]) + ); + expect(providers.map((provider) => provider.providerId)).toEqual([ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + ]); + expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({ + authenticated: true, + models: ['gpt-5-codex'], + backend: { kind: 'codex-native' }, + }); + expect(onUpdate).toHaveBeenCalled(); + expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers); + }); + it('overrides provider auth status when provider-aware env reports a missing API key', async () => { buildProviderAwareCliEnvMock.mockResolvedValue({ env: { HOME: '/Users/tester' }, diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 237ee822..407fea56 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -55,6 +55,7 @@ import { getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, + reconcileMultimodelProviderLoading, } from '@renderer/store/slices/cliInstallerSlice'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -251,6 +252,44 @@ describe('cliInstallerSlice', () => { expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); }); + it('clears loading for hydrated providers while keeping pending providers marked', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: null, + models: ['claude-sonnet-4-5'], + backend: { kind: 'anthropic', label: 'Anthropic' }, + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + backend: null, + availableBackends: [], + }), + ]); + + expect( + reconcileMultimodelProviderLoading(status, { + anthropic: true, + codex: true, + opencode: true, + }) + ).toEqual({ + anthropic: false, + codex: true, + opencode: true, + }); + }); + it('still allows real OpenCode runtime errors to replace previous ready status', () => { const current = createMultimodelStatus([ createMultimodelProvider({