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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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]!);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.`;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue