Merge pull request #179 from 777genius/fix/windows-provider-status-fallback
fix: add Windows provider status fallback
This commit is contained in:
commit
bb18979d60
3 changed files with 455 additions and 62 deletions
|
|
@ -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<string, ProviderStatusPayloadResponse>;
|
||||
}
|
||||
|
||||
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<CliProviderStatus> {
|
||||
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<ProviderStatusCommandResponse>(
|
||||
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<ProviderModelsCommandResponse>(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<CliProviderStatus> {
|
||||
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<UnifiedRuntimeStatusResponse['providers']>[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue