fix(team): scope OpenCode preflight runtime failures

This commit is contained in:
777genius 2026-05-14 11:10:34 +03:00
parent 9a8a59757c
commit b77eaf2b26
6 changed files with 750 additions and 59 deletions

View file

@ -556,6 +556,7 @@ import type {
TeamMember, TeamMember,
TeamProviderBackendId, TeamProviderBackendId,
TeamProviderId, TeamProviderId,
TeamProvisioningPrepareIssue,
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
@ -17306,6 +17307,7 @@ export class TeamProvisioningService {
const warnings: string[] = []; const warnings: string[] = [];
const details: string[] = []; const details: string[] = [];
const blockingMessages: string[] = []; const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const selectedModelIds = Array.from( const selectedModelIds = Array.from(
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
); );
@ -17349,6 +17351,7 @@ export class TeamProvisioningService {
details.push(...openCodeModelPrepare.details); details.push(...openCodeModelPrepare.details);
warnings.push(...openCodeModelPrepare.warnings); warnings.push(...openCodeModelPrepare.warnings);
blockingMessages.push(...openCodeModelPrepare.blockingMessages); blockingMessages.push(...openCodeModelPrepare.blockingMessages);
issues.push(...openCodeModelPrepare.issues);
continue; continue;
} }
@ -17512,6 +17515,7 @@ export class TeamProvisioningService {
? blockingMessages[0] ? blockingMessages[0]
: 'Some provider runtimes are not ready', : 'Some provider runtimes are not ready',
warnings: failureWarnings.length > 0 ? failureWarnings : undefined, 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 ready to launch (see notes)'
: 'CLI is warmed up and ready to launch', : 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
issues: issues.length > 0 ? issues : undefined,
}; };
} }
@ -17544,14 +17549,16 @@ export class TeamProvisioningService {
details: string[]; details: string[];
warnings: string[]; warnings: string[];
blockingMessages: string[]; blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
}> { }> {
const details: string[] = []; const details: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
const blockingMessages: string[] = []; const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const startedAt = Date.now(); const startedAt = Date.now();
if (modelIds.length === 0) { if (modelIds.length === 0) {
return { details, warnings, blockingMessages }; return { details, warnings, blockingMessages, issues };
} }
if (verificationMode === 'compatibility') { if (verificationMode === 'compatibility') {
@ -17662,6 +17669,16 @@ export class TeamProvisioningService {
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason;
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${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) { if (prepare.retryable) {
warnings.push(verificationWarningLine); warnings.push(verificationWarningLine);
if (verificationMode === 'compatibility') { if (verificationMode === 'compatibility') {
@ -17685,7 +17702,7 @@ export class TeamProvisioningService {
blockingMessages, blockingMessages,
}); });
return { details, warnings, blockingMessages }; return { details, warnings, blockingMessages, issues };
} }
private async prepareSelectedOpenCodeModelsCompatibilityBatch({ private async prepareSelectedOpenCodeModelsCompatibilityBatch({
@ -17700,10 +17717,12 @@ export class TeamProvisioningService {
details: string[]; details: string[];
warnings: string[]; warnings: string[];
blockingMessages: string[]; blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
} | null> { } | null> {
const details: string[] = []; const details: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
const blockingMessages: string[] = []; const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const startedAt = Date.now(); const startedAt = Date.now();
appendPreflightDebugLog('opencode_compatibility_batch_start', { appendPreflightDebugLog('opencode_compatibility_batch_start', {
@ -17749,18 +17768,20 @@ export class TeamProvisioningService {
if (!sharedPrepare.ok) { if (!sharedPrepare.ok) {
const primaryReason = const primaryReason =
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason; sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason;
for (const modelId of modelIds) { if (primaryReason.trim().length > 0) {
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; details.push(primaryReason);
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; blockingMessages.push(primaryReason);
if (sharedPrepare.retryable) { } else {
warnings.push(verificationWarningLine); blockingMessages.push(`OpenCode: ${sharedPrepare.reason}`);
blockingMessages.push(verificationWarningLine);
} else {
details.push(unavailableLine);
blockingMessages.push(unavailableLine);
}
} }
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 = const latestReadiness =
@ -17798,6 +17819,14 @@ export class TeamProvisioningService {
const unavailableLine = `Selected model ${modelId} is unavailable. ${resolvedModel.reason}`; const unavailableLine = `Selected model ${modelId} is unavailable. ${resolvedModel.reason}`;
details.push(unavailableLine); details.push(unavailableLine);
blockingMessages.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', { appendPreflightDebugLog('opencode_compatibility_batch_complete', {
@ -17808,7 +17837,7 @@ export class TeamProvisioningService {
details, details,
}); });
return { details, warnings, blockingMessages }; return { details, warnings, blockingMessages, issues };
} }
private resolveOpenCodeCompatibilityModel( private resolveOpenCodeCompatibilityModel(

View file

@ -57,6 +57,20 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
function uniquePrepareLines(lines: Array<string | null | undefined>): string[] {
const seen = new Set<string>();
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 { function getModelLabel(providerId: TeamProviderId, modelId: string): string {
if (isDefaultProviderModelSelection(modelId)) { if (isDefaultProviderModelSelection(modelId)) {
return 'Default'; return 'Default';
@ -205,6 +219,36 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu
return trimmed; 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 { function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null {
const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message] const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message]
.map((entry) => entry?.trim() ?? '') .map((entry) => entry?.trim() ?? '')
@ -286,7 +330,18 @@ function buildModelFailureLine(
} }
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] { 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 { function extractTimedOutPreflightProbeModelId(detail: string): string | null {
@ -545,7 +600,10 @@ function resolveModelResultFromCompatibilityBatch(
const hasUnavailableLine = modelScopedEntries.some((entry) => const hasUnavailableLine = modelScopedEntries.some((entry) =>
/selected model .* is unavailable\./i.test(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 { return {
kind: 'terminal', kind: 'terminal',
result: { result: {
@ -561,9 +619,6 @@ function resolveModelResultFromCompatibilityBatch(
}; };
} }
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
/selected model .* could not be verified\./i.test(entry)
);
if (hasVerificationWarningLine) { if (hasVerificationWarningLine) {
const line = buildModelFailureLine( const line = buildModelFailureLine(
providerId, providerId,
@ -574,9 +629,9 @@ function resolveModelResultFromCompatibilityBatch(
return { return {
kind: 'terminal', kind: 'terminal',
result: { result: {
status: 'notes', status: result.ready ? 'notes' : 'failed',
line, 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({ export async function runProviderPrepareDiagnostics({
cwd, cwd,
providerId, providerId,
@ -627,12 +760,12 @@ export async function runProviderPrepareDiagnostics({
limitContext limitContext
); );
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
const runtimeWarnings = [...(runtimeResult.warnings ?? [])]; const runtimeWarnings = createRuntimeWarningLines(runtimeResult);
if (!runtimeResult.ready) { if (!runtimeResult.ready) {
return { return {
status: 'failed', status: 'failed',
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
warnings: runtimeWarnings, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
}; };
@ -712,12 +845,12 @@ export async function runProviderPrepareDiagnostics({
limitContext limitContext
); );
runtimeDetailLines = createRuntimeDetailLines(runtimeResult); runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
runtimeWarnings = [...(runtimeResult.warnings ?? [])]; runtimeWarnings = createRuntimeWarningLines(runtimeResult);
if (!runtimeResult.ready) { if (!runtimeResult.ready) {
return { return {
status: 'failed', status: 'failed',
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
warnings: runtimeWarnings, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
}; };
@ -754,7 +887,7 @@ export async function runProviderPrepareDiagnostics({
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter( runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
@ -766,18 +899,43 @@ export async function runProviderPrepareDiagnostics({
const hasSingleModelFallbackReason = const hasSingleModelFallbackReason =
uncachedModelIds.length === 1 && uncachedModelIds.length === 1 &&
looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult); looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult);
if ( const providerScopedFailure = getOpenCodeProviderScopedFailureFromModelScopedEntries(
!compatibilityResult.ready && uncachedModelIds,
!hasModelScopedEntries && compatibilityResult
(uncachedModelIds.length > 1 || );
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
) { providerId,
compatibilityResult
);
if (structuredProviderScopedFailure || providerScopedFailure) {
return { return {
status: 'failed', status: 'failed',
details: [ details: [
...runtimeDetailLines, structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
...(compatibilityResult.message ? [compatibilityResult.message] : []),
], ],
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, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
}; };
@ -862,7 +1020,7 @@ export async function runProviderPrepareDiagnostics({
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
); );
runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter( runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
); );
@ -874,18 +1032,43 @@ export async function runProviderPrepareDiagnostics({
const hasSingleModelFallbackReason = const hasSingleModelFallbackReason =
compatibilityPassedModelIds.length === 1 && compatibilityPassedModelIds.length === 1 &&
looksLikeSingleModelBatchFailure(compatibilityPassedModelIds[0], batchedModelResult); looksLikeSingleModelBatchFailure(compatibilityPassedModelIds[0], batchedModelResult);
if ( const providerScopedFailure = getOpenCodeProviderScopedFailureFromModelScopedEntries(
!batchedModelResult.ready && compatibilityPassedModelIds,
!hasModelScopedEntries && batchedModelResult
(compatibilityPassedModelIds.length > 1 || );
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
) { providerId,
batchedModelResult
);
if (structuredProviderScopedFailure || providerScopedFailure) {
return { return {
status: 'failed', status: 'failed',
details: [ details: [
...runtimeDetailLines, structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
...(batchedModelResult.message ? [batchedModelResult.message] : []),
], ],
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, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
}; };
@ -935,7 +1118,7 @@ export async function runProviderPrepareDiagnostics({
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter( runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
@ -955,10 +1138,10 @@ export async function runProviderPrepareDiagnostics({
) { ) {
return { return {
status: 'failed', status: 'failed',
details: [ details: createRuntimeFailureDetailLines(
...runtimeDetailLines, runtimeDetailLines,
...(compatibilityResult.message ? [compatibilityResult.message] : []), compatibilityResult.message
], ),
warnings: runtimeWarnings, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
}; };
@ -995,7 +1178,7 @@ export async function runProviderPrepareDiagnostics({
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter( runtimeDetailLines = createRuntimeDetailLines(deepResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = [...(deepResult.warnings ?? [])].filter( runtimeWarnings = createRuntimeWarningLines(deepResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
if ( if (
@ -1003,15 +1186,15 @@ export async function runProviderPrepareDiagnostics({
runtimeDetailLines.length === 0 && runtimeDetailLines.length === 0 &&
runtimeWarnings.length === 0 runtimeWarnings.length === 0
) { ) {
runtimeWarnings = deepResult.message ? [deepResult.message] : []; runtimeWarnings = uniquePrepareLines([deepResult.message]);
} }
} catch (deepError) { } catch (deepError) {
hasNotes = true; hasNotes = true;
runtimeWarnings = [ runtimeWarnings = uniquePrepareLines([
normalizeModelReason( normalizeModelReason(
deepError instanceof Error ? deepError.message.trim() : String(deepError).trim() deepError instanceof Error ? deepError.message.trim() : String(deepError).trim()
) ?? 'One-shot diagnostic failed', ) ?? 'One-shot diagnostic failed',
]; ]);
} }
} }
} catch (error) { } catch (error) {

View file

@ -1433,11 +1433,24 @@ export interface TeamCreateResponse {
export type TeamProvisioningModelVerificationMode = 'compatibility' | 'deep'; 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 { export interface TeamProvisioningPrepareResult {
ready: boolean; ready: boolean;
message: string; message: string;
details?: string[]; details?: string[];
warnings?: string[]; warnings?: string[];
issues?: TeamProvisioningPrepareIssue[];
} }
export interface TeamProvisioningProgress { export interface TeamProvisioningProgress {

View file

@ -1052,7 +1052,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(prepare).toHaveBeenCalledTimes(1); 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 () => ({ const prepare = vi.fn(async () => ({
ok: false as const, ok: false as const,
providerId: 'opencode' as const, providerId: 'opencode' as const,
@ -1080,12 +1080,70 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
modelVerificationMode: 'compatibility', 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.ready).toBe(false);
expect(result.message).toBe( 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([ 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?',
},
]); ]);
}); });

View file

@ -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', () => { it('picks the first real failure detail instead of a verified line', () => {
expect( expect(
getPrimaryProvisioningFailureDetail([ getPrimaryProvisioningFailureDetail([

View file

@ -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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>(() =>
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<TeamProvisioningPrepareResult>
>((_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<TeamProvisioningPrepareResult>
>((_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 () => { it('normalizes raw Codex API error envelopes into a clean model reason', async () => {
const prepareProvisioning = vi.fn< const prepareProvisioning = vi.fn<
( (
@ -707,7 +1073,7 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.details).toEqual(['Default - verified', '5.4 - verified']); 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< const prepareProvisioning = vi.fn<
( (
cwd?: string, cwd?: string,
@ -720,6 +1086,15 @@ describe('runProviderPrepareDiagnostics', () => {
ready: false, ready: false,
message: 'OpenCode: not_authenticated', message: 'OpenCode: not_authenticated',
details: ['Token refresh failed: 401'], 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.status).toBe('failed');
expect(result.details).toEqual([ expect(result.details).toEqual(['Token refresh failed: 401']);
'GPT-5.2 Codex - unavailable - OpenCode provider authentication failed (token refresh 401)', expect(result.modelResultsById).toEqual({});
]);
}); });
}); });