From 6a9f281eca21013ba1edd3fc6fc57cdc7f241d41 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 23:24:09 +0300 Subject: [PATCH] fix(opencode): preserve loading state during runtime hydration --- src/main/ipc/cliInstaller.ts | 9 +- .../infrastructure/CliInstallerService.ts | 6 +- .../runtime/providerConnectionUi.ts | 17 ++ src/renderer/store/index.ts | 34 ++- .../store/slices/cliInstallerSlice.ts | 105 ++++++- .../CliInstallerService.test.ts | 28 ++ .../runtime/providerConnectionUi.test.ts | 64 +++++ test/renderer/store/cliInstallerSlice.test.ts | 265 +++++++++++++++++- 8 files changed, 517 insertions(+), 11 deletions(-) diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 2a2794e8..14fd7d7c 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -122,9 +122,14 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo return; } - const nextProviders = cachedStatus.value.providers.map((provider) => - provider.providerId === providerStatus.providerId ? providerStatus : provider + const hasProvider = cachedStatus.value.providers.some( + (provider) => provider.providerId === providerStatus.providerId ); + const nextProviders = hasProvider + ? cachedStatus.value.providers.map((provider) => + provider.providerId === providerStatus.providerId ? providerStatus : provider + ) + : [...cachedStatus.value.providers, providerStatus]; const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null; cachedStatus = { diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 9d78e8aa..60f24614 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -499,6 +499,10 @@ export class CliInstallerService { providerId: 'gemini', displayName: 'Gemini', }, + { + providerId: 'opencode', + displayName: 'OpenCode', + }, ] as const ).map((provider) => ({ ...provider, @@ -510,7 +514,7 @@ export class CliInstallerService { statusMessage: 'Checking...', models: [], modelAvailability: [], - canLoginFromUi: true, + canLoginFromUi: provider.providerId !== 'opencode', capabilities: { teamLaunch: false, oneShot: false, diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index 7c9d1fb1..19b17799 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -86,6 +86,19 @@ function getSelectedRuntimeBackendOption( ); } +export function isProviderInventoryOnlyFallback(provider: CliProviderStatus): boolean { + return ( + provider.supported === false && + provider.authenticated === false && + provider.authMethod === null && + provider.verificationState === 'unknown' && + provider.models.length > 0 && + provider.backend == null && + (provider.availableBackends?.length ?? 0) === 0 && + provider.capabilities.teamLaunch === false + ); +} + export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean { return provider.providerId === 'codex'; } @@ -146,6 +159,10 @@ export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): s } export function formatProviderStatusText(provider: CliProviderStatus): string { + if (isProviderInventoryOnlyFallback(provider)) { + return 'Checking...'; + } + const selectedBackendOption = getSelectedRuntimeBackendOption(provider); if (provider.providerId === 'codex') { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 0de210fa..ebba97a7 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -17,7 +17,12 @@ import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; -import { createCliInstallerSlice } from './slices/cliInstallerSlice'; +import { + createCliInstallerSlice, + getIncompleteMultimodelProviderIds, + getModelOnlyFallbackProviderIds, + mergeCliStatusPreservingHydratedProviders, +} from './slices/cliInstallerSlice'; import { createConfigSlice } from './slices/configSlice'; import { createConnectionSlice } from './slices/connectionSlice'; import { createContextSlice } from './slices/contextSlice'; @@ -50,6 +55,7 @@ import type { AppState } from './types'; import type { ActiveToolCall, CliInstallerProgress, + CliProviderId, LeadContextUsage, ScheduleChangeEvent, TeamChangeEvent, @@ -1456,7 +1462,31 @@ export function initializeNotificationListeners(): () => void { break; case 'status': if (progress.status) { - useStore.setState({ cliStatus: progress.status }); + let modelOnlyFallbackProviderIds: CliProviderId[] = []; + useStore.setState((state) => { + const nextStatus = mergeCliStatusPreservingHydratedProviders( + 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, + }; + }); + for (const providerId of modelOnlyFallbackProviderIds) { + void useStore.getState().fetchCliProviderStatus(providerId, { silent: false }); + } } break; } diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index dba1861f..1924562f 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -67,11 +67,28 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { }; } +function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus): boolean { + return ( + provider.supported === false && + provider.authenticated === false && + provider.authMethod === null && + provider.verificationState === 'unknown' && + provider.models.length > 0 && + provider.backend == null && + (provider.availableBackends?.length ?? 0) === 0 && + provider.capabilities.teamLaunch === false + ); +} + function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean { if (!provider) { return false; } + if (isModelOnlyFallbackProviderStatus(provider)) { + return false; + } + return !( provider.supported === false && provider.authenticated === false && @@ -80,11 +97,81 @@ function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefi provider.statusMessage === 'Checking...' && provider.models.length === 0 && provider.backend == null && - (provider.availableBackends?.length ?? 0) === 0 && - provider.connection == null + (provider.availableBackends?.length ?? 0) === 0 ); } +export function getIncompleteMultimodelProviderIds( + status: CliInstallationStatus | null +): CliProviderId[] { + if (!status || status.flavor !== 'agent_teams_orchestrator' || !status.installed) { + return []; + } + + return status.providers + .filter((provider) => !isHydratedMultimodelProviderStatus(provider)) + .map((provider) => provider.providerId); +} + +export function getModelOnlyFallbackProviderIds( + status: CliInstallationStatus | null +): CliProviderId[] { + if (!status || status.flavor !== 'agent_teams_orchestrator' || !status.installed) { + return []; + } + + return status.providers + .filter((provider) => isModelOnlyFallbackProviderStatus(provider)) + .map((provider) => provider.providerId); +} + +export function mergeCliStatusPreservingHydratedProviders( + current: CliInstallationStatus | null, + incoming: CliInstallationStatus +): CliInstallationStatus { + if ( + !current || + current.flavor !== 'agent_teams_orchestrator' || + incoming.flavor !== 'agent_teams_orchestrator' + ) { + return incoming; + } + + const currentProvidersById = new Map( + current.providers.map((provider) => [provider.providerId, provider]) + ); + const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId)); + const providers = incoming.providers.map((incomingProvider) => { + const currentProvider = currentProvidersById.get(incomingProvider.providerId); + if ( + currentProvider && + isHydratedMultimodelProviderStatus(currentProvider) && + !isHydratedMultimodelProviderStatus(incomingProvider) + ) { + return currentProvider; + } + return incomingProvider; + }); + + for (const currentProvider of current.providers) { + if ( + !incomingProviderIds.has(currentProvider.providerId) && + isHydratedMultimodelProviderStatus(currentProvider) + ) { + providers.push(currentProvider); + } + } + + const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null; + + return { + ...incoming, + providers, + authLoggedIn: providers.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }; +} + // ============================================================================= // Slice Interface // ============================================================================= @@ -178,8 +265,13 @@ export const createCliInstallerSlice: StateCreator 0, }, @@ -270,7 +362,10 @@ export const createCliInstallerSlice: StateCreator ({ + cliStatus: mergeCliStatusPreservingHydratedProviders(state.cliStatus, status), + cliProviderStatusLoading: {}, + })); if (status.installed) { for (const provider of status.providers) { void get().fetchCliProviderStatus(provider.providerId, { diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 9fb174f3..9a657f46 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -128,6 +128,34 @@ describe('CliInstallerService', () => { expect(status.updateAvailable).toBe(false); }); + it('includes OpenCode in unavailable multimodel bootstrap status', async () => { + allowConsoleLogs(); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null); + + const status = await service.getStatus(); + const openCodeStatus = status.providers.find((provider) => provider.providerId === 'opencode'); + + expect(status.providers.map((provider) => provider.providerId)).toEqual([ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + ]); + expect(openCodeStatus).toMatchObject({ + displayName: 'OpenCode', + supported: false, + statusMessage: 'Runtime not found.', + canLoginFromUi: false, + }); + }); + it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index c52984de..7e63ab0b 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -5,6 +5,7 @@ import { getProviderConnectionModeSummary, getProviderCredentialSummary, getProviderCurrentRuntimeSummary, + isProviderInventoryOnlyFallback, isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; @@ -134,6 +135,41 @@ function createCodexProvider( }; } +function createOpenCodeProvider( + overrides?: Partial +): CliProviderStatus { + return { + providerId: 'opencode', + displayName: 'OpenCode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['opencode/minimax-m2.5-free'], + modelAvailability: [], + modelVerificationState: 'idle', + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: { + kind: 'opencode-cli', + label: 'OpenCode CLI', + authMethodDetail: 'ok', + }, + connection: null, + ...overrides, + }; +} + describe('providerConnectionUi', () => { it('hides Anthropic preferred auth summary once the provider is already authenticated', () => { const provider = createAnthropicProvider({ @@ -297,6 +333,34 @@ describe('providerConnectionUi', () => { expect(formatProviderStatusText(provider)).toBe('Codex native ready'); }); + it('treats OpenCode inventory-only fallback as still loading', () => { + const provider = createOpenCodeProvider({ + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + connection: { + supportsOAuth: false, + supportsApiKey: false, + configurableAuthModes: [], + configuredAuthMode: null, + apiKeyConfigured: false, + apiKeySource: null, + }, + }); + + expect(isProviderInventoryOnlyFallback(provider)).toBe(true); + expect(formatProviderStatusText(provider)).toBe('Checking...'); + }); + it('surfaces degraded ChatGPT verification warnings instead of flattening them to ready', () => { const provider = createCodexProvider({ authenticated: false, diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 222d2f55..9b0b5ef2 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -51,6 +51,11 @@ vi.mock('@renderer/api', () => ({ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { + getIncompleteMultimodelProviderIds, + getModelOnlyFallbackProviderIds, + mergeCliStatusPreservingHydratedProviders, +} from '@renderer/store/slices/cliInstallerSlice'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { CliInstallationStatus } from '@shared/types'; @@ -78,7 +83,14 @@ function createMultimodelProvider( extensions: createDefaultCliExtensionCapabilities(), }, backend: null, - connection: null, + connection: { + supportsOAuth: false, + supportsApiKey: false, + configurableAuthModes: [], + configuredAuthMode: null, + apiKeyConfigured: false, + apiKeySource: null, + }, selectedBackendId: null, resolvedBackendId: null, availableBackends: [], @@ -86,6 +98,29 @@ function createMultimodelProvider( }; } +function createMultimodelStatus( + providers: CliInstallationStatus['providers'] +): CliInstallationStatus { + const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null; + + return { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: true, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/Users/belief/.agent-teams/runtime-cache/0.0.3/darwin-arm64/claude-multimodel', + latestVersion: null, + updateAvailable: false, + authLoggedIn: providers.some((provider) => provider.authenticated), + authStatusChecking: false, + authMethod: authenticatedProvider?.authMethod ?? null, + providers, + }; +} + describe('cliInstallerSlice', () => { beforeEach(() => { vi.clearAllMocks(); @@ -115,6 +150,145 @@ describe('cliInstallerSlice', () => { }); }); + describe('mergeCliStatusPreservingHydratedProviders', () => { + it('does not let model-only OpenCode fallback overwrite hydrated runtime status', () => { + const current = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }), + ]); + const incoming = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + availableBackends: [], + }), + ]); + + const merged = mergeCliStatusPreservingHydratedProviders(current, incoming); + + expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject({ + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }); + }); + + it('classifies model-only OpenCode fallback as incomplete for progress events', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + availableBackends: [], + }), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual(['opencode']); + expect(getModelOnlyFallbackProviderIds(status)).toEqual(['opencode']); + }); + + it('keeps connection-enriched checking placeholders incomplete until provider hydration finishes', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + availableBackends: [], + }), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual(['opencode']); + expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); + }); + + it('still allows real OpenCode runtime errors to replace previous ready status', () => { + const current = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }), + ]); + const incoming = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: 'Runtime not found.', + models: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + }), + ]); + + const merged = mergeCliStatusPreservingHydratedProviders(current, incoming); + + expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject({ + supported: false, + authenticated: false, + verificationState: 'error', + statusMessage: 'Runtime not found.', + }); + }); + }); + describe('fetchCliStatus', () => { it('updates cliStatus from API', async () => { const mockStatus: CliInstallationStatus = { @@ -412,6 +586,95 @@ describe('cliInstallerSlice', () => { statusMessage: 'ChatGPT account ready', }); }); + + it('refreshes OpenCode when bootstrap metadata only has fallback models', async () => { + const mockStatus: CliInstallationStatus = { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: true, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/Users/belief/.agent-teams/runtime-cache/0.0.3/darwin-arm64/claude-multimodel', + latestVersion: null, + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: true, + authMethod: 'oauth_token', + providers: [ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected', + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + statusMessage: 'Codex unavailable', + }), + createMultimodelProvider({ + providerId: 'gemini', + displayName: 'Gemini', + statusMessage: 'Ready', + }), + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + availableBackends: [], + }), + ], + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => { + if (providerId === 'opencode') { + return createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }); + } + throw new Error(`Unexpected provider status request for ${providerId}`); + }); + + await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1); + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledWith('opencode'); + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + codex: false, + gemini: false, + opencode: false, + }); + expect(useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'opencode')) + .toMatchObject({ + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }); + }); }); describe('installCli', () => {