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,
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(

View file

@ -57,6 +57,20 @@ function escapeRegExp(value: string): string {
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 {
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) {

View file

@ -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 {

View file

@ -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?',
},
]);
});

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', () => {
expect(
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 () => {
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({});
});
});