diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 09b4ed3f..3dff5729 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -556,6 +556,7 @@ import type { TeamMember, TeamProviderBackendId, TeamProviderId, + TeamProvisioningPrepareIssue, TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -17306,6 +17307,7 @@ export class TeamProvisioningService { const warnings: string[] = []; const details: string[] = []; const blockingMessages: string[] = []; + const issues: TeamProvisioningPrepareIssue[] = []; const selectedModelIds = Array.from( new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) ); @@ -17349,6 +17351,7 @@ export class TeamProvisioningService { details.push(...openCodeModelPrepare.details); warnings.push(...openCodeModelPrepare.warnings); blockingMessages.push(...openCodeModelPrepare.blockingMessages); + issues.push(...openCodeModelPrepare.issues); continue; } @@ -17512,6 +17515,7 @@ export class TeamProvisioningService { ? blockingMessages[0] : 'Some provider runtimes are not ready', warnings: failureWarnings.length > 0 ? failureWarnings : undefined, + issues: issues.length > 0 ? issues : undefined, }; } @@ -17527,6 +17531,7 @@ export class TeamProvisioningService { ? 'CLI is ready to launch (see notes)' : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, + issues: issues.length > 0 ? issues : undefined, }; } @@ -17544,14 +17549,16 @@ export class TeamProvisioningService { details: string[]; warnings: string[]; blockingMessages: string[]; + issues: TeamProvisioningPrepareIssue[]; }> { const details: string[] = []; const warnings: string[] = []; const blockingMessages: string[] = []; + const issues: TeamProvisioningPrepareIssue[] = []; const startedAt = Date.now(); if (modelIds.length === 0) { - return { details, warnings, blockingMessages }; + return { details, warnings, blockingMessages, issues }; } if (verificationMode === 'compatibility') { @@ -17662,6 +17669,16 @@ export class TeamProvisioningService { prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; + const issueSeverity = + prepare.retryable && verificationMode !== 'compatibility' ? 'warning' : 'blocking'; + issues.push({ + providerId: 'opencode', + modelId, + scope: 'model', + severity: issueSeverity, + code: prepare.reason, + message: primaryReason, + }); if (prepare.retryable) { warnings.push(verificationWarningLine); if (verificationMode === 'compatibility') { @@ -17685,7 +17702,7 @@ export class TeamProvisioningService { blockingMessages, }); - return { details, warnings, blockingMessages }; + return { details, warnings, blockingMessages, issues }; } private async prepareSelectedOpenCodeModelsCompatibilityBatch({ @@ -17700,10 +17717,12 @@ export class TeamProvisioningService { details: string[]; warnings: string[]; blockingMessages: string[]; + issues: TeamProvisioningPrepareIssue[]; } | null> { const details: string[] = []; const warnings: string[] = []; const blockingMessages: string[] = []; + const issues: TeamProvisioningPrepareIssue[] = []; const startedAt = Date.now(); appendPreflightDebugLog('opencode_compatibility_batch_start', { @@ -17749,18 +17768,20 @@ export class TeamProvisioningService { if (!sharedPrepare.ok) { const primaryReason = sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason; - for (const modelId of modelIds) { - const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; - const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; - if (sharedPrepare.retryable) { - warnings.push(verificationWarningLine); - blockingMessages.push(verificationWarningLine); - } else { - details.push(unavailableLine); - blockingMessages.push(unavailableLine); - } + if (primaryReason.trim().length > 0) { + details.push(primaryReason); + blockingMessages.push(primaryReason); + } else { + blockingMessages.push(`OpenCode: ${sharedPrepare.reason}`); } - return { details, warnings, blockingMessages }; + issues.push({ + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: sharedPrepare.reason, + message: primaryReason.trim() || `OpenCode: ${sharedPrepare.reason}`, + }); + return { details, warnings, blockingMessages, issues }; } const latestReadiness = @@ -17798,6 +17819,14 @@ export class TeamProvisioningService { const unavailableLine = `Selected model ${modelId} is unavailable. ${resolvedModel.reason}`; details.push(unavailableLine); blockingMessages.push(unavailableLine); + issues.push({ + providerId: 'opencode', + modelId, + scope: 'model', + severity: 'blocking', + code: 'model_unavailable', + message: resolvedModel.reason, + }); } appendPreflightDebugLog('opencode_compatibility_batch_complete', { @@ -17808,7 +17837,7 @@ export class TeamProvisioningService { details, }); - return { details, warnings, blockingMessages }; + return { details, warnings, blockingMessages, issues }; } private resolveOpenCodeCompatibilityModel( diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index f627372f..69e18d87 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -57,6 +57,20 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function uniquePrepareLines(lines: Array): string[] { + const seen = new Set(); + const uniqueLines: string[] = []; + for (const line of lines) { + const trimmed = line?.trim() ?? ''; + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + uniqueLines.push(trimmed); + } + return uniqueLines; +} + function getModelLabel(providerId: TeamProviderId, modelId: string): string { if (isDefaultProviderModelSelection(modelId)) { return 'Default'; @@ -205,6 +219,36 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu return trimmed; } +function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined): boolean { + const lower = reason?.trim().toLowerCase() ?? ''; + if (!lower) { + return false; + } + + return ( + lower.includes('opencode /experimental/tool') || + lower.includes('/experimental/tool') || + lower.includes('mcp_unavailable') || + lower.includes('unable to connect') || + lower.includes('runtime store') || + lower.includes('opencode cli') + ); +} + +function getBlockingProviderIssueMessage( + providerId: TeamProviderId, + result: TeamProvisioningPrepareResult +): string | null { + const issue = result.issues?.find( + (entry) => + entry.scope === 'provider' && + entry.severity === 'blocking' && + (!entry.providerId || entry.providerId === providerId) && + entry.message.trim().length > 0 + ); + return issue?.message.trim() ?? null; +} + function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null { const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message] .map((entry) => entry?.trim() ?? '') @@ -286,7 +330,18 @@ function buildModelFailureLine( } function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] { - return [...(result.details ?? []), ...(result.warnings ?? [])]; + return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]); +} + +function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] { + return uniquePrepareLines(result.warnings ?? []); +} + +function createRuntimeFailureDetailLines( + runtimeDetailLines: readonly string[], + message: string | null | undefined +): string[] { + return uniquePrepareLines([...runtimeDetailLines, message]); } function extractTimedOutPreflightProbeModelId(detail: string): string | null { @@ -545,7 +600,10 @@ function resolveModelResultFromCompatibilityBatch( const hasUnavailableLine = modelScopedEntries.some((entry) => /selected model .* is unavailable\./i.test(entry) ); - if (hasUnavailableLine || (!result.ready && isOnlyModel)) { + const hasVerificationWarningLine = modelScopedEntries.some((entry) => + /selected model .* could not be verified\./i.test(entry) + ); + if (hasUnavailableLine || (!result.ready && isOnlyModel && !hasVerificationWarningLine)) { return { kind: 'terminal', result: { @@ -561,9 +619,6 @@ function resolveModelResultFromCompatibilityBatch( }; } - const hasVerificationWarningLine = modelScopedEntries.some((entry) => - /selected model .* could not be verified\./i.test(entry) - ); if (hasVerificationWarningLine) { const line = buildModelFailureLine( providerId, @@ -574,9 +629,9 @@ function resolveModelResultFromCompatibilityBatch( return { kind: 'terminal', result: { - status: 'notes', + status: result.ready ? 'notes' : 'failed', line, - warningLine: line, + warningLine: result.ready ? line : null, }, }; } @@ -601,6 +656,84 @@ function resolveModelResultFromCompatibilityBatch( }; } +function looksLikeSingleModelCredentialFailure(result: TeamProvisioningPrepareResult): boolean { + const combined = [...(result.details ?? []), ...(result.warnings ?? []), result.message] + .map((entry) => entry?.trim() ?? '') + .filter(Boolean) + .join('\n') + .toLowerCase(); + + const hasCredentialContext = + combined.includes('token') || + combined.includes('authenticat') || + combined.includes('credential') || + combined.includes('api key') || + combined.includes('apikey') || + combined.includes('not_authenticated'); + const hasAuthStatus = + combined.includes('unauthorized') || + combined.includes('forbidden') || + /\b401\b/.test(combined) || + /\b403\b/.test(combined); + + return ( + combined.includes('token refresh failed') || + combined.includes('authentication failed') || + combined.includes('not_authenticated') || + (hasCredentialContext && hasAuthStatus) + ); +} + +function getOpenCodeProviderScopedFailureFromModelScopedEntries( + modelIds: readonly string[], + result: TeamProvisioningPrepareResult +): string | null { + if (result.ready) { + return null; + } + + for (const modelId of modelIds) { + const scopedEntries = getModelScopedEntries(modelId, result); + for (const entry of scopedEntries) { + const stripped = stripSelectedModelPrefix(modelId, entry); + if (looksLikeOpenCodeRuntimeFailureReason(stripped)) { + return stripped; + } + } + } + + return null; +} + +function shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure(params: { + result: TeamProvisioningPrepareResult; + modelIds: readonly string[]; + modelScopedEntriesPresent: boolean; + runtimeDetailLines: readonly string[]; + runtimeWarnings: readonly string[]; +}): boolean { + if (params.result.ready || params.modelScopedEntriesPresent) { + return false; + } + + const hasRuntimeScopedDiagnostics = + params.runtimeDetailLines.length > 0 || + params.runtimeWarnings.length > 0 || + Boolean(params.result.message?.trim()); + + if (!hasRuntimeScopedDiagnostics) { + return false; + } + + const canTreatAsLegacySingleModelFailure = + params.modelIds.length === 1 && + (looksLikeSingleModelBatchFailure(params.modelIds[0], params.result) || + looksLikeSingleModelCredentialFailure(params.result)) && + params.runtimeWarnings.length === 0; + + return !canTreatAsLegacySingleModelFailure; +} + export async function runProviderPrepareDiagnostics({ cwd, providerId, @@ -627,12 +760,12 @@ export async function runProviderPrepareDiagnostics({ limitContext ); const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); - const runtimeWarnings = [...(runtimeResult.warnings ?? [])]; + const runtimeWarnings = createRuntimeWarningLines(runtimeResult); if (!runtimeResult.ready) { return { status: 'failed', - details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], + details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), warnings: runtimeWarnings, modelResultsById: {}, }; @@ -712,12 +845,12 @@ export async function runProviderPrepareDiagnostics({ limitContext ); runtimeDetailLines = createRuntimeDetailLines(runtimeResult); - runtimeWarnings = [...(runtimeResult.warnings ?? [])]; + runtimeWarnings = createRuntimeWarningLines(runtimeResult); if (!runtimeResult.ready) { return { status: 'failed', - details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], + details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), warnings: runtimeWarnings, modelResultsById: {}, }; @@ -754,7 +887,7 @@ export async function runProviderPrepareDiagnostics({ runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter( + runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); @@ -766,18 +899,43 @@ export async function runProviderPrepareDiagnostics({ const hasSingleModelFallbackReason = uncachedModelIds.length === 1 && looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult); - if ( - !compatibilityResult.ready && - !hasModelScopedEntries && - (uncachedModelIds.length > 1 || - (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) - ) { + const providerScopedFailure = getOpenCodeProviderScopedFailureFromModelScopedEntries( + uncachedModelIds, + compatibilityResult + ); + const structuredProviderScopedFailure = getBlockingProviderIssueMessage( + providerId, + compatibilityResult + ); + if (structuredProviderScopedFailure || providerScopedFailure) { return { status: 'failed', details: [ - ...runtimeDetailLines, - ...(compatibilityResult.message ? [compatibilityResult.message] : []), + structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', ], + warnings: [], + modelResultsById: {}, + }; + } + if ( + shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({ + result: compatibilityResult, + modelIds: uncachedModelIds, + modelScopedEntriesPresent: hasModelScopedEntries, + runtimeDetailLines, + runtimeWarnings, + }) || + (!compatibilityResult.ready && + !hasModelScopedEntries && + (uncachedModelIds.length > 1 || + (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))) + ) { + return { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + compatibilityResult.message + ), warnings: runtimeWarnings, modelResultsById: {}, }; @@ -862,7 +1020,7 @@ export async function runProviderPrepareDiagnostics({ runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) ); - runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter( + runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter( (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) ); @@ -874,18 +1032,43 @@ export async function runProviderPrepareDiagnostics({ const hasSingleModelFallbackReason = compatibilityPassedModelIds.length === 1 && looksLikeSingleModelBatchFailure(compatibilityPassedModelIds[0], batchedModelResult); - if ( - !batchedModelResult.ready && - !hasModelScopedEntries && - (compatibilityPassedModelIds.length > 1 || - (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) - ) { + const providerScopedFailure = getOpenCodeProviderScopedFailureFromModelScopedEntries( + compatibilityPassedModelIds, + batchedModelResult + ); + const structuredProviderScopedFailure = getBlockingProviderIssueMessage( + providerId, + batchedModelResult + ); + if (structuredProviderScopedFailure || providerScopedFailure) { return { status: 'failed', details: [ - ...runtimeDetailLines, - ...(batchedModelResult.message ? [batchedModelResult.message] : []), + structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', ], + warnings: [], + modelResultsById: {}, + }; + } + if ( + shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({ + result: batchedModelResult, + modelIds: compatibilityPassedModelIds, + modelScopedEntriesPresent: hasModelScopedEntries, + runtimeDetailLines, + runtimeWarnings, + }) || + (!batchedModelResult.ready && + !hasModelScopedEntries && + (compatibilityPassedModelIds.length > 1 || + (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))) + ) { + return { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + batchedModelResult.message + ), warnings: runtimeWarnings, modelResultsById: {}, }; @@ -935,7 +1118,7 @@ export async function runProviderPrepareDiagnostics({ runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter( + runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); @@ -955,10 +1138,10 @@ export async function runProviderPrepareDiagnostics({ ) { return { status: 'failed', - details: [ - ...runtimeDetailLines, - ...(compatibilityResult.message ? [compatibilityResult.message] : []), - ], + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + compatibilityResult.message + ), warnings: runtimeWarnings, modelResultsById: {}, }; @@ -995,7 +1178,7 @@ export async function runProviderPrepareDiagnostics({ runtimeDetailLines = createRuntimeDetailLines(deepResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = [...(deepResult.warnings ?? [])].filter( + runtimeWarnings = createRuntimeWarningLines(deepResult).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); if ( @@ -1003,15 +1186,15 @@ export async function runProviderPrepareDiagnostics({ runtimeDetailLines.length === 0 && runtimeWarnings.length === 0 ) { - runtimeWarnings = deepResult.message ? [deepResult.message] : []; + runtimeWarnings = uniquePrepareLines([deepResult.message]); } } catch (deepError) { hasNotes = true; - runtimeWarnings = [ + runtimeWarnings = uniquePrepareLines([ normalizeModelReason( deepError instanceof Error ? deepError.message.trim() : String(deepError).trim() ) ?? 'One-shot diagnostic failed', - ]; + ]); } } } catch (error) { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 1feac3b9..6fd70d83 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1433,11 +1433,24 @@ export interface TeamCreateResponse { export type TeamProvisioningModelVerificationMode = 'compatibility' | 'deep'; +export type TeamProvisioningPrepareIssueScope = 'provider' | 'model'; +export type TeamProvisioningPrepareIssueSeverity = 'blocking' | 'warning'; + +export interface TeamProvisioningPrepareIssue { + providerId?: TeamProviderId; + modelId?: string; + scope: TeamProvisioningPrepareIssueScope; + severity: TeamProvisioningPrepareIssueSeverity; + code: string; + message: string; +} + export interface TeamProvisioningPrepareResult { ready: boolean; message: string; details?: string[]; warnings?: string[]; + issues?: TeamProvisioningPrepareIssue[]; } export interface TeamProvisioningProgress { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index dad1923a..96b8168d 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1052,7 +1052,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(prepare).toHaveBeenCalledTimes(1); }); - it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => { + it('keeps shared OpenCode auth compatibility failures provider-scoped', async () => { const prepare = vi.fn(async () => ({ ok: false as const, providerId: 'opencode' as const, @@ -1080,12 +1080,70 @@ describe('TeamProvisioningService prepare/auth behavior', () => { modelVerificationMode: 'compatibility', }); + expect(result.ready).toBe(false); + expect(result.message).toBe('OpenCode provider authentication failed'); + expect(result.details).toEqual(['OpenCode provider authentication failed']); + expect(result.warnings).toEqual(['OpenCode provider authentication failed']); + expect(result.issues).toEqual([ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'not_authenticated', + message: 'OpenCode provider authentication failed', + }, + ]); + }); + + it('keeps shared OpenCode MCP compatibility failures provider-scoped', async () => { + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'mcp_unavailable', + retryable: true, + diagnostics: [ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ], + warnings: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + + const result = await svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: ['opencode/big-pickle'], + modelVerificationMode: 'compatibility', + }); + expect(result.ready).toBe(false); expect(result.message).toBe( - 'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed' + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?' ); + expect(result.details).toEqual([ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ]); expect(result.warnings).toEqual([ - 'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed', + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ]); + expect(result.issues).toEqual([ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'mcp_unavailable', + message: + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + }, ]); }); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 7ff82ac5..b7b4ad20 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -82,6 +82,40 @@ describe('ProvisioningProviderStatusList', () => { }); }); + it('keeps OpenCode runtime connectivity failures out of selected-model summaries', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'opencode', + status: 'failed', + backendSummary: 'OpenCode CLI', + details: [ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('OpenCode (OpenCode CLI): Needs attention'); + expect(host.textContent).not.toContain('Selected model checks'); + expect(host.textContent).not.toContain('model unavailable'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('picks the first real failure detail instead of a verified line', () => { expect( getPrimaryProvisioningFailureDetail([ diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index 38e62b5a..0edc25e7 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -256,6 +256,372 @@ describe('runProviderPrepareDiagnostics', () => { ); }); + it('does not mislabel OpenCode runtime connectivity failures as model unavailable', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: 'OpenCode: mcp_unavailable', + details: [ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + 'OpenCode: mcp_unavailable', + ]); + expect(result.modelResultsById).toEqual({}); + expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + expect(prepareProvisioning).toHaveBeenCalledWith( + '/tmp/project', + 'opencode', + ['opencode'], + ['opencode/big-pickle'], + undefined, + 'compatibility' + ); + }); + + it('uses structured provider-scoped issues before OpenCode runtime text heuristics', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: 'OpenCode runtime failed with a future diagnostic shape', + details: ['Future OpenCode health check failed without known marker words'], + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'future_runtime_failure', + message: 'Future OpenCode health check failed without known marker words', + }, + ], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['Future OpenCode health check failed without known marker words']); + expect(result.modelResultsById).toEqual({}); + expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); + }); + + it('deduplicates repeated OpenCode provider runtime failure details', async () => { + const runtimeFailure = + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: runtimeFailure, + details: [runtimeFailure], + warnings: [runtimeFailure], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([runtimeFailure]); + expect(result.warnings).toEqual([runtimeFailure]); + expect(result.modelResultsById).toEqual({}); + }); + + it('treats OpenCode compatibility verification warnings as blocking when the batch failed', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: + 'Selected model opencode/big-pickle could not be verified. OpenCode provider authentication failed', + warnings: [ + 'Selected model opencode/big-pickle could not be verified. OpenCode provider authentication failed', + ], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'big-pickle - check failed - OpenCode provider authentication failed', + ]); + expect(result.warnings).toEqual([]); + expect(result.modelResultsById).toEqual({ + 'opencode/big-pickle': { + status: 'failed', + line: 'big-pickle - check failed - OpenCode provider authentication failed', + warningLine: null, + }, + }); + }); + + it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => { + const runtimeFailure = + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: `Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`, + warnings: [`Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([runtimeFailure]); + expect(result.warnings).toEqual([]); + expect(result.modelResultsById).toEqual({}); + }); + + it('does not mislabel OpenCode endpoint authorization failures as model unavailable', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: 'OpenCode: mcp_unavailable', + details: ['OpenCode /experimental/tool/ids unavailable - HTTP 403 Forbidden'], + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'OpenCode /experimental/tool/ids unavailable - HTTP 403 Forbidden', + 'OpenCode: mcp_unavailable', + ]); + expect(result.modelResultsById).toEqual({}); + expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); + }); + + it('keeps OpenCode selected-model compatibility failures scoped to the selected model', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >(() => + Promise.resolve({ + ready: false, + message: + 'Selected model opencode/not-real is unavailable. Selected model opencode/not-real is not available', + }) + ); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/not-real'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'not-real - unavailable - Selected model opencode/not-real is not available', + ]); + expect(result.modelResultsById).toEqual({ + 'opencode/not-real': { + status: 'failed', + line: 'not-real - unavailable - Selected model opencode/not-real is not available', + warningLine: null, + }, + }); + }); + + it('does not mislabel OpenCode deep runtime failures as model unavailable', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => { + if (modelVerificationMode === 'compatibility') { + expect(selectedModels).toEqual(['opencode/big-pickle']); + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: [ + 'Selected model opencode/big-pickle is compatible. Deep verification pending.', + ], + }); + } + + expect(modelVerificationMode).toBe('deep'); + expect(selectedModels).toEqual(['opencode/big-pickle']); + return Promise.resolve({ + ready: false, + message: 'OpenCode: mcp_unavailable', + details: [ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + 'OpenCode: mcp_unavailable', + ]); + expect(result.modelResultsById).toEqual({}); + expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); + }); + + it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => { + if (modelVerificationMode === 'compatibility') { + expect(selectedModels).toEqual(['openrouter/example/not-available']); + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: [ + 'Selected model openrouter/example/not-available is compatible. Deep verification pending.', + ], + }); + } + + expect(modelVerificationMode).toBe('deep'); + expect(selectedModels).toEqual(['openrouter/example/not-available']); + return Promise.resolve({ + ready: false, + message: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['openrouter/example/not-available'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'example/not-available - unavailable - Not available for this account', + ]); + expect(result.modelResultsById).toEqual({ + 'openrouter/example/not-available': { + status: 'failed', + line: 'example/not-available - unavailable - Not available for this account', + warningLine: null, + }, + }); + }); + it('normalizes raw Codex API error envelopes into a clean model reason', async () => { const prepareProvisioning = vi.fn< ( @@ -707,7 +1073,7 @@ describe('runProviderPrepareDiagnostics', () => { expect(result.details).toEqual(['Default - verified', '5.4 - verified']); }); - it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => { + it('uses structured OpenCode auth diagnostics as provider-scoped failures', async () => { const prepareProvisioning = vi.fn< ( cwd?: string, @@ -720,6 +1086,15 @@ describe('runProviderPrepareDiagnostics', () => { ready: false, message: 'OpenCode: not_authenticated', details: ['Token refresh failed: 401'], + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'not_authenticated', + message: 'Token refresh failed: 401', + }, + ], }); }); @@ -731,8 +1106,7 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(result.status).toBe('failed'); - expect(result.details).toEqual([ - 'GPT-5.2 Codex - unavailable - OpenCode provider authentication failed (token refresh 401)', - ]); + expect(result.details).toEqual(['Token refresh failed: 401']); + expect(result.modelResultsById).toEqual({}); }); });