fix(opencode): scope model preflight by provider

This commit is contained in:
777genius 2026-04-21 21:22:40 +03:00
parent 09004df72c
commit 28b64ec467
10 changed files with 497 additions and 62 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 };
}

View file

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

View file

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

View file

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

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