fix(opencode): avoid busy preflight warnings after compatibility
This commit is contained in:
parent
6e4f8ff8c4
commit
7c5832bd7e
7 changed files with 463 additions and 61 deletions
|
|
@ -903,13 +903,6 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
|||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
// Facts:
|
||||
// - Deep OpenCode preflight below calls adapter.prepare({ runtimeOnly: false }).
|
||||
// - OpenCodeTeamRuntimeAdapter maps runtimeOnly:false to requireExecutionProbe:true.
|
||||
// - The readiness bridge then runs modelExecution.verify against the OpenCode host.
|
||||
// - The host reports "session status busy" when another foreground probe/request is active.
|
||||
// Keep probes serial so preflight does not create its own busy failures.
|
||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 1;
|
||||
const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([
|
||||
'not_installed',
|
||||
'not_authenticated',
|
||||
|
|
@ -989,11 +982,38 @@ function isRetryableOpenCodePreflightBusyDiagnostic(value: string | null | undef
|
|||
);
|
||||
}
|
||||
|
||||
function buildOpenCodeModelVerificationDeferredLine(modelId: string, reason: string): string {
|
||||
function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | undefined): boolean {
|
||||
const lower = value?.trim().toLowerCase() ?? '';
|
||||
return lower.includes('model verification timed out');
|
||||
}
|
||||
|
||||
function selectOpenCodeModelPreparePrimaryReason(
|
||||
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>
|
||||
): string {
|
||||
const candidates = [...prepare.diagnostics, prepare.reason]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
const timeoutReason = candidates.find(isOpenCodeModelVerificationTimeoutDiagnostic);
|
||||
return timeoutReason ?? candidates[0] ?? prepare.reason;
|
||||
}
|
||||
|
||||
function isOpenCodeModelPrepareBusyDeferred(
|
||||
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>,
|
||||
primaryReason: string
|
||||
): boolean {
|
||||
const candidates = [primaryReason, prepare.reason, ...prepare.diagnostics];
|
||||
return (
|
||||
prepare.retryable &&
|
||||
!candidates.some(isOpenCodeModelVerificationTimeoutDiagnostic) &&
|
||||
candidates.some(isRetryableOpenCodePreflightBusyDiagnostic)
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenCodeProviderVerificationDeferredLine(reason: string): string {
|
||||
const normalizedReason = isRetryableOpenCodePreflightBusyDiagnostic(reason)
|
||||
? 'OpenCode session is busy; retry when idle.'
|
||||
? 'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.'
|
||||
: reason;
|
||||
return `Selected model ${modelId} verification deferred. ${normalizedReason}`;
|
||||
return normalizedReason;
|
||||
}
|
||||
|
||||
function applyDistinctProvisioningMemberColors<
|
||||
|
|
@ -19227,11 +19247,14 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
const results = new Array<{ modelId: string; prepare: TeamRuntimePrepareResult }>(
|
||||
const results = new Array<{ modelId: string; prepare: TeamRuntimePrepareResult } | undefined>(
|
||||
modelIds.length
|
||||
);
|
||||
const workerCount = Math.min(OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY, modelIds.length);
|
||||
let nextIndex = 0;
|
||||
let providerBusyDeferred: {
|
||||
modelId: string;
|
||||
reason: string;
|
||||
code: string;
|
||||
} | null = null;
|
||||
|
||||
const prepareModel = async (modelId: string): Promise<TeamRuntimePrepareResult> => {
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -19281,26 +19304,45 @@ export class TeamProvisioningService {
|
|||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (currentIndex >= modelIds.length) {
|
||||
return;
|
||||
}
|
||||
// Facts:
|
||||
// - Deep OpenCode preflight maps to a real foreground execution probe.
|
||||
// - The host reports "session status busy" while another probe/member turn is active.
|
||||
// - Once busy is observed, probing more selected models only repeats the same host state.
|
||||
for (let index = 0; index < modelIds.length; index += 1) {
|
||||
const modelId = modelIds[index];
|
||||
const prepare = await prepareModel(modelId);
|
||||
results[index] = { modelId, prepare };
|
||||
|
||||
const modelId = modelIds[currentIndex];
|
||||
results[currentIndex] = {
|
||||
modelId,
|
||||
prepare: await prepareModel(modelId),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
if (verificationMode === 'compatibility' || prepare.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||
selectOpenCodeModelPreparePrimaryReason(prepare),
|
||||
prepare.reason
|
||||
);
|
||||
if (isOpenCodeModelPrepareBusyDeferred(prepare, primaryReason)) {
|
||||
providerBusyDeferred = {
|
||||
modelId,
|
||||
reason: primaryReason,
|
||||
code: prepare.reason,
|
||||
};
|
||||
appendPreflightDebugLog('opencode_model_prepare_batch_busy_deferred', {
|
||||
cwd,
|
||||
modelId,
|
||||
verificationMode,
|
||||
skippedModelIds: modelIds.slice(index + 1),
|
||||
reason: primaryReason,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
if (providerBusyDeferred) {
|
||||
continue;
|
||||
}
|
||||
blockingMessages.push(
|
||||
'OpenCode preflight could not collect model verification results for all selected models.'
|
||||
);
|
||||
|
|
@ -19324,25 +19366,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason,
|
||||
selectOpenCodeModelPreparePrimaryReason(prepare),
|
||||
prepare.reason
|
||||
);
|
||||
if (
|
||||
prepare.retryable &&
|
||||
[primaryReason, prepare.reason, ...prepare.diagnostics].some(
|
||||
isRetryableOpenCodePreflightBusyDiagnostic
|
||||
)
|
||||
) {
|
||||
const deferredLine = buildOpenCodeModelVerificationDeferredLine(modelId, primaryReason);
|
||||
warnings.push(deferredLine);
|
||||
issues.push({
|
||||
providerId: 'opencode',
|
||||
if (isOpenCodeModelPrepareBusyDeferred(prepare, primaryReason)) {
|
||||
providerBusyDeferred ??= {
|
||||
modelId,
|
||||
scope: 'model',
|
||||
severity: 'warning',
|
||||
reason: primaryReason,
|
||||
code: prepare.reason,
|
||||
message: primaryReason,
|
||||
});
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (this.isProviderScopedOpenCodePrepareFailure(prepare, primaryReason)) {
|
||||
|
|
@ -19394,6 +19426,20 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
if (providerBusyDeferred) {
|
||||
const providerBusyLine = buildOpenCodeProviderVerificationDeferredLine(
|
||||
providerBusyDeferred.reason
|
||||
);
|
||||
pushUniqueLine(warnings, providerBusyLine);
|
||||
issues.push({
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'warning',
|
||||
code: providerBusyDeferred.code,
|
||||
message: providerBusyLine,
|
||||
});
|
||||
}
|
||||
|
||||
appendPreflightDebugLog('opencode_model_prepare_batch_complete', {
|
||||
cwd,
|
||||
modelIds,
|
||||
|
|
|
|||
|
|
@ -323,6 +323,15 @@ function buildOpenCodeAdvisoryDeepVerificationWarning(reason: string | null | un
|
|||
return `OpenCode model ping was not confirmed. ${normalizedReason}`;
|
||||
}
|
||||
|
||||
function isProviderLevelOpenCodeBusyDeepVerificationWarning(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
return (
|
||||
lower.includes('opencode is currently busy') &&
|
||||
lower.includes('deep model verification') &&
|
||||
lower.includes('idle')
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenCodeAdvisoryDeepVerificationModelResult(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string
|
||||
|
|
@ -1196,6 +1205,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
const structuredProviderScopedFailure =
|
||||
structuredProviderScopedIssue?.message.trim() ?? null;
|
||||
let handledAdvisoryDeepFailure = false;
|
||||
let handledBusyDeepDeferral = false;
|
||||
if (structuredProviderScopedFailure || providerScopedFailure) {
|
||||
const failureReason =
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
|
||||
|
|
@ -1231,6 +1241,24 @@ export async function runProviderPrepareDiagnostics({
|
|||
}
|
||||
if (
|
||||
!handledAdvisoryDeepFailure &&
|
||||
batchedModelResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
runtimeWarnings.some(isProviderLevelOpenCodeBusyDeepVerificationWarning)
|
||||
) {
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = [];
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(modelId, {
|
||||
status: 'ready',
|
||||
line: buildModelAvailableLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
});
|
||||
}
|
||||
handledBusyDeepDeferral = true;
|
||||
}
|
||||
if (
|
||||
!handledAdvisoryDeepFailure &&
|
||||
!handledBusyDeepDeferral &&
|
||||
(shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||
result: batchedModelResult,
|
||||
modelIds: compatibilityPassedModelIds,
|
||||
|
|
@ -1255,6 +1283,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
}
|
||||
if (
|
||||
!handledAdvisoryDeepFailure &&
|
||||
!handledBusyDeepDeferral &&
|
||||
!hasModelScopedEntries &&
|
||||
compatibilityPassedModelIds.length === 1
|
||||
) {
|
||||
|
|
@ -1262,7 +1291,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
runtimeWarnings = [];
|
||||
}
|
||||
|
||||
if (!handledAdvisoryDeepFailure) {
|
||||
if (!handledAdvisoryDeepFailure && !handledBusyDeepDeferral) {
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
|
|
|
|||
|
|
@ -51,12 +51,11 @@ export function buildProviderPrepareRuntimeStatusSignature(
|
|||
authMethod: provider?.authMethod ?? null,
|
||||
selectedBackendId: provider?.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider?.resolvedBackendId ?? null,
|
||||
models: normalizeModelIds(provider?.models),
|
||||
modelCatalogSource: provider?.modelCatalog?.source ?? null,
|
||||
modelCatalogStatus: provider?.modelCatalog?.status ?? null,
|
||||
modelCatalogModels: normalizeModelIds(
|
||||
provider?.modelCatalog?.models?.map((model) => model.id)
|
||||
),
|
||||
// Facts:
|
||||
// - Selected models are already represented by modelChecksSignature.
|
||||
// - OpenCode/Codex live catalogs can expand while preflight is running.
|
||||
// - Including catalog contents here retriggers duplicate preflights and can
|
||||
// make still-running OpenCode PONG probes look like persistent busy.
|
||||
connection: provider?.connection
|
||||
? {
|
||||
supportsOAuth: provider.connection.supportsOAuth,
|
||||
|
|
|
|||
|
|
@ -1083,7 +1083,120 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
|
||||
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
|
||||
]);
|
||||
expect(result.issues).toEqual([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'warning',
|
||||
code: 'provider_busy',
|
||||
message:
|
||||
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops OpenCode deep model verification after the first busy host result', async () => {
|
||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||
if (input.model === 'opencode/minimax-m2.5-free') {
|
||||
return {
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'provider_busy',
|
||||
retryable: true,
|
||||
diagnostics: ['OpenCode session status busy'],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
providerId: 'opencode' as const,
|
||||
modelId: input.model ?? null,
|
||||
diagnostics: [],
|
||||
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/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
'opencode/big-pickle',
|
||||
],
|
||||
modelVerificationMode: 'deep',
|
||||
});
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
runtimeOnly: false,
|
||||
})
|
||||
);
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toBeUndefined();
|
||||
expect(result.warnings).toEqual([
|
||||
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mask OpenCode model verification timeouts as busy deferred checks', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'model_unavailable' as const,
|
||||
retryable: true,
|
||||
diagnostics: ['OpenCode session status busy', 'Model verification timed out'],
|
||||
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: 'deep',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.warnings).toEqual([
|
||||
'Selected model opencode/big-pickle could not be verified. Model verification timed out',
|
||||
]);
|
||||
expect(result.warnings?.join('\n')).not.toContain('verification deferred');
|
||||
expect(result.issues).toEqual([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
modelId: 'opencode/big-pickle',
|
||||
scope: 'model',
|
||||
severity: 'warning',
|
||||
code: 'model_unavailable',
|
||||
message: 'Model verification timed out',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1751,7 +1751,7 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps the in-flight preflight result after a same-signature rerender', async () => {
|
||||
it('keeps the in-flight OpenCode preflight result when live catalog expands during rerender', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
|
|
@ -1764,11 +1764,11 @@ describe('LaunchTeamDialog', () => {
|
|||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'warming up',
|
||||
detailMessage: 'first render',
|
||||
detailMessage: 'catalog still loading',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
source: 'live',
|
||||
status: 'checking',
|
||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||
},
|
||||
capabilities: {
|
||||
|
|
@ -1838,13 +1838,21 @@ describe('LaunchTeamDialog', () => {
|
|||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'still warming',
|
||||
detailMessage: 'same semantic status',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
statusMessage: 'healthy',
|
||||
detailMessage: 'catalog ready',
|
||||
models: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/qwen3.6-plus-free',
|
||||
'openrouter/google/gemma-4-26b-a4b-it',
|
||||
],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/qwen3.6-plus-free' },
|
||||
{ id: 'openrouter/google/gemma-4-26b-a4b-it' },
|
||||
],
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
|
|
|
|||
|
|
@ -543,6 +543,101 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
},
|
||||
});
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats provider-level OpenCode busy after compatibility as launch-ready', 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) =>
|
||||
Promise.resolve(
|
||||
modelVerificationMode === 'compatibility'
|
||||
? {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: (selectedModels ?? []).map(
|
||||
(modelId) => `Selected model ${modelId} is compatible. Deep verification pending.`
|
||||
),
|
||||
warnings: [],
|
||||
}
|
||||
: {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
warnings: [
|
||||
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/kimi-k2.6', 'openrouter/google/gemma-4-26b-a4b-it'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.details).toEqual([
|
||||
'kimi-k2.6 - available for launch',
|
||||
'google/gemma-4-26b-a4b-it - available for launch',
|
||||
]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.details.join('\n')).not.toContain('verification deferred - OpenCode session is busy');
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats provider-level OpenCode busy after compatibility as launch-ready for one 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) =>
|
||||
Promise.resolve(
|
||||
modelVerificationMode === 'compatibility'
|
||||
? {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: (selectedModels ?? []).map(
|
||||
(modelId) => `Selected model ${modelId} is compatible. Deep verification pending.`
|
||||
),
|
||||
warnings: [],
|
||||
}
|
||||
: {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
warnings: [
|
||||
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/kimi-k2.6'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.details).toEqual([
|
||||
'kimi-k2.6 - available for launch',
|
||||
]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,118 @@ describe('providerPrepareRequestSignature', () => {
|
|||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('ignores OpenCode catalog expansion that can happen while preflight is already running', () => {
|
||||
const providerIds = ['opencode'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
selectedBackendId: 'opencode-cli',
|
||||
resolvedBackendId: 'opencode-cli',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'checking',
|
||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const second = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
selectedBackendId: 'opencode-cli',
|
||||
resolvedBackendId: 'opencode-cli',
|
||||
models: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/qwen3.6-plus-free',
|
||||
'openrouter/google/gemma-4-26b-a4b-it',
|
||||
],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/qwen3.6-plus-free' },
|
||||
{ id: 'openrouter/google/gemma-4-26b-a4b-it' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('still changes the full request signature when selected OpenCode model checks change', () => {
|
||||
const runtimeStatusSignature = buildProviderPrepareRuntimeStatusSignature(
|
||||
['opencode'],
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
selectedBackendId: 'opencode-cli',
|
||||
resolvedBackendId: 'opencode-cli',
|
||||
models: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/qwen3.6-plus-free',
|
||||
],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/qwen3.6-plus-free' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
const first = buildProviderPrepareRequestSignature({
|
||||
cwd: '/tmp/project',
|
||||
selectedProviderId: 'opencode',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
selectedMemberProviders: ['opencode'],
|
||||
runtimeStatusSignature,
|
||||
modelChecksSignature: buildProviderPrepareModelChecksSignature(
|
||||
new Map([['opencode', ['opencode/minimax-m2.5-free']]])
|
||||
),
|
||||
});
|
||||
const second = buildProviderPrepareRequestSignature({
|
||||
cwd: '/tmp/project',
|
||||
selectedProviderId: 'opencode',
|
||||
selectedModel: 'opencode/qwen3.6-plus-free',
|
||||
selectedMemberProviders: ['opencode'],
|
||||
runtimeStatusSignature,
|
||||
modelChecksSignature: buildProviderPrepareModelChecksSignature(
|
||||
new Map([['opencode', ['opencode/qwen3.6-plus-free']]])
|
||||
),
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('ignores live verification fields that can drift while preflight is already running', () => {
|
||||
const providerIds = ['opencode'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
|
|
|
|||
Loading…
Reference in a new issue