From 21404894c2183039c2345539c83a548ec8e6bb90 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 27 May 2026 21:54:24 +0300 Subject: [PATCH] fix: add Windows provider status fallback --- .../runtime/ClaudeMultimodelBridgeService.ts | 276 +++++++++++++++--- .../ClaudeMultimodelBridgeService.test.ts | 182 ++++++++++-- .../cli/CliStatusVisibility.test.ts | 59 +++- 3 files changed, 455 insertions(+), 62 deletions(-) diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 448519cc..e9f5aac0 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -25,6 +25,8 @@ const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 90_000; const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000; +const LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 5_000; +const LEGACY_PROVIDER_AUTH_TIMEOUT_MS = 15_000; const PROVIDER_MODELS_TIMEOUT_MS = 25_000; const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; @@ -112,34 +114,35 @@ interface RuntimeProviderModelCatalogResponse { }; } +interface ProviderStatusPayloadResponse { + supported?: boolean; + authenticated?: boolean; + authMethod?: string | null; + verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; + canLoginFromUi?: boolean; + statusMessage?: string | null; + detailMessage?: string | null; + capabilities?: { + teamLaunch?: boolean; + oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; + }; + backend?: { + kind?: string; + label?: string; + endpointLabel?: string | null; + projectId?: string | null; + authMethodDetail?: string | null; + } | null; + runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; + subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; - providers?: Record< - string, - { - supported?: boolean; - authenticated?: boolean; - authMethod?: string | null; - verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; - canLoginFromUi?: boolean; - statusMessage?: string | null; - detailMessage?: string | null; - capabilities?: { - teamLaunch?: boolean; - oneShot?: boolean; - extensions?: RuntimeExtensionCapabilitiesResponse; - }; - backend?: { - kind?: string; - label?: string; - endpointLabel?: string | null; - projectId?: string | null; - authMethodDetail?: string | null; - } | null; - runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; - subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null; - } - >; + provider?: string; + status?: ProviderStatusPayloadResponse; + providers?: Record; } interface ProviderModelsCommandResponse { @@ -879,6 +882,170 @@ export class ClaudeMultimodelBridgeService { return lower.includes('timed out') || lower.includes('timeout'); } + private shouldUseLegacyProviderTimeoutFallback(providerId: CliProviderId): boolean { + return providerId === 'anthropic' || providerId === 'codex'; + } + + private getProviderStatusRuntimeTimeout( + providerId: CliProviderId, + options: { summary?: boolean; timeoutMs?: number } + ): number { + if (options.summary && this.shouldUseLegacyProviderTimeoutFallback(providerId)) { + return Math.min( + options.timeoutMs ?? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS, + LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS + ); + } + return ( + options.timeoutMs ?? + (options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS) + ); + } + + private getLegacyProviderStatusPayload( + providerId: CliProviderId, + parsed: ProviderStatusCommandResponse + ): ProviderStatusPayloadResponse | undefined { + if (parsed.providers?.[providerId]) { + return parsed.providers[providerId]; + } + return parsed.provider === providerId ? parsed.status : undefined; + } + + private mergeLegacyProviderStatusPayload( + provider: CliProviderStatus, + runtimeStatus: ProviderStatusPayloadResponse | undefined + ): CliProviderStatus { + if (!runtimeStatus) { + return provider; + } + + return { + ...provider, + supported: runtimeStatus.supported === true, + authenticated: runtimeStatus.authenticated === true, + authMethod: runtimeStatus.authMethod ?? null, + verificationState: runtimeStatus.verificationState ?? 'unknown', + statusMessage: runtimeStatus.statusMessage ?? null, + detailMessage: runtimeStatus.detailMessage ?? null, + canLoginFromUi: runtimeStatus.canLoginFromUi !== false, + capabilities: { + teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, + oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities( + provider.providerId, + runtimeStatus.capabilities?.extensions + ), + }, + backend: runtimeStatus.backend?.kind + ? { + kind: runtimeStatus.backend.kind, + label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind, + endpointLabel: runtimeStatus.backend.endpointLabel ?? null, + projectId: runtimeStatus.backend.projectId ?? null, + authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null, + } + : null, + }; + } + + private async getProviderStatusFromLegacyProbes( + binaryPath: string, + providerId: CliProviderId + ): Promise { + const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId); + let provider = createDefaultProviderStatus(providerId); + let fulfilledProbeCount = 0; + + const authStatusPromise = + providerId === 'anthropic' || providerId === 'codex' + ? execCli(binaryPath, ['auth', 'status', '--json', '--provider', providerId], { + timeout: LEGACY_PROVIDER_AUTH_TIMEOUT_MS, + maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES, + env, + }) + : Promise.resolve(null); + + const modelListPromise = execCli( + binaryPath, + ['model', 'list', '--json', '--provider', providerId], + { + timeout: PROVIDER_MODELS_TIMEOUT_MS, + maxBuffer: PROVIDER_MODELS_MAX_BUFFER_BYTES, + env, + } + ); + + const [authStatusResult, modelListResult] = await Promise.allSettled([ + authStatusPromise, + modelListPromise, + ]); + + if (authStatusResult.status === 'fulfilled' && authStatusResult.value) { + const parsed = extractJsonObject( + authStatusResult.value.stdout + ); + provider = this.mergeLegacyProviderStatusPayload( + provider, + this.getLegacyProviderStatusPayload(providerId, parsed) + ); + fulfilledProbeCount += 1; + } else if (authStatusResult.status === 'rejected') { + logger.warn( + `Legacy provider auth status unavailable for ${providerId}: ${ + authStatusResult.reason instanceof Error + ? authStatusResult.reason.message + : String(authStatusResult.reason) + }` + ); + } + + if (modelListResult.status === 'fulfilled') { + const parsed = extractJsonObject(modelListResult.value.stdout); + const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models); + if (runtimeModels.length > 0) { + provider = { + ...provider, + models: runtimeModels, + }; + } + fulfilledProbeCount += 1; + } else { + logger.warn( + `Legacy provider models unavailable for ${providerId}: ${ + modelListResult.reason instanceof Error + ? modelListResult.reason.message + : String(modelListResult.reason) + }` + ); + } + + if (fulfilledProbeCount === 0) { + throw new Error(`Legacy provider probes unavailable for ${providerId}`); + } + + return providerConnectionService.enrichProviderStatus( + this.applyConnectionIssue(provider, connectionIssues) + ); + } + + private async getProviderStatusFromLegacyProbesOrError( + binaryPath: string, + providerId: CliProviderId, + originalError: unknown + ): Promise { + try { + return await this.getProviderStatusFromLegacyProbes(binaryPath, providerId); + } catch (fallbackError) { + logger.warn( + `Legacy provider probes unavailable for ${providerId}: ${ + fallbackError instanceof Error ? fallbackError.message : String(fallbackError) + }` + ); + return createRuntimeStatusErrorProviderStatus(providerId, originalError); + } + } + private mapRuntimeProviderStatus( providerId: CliProviderId, runtimeStatus: NonNullable[string] | undefined @@ -1024,9 +1191,7 @@ export class ClaudeMultimodelBridgeService { if (options.summary) { args.push('--summary'); } - const timeout = - options.timeoutMs ?? - (options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS); + const timeout = this.getProviderStatusRuntimeTimeout(providerId, options); const { stdout } = await execCli(binaryPath, args, { timeout, maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES, @@ -1081,6 +1246,7 @@ export class ClaudeMultimodelBridgeService { } }) ); + failures.sort((a, b) => providerIds.indexOf(a.providerId) - providerIds.indexOf(b.providerId)); if (failures.length === 0) { return this.buildProviderStatusesSnapshot(providers, providerIds); @@ -1091,10 +1257,18 @@ export class ClaudeMultimodelBridgeService { logger.warn( `Provider-scoped runtime status timed out for ${failures .map(({ providerId }) => providerId) - .join(', ')}; using error provider statuses without slower fallback probes` + .join(', ')}; falling back to scoped legacy provider probes` ); - for (const { providerId, error } of failures) { - providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error)); + const fallbackProviders = await Promise.all( + failures.map(async ({ providerId, error }) => ({ + providerId, + provider: this.shouldUseLegacyProviderTimeoutFallback(providerId) + ? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error) + : createRuntimeStatusErrorProviderStatus(providerId, error), + })) + ); + for (const { providerId, provider } of fallbackProviders) { + providers.set(providerId, provider); } onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds)); return this.buildProviderStatusesSnapshot(providers, providerIds); @@ -1109,8 +1283,18 @@ export class ClaudeMultimodelBridgeService { .join(', ')}; using partial provider statuses` ); - for (const { providerId, error } of failures) { - providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error)); + const fallbackProviders = await Promise.all( + failures.map(async ({ providerId, error }) => ({ + providerId, + provider: + this.isRuntimeStatusTimeoutError(error) && + this.shouldUseLegacyProviderTimeoutFallback(providerId) + ? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error) + : createRuntimeStatusErrorProviderStatus(providerId, error), + })) + ); + for (const { providerId, provider } of fallbackProviders) { + providers.set(providerId, provider); } onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds)); return this.buildProviderStatusesSnapshot(providers, providerIds); @@ -1322,6 +1506,17 @@ export class ClaudeMultimodelBridgeService { try { return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId); } catch (fullError) { + if ( + this.isRuntimeStatusTimeoutError(fullError) && + this.shouldUseLegacyProviderTimeoutFallback(providerId) + ) { + logger.warn( + `Provider-scoped full runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${ + fullError instanceof Error ? fullError.message : String(fullError) + }` + ); + return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, fullError); + } logger.warn( `Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${ fullError instanceof Error ? fullError.message : String(fullError) @@ -1332,10 +1527,21 @@ export class ClaudeMultimodelBridgeService { } logger.warn( - `Provider-scoped summary runtime status unavailable for ${providerId}, returning scoped error: ${ + `Provider-scoped summary runtime status unavailable for ${providerId}: ${ error instanceof Error ? error.message : String(error) }` ); + if ( + this.isRuntimeStatusTimeoutError(error) && + this.shouldUseLegacyProviderTimeoutFallback(providerId) + ) { + logger.warn( + `Provider-scoped summary runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error); + } return createRuntimeStatusErrorProviderStatus(providerId, error); } } diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 739f6296..09b00c33 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -281,7 +281,7 @@ describe('ClaudeMultimodelBridgeService', () => { }, } as const; - execCliMock.mockImplementation((_binaryPath, args) => { + execCliMock.mockImplementation((_binaryPath, args, _options) => { const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1; const providerId = @@ -344,16 +344,50 @@ describe('ClaudeMultimodelBridgeService', () => { expect(calls).not.toContain('model list --json --provider all'); }); - it('returns a scoped provider error when single-provider summary status times out', async () => { - execCliMock.mockImplementation((_binaryPath, args) => { + it('falls back to scoped legacy provider probes when single-provider summary status times out', async () => { + execCliMock.mockImplementation((_binaryPath, args, options) => { const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; if (normalizedArgs === 'runtime status --json --provider codex --summary') { return Promise.reject( new Error( - 'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary' + `Command timed out after ${options?.timeout}ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary` ) ); } + if (normalizedArgs === 'auth status --json --provider codex') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + provider: 'codex', + status: { + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + canLoginFromUi: false, + statusMessage: 'Codex native runtime unavailable', + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + }), + stderr: '', + }); + } + if (normalizedArgs === 'model list --json --provider codex') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }], + }, + }, + }), + stderr: '', + }); + } return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); }); @@ -367,16 +401,23 @@ describe('ClaudeMultimodelBridgeService', () => { expect(provider).toMatchObject({ providerId: 'codex', - verificationState: 'error', - statusMessage: 'Provider status unavailable', + supported: true, + authenticated: false, + verificationState: 'unknown', + statusMessage: 'Codex native runtime unavailable', + models: ['gpt-5.4'], }); - expect(provider.detailMessage).toContain('Command timed out after 30000ms'); - expect(calls).toEqual(['runtime status --json --provider codex --summary']); - expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([ - expect.stringContaining( - 'Provider-scoped summary runtime status unavailable for codex, returning scoped error' - ), + expect(calls).toEqual([ + 'runtime status --json --provider codex --summary', + 'auth status --json --provider codex', + 'model list --json --provider codex', ]); + expect(execCliMock.mock.calls[0][2]?.timeout).toBe(5000); + expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual( + expect.arrayContaining([ + expect.stringContaining('Provider-scoped summary runtime status timed out for codex'), + ]) + ); vi.mocked(console.warn).mockClear(); }); @@ -480,7 +521,7 @@ describe('ClaudeMultimodelBridgeService', () => { ]); }); - it('does not cascade aggregate summary timeouts into slower fallback probes', async () => { + it('falls back to scoped legacy probes for Anthropic and Codex aggregate summary timeouts', async () => { execCliMock.mockImplementation((_binaryPath, args, options) => { const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; if ( @@ -494,6 +535,73 @@ describe('ClaudeMultimodelBridgeService', () => { ) ); } + if (normalizedArgs === 'auth status --json --provider anthropic') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + provider: 'anthropic', + status: { + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + }), + stderr: '', + }); + } + if (normalizedArgs === 'model list --json --provider anthropic') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + anthropic: { + models: [{ id: 'opus[1m]', label: 'Opus 4.7 (1M)' }], + }, + }, + }), + stderr: '', + }); + } + if (normalizedArgs === 'auth status --json --provider codex') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + provider: 'codex', + status: { + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + canLoginFromUi: false, + statusMessage: 'Codex native runtime unavailable', + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + }), + stderr: '', + }); + } + if (normalizedArgs === 'model list --json --provider codex') { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }], + }, + }, + }), + stderr: '', + }); + } return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); }); @@ -505,22 +613,46 @@ describe('ClaudeMultimodelBridgeService', () => { const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator'); const calls = execCliMock.mock.calls.map((call) => call[1].join(' ')); - expect(execCliMock).toHaveBeenCalledTimes(3); - expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([30000, 30000, 30000]); - expect(calls).toEqual([ - 'runtime status --json --provider anthropic --summary', - 'runtime status --json --provider codex --summary', - 'runtime status --json --provider opencode --summary', - ]); + expect(execCliMock).toHaveBeenCalledTimes(7); + expect( + execCliMock.mock.calls.map((call) => call[2]?.timeout as number).sort((a, b) => a - b) + ).toEqual([5000, 5000, 15000, 15000, 25000, 25000, 30000]); + expect(calls).toEqual( + expect.arrayContaining([ + 'runtime status --json --provider anthropic --summary', + 'runtime status --json --provider codex --summary', + 'runtime status --json --provider opencode --summary', + 'auth status --json --provider anthropic', + 'model list --json --provider anthropic', + 'auth status --json --provider codex', + 'model list --json --provider codex', + ]) + ); expect(providers.map((provider) => provider.providerId)).toEqual([ 'anthropic', 'codex', 'opencode', ]); - expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true); - expect( - providers.every((provider) => provider.statusMessage === 'Provider status unavailable') - ).toBe(true); + expect(providers[0]).toMatchObject({ + providerId: 'anthropic', + supported: true, + authenticated: true, + verificationState: 'verified', + models: ['opus[1m]'], + }); + expect(providers[1]).toMatchObject({ + providerId: 'codex', + supported: true, + authenticated: false, + verificationState: 'unknown', + statusMessage: 'Codex native runtime unavailable', + models: ['gpt-5.4'], + }); + expect(providers[2]).toMatchObject({ + providerId: 'opencode', + verificationState: 'error', + statusMessage: 'Provider status unavailable', + }); expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([ expect.stringContaining( 'Provider-scoped runtime status timed out for anthropic, codex, opencode' @@ -1016,7 +1148,7 @@ describe('ClaudeMultimodelBridgeService', () => { execCliMock.mock.calls.find( (call) => call[1].join(' ') === 'runtime status --json --provider codex --summary' )?.[2]?.timeout - ).toBe(30_000); + ).toBe(5_000); expect( execCliMock.mock.calls.find( (call) => call[1].join(' ') === 'runtime status --json --provider codex' diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index b6168105..6c6efd16 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -583,6 +583,62 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('renders Anthropic legacy fallback status as connected with model badges', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + authStatusChecking: false, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: null, + models: ['opus', 'opus[1m]'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: null, + modelCatalog: null, + modelCatalogRefreshState: 'idle', + runtimeCapabilities: null, + }, + ], + }); + + 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).toContain('Anthropic'); + expect(host.textContent).toContain('Connected via'); + expect(host.textContent).toContain('Opus'); + expect(host.textContent).not.toContain('Provider status unavailable'); + expect(host.textContent).not.toContain('Models unavailable for this runtime build'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps connected provider details visible while a refresh is in flight', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -752,8 +808,7 @@ describe('CLI status visibility during completed install state', () => { state: 'runtime-missing', available: false, selectable: false, - statusMessage: - 'Codex CLI not found. Install Codex to use native account management.', + statusMessage: 'Codex CLI not found. Install Codex to use native account management.', detailMessage: 'Codex native runtime is missing.', models: [], }),