fix(opencode): scope model preflight by provider
This commit is contained in:
parent
09004df72c
commit
28b64ec467
10 changed files with 497 additions and 62 deletions
|
|
@ -1,16 +1,17 @@
|
||||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
|
||||||
import {
|
|
||||||
buildOpenCodeCanonicalMcpToolId,
|
|
||||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
|
||||||
} from '../mcp/OpenCodeMcpToolAvailability';
|
|
||||||
import type {
|
|
||||||
OpenCodeTeamLaunchReadiness,
|
|
||||||
OpenCodeTeamLaunchReadinessState,
|
|
||||||
} from '../readiness/OpenCodeTeamLaunchReadiness';
|
|
||||||
import {
|
import {
|
||||||
assertOpenCodeProductionE2EArtifactGate,
|
assertOpenCodeProductionE2EArtifactGate,
|
||||||
type OpenCodeProductionE2EEvidence,
|
type OpenCodeProductionE2EEvidence,
|
||||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||||
|
import {
|
||||||
|
buildOpenCodeCanonicalMcpToolId,
|
||||||
|
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||||
|
} from '../mcp/OpenCodeMcpToolAvailability';
|
||||||
|
|
||||||
|
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||||
|
import type {
|
||||||
|
OpenCodeTeamLaunchReadiness,
|
||||||
|
OpenCodeTeamLaunchReadinessState,
|
||||||
|
} from '../readiness/OpenCodeTeamLaunchReadiness';
|
||||||
import type {
|
import type {
|
||||||
OpenCodeBridgeCommandName,
|
OpenCodeBridgeCommandName,
|
||||||
OpenCodeBridgeDiagnosticEvent,
|
OpenCodeBridgeDiagnosticEvent,
|
||||||
|
|
@ -50,7 +51,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||||
read(): Promise<{
|
read(input?: { selectedModel?: string | null }): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
evidence: OpenCodeProductionE2EEvidence | null;
|
evidence: OpenCodeProductionE2EEvidence | null;
|
||||||
artifactPath: string;
|
artifactPath: string;
|
||||||
|
|
@ -126,15 +127,15 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
return input.readiness;
|
return input.readiness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||||
const evidenceRead = this.options.productionE2eEvidence
|
const evidenceRead = this.options.productionE2eEvidence
|
||||||
? await this.options.productionE2eEvidence.read()
|
? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel })
|
||||||
: {
|
: {
|
||||||
ok: false,
|
ok: false,
|
||||||
evidence: null,
|
evidence: null,
|
||||||
artifactPath: '',
|
artifactPath: '',
|
||||||
diagnostics: ['OpenCode production E2E evidence store is not configured'],
|
diagnostics: ['OpenCode production E2E evidence store is not configured'],
|
||||||
};
|
};
|
||||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
|
||||||
const gate = evidenceRead.ok
|
const gate = evidenceRead.ok
|
||||||
? assertOpenCodeProductionE2EArtifactGate({
|
? assertOpenCodeProductionE2EArtifactGate({
|
||||||
evidence: evidenceRead.evidence,
|
evidence: evidenceRead.evidence,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
|
@ -76,6 +77,16 @@ export interface OpenCodeProductionE2EEvidence {
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeProductionE2EEvidenceCollection {
|
||||||
|
collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION;
|
||||||
|
entriesByModel: Record<string, OpenCodeProductionE2EEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenCodeProductionE2EEvidenceStoreData =
|
||||||
|
| OpenCodeProductionE2EEvidence
|
||||||
|
| OpenCodeProductionE2EEvidenceCollection
|
||||||
|
| null;
|
||||||
|
|
||||||
export interface OpenCodeProductionE2EGateExpectation {
|
export interface OpenCodeProductionE2EGateExpectation {
|
||||||
opencodeVersion: string | null;
|
opencodeVersion: string | null;
|
||||||
binaryFingerprint: string | null;
|
binaryFingerprint: string | null;
|
||||||
|
|
@ -134,6 +145,34 @@ export function validateNullableOpenCodeProductionE2EEvidence(
|
||||||
return validateOpenCodeProductionE2EEvidence(value);
|
return validateOpenCodeProductionE2EEvidence(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateOpenCodeProductionE2EEvidenceStoreData(
|
||||||
|
value: unknown
|
||||||
|
): OpenCodeProductionE2EEvidenceStoreData {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (
|
||||||
|
record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||||
|
) {
|
||||||
|
return validateOpenCodeProductionE2EEvidenceCollection(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateOpenCodeProductionE2EEvidence(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenCodeProductionE2EEvidenceCollection(
|
||||||
|
value: OpenCodeProductionE2EEvidenceStoreData
|
||||||
|
): value is OpenCodeProductionE2EEvidenceCollection {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
'collectionSchemaVersion' in value &&
|
||||||
|
value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function assertOpenCodeProductionE2EEvidenceBasics(input: {
|
export function assertOpenCodeProductionE2EEvidenceBasics(input: {
|
||||||
evidence: OpenCodeProductionE2EEvidence | null;
|
evidence: OpenCodeProductionE2EEvidence | null;
|
||||||
testedVersion: string;
|
testedVersion: string;
|
||||||
|
|
@ -370,6 +409,36 @@ function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateOpenCodeProductionE2EEvidenceCollection(
|
||||||
|
value: Record<string, unknown>
|
||||||
|
): OpenCodeProductionE2EEvidenceCollection {
|
||||||
|
const entriesRecord = asRecord(value.entriesByModel);
|
||||||
|
if (!entriesRecord) {
|
||||||
|
throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION,
|
||||||
|
entriesByModel: entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] {
|
function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import {
|
|
||||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
|
||||||
validateNullableOpenCodeProductionE2EEvidence,
|
|
||||||
validateOpenCodeProductionE2EEvidence,
|
|
||||||
type OpenCodeProductionE2EEvidence,
|
|
||||||
} from './OpenCodeProductionE2EEvidence';
|
|
||||||
import { VersionedJsonStore } from '../store/VersionedJsonStore';
|
import { VersionedJsonStore } from '../store/VersionedJsonStore';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isOpenCodeProductionE2EEvidenceCollection,
|
||||||
|
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||||
|
type OpenCodeProductionE2EEvidence,
|
||||||
|
type OpenCodeProductionE2EEvidenceCollection,
|
||||||
|
type OpenCodeProductionE2EEvidenceStoreData,
|
||||||
|
validateOpenCodeProductionE2EEvidence,
|
||||||
|
validateOpenCodeProductionE2EEvidenceStoreData,
|
||||||
|
} from './OpenCodeProductionE2EEvidence';
|
||||||
|
|
||||||
export interface OpenCodeProductionE2EEvidenceStoreReadResult {
|
export interface OpenCodeProductionE2EEvidenceStoreReadResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
evidence: OpenCodeProductionE2EEvidence | null;
|
evidence: OpenCodeProductionE2EEvidence | null;
|
||||||
|
|
@ -20,23 +24,29 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
||||||
clock?: () => Date;
|
clock?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||||
|
selectedModel?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenCodeProductionE2EEvidenceStore {
|
export class OpenCodeProductionE2EEvidenceStore {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidence | null>;
|
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>;
|
||||||
|
|
||||||
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
||||||
this.filePath = options.filePath;
|
this.filePath = options.filePath;
|
||||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidence | null>({
|
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>({
|
||||||
filePath: options.filePath,
|
filePath: options.filePath,
|
||||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||||
defaultData: () => null,
|
defaultData: () => null,
|
||||||
validate: validateNullableOpenCodeProductionE2EEvidence,
|
validate: validateOpenCodeProductionE2EEvidenceStoreData,
|
||||||
clock: options.clock,
|
clock: options.clock,
|
||||||
quarantineDir: path.dirname(options.filePath),
|
quarantineDir: path.dirname(options.filePath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
async read(
|
||||||
|
options: OpenCodeProductionE2EEvidenceStoreReadOptions = {}
|
||||||
|
): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||||
const result = await this.store.read();
|
const result = await this.store.read();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -52,22 +62,87 @@ export class OpenCodeProductionE2EEvidenceStore {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selection = selectEvidence(result.data, options.selectedModel);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
evidence: result.data,
|
evidence: selection.evidence,
|
||||||
artifactPath: this.filePath,
|
artifactPath: this.filePath,
|
||||||
diagnostics:
|
diagnostics: [
|
||||||
result.status === 'missing'
|
...selection.diagnostics,
|
||||||
|
...(result.status === 'missing'
|
||||||
? ['OpenCode production E2E evidence artifact has not been written yet']
|
? ['OpenCode production E2E evidence artifact has not been written yet']
|
||||||
: [],
|
: []),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
|
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
|
||||||
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
||||||
await this.store.updateLocked(() => ({
|
await this.store.updateLocked((current) => {
|
||||||
...validated,
|
const nextEvidence = {
|
||||||
artifactPath: validated.artifactPath ?? this.filePath,
|
...validated,
|
||||||
}));
|
artifactPath: validated.artifactPath ?? this.filePath,
|
||||||
|
};
|
||||||
|
return upsertEvidence(current, nextEvidence);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectEvidence(
|
||||||
|
data: OpenCodeProductionE2EEvidenceStoreData,
|
||||||
|
selectedModel: string | null | undefined
|
||||||
|
): {
|
||||||
|
evidence: OpenCodeProductionE2EEvidence | null;
|
||||||
|
diagnostics: string[];
|
||||||
|
} {
|
||||||
|
if (!data) {
|
||||||
|
return { evidence: null, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpenCodeProductionE2EEvidenceCollection(data)) {
|
||||||
|
return { evidence: data, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = selectedModel?.trim() ?? '';
|
||||||
|
if (modelId) {
|
||||||
|
return {
|
||||||
|
evidence: data.entriesByModel[modelId] ?? null,
|
||||||
|
diagnostics: data.entriesByModel[modelId]
|
||||||
|
? []
|
||||||
|
: [`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.values(data.entriesByModel);
|
||||||
|
if (entries.length === 1) {
|
||||||
|
return { evidence: entries[0] ?? null, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
evidence: null,
|
||||||
|
diagnostics:
|
||||||
|
entries.length === 0
|
||||||
|
? ['OpenCode production E2E evidence artifact has no model entries']
|
||||||
|
: [
|
||||||
|
`OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEvidence(
|
||||||
|
current: OpenCodeProductionE2EEvidenceStoreData,
|
||||||
|
evidence: OpenCodeProductionE2EEvidence
|
||||||
|
): OpenCodeProductionE2EEvidenceCollection {
|
||||||
|
const entriesByModel: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||||
|
if (isOpenCodeProductionE2EEvidenceCollection(current)) {
|
||||||
|
Object.assign(entriesByModel, current.entriesByModel);
|
||||||
|
} else if (current) {
|
||||||
|
entriesByModel[current.selectedModel] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesByModel[evidence.selectedModel] = evidence;
|
||||||
|
return {
|
||||||
|
collectionSchemaVersion: 1,
|
||||||
|
entriesByModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
import {
|
||||||
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
|
resolveProviderScopedMemberModel,
|
||||||
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
|
|
@ -564,6 +568,15 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
return new Map<TeamProviderId, string | null>(entries);
|
return new Map<TeamProviderId, string | null>(entries);
|
||||||
}, [effectiveCliStatus?.providers]);
|
}, [effectiveCliStatus?.providers]);
|
||||||
|
const runtimeProviderStatusById = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
(effectiveCliStatus?.providers ?? []).map(
|
||||||
|
(provider) => [provider.providerId, provider] as const
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[effectiveCliStatus?.providers]
|
||||||
|
);
|
||||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareModelResultsCacheRef = useRef(
|
const prepareModelResultsCacheRef = useRef(
|
||||||
|
|
@ -574,6 +587,17 @@ export const CreateTeamDialog = ({
|
||||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||||
}, [runtimeBackendSummaryByProvider]);
|
}, [runtimeBackendSummaryByProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
|
||||||
|
members,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
});
|
||||||
|
if (sanitized.changed) {
|
||||||
|
setMembers(sanitized.members);
|
||||||
|
}
|
||||||
|
}, [members, runtimeProviderStatusById, selectedProviderId, setMembers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prepareChecksRef.current = prepareChecks;
|
prepareChecksRef.current = prepareChecks;
|
||||||
}, [prepareChecks]);
|
}, [prepareChecks]);
|
||||||
|
|
@ -672,14 +696,17 @@ export const CreateTeamDialog = ({
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const memberProviderId =
|
const scopedModel = resolveProviderScopedMemberModel({
|
||||||
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
memberProviderId: member.providerId,
|
||||||
if (memberProviderId !== providerId) {
|
memberModel: member.model,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
});
|
||||||
|
if (scopedModel.providerId !== providerId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const memberModel = member.model?.trim();
|
if (scopedModel.model) {
|
||||||
if (memberModel) {
|
next.add(scopedModel.model);
|
||||||
next.add(memberModel);
|
|
||||||
} else if (supportsProviderDefaultCheck) {
|
} else if (supportsProviderDefaultCheck) {
|
||||||
hasDefaultSelection = true;
|
hasDefaultSelection = true;
|
||||||
}
|
}
|
||||||
|
|
@ -812,6 +839,7 @@ export const CreateTeamDialog = ({
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
effectiveMemberDrafts,
|
effectiveMemberDrafts,
|
||||||
limitContext,
|
limitContext,
|
||||||
|
runtimeProviderStatusById,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedMemberProviders,
|
selectedMemberProviders,
|
||||||
|
|
@ -1005,15 +1033,6 @@ export const CreateTeamDialog = ({
|
||||||
[memberColorMap, members, soloTeam]
|
[memberColorMap, members, soloTeam]
|
||||||
);
|
);
|
||||||
|
|
||||||
const runtimeProviderStatusById = useMemo(
|
|
||||||
() =>
|
|
||||||
new Map(
|
|
||||||
(effectiveCliStatus?.providers ?? []).map(
|
|
||||||
(provider) => [provider.providerId, provider] as const
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[effectiveCliStatus?.providers]
|
|
||||||
);
|
|
||||||
const effectiveModel = useMemo(
|
const effectiveModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computeEffectiveTeamModel(
|
computeEffectiveTeamModel(
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
|
import {
|
||||||
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
|
resolveProviderScopedMemberModel,
|
||||||
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
|
|
@ -464,6 +468,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
[effectiveCliStatus?.providers]
|
[effectiveCliStatus?.providers]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMembersDrafts((prev) => {
|
||||||
|
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
|
||||||
|
members: prev,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
});
|
||||||
|
return sanitized.changed ? sanitized.members : prev;
|
||||||
|
});
|
||||||
|
}, [runtimeProviderStatusById, selectedProviderId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -889,11 +904,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
const scopedModel = resolveProviderScopedMemberModel({
|
||||||
if (member.model?.trim()) {
|
memberProviderId: member.providerId,
|
||||||
addModel(providerId, member.model);
|
memberModel: member.model,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
});
|
||||||
|
if (scopedModel.model) {
|
||||||
|
addModel(scopedModel.providerId, scopedModel.model);
|
||||||
} else {
|
} else {
|
||||||
addDefaultSelection(providerId);
|
addDefaultSelection(scopedModel.providerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const providerId of defaultSelectionByProvider.keys()) {
|
for (const providerId of defaultSelectionByProvider.keys()) {
|
||||||
|
|
@ -901,7 +921,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
|
|
||||||
return modelsByProvider;
|
return modelsByProvider;
|
||||||
}, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]);
|
}, [
|
||||||
|
effectiveLeadRuntimeModel,
|
||||||
|
effectiveMemberDrafts,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
selectedModel,
|
||||||
|
selectedProviderId,
|
||||||
|
]);
|
||||||
|
|
||||||
const runtimeChangeNotes = useMemo(() => {
|
const runtimeChangeNotes = useMemo(() => {
|
||||||
if (!isLaunchMode) {
|
if (!isLaunchMode) {
|
||||||
|
|
|
||||||
83
src/renderer/components/team/dialogs/memberModelScope.ts
Normal file
83
src/renderer/components/team/dialogs/memberModelScope.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import {
|
||||||
|
isTeamModelAvailableForUi,
|
||||||
|
normalizeExplicitTeamModelForUi,
|
||||||
|
type TeamModelRuntimeProviderStatus,
|
||||||
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
type RuntimeProviderStatusById = ReadonlyMap<
|
||||||
|
TeamProviderId,
|
||||||
|
TeamModelRuntimeProviderStatus | null | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function resolveMemberProviderForModelScope(input: {
|
||||||
|
memberProviderId?: TeamProviderId;
|
||||||
|
selectedProviderId: TeamProviderId;
|
||||||
|
}): TeamProviderId {
|
||||||
|
return normalizeOptionalTeamProviderId(input.memberProviderId) ?? input.selectedProviderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderScopedMemberModel(input: {
|
||||||
|
memberProviderId?: TeamProviderId;
|
||||||
|
memberModel?: string | null;
|
||||||
|
selectedProviderId: TeamProviderId;
|
||||||
|
runtimeProviderStatusById: RuntimeProviderStatusById;
|
||||||
|
}): { providerId: TeamProviderId; model: string } {
|
||||||
|
const providerId = resolveMemberProviderForModelScope(input);
|
||||||
|
const rawModel = input.memberModel?.trim() ?? '';
|
||||||
|
if (!rawModel) {
|
||||||
|
return { providerId, model: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedModel = normalizeExplicitTeamModelForUi(providerId, rawModel);
|
||||||
|
if (!normalizedModel) {
|
||||||
|
return { providerId, model: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerStatus = input.runtimeProviderStatusById.get(providerId) ?? null;
|
||||||
|
if (!isTeamModelAvailableForUi(providerId, normalizedModel, providerStatus)) {
|
||||||
|
return { providerId, model: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providerId, model: normalizedModel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearInheritedMemberModelsUnavailableForProvider(input: {
|
||||||
|
members: MemberDraft[];
|
||||||
|
selectedProviderId: TeamProviderId;
|
||||||
|
runtimeProviderStatusById: RuntimeProviderStatusById;
|
||||||
|
}): { members: MemberDraft[]; changed: boolean } {
|
||||||
|
let changed = false;
|
||||||
|
const members = input.members.map((member) => {
|
||||||
|
if (member.removedAt || member.providerId || !member.model?.trim()) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.selectedProviderId !== 'anthropic' &&
|
||||||
|
!input.runtimeProviderStatusById.get(input.selectedProviderId)
|
||||||
|
) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoped = resolveProviderScopedMemberModel({
|
||||||
|
memberProviderId: member.providerId,
|
||||||
|
memberModel: member.model,
|
||||||
|
selectedProviderId: input.selectedProviderId,
|
||||||
|
runtimeProviderStatusById: input.runtimeProviderStatusById,
|
||||||
|
});
|
||||||
|
if (scoped.model) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
model: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { members, changed };
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,41 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores production evidence for multiple raw model ids and reads exact model matches', async () => {
|
||||||
|
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||||
|
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||||
|
filePath,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.write(passingEvidence({ selectedModel: 'opencode/big-pickle' }));
|
||||||
|
await store.write(
|
||||||
|
passingEvidence({
|
||||||
|
evidenceId: 'e2e-2',
|
||||||
|
selectedModel: 'opencode/minimax-m2.5-free',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.read({ selectedModel: 'opencode/minimax-m2.5-free' })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
evidence: {
|
||||||
|
evidenceId: 'e2e-2',
|
||||||
|
selectedModel: 'opencode/minimax-m2.5-free',
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.read({ selectedModel: 'openai/gpt-5.4-mini' })).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
evidence: null,
|
||||||
|
diagnostics: [
|
||||||
|
'OpenCode production E2E evidence artifact has no entry for selected model openai/gpt-5.4-mini',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function passingEvidence(
|
function passingEvidence(
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,9 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
const executor = fakeExecutor(
|
const executor = fakeExecutor(
|
||||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||||
);
|
);
|
||||||
|
const evidence = fakeEvidenceStore(productionEvidence());
|
||||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||||
productionE2eEvidence: fakeEvidenceStore(productionEvidence()),
|
productionE2eEvidence: evidence,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -164,6 +165,9 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
supportLevel: 'production_supported',
|
supportLevel: 'production_supported',
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
expect(evidence.read).toHaveBeenCalledWith({
|
||||||
|
selectedModel: 'openai/gpt-5.4-mini',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,11 @@ vi.mock('@renderer/components/ui/button', () => ({
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) =>
|
}) =>
|
||||||
React.createElement('button', { type: type ?? 'button', onClick, disabled, className }, children),
|
React.createElement(
|
||||||
|
'button',
|
||||||
|
{ type: type ?? 'button', onClick, disabled, className },
|
||||||
|
children
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||||
|
|
@ -156,8 +160,7 @@ vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||||
id,
|
id,
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked,
|
checked,
|
||||||
onChange: (event: Event) =>
|
onChange: (event: Event) => onCheckedChange?.((event.target as HTMLInputElement).checked),
|
||||||
onCheckedChange?.((event.target as HTMLInputElement).checked),
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -166,13 +169,8 @@ vi.mock('@renderer/components/ui/combobox', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||||
Dialog: ({
|
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||||
open,
|
open ? React.createElement('div', null, children) : null,
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => (open ? React.createElement('div', null, children) : null),
|
|
||||||
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement('div', null, children),
|
React.createElement('div', null, children),
|
||||||
DialogHeader: ({ children }: { children: React.ReactNode }) =>
|
DialogHeader: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
|
@ -258,6 +256,7 @@ vi.mock('@renderer/utils/geminiUiFreeze', () => ({
|
||||||
|
|
||||||
vi.mock('@renderer/utils/teamModelAvailability', () => ({
|
vi.mock('@renderer/utils/teamModelAvailability', () => ({
|
||||||
getTeamModelSelectionError: () => null,
|
getTeamModelSelectionError: () => null,
|
||||||
|
isTeamModelAvailableForUi: () => true,
|
||||||
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model,
|
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -405,7 +404,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
|
|
||||||
const [request, members] = onRelaunch.mock.calls[0] as unknown as [
|
const [request, members] = onRelaunch.mock.calls[0] as unknown as [
|
||||||
{ teamName: string; cwd: string; providerId?: string; model?: string },
|
{ teamName: string; cwd: string; providerId?: string; model?: string },
|
||||||
Array<{ name: string; providerId?: string; model?: string }>
|
Array<{ name: string; providerId?: string; model?: string }>,
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(request.teamName).toBe('team-alpha');
|
expect(request.teamName).toBe('team-alpha');
|
||||||
|
|
|
||||||
124
test/renderer/components/team/dialogs/memberModelScope.test.ts
Normal file
124
test/renderer/components/team/dialogs/memberModelScope.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
|
resolveProviderScopedMemberModel,
|
||||||
|
} from '@renderer/components/team/dialogs/memberModelScope';
|
||||||
|
|
||||||
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
describe('memberModelScope', () => {
|
||||||
|
it('drops stale inherited member models that are not in the selected provider catalog', () => {
|
||||||
|
const scoped = resolveProviderScopedMemberModel({
|
||||||
|
memberModel: 'gemini-3-pro-preview',
|
||||||
|
selectedProviderId: 'opencode',
|
||||||
|
runtimeProviderStatusById: providerStatuses([
|
||||||
|
providerStatus('opencode', ['opencode/minimax-m2.5-free']),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scoped).toEqual({
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves exact OpenCode raw model ids from the runtime catalog', () => {
|
||||||
|
const scoped = resolveProviderScopedMemberModel({
|
||||||
|
memberModel: 'opencode/minimax-m2.5-free',
|
||||||
|
selectedProviderId: 'opencode',
|
||||||
|
runtimeProviderStatusById: providerStatuses([
|
||||||
|
providerStatus('opencode', ['opencode/minimax-m2.5-free']),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scoped).toEqual({
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears only inherited stale models after the selected non-Anthropic provider status is loaded', () => {
|
||||||
|
const inheritedStale = draft({ id: 'inherited', model: 'gemini-3-pro-preview' });
|
||||||
|
const explicitGemini = draft({
|
||||||
|
id: 'explicit',
|
||||||
|
providerId: 'gemini',
|
||||||
|
model: 'gemini-3-pro-preview',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = clearInheritedMemberModelsUnavailableForProvider({
|
||||||
|
members: [inheritedStale, explicitGemini],
|
||||||
|
selectedProviderId: 'opencode',
|
||||||
|
runtimeProviderStatusById: providerStatuses([
|
||||||
|
providerStatus('opencode', ['opencode/minimax-m2.5-free']),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.members).toMatchObject([
|
||||||
|
{ id: 'inherited', model: '' },
|
||||||
|
{ id: 'explicit', providerId: 'gemini', model: 'gemini-3-pro-preview' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for non-Anthropic runtime status before mutating inherited models', () => {
|
||||||
|
const member = draft({ model: 'opencode/minimax-m2.5-free' });
|
||||||
|
|
||||||
|
const result = clearInheritedMemberModelsUnavailableForProvider({
|
||||||
|
members: [member],
|
||||||
|
selectedProviderId: 'opencode',
|
||||||
|
runtimeProviderStatusById: providerStatuses([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.members[0]).toBe(member);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function providerStatuses(
|
||||||
|
statuses: CliProviderStatus[]
|
||||||
|
): ReadonlyMap<TeamProviderId, CliProviderStatus> {
|
||||||
|
return new Map(statuses.map((status) => [status.providerId as TeamProviderId, status]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerStatus(providerId: TeamProviderId, models: string[]): CliProviderStatus {
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
displayName: providerId,
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
canLoginFromUi: false,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
extensions: {
|
||||||
|
plugins: { status: 'read-only', ownership: 'provider-scoped' },
|
||||||
|
mcp: { status: 'read-only', ownership: 'provider-scoped' },
|
||||||
|
skills: { status: 'read-only', ownership: 'provider-scoped' },
|
||||||
|
apiKeys: { status: 'read-only', ownership: 'provider-scoped' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
selectedBackendId: null,
|
||||||
|
resolvedBackendId: null,
|
||||||
|
availableBackends: [],
|
||||||
|
externalRuntimeDiagnostics: [],
|
||||||
|
models,
|
||||||
|
modelAvailability: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draft(overrides: Partial<MemberDraft>): MemberDraft {
|
||||||
|
return {
|
||||||
|
id: 'member',
|
||||||
|
name: 'member',
|
||||||
|
roleSelection: '',
|
||||||
|
customRole: '',
|
||||||
|
model: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue