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 {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
type 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 {
|
||||
OpenCodeBridgeCommandName,
|
||||
OpenCodeBridgeDiagnosticEvent,
|
||||
|
|
@ -50,7 +51,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||
read(): Promise<{
|
||||
read(input?: { selectedModel?: string | null }): Promise<{
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
|
|
@ -126,15 +127,15 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
return input.readiness;
|
||||
}
|
||||
|
||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||
const evidenceRead = this.options.productionE2eEvidence
|
||||
? await this.options.productionE2eEvidence.read()
|
||||
? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel })
|
||||
: {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: '',
|
||||
diagnostics: ['OpenCode production E2E evidence store is not configured'],
|
||||
};
|
||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||
const gate = evidenceRead.ok
|
||||
? assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: evidenceRead.evidence,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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;
|
||||
|
||||
|
|
@ -76,6 +77,16 @@ export interface OpenCodeProductionE2EEvidence {
|
|||
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 {
|
||||
opencodeVersion: string | null;
|
||||
binaryFingerprint: string | null;
|
||||
|
|
@ -134,6 +145,34 @@ export function validateNullableOpenCodeProductionE2EEvidence(
|
|||
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: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
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'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
validateNullableOpenCodeProductionE2EEvidence,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from './OpenCodeProductionE2EEvidence';
|
||||
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 {
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
|
|
@ -20,23 +24,29 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
|||
clock?: () => Date;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||
selectedModel?: string | null;
|
||||
}
|
||||
|
||||
export class OpenCodeProductionE2EEvidenceStore {
|
||||
private readonly filePath: string;
|
||||
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidence | null>;
|
||||
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>;
|
||||
|
||||
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidence | null>({
|
||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>({
|
||||
filePath: options.filePath,
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
defaultData: () => null,
|
||||
validate: validateNullableOpenCodeProductionE2EEvidence,
|
||||
validate: validateOpenCodeProductionE2EEvidenceStoreData,
|
||||
clock: options.clock,
|
||||
quarantineDir: path.dirname(options.filePath),
|
||||
});
|
||||
}
|
||||
|
||||
async read(): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||
async read(
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions = {}
|
||||
): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||
const result = await this.store.read();
|
||||
if (!result.ok) {
|
||||
return {
|
||||
|
|
@ -52,22 +62,87 @@ export class OpenCodeProductionE2EEvidenceStore {
|
|||
};
|
||||
}
|
||||
|
||||
const selection = selectEvidence(result.data, options.selectedModel);
|
||||
return {
|
||||
ok: true,
|
||||
evidence: result.data,
|
||||
evidence: selection.evidence,
|
||||
artifactPath: this.filePath,
|
||||
diagnostics:
|
||||
result.status === 'missing'
|
||||
diagnostics: [
|
||||
...selection.diagnostics,
|
||||
...(result.status === 'missing'
|
||||
? ['OpenCode production E2E evidence artifact has not been written yet']
|
||||
: [],
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
|
||||
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
||||
await this.store.updateLocked(() => ({
|
||||
...validated,
|
||||
artifactPath: validated.artifactPath ?? this.filePath,
|
||||
}));
|
||||
await this.store.updateLocked((current) => {
|
||||
const nextEvidence = {
|
||||
...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 { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
resolveProviderScopedMemberModel,
|
||||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
|
|
@ -564,6 +568,15 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
return new Map<TeamProviderId, string | null>(entries);
|
||||
}, [effectiveCliStatus?.providers]);
|
||||
const runtimeProviderStatusById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(effectiveCliStatus?.providers ?? []).map(
|
||||
(provider) => [provider.providerId, provider] as const
|
||||
)
|
||||
),
|
||||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
|
|
@ -574,6 +587,17 @@ export const CreateTeamDialog = ({
|
|||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
}, [runtimeBackendSummaryByProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
|
||||
members,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (sanitized.changed) {
|
||||
setMembers(sanitized.members);
|
||||
}
|
||||
}, [members, runtimeProviderStatusById, selectedProviderId, setMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
prepareChecksRef.current = prepareChecks;
|
||||
}, [prepareChecks]);
|
||||
|
|
@ -672,14 +696,17 @@ export const CreateTeamDialog = ({
|
|||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const memberProviderId =
|
||||
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
||||
if (memberProviderId !== providerId) {
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const memberModel = member.model?.trim();
|
||||
if (memberModel) {
|
||||
next.add(memberModel);
|
||||
if (scopedModel.model) {
|
||||
next.add(scopedModel.model);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
|
|
@ -812,6 +839,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveCwd,
|
||||
effectiveMemberDrafts,
|
||||
limitContext,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
|
|
@ -1005,15 +1033,6 @@ export const CreateTeamDialog = ({
|
|||
[memberColorMap, members, soloTeam]
|
||||
);
|
||||
|
||||
const runtimeProviderStatusById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(effectiveCliStatus?.providers ?? []).map(
|
||||
(provider) => [provider.providerId, provider] as const
|
||||
)
|
||||
),
|
||||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
const effectiveModel = useMemo(
|
||||
() =>
|
||||
computeEffectiveTeamModel(
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
|||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
resolveProviderScopedMemberModel,
|
||||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
|
|
@ -464,6 +468,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMembersDrafts((prev) => {
|
||||
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
|
||||
members: prev,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
return sanitized.changed ? sanitized.members : prev;
|
||||
});
|
||||
}, [runtimeProviderStatusById, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (multimodelEnabled) {
|
||||
return;
|
||||
|
|
@ -889,11 +904,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
||||
if (member.model?.trim()) {
|
||||
addModel(providerId, member.model);
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.model) {
|
||||
addModel(scopedModel.providerId, scopedModel.model);
|
||||
} else {
|
||||
addDefaultSelection(providerId);
|
||||
addDefaultSelection(scopedModel.providerId);
|
||||
}
|
||||
}
|
||||
for (const providerId of defaultSelectionByProvider.keys()) {
|
||||
|
|
@ -901,7 +921,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
|
||||
return modelsByProvider;
|
||||
}, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]);
|
||||
}, [
|
||||
effectiveLeadRuntimeModel,
|
||||
effectiveMemberDrafts,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
]);
|
||||
|
||||
const runtimeChangeNotes = useMemo(() => {
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -147,8 +147,9 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(productionEvidence());
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: fakeEvidenceStore(productionEvidence()),
|
||||
productionE2eEvidence: evidence,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -164,6 +165,9 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
supportLevel: 'production_supported',
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,11 @@ vi.mock('@renderer/components/ui/button', () => ({
|
|||
disabled?: boolean;
|
||||
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', () => ({
|
||||
|
|
@ -156,8 +160,7 @@ vi.mock('@renderer/components/ui/checkbox', () => ({
|
|||
id,
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
onChange: (event: Event) =>
|
||||
onCheckedChange?.((event.target as HTMLInputElement).checked),
|
||||
onChange: (event: Event) => onCheckedChange?.((event.target as HTMLInputElement).checked),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -166,13 +169,8 @@ vi.mock('@renderer/components/ui/combobox', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (open ? React.createElement('div', null, children) : null),
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) =>
|
||||
|
|
@ -258,6 +256,7 @@ vi.mock('@renderer/utils/geminiUiFreeze', () => ({
|
|||
|
||||
vi.mock('@renderer/utils/teamModelAvailability', () => ({
|
||||
getTeamModelSelectionError: () => null,
|
||||
isTeamModelAvailableForUi: () => true,
|
||||
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model,
|
||||
}));
|
||||
|
||||
|
|
@ -405,7 +404,7 @@ describe('LaunchTeamDialog', () => {
|
|||
|
||||
const [request, members] = onRelaunch.mock.calls[0] as unknown as [
|
||||
{ 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');
|
||||
|
|
|
|||
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