fix(opencode): tighten readiness and create-team preflight
This commit is contained in:
parent
83a9db5313
commit
185789cc0a
18 changed files with 682 additions and 73 deletions
|
|
@ -6245,6 +6245,7 @@ export class TeamProvisioningService {
|
|||
cwd: targetCwd,
|
||||
providerId: 'opencode',
|
||||
model: selectedModelIds[0],
|
||||
runtimeOnly: selectedModelIds.length === 0,
|
||||
skipPermissions: true,
|
||||
expectedMembers: [],
|
||||
previousLaunchState: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
|
|
@ -51,7 +52,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||
read(input?: { selectedModel?: string | null }): Promise<{
|
||||
read(input?: { selectedModel?: string | null; projectPathFingerprint?: string | null }): Promise<{
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
|
|
@ -128,8 +129,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
}
|
||||
|
||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||
const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath);
|
||||
const evidenceRead = this.options.productionE2eEvidence
|
||||
? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel })
|
||||
? await this.options.productionE2eEvidence.read({
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
|
|
@ -145,6 +150,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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_COLLECTION_SCHEMA_VERSION = 1;
|
||||
|
||||
|
|
@ -92,6 +95,7 @@ export interface OpenCodeProductionE2EGateExpectation {
|
|||
binaryFingerprint: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
selectedModel: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
requiredMcpTools?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +104,18 @@ export interface OpenCodeProductionE2EGateResult {
|
|||
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(
|
||||
value: unknown
|
||||
): 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 ?? [];
|
||||
if (requiredTools.length > 0) {
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
|
|
@ -418,19 +443,14 @@ function validateOpenCodeProductionE2EEvidenceCollection(
|
|||
}
|
||||
|
||||
const entries: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||
for (const [modelId, rawEvidence] of Object.entries(entriesRecord)) {
|
||||
const trimmedModelId = modelId.trim();
|
||||
if (!trimmedModelId) {
|
||||
throw new Error('OpenCode production E2E evidence collection model id must be non-empty');
|
||||
for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) {
|
||||
const trimmedEntryKey = entryKey.trim();
|
||||
if (!trimmedEntryKey) {
|
||||
throw new Error('OpenCode production E2E evidence collection key must be non-empty');
|
||||
}
|
||||
|
||||
const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence);
|
||||
if (evidence.selectedModel !== trimmedModelId) {
|
||||
throw new Error(
|
||||
`OpenCode production E2E evidence collection key ${trimmedModelId} does not match selectedModel ${evidence.selectedModel}`
|
||||
);
|
||||
}
|
||||
entries[trimmedModelId] = evidence;
|
||||
entries[trimmedEntryKey] = evidence;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
|||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
}
|
||||
|
||||
export class OpenCodeProductionE2EEvidenceStore {
|
||||
|
|
@ -62,7 +63,7 @@ export class OpenCodeProductionE2EEvidenceStore {
|
|||
};
|
||||
}
|
||||
|
||||
const selection = selectEvidence(result.data, options.selectedModel);
|
||||
const selection = selectEvidence(result.data, options);
|
||||
return {
|
||||
ok: true,
|
||||
evidence: selection.evidence,
|
||||
|
|
@ -90,7 +91,7 @@ export class OpenCodeProductionE2EEvidenceStore {
|
|||
|
||||
function selectEvidence(
|
||||
data: OpenCodeProductionE2EEvidenceStoreData,
|
||||
selectedModel: string | null | undefined
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
diagnostics: string[];
|
||||
|
|
@ -103,13 +104,43 @@ function selectEvidence(
|
|||
return { evidence: data, diagnostics: [] };
|
||||
}
|
||||
|
||||
const modelId = selectedModel?.trim() ?? '';
|
||||
const modelId = options.selectedModel?.trim() ?? '';
|
||||
const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? '';
|
||||
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 {
|
||||
evidence: data.entriesByModel[modelId] ?? null,
|
||||
diagnostics: data.entriesByModel[modelId]
|
||||
? []
|
||||
: [`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`],
|
||||
evidence: pickNewestEvidence(entries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -140,9 +171,33 @@ function upsertEvidence(
|
|||
entriesByModel[current.selectedModel] = current;
|
||||
}
|
||||
|
||||
entriesByModel[evidence.selectedModel] = evidence;
|
||||
entriesByModel[buildEvidenceKey(evidence)] = evidence;
|
||||
return {
|
||||
collectionSchemaVersion: 1,
|
||||
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]!);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
) {}
|
||||
|
||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
||||
const launchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
if (launchMode === 'disabled') {
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
if (configuredLaunchMode === 'disabled') {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
|
|
@ -80,11 +80,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
const runtimeOnly = input.runtimeOnly === true;
|
||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: input.cwd,
|
||||
selectedModel: input.model ?? null,
|
||||
requireExecutionProbe: true,
|
||||
launchMode,
|
||||
requireExecutionProbe: !runtimeOnly,
|
||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
||||
});
|
||||
|
||||
if (!readiness.launchAllowed) {
|
||||
|
|
@ -99,13 +100,17 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
}
|
||||
|
||||
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.',
|
||||
]
|
||||
: [];
|
||||
|
||||
if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') {
|
||||
if (
|
||||
!runtimeOnly &&
|
||||
configuredLaunchMode === 'production' &&
|
||||
readiness.supportLevel !== 'production_supported'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
|
|
@ -128,6 +133,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
}
|
||||
|
||||
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
const prepared = await this.prepare(input);
|
||||
if (!prepared.ok) {
|
||||
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;
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
mode: resolveOpenCodeTeamLaunchMode(this.options),
|
||||
mode: configuredLaunchMode,
|
||||
runId: input.runId,
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export interface TeamRuntimeLaunchInput {
|
|||
providerId: TeamRuntimeProviderId;
|
||||
model?: string;
|
||||
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;
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||
|
|
|
|||
|
|
@ -494,6 +494,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMembersDrafts((prev) => {
|
||||
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
|
||||
members: prev,
|
||||
|
|
@ -502,7 +506,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
});
|
||||
return sanitized.changed ? sanitized.members : prev;
|
||||
});
|
||||
}, [runtimeProviderStatusById, selectedProviderId]);
|
||||
}, [membersDrafts, open, runtimeProviderStatusById, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (multimodelEnabled) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
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 (
|
||||
trimmed.toLowerCase().includes('timeout running:') ||
|
||||
trimmed.toLowerCase().includes('timed out') ||
|
||||
|
|
@ -247,6 +253,19 @@ function extractTimedOutPreflightProbeModelId(detail: string): string | 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: {
|
||||
runtimeDetailLines: string[];
|
||||
runtimeWarnings: string[];
|
||||
|
|
@ -256,16 +275,23 @@ function suppressSupersededRuntimeWarnings(params: {
|
|||
runtimeWarnings: 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) {
|
||||
const probedModelId = extractTimedOutPreflightProbeModelId(warning);
|
||||
if (!probedModelId) {
|
||||
if (probedModelId) {
|
||||
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
|
||||
continue;
|
||||
}
|
||||
suppressedEntries.add(warning);
|
||||
continue;
|
||||
}
|
||||
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
|
||||
continue;
|
||||
|
||||
if (allSelectedModelsReady && isSuppressibleGenericPreflightWarning(warning)) {
|
||||
suppressedEntries.add(warning);
|
||||
}
|
||||
suppressedEntries.add(warning);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -283,9 +309,11 @@ function resolveModelResultFromBatch(
|
|||
isOnlyModel: boolean
|
||||
): ProviderPrepareDiagnosticsModelResult {
|
||||
const modelScopedEntries = getModelScopedEntries(modelId, result);
|
||||
const normalizedReason =
|
||||
getScopedModelReason(modelId, modelScopedEntries) ??
|
||||
(isOnlyModel ? normalizeModelReason(result.message) : null);
|
||||
const hasModelScopedEntries = modelScopedEntries.length > 0;
|
||||
const scopedReason = getScopedModelReason(modelId, modelScopedEntries);
|
||||
const fallbackBatchReason = isOnlyModel
|
||||
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
|
||||
: null;
|
||||
|
||||
const hasVerifiedLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* verified for launch\./i.test(entry)
|
||||
|
|
@ -304,7 +332,12 @@ function resolveModelResultFromBatch(
|
|||
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
|
||||
return {
|
||||
status: 'failed',
|
||||
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason),
|
||||
line: buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'unavailable',
|
||||
scopedReason ?? fallbackBatchReason
|
||||
),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -312,8 +345,11 @@ function resolveModelResultFromBatch(
|
|||
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* could not be verified\./i.test(entry)
|
||||
);
|
||||
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason);
|
||||
if (
|
||||
hasVerificationWarningLine ||
|
||||
((result.warnings?.length ?? 0) > 0 && isOnlyModel && hasModelScopedEntries)
|
||||
) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', scopedReason);
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
|
|
@ -333,7 +369,7 @@ function resolveModelResultFromBatch(
|
|||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
normalizedReason ?? 'Model verification failed'
|
||||
scopedReason ?? 'Model verification failed'
|
||||
);
|
||||
return {
|
||||
status: 'notes',
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ export type TeamModelRuntimeProviderStatus = Pick<
|
|||
| 'backend'
|
||||
| 'authenticated'
|
||||
| 'supported'
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
|
||||
|
||||
export type TeamRuntimeModelOption = TeamProviderModelOption & {
|
||||
availabilityStatus?: CliProviderModelAvailabilityStatus | null;
|
||||
|
|
@ -77,6 +78,34 @@ export function isTeamModelUiDisabled(
|
|||
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[] {
|
||||
return getVisibleTeamProviderModels(
|
||||
providerId,
|
||||
|
|
@ -304,6 +333,10 @@ export function getAvailableTeamProviderModelOptions(
|
|||
return [{ value: '', label: 'Default', badgeLabel: 'Default' }];
|
||||
}
|
||||
|
||||
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
|
||||
return getFallbackTeamProviderModelOptions(providerId, providerStatus);
|
||||
}
|
||||
|
||||
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
|
||||
return [
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
|
|
@ -348,6 +381,10 @@ export function isTeamModelAvailableForUi(
|
|||
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||
}
|
||||
|
||||
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +419,10 @@ export function normalizeTeamModelForUi(
|
|||
return '';
|
||||
}
|
||||
|
||||
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
|
||||
if (!visibleModels.includes(trimmed)) {
|
||||
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.`;
|
||||
}
|
||||
|
||||
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
|
||||
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.`;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
|
|
@ -43,6 +44,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
},
|
||||
})
|
||||
|
|
@ -73,6 +75,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/open
|
|||
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
|
|
@ -99,8 +101,8 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
selectedModel,
|
||||
requireExecutionProbe: false,
|
||||
});
|
||||
const runtime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
|
||||
if (!runtime) {
|
||||
const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
|
||||
if (!initialRuntime) {
|
||||
throw new Error(
|
||||
`OpenCode live readiness did not return runtime snapshot: ${[
|
||||
...readiness.diagnostics,
|
||||
|
|
@ -108,8 +110,8 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
].join('; ')}`
|
||||
);
|
||||
}
|
||||
expect(runtime?.version).toBe('1.14.19');
|
||||
expect(runtime?.capabilitySnapshotId).toBeTruthy();
|
||||
expect(initialRuntime?.version).toBe('1.14.19');
|
||||
expect(initialRuntime?.capabilitySnapshotId).toBeTruthy();
|
||||
|
||||
const runId = `opencode-e2e-${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',
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
|
|
@ -148,7 +150,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
expectedMembers: [{ name: memberName, model: selectedModel }],
|
||||
reason: 'production_gate_e2e',
|
||||
|
|
@ -182,19 +184,36 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_cleanup',
|
||||
force: 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({
|
||||
runId,
|
||||
teamName,
|
||||
memberName,
|
||||
selectedModel,
|
||||
runtime: runtime!,
|
||||
runtime: finalRuntime,
|
||||
readinessObservedTools: readiness.evidence.observedMcpTools,
|
||||
launch,
|
||||
reconcile,
|
||||
|
|
@ -207,10 +226,11 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
evidence: candidate,
|
||||
artifactPath: candidate.artifactPath,
|
||||
expected: {
|
||||
opencodeVersion: runtime?.version ?? null,
|
||||
binaryFingerprint: runtime?.binaryFingerprint ?? null,
|
||||
capabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
opencodeVersion: finalRuntime.version ?? null,
|
||||
binaryFingerprint: finalRuntime.binaryFingerprint ?? null,
|
||||
capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null,
|
||||
selectedModel,
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
|
|
@ -230,7 +250,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_finally_cleanup',
|
||||
force: true,
|
||||
|
|
@ -373,7 +393,7 @@ function buildCandidateEvidence(input: {
|
|||
binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown',
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown',
|
||||
selectedModel: input.selectedModel,
|
||||
projectPathFingerprint: null,
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
|
||||
requiredSignals: {
|
||||
...Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
|
|
@ -433,23 +453,9 @@ function withBunOnPath(pathValue: string): string {
|
|||
|
||||
function createStableBridgeEnv(): NodeJS.ProcessEnv {
|
||||
const realHome = os.userInfo().homedir;
|
||||
const passThroughKeys = [
|
||||
'USER',
|
||||
'LOGNAME',
|
||||
'SHELL',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'OPENCODE_E2E_MODEL',
|
||||
'OPENCODE_DISABLE_CLAUDE_CODE',
|
||||
'OPENCODE_DISABLE_AUTOUPDATE',
|
||||
];
|
||||
const env = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
passThroughKeys
|
||||
.map((key) => [key, process.env[key]] as const)
|
||||
.filter((entry): entry is readonly [string, string] => typeof entry[1] === 'string')
|
||||
),
|
||||
...env,
|
||||
HOME: realHome,
|
||||
USERPROFILE: realHome,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
|
|
@ -167,6 +168,7 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
});
|
||||
expect(evidence.read).toHaveBeenCalledWith({
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -365,7 +367,7 @@ function productionEvidence(
|
|||
binaryFingerprint: 'bin-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
|
||||
requiredSignals: Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ vi.mock('@main/utils/childProcess', () => ({
|
|||
|
||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
|
||||
import { spawnCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
|
|
@ -327,6 +328,59 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -261,9 +261,9 @@ vi.mock('@renderer/utils/geminiUiFreeze', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/utils/teamModelAvailability', () => ({
|
||||
getTeamModelSelectionError: () => null,
|
||||
isTeamModelAvailableForUi: () => true,
|
||||
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model,
|
||||
getTeamModelSelectionError: vi.fn(() => null),
|
||||
isTeamModelAvailableForUi: vi.fn(() => true),
|
||||
normalizeExplicitTeamModelForUi: vi.fn((_providerId: string, model: string) => model),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
|
||||
|
|
@ -356,6 +356,8 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
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> {
|
||||
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 () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
|
|||
|
|
@ -413,4 +413,83 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
expect(result.warnings).toEqual([]);
|
||||
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)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import {
|
|||
getAvailableTeamProviderModelOptions,
|
||||
getAvailableTeamProviderModels,
|
||||
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,
|
||||
type TeamModelRuntimeProviderStatus,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
|
|
@ -188,6 +191,47 @@ describe('teamModelAvailability', () => {
|
|||
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', () => {
|
||||
const providerStatus = createCodexProviderStatus(['gpt-5.4']);
|
||||
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');
|
||||
|
|
|
|||
|
|
@ -154,4 +154,67 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
expect(presentation?.panelMessage).toContain('requested model is not available');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue