diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 33c4a9ba..6938ba3e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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, diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts index b36a8ea5..4306af63 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -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; +} + +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 +): OpenCodeProductionE2EEvidenceCollection { + const entriesRecord = asRecord(value.entriesByModel); + if (!entriesRecord) { + throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object'); + } + + const entries: Record = {}; + 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) { diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts index fd1fa414..77ec72b8 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -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; + private readonly store: VersionedJsonStore; constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) { this.filePath = options.filePath; - this.store = new VersionedJsonStore({ + this.store = new VersionedJsonStore({ 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 { + async read( + options: OpenCodeProductionE2EEvidenceStoreReadOptions = {} + ): Promise { 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 { 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 = {}; + if (isOpenCodeProductionE2EEvidenceCollection(current)) { + Object.assign(entriesByModel, current.entriesByModel); + } else if (current) { + entriesByModel[current.selectedModel] = current; + } + + entriesByModel[evidence.selectedModel] = evidence; + return { + collectionSchemaVersion: 1, + entriesByModel, + }; +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 34285df3..101762a7 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(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([]); 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( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 413080f1..3edceb7c 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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) { diff --git a/src/renderer/components/team/dialogs/memberModelScope.ts b/src/renderer/components/team/dialogs/memberModelScope.ts new file mode 100644 index 00000000..a70c9e39 --- /dev/null +++ b/src/renderer/components/team/dialogs/memberModelScope.ts @@ -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 }; +} diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts index aca11a1f..5fe26978 100644 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts @@ -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( diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 79625800..0b717134 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -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 () => { diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index bdc4ef8a..651a2c27 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -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'); diff --git a/test/renderer/components/team/dialogs/memberModelScope.test.ts b/test/renderer/components/team/dialogs/memberModelScope.test.ts new file mode 100644 index 00000000..c660a10c --- /dev/null +++ b/test/renderer/components/team/dialogs/memberModelScope.test.ts @@ -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 { + 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 { + return { + id: 'member', + name: 'member', + roleSelection: '', + customRole: '', + model: '', + ...overrides, + }; +}