fix(opencode): tighten readiness and create-team preflight

This commit is contained in:
777genius 2026-04-22 02:13:42 +03:00
parent 83a9db5313
commit 185789cc0a
18 changed files with 682 additions and 73 deletions

View file

@ -6245,6 +6245,7 @@ export class TeamProvisioningService {
cwd: targetCwd, cwd: targetCwd,
providerId: 'opencode', providerId: 'opencode',
model: selectedModelIds[0], model: selectedModelIds[0],
runtimeOnly: selectedModelIds.length === 0,
skipPermissions: true, skipPermissions: true,
expectedMembers: [], expectedMembers: [],
previousLaunchState: null, previousLaunchState: null,

View file

@ -1,5 +1,6 @@
import { import {
assertOpenCodeProductionE2EArtifactGate, assertOpenCodeProductionE2EArtifactGate,
buildOpenCodeProjectPathFingerprint,
type OpenCodeProductionE2EEvidence, type OpenCodeProductionE2EEvidence,
} from '../e2e/OpenCodeProductionE2EEvidence'; } from '../e2e/OpenCodeProductionE2EEvidence';
import { import {
@ -51,7 +52,7 @@ export interface OpenCodeReadinessBridgeOptions {
} }
export interface OpenCodeProductionE2EEvidenceReadPort { export interface OpenCodeProductionE2EEvidenceReadPort {
read(input?: { selectedModel?: string | null }): Promise<{ read(input?: { selectedModel?: string | null; projectPathFingerprint?: string | null }): Promise<{
ok: boolean; ok: boolean;
evidence: OpenCodeProductionE2EEvidence | null; evidence: OpenCodeProductionE2EEvidence | null;
artifactPath: string; artifactPath: string;
@ -128,8 +129,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
} }
const expectedModel = input.readiness.modelId ?? input.input.selectedModel; const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath);
const evidenceRead = this.options.productionE2eEvidence const evidenceRead = this.options.productionE2eEvidence
? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel }) ? await this.options.productionE2eEvidence.read({
selectedModel: expectedModel,
projectPathFingerprint,
})
: { : {
ok: false, ok: false,
evidence: null, evidence: null,
@ -145,6 +150,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
binaryFingerprint: input.runtime.binaryFingerprint, binaryFingerprint: input.runtime.binaryFingerprint,
capabilitySnapshotId: input.runtime.capabilitySnapshotId, capabilitySnapshotId: input.runtime.capabilitySnapshotId,
selectedModel: expectedModel, selectedModel: expectedModel,
projectPathFingerprint,
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
buildOpenCodeCanonicalMcpToolId('agent-teams', tool) buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
), ),

View file

@ -1,3 +1,6 @@
import { createHash } from 'node:crypto';
import * as path from 'node:path';
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1;
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1; export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1;
@ -92,6 +95,7 @@ export interface OpenCodeProductionE2EGateExpectation {
binaryFingerprint: string | null; binaryFingerprint: string | null;
capabilitySnapshotId: string | null; capabilitySnapshotId: string | null;
selectedModel: string | null; selectedModel: string | null;
projectPathFingerprint?: string | null;
requiredMcpTools?: string[]; requiredMcpTools?: string[];
} }
@ -100,6 +104,18 @@ export interface OpenCodeProductionE2EGateResult {
diagnostics: string[]; diagnostics: string[];
} }
export function buildOpenCodeProjectPathFingerprint(
projectPath: string | null | undefined
): string | null {
const trimmed = projectPath?.trim() ?? '';
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed).replace(/\\/g, '/');
return `project:${createHash('sha256').update(normalized).digest('hex')}`;
}
export function validateOpenCodeProductionE2EEvidence( export function validateOpenCodeProductionE2EEvidence(
value: unknown value: unknown
): OpenCodeProductionE2EEvidence { ): OpenCodeProductionE2EEvidence {
@ -368,6 +384,15 @@ function collectExpectedRuntimeDiagnostics(
); );
} }
if (
expected.projectPathFingerprint &&
evidence.projectPathFingerprint !== expected.projectPathFingerprint
) {
diagnostics.push(
'OpenCode production E2E evidence project context does not match the current working directory'
);
}
const requiredTools = expected.requiredMcpTools ?? []; const requiredTools = expected.requiredMcpTools ?? [];
if (requiredTools.length > 0) { if (requiredTools.length > 0) {
const observedTools = new Set(evidence.mcpTools.observedTools); const observedTools = new Set(evidence.mcpTools.observedTools);
@ -418,19 +443,14 @@ function validateOpenCodeProductionE2EEvidenceCollection(
} }
const entries: Record<string, OpenCodeProductionE2EEvidence> = {}; const entries: Record<string, OpenCodeProductionE2EEvidence> = {};
for (const [modelId, rawEvidence] of Object.entries(entriesRecord)) { for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) {
const trimmedModelId = modelId.trim(); const trimmedEntryKey = entryKey.trim();
if (!trimmedModelId) { if (!trimmedEntryKey) {
throw new Error('OpenCode production E2E evidence collection model id must be non-empty'); throw new Error('OpenCode production E2E evidence collection key must be non-empty');
} }
const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence); const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence);
if (evidence.selectedModel !== trimmedModelId) { entries[trimmedEntryKey] = evidence;
throw new Error(
`OpenCode production E2E evidence collection key ${trimmedModelId} does not match selectedModel ${evidence.selectedModel}`
);
}
entries[trimmedModelId] = evidence;
} }
return { return {

View file

@ -26,6 +26,7 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions {
export interface OpenCodeProductionE2EEvidenceStoreReadOptions { export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
selectedModel?: string | null; selectedModel?: string | null;
projectPathFingerprint?: string | null;
} }
export class OpenCodeProductionE2EEvidenceStore { export class OpenCodeProductionE2EEvidenceStore {
@ -62,7 +63,7 @@ export class OpenCodeProductionE2EEvidenceStore {
}; };
} }
const selection = selectEvidence(result.data, options.selectedModel); const selection = selectEvidence(result.data, options);
return { return {
ok: true, ok: true,
evidence: selection.evidence, evidence: selection.evidence,
@ -90,7 +91,7 @@ export class OpenCodeProductionE2EEvidenceStore {
function selectEvidence( function selectEvidence(
data: OpenCodeProductionE2EEvidenceStoreData, data: OpenCodeProductionE2EEvidenceStoreData,
selectedModel: string | null | undefined options: OpenCodeProductionE2EEvidenceStoreReadOptions
): { ): {
evidence: OpenCodeProductionE2EEvidence | null; evidence: OpenCodeProductionE2EEvidence | null;
diagnostics: string[]; diagnostics: string[];
@ -103,13 +104,43 @@ function selectEvidence(
return { evidence: data, diagnostics: [] }; return { evidence: data, diagnostics: [] };
} }
const modelId = selectedModel?.trim() ?? ''; const modelId = options.selectedModel?.trim() ?? '';
const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? '';
if (modelId) { if (modelId) {
const entries = Object.values(data.entriesByModel).filter(
(entry) => entry.selectedModel === modelId
);
if (entries.length === 0) {
return {
evidence: null,
diagnostics: [
`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`,
],
};
}
if (projectPathFingerprint) {
const exactMatch = pickNewestEvidence(
entries.filter((entry) => entry.projectPathFingerprint === projectPathFingerprint)
);
if (exactMatch) {
return {
evidence: exactMatch,
diagnostics: [],
};
}
return {
evidence: null,
diagnostics: [
`OpenCode production E2E evidence artifact has no entry for selected model ${modelId} and the current working directory`,
],
};
}
return { return {
evidence: data.entriesByModel[modelId] ?? null, evidence: pickNewestEvidence(entries),
diagnostics: data.entriesByModel[modelId] diagnostics: [],
? []
: [`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`],
}; };
} }
@ -140,9 +171,33 @@ function upsertEvidence(
entriesByModel[current.selectedModel] = current; entriesByModel[current.selectedModel] = current;
} }
entriesByModel[evidence.selectedModel] = evidence; entriesByModel[buildEvidenceKey(evidence)] = evidence;
return { return {
collectionSchemaVersion: 1, collectionSchemaVersion: 1,
entriesByModel, entriesByModel,
}; };
} }
function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string {
return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::');
}
function pickNewestEvidence(
entries: OpenCodeProductionE2EEvidence[]
): OpenCodeProductionE2EEvidence | null {
if (entries.length === 0) {
return null;
}
return entries.slice(1).reduce<OpenCodeProductionE2EEvidence>((latest, entry) => {
const latestAt = Date.parse(latest.createdAt);
const entryAt = Date.parse(entry.createdAt);
if (!Number.isFinite(entryAt)) {
return latest;
}
if (!Number.isFinite(latestAt) || entryAt >= latestAt) {
return entry;
}
return latest;
}, entries[0]!);
}

View file

@ -66,8 +66,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
) {} ) {}
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> { async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
const launchMode = resolveOpenCodeTeamLaunchMode(this.options); const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
if (launchMode === 'disabled') { if (configuredLaunchMode === 'disabled') {
return { return {
ok: false, ok: false,
providerId: this.providerId, providerId: this.providerId,
@ -80,11 +80,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}; };
} }
const runtimeOnly = input.runtimeOnly === true;
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
projectPath: input.cwd, projectPath: input.cwd,
selectedModel: input.model ?? null, selectedModel: input.model ?? null,
requireExecutionProbe: true, requireExecutionProbe: !runtimeOnly,
launchMode, launchMode: runtimeOnly ? undefined : configuredLaunchMode,
}); });
if (!readiness.launchAllowed) { if (!readiness.launchAllowed) {
@ -99,13 +100,17 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
} }
const warnings = const warnings =
launchMode === 'dogfood' configuredLaunchMode === 'dogfood' && !runtimeOnly
? [ ? [
'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.',
] ]
: []; : [];
if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') { if (
!runtimeOnly &&
configuredLaunchMode === 'production' &&
readiness.supportLevel !== 'production_supported'
) {
return { return {
ok: false, ok: false,
providerId: this.providerId, providerId: this.providerId,
@ -128,6 +133,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
} }
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> { async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
const prepared = await this.prepare(input); const prepared = await this.prepare(input);
if (!prepared.ok) { if (!prepared.ok) {
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
@ -149,7 +155,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
this.lastProjectPathByTeamName.set(input.teamName, input.cwd); this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
const data = await this.bridge.launchOpenCodeTeam({ const data = await this.bridge.launchOpenCodeTeam({
mode: resolveOpenCodeTeamLaunchMode(this.options), mode: configuredLaunchMode,
runId: input.runId, runId: input.runId,
teamId: input.teamName, teamId: input.teamName,
teamName: input.teamName, teamName: input.teamName,

View file

@ -30,6 +30,11 @@ export interface TeamRuntimeLaunchInput {
providerId: TeamRuntimeProviderId; providerId: TeamRuntimeProviderId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
/**
* Runtime-only preflight skips model-scoped execution/evidence checks.
* Use only for warm-up diagnostics before a concrete launch model is selected.
*/
runtimeOnly?: boolean;
skipPermissions: boolean; skipPermissions: boolean;
expectedMembers: TeamRuntimeMemberSpec[]; expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null; previousLaunchState: PersistedTeamLaunchSnapshot | null;

View file

@ -494,6 +494,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
useEffect(() => { useEffect(() => {
if (!open) {
return;
}
setMembersDrafts((prev) => { setMembersDrafts((prev) => {
const sanitized = clearInheritedMemberModelsUnavailableForProvider({ const sanitized = clearInheritedMemberModelsUnavailableForProvider({
members: prev, members: prev,
@ -502,7 +506,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}); });
return sanitized.changed ? sanitized.members : prev; return sanitized.changed ? sanitized.members : prev;
}); });
}, [runtimeProviderStatusById, selectedProviderId]); }, [membersDrafts, open, runtimeProviderStatusById, selectedProviderId]);
useEffect(() => { useEffect(() => {
if (multimodelEnabled) { if (multimodelEnabled) {

View file

@ -154,6 +154,12 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu
if (/The requested model is not available for your account\./i.test(trimmed)) { if (/The requested model is not available for your account\./i.test(trimmed)) {
return 'Not available for this account'; return 'Not available for this account';
} }
if (/token refresh failed:\s*401/i.test(trimmed)) {
return 'OpenCode provider authentication failed (token refresh 401)';
}
if (/unauthorized|forbidden|\b401\b|\b403\b/i.test(trimmed)) {
return 'OpenCode provider authentication failed';
}
if ( if (
trimmed.toLowerCase().includes('timeout running:') || trimmed.toLowerCase().includes('timeout running:') ||
trimmed.toLowerCase().includes('timed out') || trimmed.toLowerCase().includes('timed out') ||
@ -247,6 +253,19 @@ function extractTimedOutPreflightProbeModelId(detail: string): string | null {
return match?.[1]?.trim() || null; return match?.[1]?.trim() || null;
} }
function isSuppressibleGenericPreflightWarning(detail: string): boolean {
const lower = detail.trim().toLowerCase();
if (!lower) {
return false;
}
return (
lower.includes('preflight check failed') ||
(lower.includes('preflight check for `') && lower.includes('-p` did not complete')) ||
lower.includes('preflight ping completed but did not return the expected pong')
);
}
function suppressSupersededRuntimeWarnings(params: { function suppressSupersededRuntimeWarnings(params: {
runtimeDetailLines: string[]; runtimeDetailLines: string[];
runtimeWarnings: string[]; runtimeWarnings: string[];
@ -256,16 +275,23 @@ function suppressSupersededRuntimeWarnings(params: {
runtimeWarnings: string[]; runtimeWarnings: string[];
} { } {
const suppressedEntries = new Set<string>(); const suppressedEntries = new Set<string>();
const allSelectedModelsReady =
params.modelResultsById.size > 0 &&
Array.from(params.modelResultsById.values()).every((result) => result.status === 'ready');
for (const warning of params.runtimeWarnings) { for (const warning of params.runtimeWarnings) {
const probedModelId = extractTimedOutPreflightProbeModelId(warning); const probedModelId = extractTimedOutPreflightProbeModelId(warning);
if (!probedModelId) { if (probedModelId) {
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
continue;
}
suppressedEntries.add(warning);
continue; continue;
} }
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
continue; if (allSelectedModelsReady && isSuppressibleGenericPreflightWarning(warning)) {
suppressedEntries.add(warning);
} }
suppressedEntries.add(warning);
} }
return { return {
@ -283,9 +309,11 @@ function resolveModelResultFromBatch(
isOnlyModel: boolean isOnlyModel: boolean
): ProviderPrepareDiagnosticsModelResult { ): ProviderPrepareDiagnosticsModelResult {
const modelScopedEntries = getModelScopedEntries(modelId, result); const modelScopedEntries = getModelScopedEntries(modelId, result);
const normalizedReason = const hasModelScopedEntries = modelScopedEntries.length > 0;
getScopedModelReason(modelId, modelScopedEntries) ?? const scopedReason = getScopedModelReason(modelId, modelScopedEntries);
(isOnlyModel ? normalizeModelReason(result.message) : null); const fallbackBatchReason = isOnlyModel
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
: null;
const hasVerifiedLine = modelScopedEntries.some((entry) => const hasVerifiedLine = modelScopedEntries.some((entry) =>
/selected model .* verified for launch\./i.test(entry) /selected model .* verified for launch\./i.test(entry)
@ -304,7 +332,12 @@ function resolveModelResultFromBatch(
if (hasUnavailableLine || (!result.ready && isOnlyModel)) { if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
return { return {
status: 'failed', status: 'failed',
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason), line: buildModelFailureLine(
providerId,
modelId,
'unavailable',
scopedReason ?? fallbackBatchReason
),
warningLine: null, warningLine: null,
}; };
} }
@ -312,8 +345,11 @@ function resolveModelResultFromBatch(
const hasVerificationWarningLine = modelScopedEntries.some((entry) => const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
/selected model .* could not be verified\./i.test(entry) /selected model .* could not be verified\./i.test(entry)
); );
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) { if (
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason); hasVerificationWarningLine ||
((result.warnings?.length ?? 0) > 0 && isOnlyModel && hasModelScopedEntries)
) {
const line = buildModelFailureLine(providerId, modelId, 'check failed', scopedReason);
return { return {
status: 'notes', status: 'notes',
line, line,
@ -333,7 +369,7 @@ function resolveModelResultFromBatch(
providerId, providerId,
modelId, modelId,
'check failed', 'check failed',
normalizedReason ?? 'Model verification failed' scopedReason ?? 'Model verification failed'
); );
return { return {
status: 'notes', status: 'notes',

View file

@ -48,7 +48,8 @@ export type TeamModelRuntimeProviderStatus = Pick<
| 'backend' | 'backend'
| 'authenticated' | 'authenticated'
| 'supported' | 'supported'
>; > &
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
export type TeamRuntimeModelOption = TeamProviderModelOption & { export type TeamRuntimeModelOption = TeamProviderModelOption & {
availabilityStatus?: CliProviderModelAvailabilityStatus | null; availabilityStatus?: CliProviderModelAvailabilityStatus | null;
@ -77,6 +78,34 @@ export function isTeamModelUiDisabled(
return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null; return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null;
} }
export function isTeamProviderModelVerificationPending(
providerId: SupportedProviderId | undefined,
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
if (!providerId || providerId === 'anthropic' || !providerStatus) {
return false;
}
if (providerStatus.modelVerificationState === 'verifying') {
return true;
}
if (providerStatus.verificationState !== 'unknown') {
return false;
}
const hasRuntimeModelTruth =
providerStatus.models.length > 0 ||
(providerStatus.modelCatalog?.models.length ?? 0) > 0 ||
(providerStatus.modelAvailability?.length ?? 0) > 0;
if (hasRuntimeModelTruth) {
return false;
}
const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? '';
return statusMessage.length === 0 || statusMessage === 'checking...';
}
function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] {
return getVisibleTeamProviderModels( return getVisibleTeamProviderModels(
providerId, providerId,
@ -304,6 +333,10 @@ export function getAvailableTeamProviderModelOptions(
return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; return [{ value: '', label: 'Default', badgeLabel: 'Default' }];
} }
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
return getFallbackTeamProviderModelOptions(providerId, providerStatus);
}
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
return [ return [
{ value: '', label: 'Default', badgeLabel: 'Default' }, { value: '', label: 'Default', badgeLabel: 'Default' },
@ -348,6 +381,10 @@ export function isTeamModelAvailableForUi(
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
} }
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
return true;
}
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
} }
@ -382,6 +419,10 @@ export function normalizeTeamModelForUi(
return ''; return '';
} }
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
return normalized;
}
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
if (!visibleModels.includes(trimmed)) { if (!visibleModels.includes(trimmed)) {
return ''; return '';
@ -416,6 +457,10 @@ export function getTeamModelSelectionError(
return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`;
} }
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
return null;
}
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
if (!visibleModels.includes(trimmed)) { if (!visibleModels.includes(trimmed)) {
return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`;

View file

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
assertOpenCodeProductionE2EArtifactGate, assertOpenCodeProductionE2EArtifactGate,
buildOpenCodeProjectPathFingerprint,
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
validateOpenCodeProductionE2EEvidence, validateOpenCodeProductionE2EEvidence,
@ -43,6 +44,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
binaryFingerprint: 'version:1.14.19', binaryFingerprint: 'version:1.14.19',
capabilitySnapshotId: 'cap-1', capabilitySnapshotId: 'cap-1',
selectedModel: 'openai/gpt-5.4-mini', selectedModel: 'openai/gpt-5.4-mini',
projectPathFingerprint: 'project-a',
requiredMcpTools: ['agent-teams_runtime_deliver_message'], requiredMcpTools: ['agent-teams_runtime_deliver_message'],
}, },
}) })
@ -73,6 +75,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
binaryFingerprint: 'version:1.14.19', binaryFingerprint: 'version:1.14.19',
capabilitySnapshotId: 'cap-1', capabilitySnapshotId: 'cap-1',
selectedModel: 'openai/gpt-5.4-mini', selectedModel: 'openai/gpt-5.4-mini',
projectPathFingerprint: 'project-a',
requiredMcpTools: ['agent-teams_runtime_deliver_message'], requiredMcpTools: ['agent-teams_runtime_deliver_message'],
}, },
}) })
@ -170,6 +173,56 @@ describe('OpenCodeProductionE2EEvidence', () => {
], ],
}); });
}); });
it('stores production evidence for the same raw model across multiple project contexts', async () => {
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
const store = new OpenCodeProductionE2EEvidenceStore({
filePath,
clock: () => now,
});
await store.write(
passingEvidence({
evidenceId: 'e2e-project-a',
selectedModel: 'opencode/minimax-m2.5-free',
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
})
);
await store.write(
passingEvidence({
evidenceId: 'e2e-project-b',
selectedModel: 'opencode/minimax-m2.5-free',
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
})
);
await expect(
store.read({
selectedModel: 'opencode/minimax-m2.5-free',
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
})
).resolves.toMatchObject({
ok: true,
evidence: {
evidenceId: 'e2e-project-b',
selectedModel: 'opencode/minimax-m2.5-free',
},
diagnostics: [],
});
await expect(
store.read({
selectedModel: 'opencode/minimax-m2.5-free',
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'),
})
).resolves.toMatchObject({
ok: true,
evidence: null,
diagnostics: [
'OpenCode production E2E evidence artifact has no entry for selected model opencode/minimax-m2.5-free and the current working directory',
],
});
});
}); });
function passingEvidence( function passingEvidence(

View file

@ -17,6 +17,7 @@ import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/open
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import { import {
assertOpenCodeProductionE2EArtifactGate, assertOpenCodeProductionE2EArtifactGate,
buildOpenCodeProjectPathFingerprint,
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS, OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS,
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
@ -29,6 +30,7 @@ import {
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import type { import type {
OpenCodeBridgeRuntimeSnapshot, OpenCodeBridgeRuntimeSnapshot,
@ -99,8 +101,8 @@ liveDescribe('OpenCode production gate live e2e', () => {
selectedModel, selectedModel,
requireExecutionProbe: false, requireExecutionProbe: false,
}); });
const runtime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
if (!runtime) { if (!initialRuntime) {
throw new Error( throw new Error(
`OpenCode live readiness did not return runtime snapshot: ${[ `OpenCode live readiness did not return runtime snapshot: ${[
...readiness.diagnostics, ...readiness.diagnostics,
@ -108,8 +110,8 @@ liveDescribe('OpenCode production gate live e2e', () => {
].join('; ')}` ].join('; ')}`
); );
} }
expect(runtime?.version).toBe('1.14.19'); expect(initialRuntime?.version).toBe('1.14.19');
expect(runtime?.capabilitySnapshotId).toBeTruthy(); expect(initialRuntime?.capabilitySnapshotId).toBeTruthy();
const runId = `opencode-e2e-${Date.now()}`; const runId = `opencode-e2e-${Date.now()}`;
const teamName = `opencode-e2e-team-${Date.now()}`; const teamName = `opencode-e2e-team-${Date.now()}`;
@ -136,7 +138,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
}, },
], ],
leadPrompt: 'Live OpenCode production gate e2e', leadPrompt: 'Live OpenCode production gate e2e',
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
manifestHighWatermark: null, manifestHighWatermark: null,
}); });
@ -148,7 +150,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
teamId: teamName, teamId: teamName,
teamName, teamName,
projectPath: PROJECT_PATH, projectPath: PROJECT_PATH,
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
manifestHighWatermark: null, manifestHighWatermark: null,
expectedMembers: [{ name: memberName, model: selectedModel }], expectedMembers: [{ name: memberName, model: selectedModel }],
reason: 'production_gate_e2e', reason: 'production_gate_e2e',
@ -182,19 +184,36 @@ liveDescribe('OpenCode production gate live e2e', () => {
teamId: teamName, teamId: teamName,
teamName, teamName,
projectPath: PROJECT_PATH, projectPath: PROJECT_PATH,
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
manifestHighWatermark: null, manifestHighWatermark: null,
reason: 'production_gate_e2e_cleanup', reason: 'production_gate_e2e_cleanup',
force: true, force: true,
}); });
expect(stop.stopped).toBe(true); expect(stop.stopped).toBe(true);
const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
projectPath: PROJECT_PATH,
selectedModel,
requireExecutionProbe: true,
});
const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
if (!finalRuntime) {
throw new Error(
`OpenCode final readiness did not return runtime snapshot: ${[
...finalReadiness.diagnostics,
...finalReadiness.missing,
].join('; ')}`
);
}
expect(finalRuntime.version).toBe('1.14.19');
expect(finalRuntime.capabilitySnapshotId).toBeTruthy();
const candidate = buildCandidateEvidence({ const candidate = buildCandidateEvidence({
runId, runId,
teamName, teamName,
memberName, memberName,
selectedModel, selectedModel,
runtime: runtime!, runtime: finalRuntime,
readinessObservedTools: readiness.evidence.observedMcpTools, readinessObservedTools: readiness.evidence.observedMcpTools,
launch, launch,
reconcile, reconcile,
@ -207,10 +226,11 @@ liveDescribe('OpenCode production gate live e2e', () => {
evidence: candidate, evidence: candidate,
artifactPath: candidate.artifactPath, artifactPath: candidate.artifactPath,
expected: { expected: {
opencodeVersion: runtime?.version ?? null, opencodeVersion: finalRuntime.version ?? null,
binaryFingerprint: runtime?.binaryFingerprint ?? null, binaryFingerprint: finalRuntime.binaryFingerprint ?? null,
capabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null,
selectedModel, selectedModel,
projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
buildOpenCodeCanonicalMcpToolId('agent-teams', tool) buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
), ),
@ -230,7 +250,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
teamId: teamName, teamId: teamName,
teamName, teamName,
projectPath: PROJECT_PATH, projectPath: PROJECT_PATH,
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
manifestHighWatermark: null, manifestHighWatermark: null,
reason: 'production_gate_e2e_finally_cleanup', reason: 'production_gate_e2e_finally_cleanup',
force: true, force: true,
@ -373,7 +393,7 @@ function buildCandidateEvidence(input: {
binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown', binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown',
capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown', capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown',
selectedModel: input.selectedModel, selectedModel: input.selectedModel,
projectPathFingerprint: null, projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
requiredSignals: { requiredSignals: {
...Object.fromEntries( ...Object.fromEntries(
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
@ -433,23 +453,9 @@ function withBunOnPath(pathValue: string): string {
function createStableBridgeEnv(): NodeJS.ProcessEnv { function createStableBridgeEnv(): NodeJS.ProcessEnv {
const realHome = os.userInfo().homedir; const realHome = os.userInfo().homedir;
const passThroughKeys = [ const env = applyOpenCodeAutoUpdatePolicy({ ...process.env });
'USER',
'LOGNAME',
'SHELL',
'TMPDIR',
'LANG',
'LC_ALL',
'OPENCODE_E2E_MODEL',
'OPENCODE_DISABLE_CLAUDE_CODE',
'OPENCODE_DISABLE_AUTOUPDATE',
];
return { return {
...Object.fromEntries( ...env,
passThroughKeys
.map((key) => [key, process.env[key]] as const)
.filter((entry): entry is readonly [string, string] => typeof entry[1] === 'string')
),
HOME: realHome, HOME: realHome,
USERPROFILE: realHome, USERPROFILE: realHome,
}; };

View file

@ -8,6 +8,7 @@ import {
import { import {
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
buildOpenCodeProjectPathFingerprint,
type OpenCodeProductionE2EEvidence, type OpenCodeProductionE2EEvidence,
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
import { import {
@ -167,6 +168,7 @@ describe('OpenCodeReadinessBridge', () => {
}); });
expect(evidence.read).toHaveBeenCalledWith({ expect(evidence.read).toHaveBeenCalledWith({
selectedModel: 'openai/gpt-5.4-mini', selectedModel: 'openai/gpt-5.4-mini',
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
}); });
}); });
@ -365,7 +367,7 @@ function productionEvidence(
binaryFingerprint: 'bin-1', binaryFingerprint: 'bin-1',
capabilitySnapshotId: 'cap-1', capabilitySnapshotId: 'cap-1',
selectedModel: 'openai/gpt-5.4-mini', selectedModel: 'openai/gpt-5.4-mini',
projectPathFingerprint: 'project-a', projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
requiredSignals: Object.fromEntries( requiredSignals: Object.fromEntries(
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
) as OpenCodeProductionE2EEvidence['requiredSignals'], ) as OpenCodeProductionE2EEvidence['requiredSignals'],

View file

@ -38,6 +38,25 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
}); });
}); });
it('uses runtime-only readiness for model-less preflight checks', async () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
await expect(adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))).resolves
.toMatchObject({
ok: true,
providerId: 'opencode',
modelId: null,
});
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
projectPath: '/repo',
selectedModel: null,
requireExecutionProbe: false,
launchMode: undefined,
});
});
it('fails closed when launch mode is disabled', async () => { it('fails closed when launch mode is disabled', async () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true })); const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
const adapter = new OpenCodeTeamRuntimeAdapter( const adapter = new OpenCodeTeamRuntimeAdapter(

View file

@ -92,6 +92,7 @@ vi.mock('@main/utils/childProcess', () => ({
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
import { spawnCli } from '@main/utils/childProcess'; import { spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
@ -327,6 +328,59 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(ClaudeBinaryResolver.resolve).not.toHaveBeenCalled(); expect(ClaudeBinaryResolver.resolve).not.toHaveBeenCalled();
}); });
it('marks model-less OpenCode prepare as runtime-only and keeps model checks strict', async () => {
const prepare = vi.fn(async () => ({
ok: true as const,
providerId: 'opencode' as const,
modelId: 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);
await expect(
svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
})
).resolves.toMatchObject({
ready: true,
message: 'CLI is warmed up and ready to launch',
});
expect(prepare).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
providerId: 'opencode',
model: undefined,
runtimeOnly: true,
})
);
await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: ['opencode/minimax-m2.5-free'],
});
expect(prepare).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
runtimeOnly: false,
})
);
});
it('keys the prepare probe cache by cwd', async () => { it('keys the prepare probe cache by cwd', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({

View file

@ -261,9 +261,9 @@ vi.mock('@renderer/utils/geminiUiFreeze', () => ({
})); }));
vi.mock('@renderer/utils/teamModelAvailability', () => ({ vi.mock('@renderer/utils/teamModelAvailability', () => ({
getTeamModelSelectionError: () => null, getTeamModelSelectionError: vi.fn(() => null),
isTeamModelAvailableForUi: () => true, isTeamModelAvailableForUi: vi.fn(() => true),
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model, normalizeExplicitTeamModelForUi: vi.fn((_providerId: string, model: string) => model),
})); }));
vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({ vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
@ -356,6 +356,8 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog'; import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
async function flush(): Promise<void> { async function flush(): Promise<void> {
await Promise.resolve(); await Promise.resolve();
@ -484,6 +486,115 @@ describe('LaunchTeamDialog', () => {
}); });
}); });
it('clears stale inherited member models after saved launch hydration for OpenCode', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(isTeamModelAvailableForUi).mockImplementation(
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: null,
detailMessage: null,
models: ['opencode/minimax-m2.5-free'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
],
} as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValue({
teamName: 'team-alpha',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
members: [
{
name: 'alice',
role: 'Reviewer',
model: 'gemini-3-pro-preview',
},
],
} as any);
const onLaunch = vi.fn<
(request: { providerId?: string; model?: string }) => Promise<void>
>(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch,
})
);
await flush();
await flush();
await flush();
});
const opencodePrepareCalls = vi
.mocked(runProviderPrepareDiagnostics)
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
expect(opencodePrepareCalls.at(-1)?.[0]?.selectedModelIds).toEqual([
'opencode/minimax-m2.5-free',
]);
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team'
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flush();
await flush();
});
expect(vi.mocked(api.teams.replaceMembers)).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({
members: [
{
name: 'alice',
role: 'Reviewer',
model: '',
},
],
});
expect(onLaunch).toHaveBeenCalledTimes(1);
const launchRequest = (onLaunch.mock.calls as Array<
[{ providerId?: string; model?: string }]
>)[0]?.[0] as
| { providerId?: string; model?: string }
| undefined;
expect(launchRequest).toMatchObject({
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
});
await act(async () => {
root.unmount();
await flush();
});
});
it('prefills and saves Anthropic schedule runtime contract including max effort and fast mode', async () => { it('prefills and saves Anthropic schedule runtime contract including max effort and fast mode', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { storeState.cliStatus = {

View file

@ -413,4 +413,83 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.warnings).toEqual([]); expect(result.warnings).toEqual([]);
expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']); expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']);
}); });
it('suppresses a generic runtime preflight note when all selected models verify', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[]
) => Promise<TeamProvisioningPrepareResult>
>((_, __, ___, selectedModels) => {
if (!selectedModels || selectedModels.length === 0) {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch (see notes)',
warnings: ['orchestrator-cli preflight check failed (exit code 1).'],
});
}
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch (see notes)',
warnings: ['orchestrator-cli preflight check failed (exit code 1).'],
});
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'codex',
selectedModelIds: ['gpt-5.4'],
prepareProvisioning,
});
expect(result.status).toBe('ready');
expect(result.warnings).toEqual([]);
expect(result.details).toEqual(['5.4 - verified']);
expect(result.modelResultsById).toEqual({
'gpt-5.4': {
status: 'ready',
line: '5.4 - verified',
warningLine: null,
},
});
});
it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[]
) => Promise<TeamProvisioningPrepareResult>
>((_, __, ___, selectedModels) => {
if (!selectedModels || selectedModels.length === 0) {
return Promise.resolve({
ready: true,
message: 'CLI is warmed up and ready to launch',
});
}
return Promise.resolve({
ready: false,
message: 'OpenCode: not_authenticated',
details: ['Token refresh failed: 401'],
});
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['openai/gpt-5.2-codex'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([
'GPT-5.2 Codex - unavailable - OpenCode provider authentication failed (token refresh 401)',
]);
});
}); });

View file

@ -4,6 +4,9 @@ import {
getAvailableTeamProviderModelOptions, getAvailableTeamProviderModelOptions,
getAvailableTeamProviderModels, getAvailableTeamProviderModels,
getTeamModelSelectionError, getTeamModelSelectionError,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
normalizeTeamModelForUi, normalizeTeamModelForUi,
type TeamModelRuntimeProviderStatus, type TeamModelRuntimeProviderStatus,
} from '@renderer/utils/teamModelAvailability'; } from '@renderer/utils/teamModelAvailability';
@ -188,6 +191,47 @@ describe('teamModelAvailability', () => {
expect(getTeamModelSelectionError('codex', '')).toBeNull(); expect(getTeamModelSelectionError('codex', '')).toBeNull();
}); });
it('keeps known Codex selections stable while the runtime is still on placeholder checking state', () => {
const providerStatus = createCodexProviderStatus([], {
authMethod: null,
backend: null,
authenticated: false,
supported: false,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: 'Checking...',
});
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');
expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull();
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'gpt-5.4', label: '5.4', badgeLabel: '5.4' },
{ value: 'gpt-5.4-mini', label: '5.4 Mini', badgeLabel: '5.4-mini' },
{ value: 'gpt-5.3-codex', label: '5.3 Codex', badgeLabel: '5.3-codex' },
{
value: 'gpt-5.3-codex-spark',
label: '5.3 Codex Spark',
badgeLabel: '5.3-codex-spark',
uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
},
{ value: 'gpt-5.2', label: '5.2', badgeLabel: '5.2' },
{
value: 'gpt-5.2-codex',
label: '5.2 Codex',
badgeLabel: '5.2-codex',
uiDisabledReason: GPT_5_2_CODEX_UI_DISABLED_REASON,
},
{
value: 'gpt-5.1-codex-mini',
label: '5.1 Codex Mini',
badgeLabel: '5.1-codex-mini',
uiDisabledReason: GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
},
{ value: 'gpt-5.1-codex-max', label: '5.1 Codex Max', badgeLabel: '5.1-codex-max' },
]);
});
it('keeps runtime models selectable without per-model verification state', () => { it('keeps runtime models selectable without per-model verification state', () => {
const providerStatus = createCodexProviderStatus(['gpt-5.4']); const providerStatus = createCodexProviderStatus(['gpt-5.4']);
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');

View file

@ -154,4 +154,67 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.panelMessage).toContain('requested model is not available'); expect(presentation?.panelMessage).toContain('requested model is not available');
expect(presentation?.compactDetail).toBe('jack failed to start'); expect(presentation?.compactDetail).toBe('jack failed to start');
}); });
it('prefers live member spawn statuses over a stale persisted launch summary', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-4',
teamName: 'codex-team',
state: 'ready',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:00:08.000Z',
message: 'Launch completed',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
agentType: 'engineer',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
bob: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
livenessSource: 'process',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z',
},
},
memberSpawnSnapshot: {
expectedMembers: ['bob'],
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
},
});
expect(presentation?.compactTitle).toBe('Finishing launch');
expect(presentation?.compactDetail).toBe('1 teammate still joining');
expect(presentation?.panelMessage).toBe('1 teammate still joining');
});
}); });