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,
providerId: 'opencode',
model: selectedModelIds[0],
runtimeOnly: selectedModelIds.length === 0,
skipPermissions: true,
expectedMembers: [],
previousLaunchState: null,

View file

@ -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)
),

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

View file

@ -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]!);
}

View file

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

View file

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

View file

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

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)) {
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',

View file

@ -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.`;

View file

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

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 {
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,
};

View file

@ -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'],

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 () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
const adapter = new OpenCodeTeamRuntimeAdapter(

View file

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

View file

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

View file

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

View file

@ -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');

View file

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