From 19463edfc9e558c980cf5bcdc34831cc190b54f3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:40:21 +0300 Subject: [PATCH 1/3] fix(agent-graph): center launch stepper hud --- packages/agent-graph/src/ui/GraphControls.tsx | 10 +-- .../renderer/ui/GraphProvisioningHud.tsx | 69 ++++--------------- .../agent-graph/GraphProvisioningHud.test.ts | 2 +- 3 files changed, 18 insertions(+), 63 deletions(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index e933c9c1..a3bc581d 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -107,8 +107,8 @@ export function GraphControls({ return ( <> -
-
+
+
{onToggleSidebar ? (
-
+
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null}
-
+
, - iconClassName: 'text-red-400', + border: 'border-red-400/35 bg-[rgba(26,10,16,0.9)]', }; case 'warning': return { - border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', - badge: 'border-amber-500/30 text-amber-200', - icon: , - iconClassName: 'text-amber-400', + border: 'border-amber-400/35 bg-[rgba(31,18,8,0.9)]', }; case 'success': return { - border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', - badge: 'border-emerald-500/30 text-emerald-200', - icon: , - iconClassName: 'text-emerald-400', + border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.9)]', }; default: return { - border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', - badge: 'border-cyan-500/20 text-cyan-200', - icon: , - iconClassName: 'text-cyan-300', + border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.9)]', }; } } @@ -109,14 +92,10 @@ export const GraphProvisioningHud = ({ } }, [presentation]); - const compactLabel = useMemo(() => { - if (!presentation?.compactDetail) { - return null; - } - return presentation.compactDetail.length > 54 - ? `${presentation.compactDetail.slice(0, 54)}...` - : presentation.compactDetail; - }, [presentation?.compactDetail]); + const ariaLabel = useMemo(() => { + const parts = [presentation?.compactTitle, presentation?.compactDetail].filter(Boolean); + return parts.join(' - ') || 'Open launch details'; + }, [presentation?.compactDetail, presentation?.compactTitle]); if (!shouldRender || !presentation || !tone) { return null; @@ -131,44 +110,20 @@ export const GraphProvisioningHud = ({ tone.border )} onClick={() => setDetailsOpen(true)} - aria-label="Open launch details" + aria-label={ariaLabel} > -
- {tone.icon} -
-
-
- {presentation.compactTitle} -
- - {presentation.isFailed - ? 'Issue' - : presentation.hasMembersStillJoining - ? 'Joining' - : presentation.isActive - ? 'Live' - : 'Ready'} - -
- {compactLabel ? ( -
- {compactLabel} -
- ) : null} -
-
-
+ {ariaLabel} diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index 383f7951..476f9f47 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -118,7 +118,7 @@ describe('GraphProvisioningHud', () => { await Promise.resolve(); }); - const openButton = host.querySelector('button[aria-label="Open launch details"]'); + const openButton = host.querySelector('button[aria-label]'); expect(openButton).not.toBeNull(); await act(async () => { From 0b97cc0794cff683c876939e9ec7089059ae2565 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 14:57:42 +0300 Subject: [PATCH 2/3] fix(agent-graph): stabilize startup slots and launch hud --- .../renderer/adapters/TeamGraphAdapter.ts | 54 ++-- .../renderer/hooks/useTeamGraphAdapter.ts | 45 +++- .../renderer/ui/GraphProvisioningHud.tsx | 39 +-- src/renderer/store/slices/teamSlice.ts | 233 ++++++++++++------ src/shared/utils/teamGraphDefaultLayout.ts | 97 ++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 59 +++++ test/renderer/store/teamSlice.test.ts | 80 +++++- 7 files changed, 451 insertions(+), 156 deletions(-) create mode 100644 src/shared/utils/teamGraphDefaultLayout.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 86b53c9f..9c9233b7 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -23,6 +23,7 @@ import { } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { buildInlineActivityEntries, @@ -247,10 +248,11 @@ export class TeamGraphAdapter { const visibleMemberByStableOwnerId = new Map( visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) ); - const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); - const configStableOwnerIds = new Set( - (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) + const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds( + data.members, + data.config.members ?? [] ); + const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); const pushMember = (member: TeamData['members'][number] | undefined): void => { if (!member) { @@ -264,44 +266,26 @@ export class TeamGraphAdapter { ownerOrder.push(nodeId); }; - const assignedVisibleMembersOutsideConfig = visibleMembers - .filter((member) => { - const stableOwnerId = getGraphStableOwnerId(member); - return ( - assignedStableOwnerIds.has(stableOwnerId) && !configStableOwnerIds.has(stableOwnerId) - ); - }) - .toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } if (!assignedStableOwnerIds.has(stableOwnerId)) { continue; } - pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); - } - - for (const member of assignedVisibleMembersOutsideConfig) { - pushMember(member); - } - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); - if (assignedStableOwnerIds.has(stableOwnerId)) { - continue; - } - const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); pushMember(visibleMember); } - const remainingMembers = visibleMembers.toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const member of remainingMembers) { - pushMember(member); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMember); } const normalizedAssignments: Record = {}; diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 7f33931e..42545bb3 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,17 +3,16 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { - getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, - hasAppliedDefaultTeamGraphSlotAssignments, isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; @@ -35,6 +34,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress, memberSpawnSnapshot, slotAssignments, + graphLayoutSession, ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ @@ -49,6 +49,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, + graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -72,18 +73,44 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { if (!isTeamGraphSlotPersistenceDisabled()) { return slotAssignments; } - if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + if (graphLayoutSession?.mode === 'manual') { return slotAssignments; } - const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); - return Object.keys(defaults).length === 0 ? undefined : defaults; - }, [slotAssignments, teamData, teamName]); + const defaultSeed = buildTeamGraphDefaultLayoutSeed( + teamData.members, + teamData.config.members ?? [] + ); + const defaultAssignments = + Object.keys(defaultSeed.assignments).length === 0 ? undefined : defaultSeed.assignments; + if (!slotAssignments) { + return defaultAssignments; + } + if (graphLayoutSession?.signature !== defaultSeed.signature) { + return defaultAssignments; + } + const visibleAssignmentKeys = defaultSeed.orderedVisibleOwnerIds.filter( + (stableOwnerId) => slotAssignments[stableOwnerId] + ); + const hasExactVisibleDefaults = + visibleAssignmentKeys.length === Object.keys(defaultSeed.assignments).length && + visibleAssignmentKeys.every((stableOwnerId) => { + const currentAssignment = slotAssignments[stableOwnerId]; + const defaultAssignment = defaultSeed.assignments[stableOwnerId]; + return ( + currentAssignment && + defaultAssignment && + currentAssignment.ringIndex === defaultAssignment.ringIndex && + currentAssignment.sectorIndex === defaultAssignment.sectorIndex + ); + }); + return hasExactVisibleDefaults ? slotAssignments : defaultAssignments; + }, [graphLayoutSession, slotAssignments, teamData]); - useEffect(() => { + useLayoutEffect(() => { if (!teamName || !teamData) { return; } - ensureTeamGraphSlotAssignments(teamName, teamData.members); + ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); }, [ensureTeamGraphSlotAssignments, teamData, teamName]); return useMemo( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 1fe09548..06dc290a 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -11,7 +11,6 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; -import { cn } from '@renderer/lib/utils'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -38,29 +37,6 @@ function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null return presentation != null; } -function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { - border: string; -} { - switch (tone) { - case 'error': - return { - border: 'border-red-400/35 bg-[rgba(26,10,16,0.9)]', - }; - case 'warning': - return { - border: 'border-amber-400/35 bg-[rgba(31,18,8,0.9)]', - }; - case 'success': - return { - border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.9)]', - }; - default: - return { - border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.9)]', - }; - } -} - export interface GraphProvisioningHudProps { teamName: string; enabled?: boolean; @@ -74,7 +50,6 @@ export const GraphProvisioningHud = ({ const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); const shouldRender = enabled && shouldRenderLaunchHud(presentation); - const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 ? lastActiveStepRef.current @@ -97,7 +72,7 @@ export const GraphProvisioningHud = ({ return parts.join(' - ') || 'Open launch details'; }, [presentation?.compactDetail, presentation?.compactTitle]); - if (!shouldRender || !presentation || !tone) { + if (!shouldRender || !presentation) { return null; } @@ -105,22 +80,16 @@ export const GraphProvisioningHud = ({ <>
) : null} - {canCreate && launchTeam && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

-
-
-
- ) : null} - {!canCreate ? (

) : null} + + {canCreate && launchTeam && prepareState === 'failed' ? ( +

+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dcca38bb..16d8993b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -43,8 +43,12 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { + getTeamModelSelectionError, + normalizeTeamModelForUi, +} from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, @@ -66,15 +70,21 @@ import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { - createInitialProviderChecks, failIncompleteProviderChecks, getProvisioningFailureHint, + getPrimaryProvisioningFailureDetail, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { + getProviderPrepareCachedSnapshot, + runProviderPrepareDiagnostics, + type ProviderPrepareDiagnosticsModelResult, +} from './providerPrepareDiagnostics'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -93,10 +103,35 @@ import type { ScheduleLaunchConfig, TeamLaunchRequest, TeamProviderId, - TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; +function buildPrepareModelCacheKey( + cwd: string, + providerId: TeamProviderId, + backendSummary: string | null | undefined +): string { + return `${cwd}::${providerId}::${backendSummary ?? ''}`; +} + +function alignProvisioningChecks( + existingChecks: ProvisioningProviderCheck[], + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + const existingByProviderId = new Map( + existingChecks.map((check) => [check.providerId, check] as const) + ); + return providerIds.map( + (providerId) => + existingByProviderId.get(providerId) ?? { + providerId, + status: 'pending', + backendSummary: null, + details: [], + } + ); +} + // ============================================================================= // Props — discriminated union // ============================================================================= @@ -341,6 +376,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); return new Map(entries); }, [cliStatus?.providers]); + const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); + const prepareChecksRef = useRef([]); + const prepareModelResultsCacheRef = useRef( + new Map>() + ); + + useEffect(() => { + runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; + }, [runtimeBackendSummaryByProvider]); + useEffect(() => { + prepareChecksRef.current = prepareChecks; + }, [prepareChecks]); + useEffect(() => { + if (!open) { + prepareModelResultsCacheRef.current.clear(); + } + }, [open]); + const runtimeProviderStatusById = useMemo( + () => + new Map( + (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + ), + [cliStatus?.providers] + ); useEffect(() => { if (multimodelEnabled) { @@ -629,6 +688,51 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', [selectedModel, limitContext, selectedProviderId] ); + const selectedModelChecksByProvider = useMemo(() => { + const modelsByProvider = new Map(); + const defaultSelectionByProvider = new Map(); + const addModel = (providerId: TeamProviderId, model: string | undefined): void => { + const trimmed = model?.trim() ?? ''; + if (!trimmed) { + return; + } + const existing = modelsByProvider.get(providerId) ?? []; + if (!existing.includes(trimmed)) { + modelsByProvider.set(providerId, [...existing, trimmed]); + } + }; + const addDefaultSelection = (providerId: TeamProviderId): void => { + if ( + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic') + ) { + defaultSelectionByProvider.set(providerId, true); + } + }; + + if (selectedModel.trim()) { + addModel(selectedProviderId, effectiveLeadRuntimeModel); + } else { + addDefaultSelection(selectedProviderId); + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (member.model?.trim()) { + addModel(providerId, member.model); + } else { + addDefaultSelection(providerId); + } + } + for (const providerId of defaultSelectionByProvider.keys()) { + addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION); + } + + return modelsByProvider; + }, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]); const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { @@ -811,61 +915,95 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; + const initialChecks = alignProvisioningChecks( + prepareChecksRef.current, + selectedMemberProviders + ); setPrepareState('loading'); setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); - setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); + setPrepareChecks(initialChecks); void (async () => { - let checks = createInitialProviderChecks(selectedMemberProviders); + let checks = initialChecks; let anyFailure = false; let anyNotes = false; const collectedWarnings: string[] = []; try { for (const providerId of selectedMemberProviders) { + const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: [], + status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', + backendSummary, + details: cachedSnapshot.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); - setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + setPrepareMessage( + selectedModelChecks.length > 0 + ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` + : `Checking ${getProviderLabel(providerId)} runtime...` + ); } - const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( - effectiveCwd, + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, providerId, - [providerId] - ); - const detailLines = [ - ...(prepResult.warnings ?? []).filter(Boolean), - ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), - ]; - if (prepResult.warnings?.length) { + selectedModelIds: selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById, + onModelProgress: ({ details, completedCount, totalCount }) => { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage( + `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` + ); + } + }, + }); + if (prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) ); } - if (!prepResult.ready) { + if (prepResult.status === 'failed') { anyFailure = true; + } else if (prepResult.status === 'notes') { + anyNotes = true; } + prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); checks = updateProviderCheck(checks, providerId, { - status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: detailLines, + status: prepResult.status, + backendSummary, + details: prepResult.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; setPrepareState(anyFailure ? 'failed' : 'ready'); setPrepareMessage( anyFailure - ? 'Some selected providers need attention.' + ? failureMessage : anyNotes ? 'Selected providers are ready with notes.' : 'Selected providers are ready.' @@ -891,7 +1029,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen effectiveCwd, selectedProviderId, selectedMemberProviders, - runtimeBackendSummaryByProvider, + selectedModelChecksByProvider, ]); // --------------------------------------------------------------------------- @@ -1066,6 +1204,84 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } return errors; }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); + const modelValidationError = useMemo(() => { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } + + if (!isLaunch) { + return null; + } + + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const memberError = getTeamModelSelectionError( + providerId, + member.model, + runtimeProviderStatusById.get(providerId) + ); + if (!memberError) { + continue; + } + + const memberName = member.name.trim(); + return memberName ? `${memberName}: ${memberError}` : memberError; + } + + return null; + }, [ + effectiveMemberDrafts, + isLaunch, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ]); + const leadModelIssueText = useMemo(() => { + const issue = getProvisioningModelIssue( + prepareChecks, + selectedProviderId, + effectiveLeadRuntimeModel || selectedModel + ); + return issue?.reason ?? issue?.detail ?? null; + }, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]); + const memberModelIssueById = useMemo(() => { + const next: Record = {}; + if (!isLaunch) { + return next; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + if (syncModelsWithLead && leadModelIssueText) { + next[member.id] = leadModelIssueText; + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model); + const issueText = issue?.reason ?? issue?.detail ?? null; + if (issueText) { + next[member.id] = issueText; + } + } + return next; + }, [ + effectiveMemberDrafts, + isLaunch, + leadModelIssueText, + prepareChecks, + selectedProviderId, + syncModelsWithLead, + ]); const hasInvalidLaunchMemberNames = useMemo( () => isLaunch && @@ -1087,7 +1303,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const provisioningError = isLaunch ? props.provisioningError : null; - const activeError = localError ?? provisioningError; + const activeError = localError ?? modelValidationError ?? provisioningError; const launchInFlight = useStore((s) => isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -1119,6 +1335,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError(validationErrors[0]); return; } + if (modelValidationError) { + setLocalError(modelValidationError); + return; + } if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; @@ -1228,9 +1448,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? isSubmitting || launchInFlight || validationErrors.length > 0 || + !!modelValidationError || hasInvalidLaunchMemberNames || hasDuplicateLaunchMemberNames - : isSubmitting || validationErrors.length > 0; + : isSubmitting || validationErrors.length > 0 || !!modelValidationError; // --------------------------------------------------------------------------- // Dynamic labels @@ -1318,63 +1539,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {/* Launch-only: CLI env failed */} - {isLaunch && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
-

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

- {(prepareMessage ?? '').toLowerCase().includes('spawn ') || - prepareChecks.some((check) => - check.details.some((detail) => detail.toLowerCase().includes('spawn ')) - ) ? ( - - ) : null} -
-
-
-
- ) : null} -
{/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Team selector (standalone mode) @@ -1553,6 +1717,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onSyncModelsWithTeammatesChange={setSyncModelsWithLead} leadWarningText={leadRuntimeWarningText} memberWarningById={memberRuntimeWarningById} + leadModelIssueText={leadModelIssueText} + memberModelIssueById={memberModelIssueById} softDeleteMembers disableGeminiOption={isGeminiUiFrozen()} /> @@ -1816,7 +1982,64 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {prepareState === 'failed' ?
: null} + {prepareState === 'failed' ? ( +
+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+ {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + prepareChecks.some((check) => + check.details.some((detail) => detail.toLowerCase().includes('spawn ')) + ) ? ( + + ) : null} +
+
+ ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index dcf79f8d..27bb8dfe 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -84,6 +84,21 @@ export function failIncompleteProviderChecks( ); } +type ProvisioningDetailSummary = + | 'CLI binary missing' + | 'Working directory missing' + | 'CLI binary could not be started' + | 'CLI preflight did not complete' + | 'Authentication required' + | 'Runtime provider is not configured' + | 'CLI preflight failed' + | 'Selected model verified' + | 'Selected model unavailable' + | 'Selected model verification timed out' + | 'Selected model check failed' + | 'Ready with notes' + | 'Needs attention'; + function getStatusLabel(status: ProvisioningProviderCheckStatus): string { switch (status) { case 'checking': @@ -100,7 +115,10 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string { } } -function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null { +function summarizeDetail( + detail: string, + status: ProvisioningProviderCheckStatus +): ProvisioningDetailSummary | null { const lower = detail.toLowerCase(); if (lower.includes('spawn ') && lower.includes(' enoent')) { @@ -132,6 +150,34 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus if (lower.includes('claude cli preflight check failed')) { return 'CLI preflight failed'; } + if (lower.includes('selected model') && lower.includes('verified for launch')) { + return 'Selected model verified'; + } + if (lower.includes('selected model') && lower.includes('is unavailable')) { + return 'Selected model unavailable'; + } + if ( + lower.includes('selected model') && + lower.includes('could not be verified') && + lower.includes('timed out') + ) { + return 'Selected model verification timed out'; + } + if (lower.includes('selected model') && lower.includes('could not be verified')) { + return 'Selected model check failed'; + } + if (lower.includes(' - verified')) { + return 'Selected model verified'; + } + if (lower.includes(' - unavailable -')) { + return 'Selected model unavailable'; + } + if (lower.includes('timed out')) { + return 'Selected model verification timed out'; + } + if (lower.includes(' - check failed -')) { + return 'Selected model check failed'; + } if (status === 'notes') { return 'Ready with notes'; @@ -142,13 +188,173 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus return null; } +function getModelDetailSummary(details: string[]): string | null { + let verifiedCount = 0; + let unavailableCount = 0; + let timedOutCount = 0; + let checkFailedCount = 0; + let checkingCount = 0; + + for (const detail of details) { + const lower = detail.toLowerCase(); + if (lower.includes(' - verified')) { + verifiedCount += 1; + continue; + } + if (lower.includes(' - unavailable -')) { + unavailableCount += 1; + continue; + } + if (lower.includes('timed out')) { + timedOutCount += 1; + continue; + } + if (lower.includes(' - check failed -')) { + checkFailedCount += 1; + continue; + } + if (lower.includes(' - checking...')) { + checkingCount += 1; + } + } + + const parts: string[] = []; + if (unavailableCount > 0) { + parts.push(`${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`); + } + if (checkFailedCount > 0) { + parts.push(`${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`); + } + if (timedOutCount > 0) { + parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`); + } + if (checkingCount > 0) { + parts.push(`${checkingCount} checking`); + } + if (verifiedCount > 0) { + parts.push(`${verifiedCount} verified`); + } + + return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null; +} + function getDisplayStatusText(check: ProvisioningProviderCheck): string { - const summary = check.details.find(Boolean) - ? summarizeDetail(check.details[0], check.status) - : null; + const modelSummary = getModelDetailSummary(check.details); + if (modelSummary) { + return modelSummary; + } + + const summarizedDetails = check.details + .map((detail) => summarizeDetail(detail, check.status)) + .filter((detail): detail is ProvisioningDetailSummary => Boolean(detail)); + + const summary = + check.status === 'failed' + ? (summarizedDetails.find( + (detail) => + detail === 'Selected model unavailable' || + detail === 'Selected model check failed' || + detail === 'Authentication required' || + detail === 'CLI preflight failed' || + detail === 'CLI binary could not be started' + ) ?? + summarizedDetails[0] ?? + null) + : (summarizedDetails[0] ?? null); return summary ?? getStatusLabel(check.status); } +function getDetailTone( + detail: string, + status: ProvisioningProviderCheckStatus +): 'success' | 'failure' | 'checking' | 'neutral' { + const summary = summarizeDetail(detail, status); + if (summary === 'Selected model verified') { + return 'success'; + } + if (summary === 'Selected model verification timed out') { + return 'neutral'; + } + if ( + summary === 'Selected model unavailable' || + summary === 'Selected model check failed' || + summary === 'CLI binary missing' || + summary === 'Working directory missing' || + summary === 'CLI binary could not be started' || + summary === 'CLI preflight did not complete' || + summary === 'Authentication required' || + summary === 'Runtime provider is not configured' || + summary === 'CLI preflight failed' || + summary === 'Needs attention' + ) { + return 'failure'; + } + if (detail.toLowerCase().includes(' - checking...')) { + return 'checking'; + } + return 'neutral'; +} + +function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string { + switch (getDetailTone(detail, status)) { + case 'success': + return 'text-emerald-400'; + case 'failure': + return 'text-red-300'; + case 'checking': + return 'text-[var(--color-text-secondary)]'; + case 'neutral': + default: + return 'text-[var(--color-text-muted)]'; + } +} + +export function getPrimaryProvisioningFailureDetail( + checks: ProvisioningProviderCheck[] +): string | null { + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const unavailableDetail = check.details.find((detail) => + detail.toLowerCase().includes('selected model') && + detail.toLowerCase().includes('is unavailable') + ? true + : detail.toLowerCase().includes(' - unavailable -') + ); + if (unavailableDetail) { + return unavailableDetail; + } + } + + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const preferredFailure = check.details.find( + (detail) => getDetailTone(detail, check.status) === 'failure' + ); + if (preferredFailure) { + return preferredFailure; + } + + const nonSuccessDetail = check.details.find( + (detail) => getDetailTone(detail, check.status) !== 'success' + ); + if (nonSuccessDetail) { + return nonSuccessDetail; + } + + if (check.details.length > 0) { + return check.details[0]; + } + } + + return null; +} + export function shouldHideProvisioningProviderStatusList( checks: ProvisioningProviderCheck[], message: string | null | undefined @@ -236,7 +442,10 @@ export const ProvisioningProviderStatusList = ({ {visibleDetails.length > 0 ? (
{visibleDetails.map((detail) => ( -

+

{detail}

))} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c0175dbf..0e593d34 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -11,23 +11,26 @@ import { } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { GEMINI_UI_DISABLED_BADGE_LABEL, GEMINI_UI_DISABLED_REASON, isGeminiUiFrozen, } from '@renderer/utils/geminiUiFreeze'; +import { + getAvailableTeamProviderModelOptions, + getTeamModelUiDisabledReason, + normalizeTeamModelForUi, + TEAM_MODEL_UI_DISABLED_BADGE_LABEL, +} from '@renderer/utils/teamModelAvailability'; import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, - getTeamModelUiDisabledReason, getTeamProviderLabel as getCatalogTeamProviderLabel, - getTeamProviderModelOptions, - normalizeTeamModelForUi, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; -import { Info } from 'lucide-react'; +import { AlertTriangle, Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; @@ -105,9 +108,9 @@ export function computeEffectiveTeamModel( } const base = extractProviderScopedBaseModel(selectedModel, providerId); - if (limitContext) return base; + if (limitContext) return base || getAnthropicDefaultTeamModel(true); if (base === 'haiku') return base; - return base ? `${base}[1m]` : 'opus[1m]'; + return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext); } export interface TeamModelSelectorProps { @@ -117,6 +120,7 @@ export interface TeamModelSelectorProps { onValueChange: (value: string) => void; id?: string; disableGeminiOption?: boolean; + modelIssueReasonByValue?: Partial>; } export const TeamModelSelector: React.FC = ({ @@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC = ({ onValueChange, id, disableGeminiOption = false, + modelIssueReasonByValue, }) => { const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator'; @@ -135,7 +141,7 @@ export const TeamModelSelector: React.FC = ({ disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { - return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; + return 'Uses the Claude team default model.\nResolves to Opus 1M, or Opus 200K when Limit context is enabled.'; } return 'Uses the runtime default for the selected provider.'; }, [effectiveProviderId]); @@ -181,13 +187,20 @@ export const TeamModelSelector: React.FC = ({ return statusBadge; }; - const runtimeModels = useMemo( + const runtimeProviderStatus = useMemo( () => - cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) - ?.models ?? [], + cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null, [cliStatus?.providers, effectiveProviderId] ); - const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value); + const shouldAwaitRuntimeModelList = + effectiveProviderId !== 'anthropic' && + (cliStatus == null || cliStatusLoading) && + runtimeProviderStatus == null; + const normalizedValue = normalizeTeamModelForUi( + effectiveProviderId, + value, + runtimeProviderStatus + ); useEffect(() => { if (normalizedValue !== value) { @@ -196,22 +209,11 @@ export const TeamModelSelector: React.FC = ({ }, [normalizedValue, onValueChange, value]); const modelOptions = useMemo(() => { - const fallback = getTeamProviderModelOptions(effectiveProviderId); - if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) { - return fallback.map((option) => ({ - ...option, - label: - option.value === '' - ? option.label - : getProviderScopedTeamModelLabel(effectiveProviderId, option.value), - })); + if (shouldAwaitRuntimeModelList) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; } - const dynamicOptions = runtimeModels.map((model) => ({ - value: model, - label: getProviderScopedTeamModelLabel(effectiveProviderId, model), - })); - return [{ value: '', label: 'Default' }, ...dynamicOptions]; - }, [effectiveProviderId, runtimeModels]); + return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus); + }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]); return (
@@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC = ({ ) : null}
+ {shouldAwaitRuntimeModelList ? ( +

+ Explicit models load from the current runtime. Default remains available while the + list is syncing. +

+ ) : null}
= ({ (() => { const modelDisabledReason = getTeamModelUiDisabledReason( effectiveProviderId, - opt.value + opt.value, + runtimeProviderStatus ); - const modelSelectable = activeProviderSelectable && !modelDisabledReason; + const availabilityStatus = + opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available'); + const availabilityReason = + opt.value === '' ? null : (opt.availabilityReason ?? null); + const modelIssueReason = + opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null); + const hasModelIssue = Boolean(modelIssueReason); + const modelSelectable = + activeProviderSelectable && + !modelDisabledReason && + (opt.value === '' || + availabilityStatus == null || + availabilityStatus === 'available'); + const modelStatusMessage = + modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null; return (
@@ -130,6 +139,7 @@ export const LeadModelRow = ({ onValueChange={onModelChange} id="lead-model" disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} /> void; warningText?: string | null; disableGeminiOption?: boolean; + modelIssueText?: string | null; } export const MemberDraftRow = ({ @@ -87,6 +89,7 @@ export const MemberDraftRow = ({ onRestore, warningText, disableGeminiOption = false, + modelIssueText, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -175,6 +178,7 @@ export const MemberDraftRow = ({ const modelTooltipText = forceInheritedModelSettings ? 'Provider, model, and effort are inherited from the lead while sync is enabled.' : modelLockReason; + const hasModelIssue = Boolean(modelIssueText); return (
setModelExpanded((prev) => !prev)} @@ -262,13 +270,21 @@ export const MemberDraftRow = ({ providerId={effectiveProviderId} className="size-3.5 shrink-0" /> - {modelButtonLabel} + {modelButtonLabel} + {hasModelIssue ? ( + + ) : null} - {modelTooltipText ? ( + {modelTooltipText || modelIssueText ? ( - {modelTooltipText} + {modelIssueText ?

{modelIssueText}

: null} + {modelTooltipText ? ( +

+ {modelTooltipText} +

+ ) : null}
) : null} @@ -355,6 +371,9 @@ export const MemberDraftRow = ({ }} id={`member-${member.id}-model`} disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={ + effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined + } /> )} -
- -

- If this teammate uses a different provider than the lead, they will be started in a - separate process automatically. -

-
)}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 70beadaa..93b95459 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -101,6 +101,7 @@ export interface MembersEditorSectionProps { softDeleteMembers?: boolean; memberWarningById?: Record; disableGeminiOption?: boolean; + memberModelIssueById?: Record; } export const MembersEditorSection = ({ @@ -128,6 +129,7 @@ export const MembersEditorSection = ({ softDeleteMembers = false, memberWarningById, disableGeminiOption = false, + memberModelIssueById, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -316,6 +318,7 @@ export const MembersEditorSection = ({ modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} disableGeminiOption={disableGeminiOption} + modelIssueText={memberModelIssueById?.[member.id] ?? null} /> ))} {softDeleteMembers && removedMembers.length > 0 ? ( @@ -356,6 +359,7 @@ export const MembersEditorSection = ({ isRemoved warningText={null} disableGeminiOption={disableGeminiOption} + modelIssueText={null} /> ))}
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index e02a2262..5b6480ba 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps { leadWarningText?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; + leadModelIssueText?: string | null; + memberModelIssueById?: Record; } export const TeamRosterEditorSection = ({ @@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({ leadWarningText, memberWarningById, disableGeminiOption = false, + leadModelIssueText, + memberModelIssueById, }: TeamRosterEditorSectionProps): React.JSX.Element => { return ( {headerTop} @@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({ onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} disableGeminiOption={disableGeminiOption} + modelIssueText={leadModelIssueText} /> {headerBottom}
diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 3f601835..c62bf068 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -34,7 +34,7 @@ export function useCliInstaller(): { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 89d2a403..b1a56b2e 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -28,8 +28,10 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { authenticated: false, authMethod: null, verificationState: 'unknown' as const, + modelVerificationState: 'idle' as const, statusMessage: 'Checking...', models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, @@ -89,14 +91,14 @@ export interface CliInstallerSlice { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; } let cliStatusInFlight: Promise | null = null; -const cliProviderStatusInFlight = new Map>(); +const cliProviderStatusInFlight = new Map>(); let cliStatusEpoch = 0; const cliProviderStatusSeq = new Map(); @@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator { const nextLoading = silent ? state.cliProviderStatusLoading @@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator; + +export type TeamRuntimeModelOption = TeamProviderModelOption & { + availabilityStatus?: CliProviderModelAvailabilityStatus | null; + availabilityReason?: string | null; +}; + +export interface TeamProviderModelVerificationCounts { + checkedCount: number; + totalCount: number; + verifying: boolean; +} + +export function getTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getRuntimeAwareTeamModelUiDisabledReason(providerId, model, providerStatus); +} + +export function isTeamModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null; +} + +function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { + return getVisibleTeamProviderModels( + providerId, + getTeamProviderModelOptions(providerId) + .map((option) => option.value) + .filter((value) => value.trim().length > 0) + ); +} + +function getFallbackTeamProviderModelOptions( + providerId: SupportedProviderId +): TeamRuntimeModelOption[] { + return getTeamProviderModelOptions(providerId).map((option) => ({ + ...option, + label: + option.value === '' + ? option.label + : (getProviderScopedTeamModelLabel(providerId, option.value) ?? option.value), + })); +} + +function getRuntimeSelectorModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (!providerStatus) { + return []; + } + + return sortTeamProviderModels(providerId, providerStatus.models); +} + +function getVisibleRuntimeModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + return getRuntimeSelectorModels(providerId, providerStatus).filter( + (model) => getTeamModelUiDisabledReason(providerId, model, providerStatus) == null + ); +} + +function getModelAvailabilityMap( + providerStatus?: TeamModelRuntimeProviderStatus | null +): Map { + return new Map( + (providerStatus?.modelAvailability ?? []).map((item) => [item.modelId.trim(), item]) + ); +} + +function getRuntimeModelAvailability( + providerId: SupportedProviderId, + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): CliProviderModelAvailabilityStatus | null { + if (providerId === 'anthropic') { + return 'available'; + } + + if (!providerStatus) { + return null; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(model)) { + return null; + } + return 'available'; +} + +function getRuntimeModelAvailabilityReason( + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getModelAvailabilityMap(providerStatus).get(model)?.reason ?? null; +} + +export function getTeamProviderModelVerificationCounts( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamProviderModelVerificationCounts { + if (providerId === 'anthropic') { + return { + checkedCount: getFallbackTeamProviderModels(providerId).length, + totalCount: getFallbackTeamProviderModels(providerId).length, + verifying: false, + }; + } + + const totalCount = getRuntimeSelectorModels(providerId, providerStatus).length; + + return { + checkedCount: totalCount, + totalCount, + verifying: false, + }; +} + +export function getAvailableTeamProviderModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId); + } + + if (!providerStatus) { + return []; + } + + return getVisibleRuntimeModels(providerId, providerStatus).filter( + (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + ); +} + +export function getAvailableTeamProviderModelOptions( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamRuntimeModelOption[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModelOptions(providerId); + } + + if (!providerStatus) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; + } + + const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); + return [ + { value: '', label: 'Default', badgeLabel: 'Default' }, + ...visibleModels.map((model) => ({ + value: model, + label: getProviderScopedTeamModelLabel(providerId, model) ?? model, + availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), + availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), + })), + ]; +} + +export function isTeamModelAvailableForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return true; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return false; + } + + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId).includes(trimmed); + } + + return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; +} + +export function normalizeTeamModelForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string { + const normalized = normalizeCatalogTeamModelForUi(providerId, model); + const trimmed = normalized.trim(); + if (!providerId || !trimmed) { + return normalized; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return ''; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : ''; + } + + if (!providerStatus) { + return ''; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return ''; + } + + const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus); + return availability === 'available' ? normalized : ''; +} + +export function getTeamModelSelectionError( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + const disabledReason = getTeamModelUiDisabledReason(providerId, trimmed, providerStatus); + if (disabledReason) { + return `Model "${trimmed}" is disabled. ${disabledReason}`; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) + ? null + : `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + if (!providerStatus) { + return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + return null; +} diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index ee7c0614..47248f19 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,6 +1,19 @@ -import type { CliProviderId, TeamProviderId } from '@shared/types'; +import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; +import { + filterVisibleProviderRuntimeModels, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; + +export { + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; type SupportedProviderId = CliProviderId | TeamProviderId; +type RuntimeAwareProviderStatus = Pick; export interface TeamProviderModelOption { value: string; @@ -10,10 +23,12 @@ export interface TeamProviderModelOption { } export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled'; -export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; -export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.'; +export const GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON = + 'Temporarily disabled for team agents when using Codex ChatGPT subscription - this model has been observed returning "Not available with Codex ChatGPT subscription".'; +export const GPT_5_2_CODEX_UI_DISABLED_REASON = + 'Temporarily disabled for team agents - this model has been observed returning "Not available with Codex ChatGPT subscription".'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; @@ -66,7 +81,12 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record !isTeamModelUiDisabled(providerId, model) - ); + return sortTeamProviderModels( + providerId, + filterVisibleProviderRuntimeModels(providerId, models) + ).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus)); } export function getTeamModelUiDisabledReason( @@ -213,6 +262,26 @@ export function getTeamModelUiDisabledReason( return getKnownTeamProviderModelOption(providerId, model)?.uiDisabledReason ?? null; } +export function getRuntimeAwareTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): string | null { + const staticReason = getTeamModelUiDisabledReason(providerId, model); + if (staticReason) { + return staticReason; + } + + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) + ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON + : null; +} + export function isTeamModelUiDisabled( providerId: SupportedProviderId | undefined, model: string | undefined diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 2253c177..c2a1adf2 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -17,6 +17,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface FailedSpawnDetail { + name: string; + reason: string | null; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -26,6 +31,73 @@ const ACTIVE_PROVISIONING_STATES = new Set([ 'verifying', ]); +function getFailedSpawnDetails( + memberSpawnStatuses: MemberSpawnStatusCollection +): FailedSpawnDetail[] { + if (!memberSpawnStatuses) { + return []; + } + const entries = + memberSpawnStatuses instanceof Map + ? [...memberSpawnStatuses.entries()] + : Object.entries(memberSpawnStatuses); + + return entries + .filter(([, entry]) => entry.launchState === 'failed_to_start' || entry.status === 'error') + .map(([name, entry]) => ({ + name, + reason: + typeof entry.hardFailureReason === 'string' && entry.hardFailureReason.trim().length > 0 + ? entry.hardFailureReason.trim() + : typeof entry.error === 'string' && entry.error.trim().length > 0 + ? entry.error.trim() + : null, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function truncateFailureReason(reason: string, maxLength = 160): string { + const normalized = reason.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function buildFailedSpawnPanelMessage( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + const [failed] = failedSpawnDetails; + return failed.reason + ? `${failed.name} failed to start - ${truncateFailureReason(failed.reason, 220)}` + : `${failed.name} failed to start`; + } + const listedFailures = failedSpawnDetails + .slice(0, 2) + .map((failed) => + failed.reason ? `${failed.name} - ${truncateFailureReason(failed.reason, 120)}` : failed.name + ) + .join('; '); + const remainingCount = failedSpawnDetails.length - Math.min(failedSpawnDetails.length, 2); + return `Failed teammates: ${listedFailures}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`; +} + +function buildFailedSpawnCompactDetail( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + return `${failedSpawnDetails[0].name} failed to start`; + } + return `${failedSpawnDetails.length} teammates failed to start`; +} + export interface TeamProvisioningPresentation { progress: TeamProvisioningProgress; isActive: boolean; @@ -99,6 +171,9 @@ export function buildTeamProvisioningPresentation({ memberSpawnStatuses, memberSpawnSnapshot, }); + const failedSpawnDetails = getFailedSpawnDetails(memberSpawnStatuses); + const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails); + const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails); const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = getLaunchJoinState({ @@ -135,7 +210,7 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launch failed', - panelMessage: progress.error ?? null, + panelMessage: progress.error ?? failedSpawnPanelMessage ?? null, panelTone: 'error', defaultLiveOutputOpen: true, compactTitle: 'Launch failed', @@ -151,7 +226,8 @@ export function buildTeamProvisioningPresentation({ : `${remainingJoinCount} teammates still joining`; const readyCompactDetail = failedSpawnCount > 0 - ? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start` + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) : hasMembersStillJoining ? joiningPhrase : expectedTeammateCount === 0 @@ -159,7 +235,7 @@ export function buildTeamProvisioningPresentation({ : `All ${expectedTeammateCount} teammates joined`; const readyDetailMessage = failedSpawnCount > 0 - ? progress.message + ? (failedSpawnPanelMessage ?? progress.message) : expectedTeammateCount === 0 ? 'Team provisioned - lead online' : allTeammatesConfirmedAlive @@ -229,15 +305,19 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launching team', - panelMessage: progress.message, - panelMessageSeverity: progress.messageSeverity, + panelMessage: + failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message, + panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, defaultLiveOutputOpen: true, compactTitle: 'Launching team', compactDetail: - expectedTeammateCount > 0 && progressStepIndex >= 2 - ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : progress.message, - compactTone: 'default', + failedSpawnCount > 0 + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) + : expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: failedSpawnCount > 0 ? 'warning' : 'default', }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index afc2aae7..1db3c89b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -438,7 +438,9 @@ export interface TeamsAPI { prepareProvisioning: ( cwd?: string, providerId?: TeamLaunchRequest['providerId'], - providerIds?: TeamLaunchRequest['providerId'][] + providerIds?: TeamLaunchRequest['providerId'][], + selectedModels?: string[], + limitContext?: boolean ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 7eaf19c0..a68947e0 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -57,6 +57,19 @@ export interface CliExternalRuntimeDiagnostic { detailMessage?: string | null; } +export type CliProviderModelAvailabilityStatus = + | 'checking' + | 'available' + | 'unavailable' + | 'unknown'; + +export interface CliProviderModelAvailability { + modelId: string; + status: CliProviderModelAvailabilityStatus; + reason?: string | null; + checkedAt?: string | null; +} + export interface CliProviderStatus { providerId: CliProviderId; displayName: string; @@ -64,8 +77,10 @@ export interface CliProviderStatus { authenticated: boolean; authMethod: string | null; verificationState: 'verified' | 'unknown' | 'offline' | 'error'; + modelVerificationState?: 'idle' | 'verifying' | 'verified'; statusMessage?: string | null; models: string[]; + modelAvailability?: CliProviderModelAvailability[]; canLoginFromUi: boolean; capabilities: { teamLaunch: boolean; @@ -172,6 +187,8 @@ export interface CliInstallerAPI { getStatus: () => Promise; /** Get current runtime/auth status for a single provider */ getProviderStatus: (providerId: CliProviderId) => Promise; + /** Start on-demand model verification for a single runtime provider */ + verifyProviderModels: (providerId: CliProviderId) => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; /** Invalidate cached status (forces fresh check on next getStatus) */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dcc8dfdf..1d970668 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -990,6 +990,7 @@ export interface TeamCreateResponse { export interface TeamProvisioningPrepareResult { ready: boolean; message: string; + details?: string[]; warnings?: string[]; } diff --git a/src/shared/utils/anthropicModelDefaults.ts b/src/shared/utils/anthropicModelDefaults.ts new file mode 100644 index 00000000..ce895df7 --- /dev/null +++ b/src/shared/utils/anthropicModelDefaults.ts @@ -0,0 +1,3 @@ +export function getAnthropicDefaultTeamModel(limitContext: boolean): string { + return limitContext ? 'opus' : 'opus[1m]'; +} diff --git a/src/shared/utils/providerModelSelection.ts b/src/shared/utils/providerModelSelection.ts new file mode 100644 index 00000000..bbc987dd --- /dev/null +++ b/src/shared/utils/providerModelSelection.ts @@ -0,0 +1,5 @@ +export const DEFAULT_PROVIDER_MODEL_SELECTION = '__provider_default__'; + +export function isDefaultProviderModelSelection(value: string | undefined): boolean { + return value?.trim() === DEFAULT_PROVIDER_MODEL_SELECTION; +} diff --git a/src/shared/utils/providerModelVisibility.ts b/src/shared/utils/providerModelVisibility.ts new file mode 100644 index 00000000..807ac8b0 --- /dev/null +++ b/src/shared/utils/providerModelVisibility.ts @@ -0,0 +1,47 @@ +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type SupportedProviderId = CliProviderId | TeamProviderId; + +export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; +export const GPT_5_2_CODEX_UI_DISABLED_MODEL = 'gpt-5.2-codex'; +export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; + +const UI_DISABLED_MODELS_BY_PROVIDER: Partial> = { + codex: [ + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + ], +}; + +export function isProviderRuntimeModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return false; + } + + return UI_DISABLED_MODELS_BY_PROVIDER[providerId]?.includes(trimmed) ?? false; +} + +export function filterVisibleProviderRuntimeModels( + providerId: SupportedProviderId, + models: readonly string[] +): string[] { + const seen = new Set(); + const visible: string[] = []; + + for (const model of models) { + const trimmed = model.trim(); + if (!trimmed || seen.has(trimmed) || isProviderRuntimeModelUiDisabled(providerId, trimmed)) { + continue; + } + + seen.add(trimmed); + visible.push(trimmed); + } + + return visible; +} diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index fecd1d15..ffad35a7 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -72,12 +72,21 @@ vi.mock('@main/services/team/cliFlavor', () => ({ })), })); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: vi.fn(async () => ({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + })), +})); + import { CliInstallerService, isVersionOlder, normalizeVersion, } from '@main/services/infrastructure/CliInstallerService'; +import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; import { execCli } from '@main/utils/childProcess'; /** @@ -96,6 +105,13 @@ describe('CliInstallerService', () => { vi.clearAllMocks(); realpathMock.mockReset(); realpathMock.mockImplementation(async (value: string) => value); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + }); service = new CliInstallerService(); }); @@ -176,6 +192,146 @@ describe('CliInstallerService', () => { expect(status.installedVersion).toBe('2.1.101'); expect(status.authLoggedIn).toBe(true); }); + + it('publishes probe-enriched runtime model status snapshots only for explicit verification requests', async () => { + allowConsoleLogs(); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation( + async (_binaryPath, onUpdate) => { + const providers = [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: null, + }, + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + { + providerId: 'gemini', + displayName: 'Gemini', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: false, oneShot: false }, + backend: null, + }, + ]; + onUpdate?.(providers as never); + return providers as never; + } + ); + + vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + if (normalizedArgs === '--version') { + return { stdout: '2.3.4', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.4')) { + return { stdout: 'PONG', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.2-codex')) { + throw new Error( + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account." + ); + } + throw new Error(`Unexpected execCli call: ${normalizedArgs}`); + }); + + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + const status = await service.getStatus(); + expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]); + + const verifiedProvider = await service.verifyProviderModels('codex'); + expect(verifiedProvider?.modelAvailability).toEqual( + expect.arrayContaining([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }), + expect.objectContaining({ modelId: 'gpt-5.2-codex', status: 'checking' }), + ]) + ); + + await vi.waitFor(() => { + const latestCodexProvider = service + .getLatestStatusSnapshot() + ?.providers.find((provider) => provider.providerId === 'codex'); + + expect(latestCodexProvider?.modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + }), + ]); + }); + + const statusEvents = mockWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'cliInstaller:progress') + .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) + .filter((event) => event.type === 'status'); + + expect(statusEvents.length).toBeGreaterThan(1); + expect( + statusEvents.some((event) => + event.status?.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'providerId' in provider && + 'modelAvailability' in provider && + (provider as { providerId?: string }).providerId === 'codex' && + Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && + (provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some( + (item) => item.status === 'unavailable' + ) + ) + ) + ).toBe(true); + }); }); describe('install mutex', () => { diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts new file mode 100644 index 00000000..a498882d --- /dev/null +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execCliMock = vi.fn(); +const buildProviderAwareCliEnvMock = vi.fn(); + +vi.mock('@main/utils/childProcess', () => ({ + execCli: (...args: Parameters) => execCliMock(...args), +})); + +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + +import { + CliProviderModelAvailabilityService, + type ProviderModelAvailabilityContext, +} from '@main/services/runtime/CliProviderModelAvailabilityService'; + +function createContext(models: string[]): ProviderModelAvailabilityContext { + return { + binaryPath: '/usr/local/bin/claude', + installedVersion: '2.3.4', + provider: { + providerId: 'codex', + models, + supported: true, + authenticated: true, + authMethod: 'oauth_token', + selectedBackendId: 'chatgpt', + resolvedBackendId: 'chatgpt', + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + }; +} + +describe('CliProviderModelAvailabilityService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reuses probe cache for the same provider signature', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + const context = createContext(['gpt-5.4', 'gpt-5.3-codex']); + + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + expect(service.getSnapshot(context).modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ modelId: 'gpt-5.3-codex', status: 'available' }), + ]); + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + it('marks unsupported models as unavailable with the runtime reason', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.2-codex'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + }), + ], + }) + ); + }); + }); + + it('marks timeout-like probe failures as unknown instead of unavailable', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue(new Error('Command timed out after 45000ms')); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.4', + status: 'unknown', + reason: 'Model verification timed out', + }), + ], + }) + ); + }); + }); + + it('invalidates the cache when the provider signature changes', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(1); + }); + + service.getSnapshot(createContext(['gpt-5.4', 'gpt-5.2'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 95ff14cd..659d5d68 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -673,4 +673,177 @@ describe('TeamProvisioningService', () => { expect(launchArgs).toContain('--resume'); expect(launchArgs).toContain(leadSessionId); }); + + it('marks persisted bootstrap as failed when member transcript shows an unsupported model error', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-unsupported-model'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeLaunchState(teamName, leadSessionId, { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${leadSessionId}.jsonl`), + `${JSON.stringify({ + timestamp: new Date(Date.now() - 10_000).toISOString(), + teamName, + type: 'user', + message: { role: 'user', content: 'Lead bootstrap context' }, + })}\n`, + 'utf8' + ); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack?.status).toBe('error'); + expect(result.statuses.jack?.launchState).toBe('failed_to_start'); + expect(result.statuses.jack?.error).toContain('gpt-5.2-codex'); + expect(result.statuses.jack?.hardFailureReason).toContain('not supported'); + expect(result.teamLaunchState).toBe('partial_failure'); + }); + + it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => { + allowConsoleLogs(); + const teamName = 'zz-live-bootstrap-model-unavailable'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-live-1', + teamName, + startedAt: new Date(Date.now() - 60_000).toISOString(), + request: { + members: [], + }, + expectedMembers: ['jack'], + memberSpawnStatuses: new Map([ + [ + 'jack', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + error: undefined, + updatedAt: acceptedAt, + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + lastHeartbeatAt: undefined, + }, + ], + ]), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + } as any; + + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(teamName, run.runId); + + await (svc as any).reconcileBootstrapTranscriptFailures(run); + + expect(run.memberSpawnStatuses.get('jack')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + }); + expect(run.memberSpawnStatuses.get('jack')?.error).toContain( + 'requested model is not available' + ); + expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 208a2385..bdd11285 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, @@ -123,6 +124,187 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ]); }); + it('verifies the selected Codex model during prepare and records a success detail', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.4'], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain('Selected model gpt-5.4 verified for launch.'); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Codex default model during prepare', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'resolveProviderDefaultModel').mockResolvedValue('gpt-5.4-mini'); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4-mini']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Anthropic default model during prepare with limitContext', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'oauth_token', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'oauth_token', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'opus']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('fails prepare when the selected Codex model is unavailable', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.2-codex'], + }); + + expect(result.ready).toBe(false); + expect(result.message).toContain('Selected model gpt-5.2-codex is unavailable.'); + expect(result.message).toContain('Not available with Codex ChatGPT subscription'); + }); + + it('keeps timed out Codex model verification as a warning with a clean generic reason', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error( + 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' + ) + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.3-codex'], + }); + + expect(result.ready).toBe(true); + expect(result.warnings).toContain( + 'Selected model gpt-5.3-codex could not be verified. Model verification timed out' + ); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 98883aa2..96527c46 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -537,4 +537,74 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); }); + + it('shows runtime model availability badges on the dashboard', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.1-codex-max', 'gpt-5.2-codex'], + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available', checkedAt: '2026-04-16T12:00:00.000Z' }, + { + modelId: 'gpt-5.1-codex-max', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + { + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + ], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('5.4'); + expect(host.textContent).not.toContain('5.1-codex-max'); + expect(host.textContent).not.toContain('5.2-codex'); + expect(host.textContent).not.toContain('Unavailable'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index f441903a..9762a168 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -6,7 +6,11 @@ import { } from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, + GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + getAvailableTeamProviderModels, + getTeamModelSelectionError, getTeamModelUiDisabledReason, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; @@ -22,10 +26,13 @@ describe('formatTeamModelSummary', () => { expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('5.4 · Medium'); }); - it('marks 5.1 Codex Mini as disabled only for Codex team selection', () => { + it('marks the known disabled Codex models only for Codex team selection', () => { expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe( GPT_5_1_CODEX_MINI_UI_DISABLED_REASON ); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.2-codex')).toBe( + GPT_5_2_CODEX_UI_DISABLED_REASON + ); expect(getTeamModelUiDisabledReason('codex', 'gpt-5.3-codex-spark')).toBe( GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON ); @@ -33,10 +40,72 @@ describe('formatTeamModelSummary', () => { expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull(); }); + it('disables 5.1 Codex Max only on the Codex ChatGPT subscription path', () => { + const chatgptCodexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [], + authenticated: true, + supported: true, + }; + + expect( + getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toBe(GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON); + expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus)).toBe( + '' + ); + expect( + getTeamModelSelectionError('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toContain('Temporarily disabled for team agents when using Codex ChatGPT subscription'); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max')).toBeNull(); + }); + it('normalizes disabled Codex model selections back to default', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex')).toBe(''); expect(normalizeTeamModelForUi('codex', 'gpt-5.3-codex-spark')).toBe(''); - expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe(''); + }); + + it('uses the runtime-reported Codex model list when provider status is available', () => { + const codexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.3-codex'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available' as const, checkedAt: null }, + { modelId: 'gpt-5.3-codex', status: 'available' as const, checkedAt: null }, + ], + authenticated: true, + supported: true, + }; + + expect(getAvailableTeamProviderModels('codex', codexProviderStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', codexProviderStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', codexProviderStatus)).toBe('gpt-5.4'); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain('waiting for Codex runtime verification'); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); }); }); @@ -60,6 +129,7 @@ describe('computeEffectiveTeamModel', () => { expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('', true, 'anthropic')).toBe('opus'); }); it('returns haiku as-is', () => { diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index fba39cb2..640cb4a6 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -61,27 +61,30 @@ vi.mock('@renderer/components/ui/tabs', () => { }; }); +const storeState = { + cliStatus: null as unknown, + cliStatusLoading: false, + appConfig: { general: { multimodelEnabled: true } }, + fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), +}; + vi.mock('@renderer/store', () => ({ - useStore: (selector: (state: unknown) => unknown) => - selector({ - cliStatus: null, - appConfig: { general: { multimodelEnabled: true } }, - }), + useStore: (selector: (state: unknown) => unknown) => selector(storeState), })); import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; -import { - GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, -} from '@renderer/utils/teamModelAvailability'; describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { document.body.innerHTML = ''; + storeState.cliStatus = null; + storeState.cliStatusLoading = false; + storeState.fetchCliProviderStatus.mockClear(); }); - it('renders 5.1 Codex Mini as disabled with an explanation tooltip', async () => { + it('shows only Default while Codex runtime models are still loading', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -98,37 +101,10 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('5.1 Codex Mini'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - - it('renders 5.3 Codex Spark as disabled with an explanation tooltip', async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render( - React.createElement(TeamModelSelector, { - providerId: 'codex', - onProviderChange: () => undefined, - value: '', - onValueChange: () => undefined, - }) - ); - await Promise.resolve(); - }); - - expect(host.textContent).toContain('5.3 Codex Spark'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON); + expect(host.textContent).toContain('Default'); + expect(host.textContent).toContain('Explicit models load from the current runtime'); + expect(host.textContent).not.toContain('5.1 Codex Mini'); + expect(host.textContent).not.toContain('5.3 Codex Spark'); await act(async () => { root.unmount(); @@ -190,6 +166,256 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.3-codex'], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith(''); + expect(host.textContent).toContain('5.4'); + expect(host.textContent).toContain('5.3 Codex'); + expect(host.textContent).not.toContain('5.2 Codex'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.2 Codex as a disabled tile when the runtime still reports it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.1 Codex Max as a disabled tile on the ChatGPT subscription path', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.1 Codex Max') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps runtime model buttons selectable without starting automatic model probes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.4-mini'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled(); + + const gpt54Button = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.4') + ); + expect(gpt54Button?.getAttribute('aria-disabled')).toBe('false'); + + await act(async () => { + gpt54Button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith('gpt-5.4'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('highlights the specific model tile when preflight found a model issue', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange: () => undefined, + modelIssueReasonByValue: { + 'gpt-5.2-codex': 'Not available with Codex ChatGPT subscription', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Issue'); + const issueButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + expect(issueButton?.className).toContain('border-red-500/40'); + expect(issueButton?.getAttribute('title')).toBe( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 6d5f69cf..7c69269d 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + getPrimaryProvisioningFailureDetail, ProvisioningProviderStatusList, createInitialProviderChecks, } from '@renderer/components/team/dialogs/ProvisioningProviderStatusList'; @@ -35,4 +36,96 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('surfaces mixed selected model diagnostics without hiding verified results', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'failed', + backendSummary: 'Default adapter', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model unavailable, 1 verified' + ); + expect(host.textContent).toContain('5.4 Mini - verified'); + expect(host.textContent).toContain( + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription' + ); + + const detailLines = Array.from(host.querySelectorAll('p')); + expect(detailLines[0]?.className).toContain('text-emerald-400'); + expect(detailLines[1]?.className).toContain('text-red-300'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('picks the first real failure detail instead of a verified line', () => { + expect( + getPrimaryProvisioningFailureDetail([ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.2 - verified', + '5.3 Codex - check failed - Model verification timed out', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ]) + ).toBe('5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription'); + }); + + it('summarizes timed out model verification separately from hard failures', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'notes', + backendSummary: 'Default adapter', + details: ['5.3 Codex - check failed - Model verification timed out'], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model timed out' + ); + expect(host.textContent).toContain('5.3 Codex - check failed - Model verification timed out'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts new file mode 100644 index 00000000..ecd70446 --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; + +import type { TeamProvisioningPrepareResult } from '@shared/types'; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe('runProviderPrepareDiagnostics', () => { + it('returns a failed provider result immediately when runtime preflight fails', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >().mockResolvedValue({ + ready: false, + message: 'Codex runtime is not authenticated.', + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['Codex runtime is not authenticated.']); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + }); + + it('emits per-model progress updates and keeps failures scoped to the affected model', async () => { + const deferred54 = createDeferred(); + const deferred52 = createDeferred(); + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + if (selectedModels[0] === 'gpt-5.4') { + return deferred54.promise; + } + return deferred52.promise; + }); + + const resultPromise = runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4', 'gpt-5.2-codex'], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + await Promise.resolve(); + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 2, + details: ['5.4 - checking...', '5.2 Codex - checking...'], + }); + + deferred54.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: ['Selected model gpt-5.4 verified for launch.'], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 1, + totalCount: 2, + details: ['5.4 - verified', '5.2 Codex - checking...'], + }); + + deferred52.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 2, + totalCount: 2, + details: [ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ], + }); + }); + + it('normalizes raw Codex API error envelopes into a clean model reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: false, + message: + `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.1-codex-max' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.1-codex-max'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ]); + }); + + it('normalizes raw timeout probe errors into a provider-agnostic reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + warnings: [ + 'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.3-codex'], + prepareProvisioning, + }); + + expect(result.status).toBe('notes'); + expect(result.details).toEqual(['5.3 Codex - check failed - Model verification timed out']); + }); + + it('renders the provider default model as a dedicated Default check line', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 1, + details: ['Default - checking...'], + }); + expect(result.status).toBe('ready'); + expect(result.details).toEqual(['Default - verified']); + }); + + it('forwards limitContext through model diagnostics for Anthropic default checks', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[], + limitContext?: boolean + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'anthropic', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + prepareProvisioning, + }); + + expect(result.details).toEqual(['Default - verified']); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'anthropic', + ['anthropic'], + undefined, + true + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 2, + '/tmp/project', + 'anthropic', + ['anthropic'], + [DEFAULT_PROVIDER_MODEL_SELECTION], + true + ); + }); + + it('reuses cached model results and probes only newly selected models', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + + expect(selectedModels).toEqual(['gpt-5.2-codex']); + return Promise.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.2', 'gpt-5.4-mini', 'gpt-5.2-codex'], + prepareProvisioning, + cachedModelResultsById: { + 'gpt-5.2': { + status: 'ready', + line: '5.2 - verified', + warningLine: null, + }, + 'gpt-5.4-mini': { + status: 'ready', + line: '5.4 Mini - verified', + warningLine: null, + }, + }, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 2, + totalCount: 3, + details: ['5.2 - verified', '5.4 Mini - verified', '5.2 Codex - checking...'], + }); + expect(result.details).toEqual([ + '5.2 - verified', + '5.4 Mini - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'codex', + ['codex'], + undefined, + undefined + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith(2, '/tmp/project', 'codex', ['codex'], [ + 'gpt-5.2-codex', + ], undefined); + }); +}); diff --git a/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts new file mode 100644 index 00000000..69399be0 --- /dev/null +++ b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { getProvisioningModelIssue } from '@renderer/components/team/dialogs/provisioningModelIssues'; + +describe('getProvisioningModelIssue', () => { + it('extracts a formatted Codex model failure with clean reason', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.1-codex-max' + ) + ).toEqual({ + providerId: 'codex', + modelId: 'gpt-5.1-codex-max', + kind: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + detail: '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + }); + }); + + it('returns null for verified models without their own failure line', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.4-mini' + ) + ).toBeNull(); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 9025be90..32a5cf15 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -6,6 +6,7 @@ vi.mock('@renderer/api', () => ({ cliInstaller: { getStatus: vi.fn(), getProviderStatus: vi.fn(), + verifyProviderModels: vi.fn(), invalidateStatus: vi.fn(), install: vi.fn(), onProgress: vi.fn(() => vi.fn()), diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts new file mode 100644 index 00000000..d9f0a21e --- /dev/null +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAvailableTeamProviderModelOptions, + getAvailableTeamProviderModels, + getTeamModelSelectionError, + normalizeTeamModelForUi, + type TeamModelRuntimeProviderStatus, +} from '@renderer/utils/teamModelAvailability'; + +function createCodexProviderStatus( + models: string[], + overrides: Partial = {} +): TeamModelRuntimeProviderStatus { + return { + providerId: 'codex', + models, + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + authenticated: true, + supported: true, + modelVerificationState: 'idle', + modelAvailability: [], + ...overrides, + }; +} + +describe('teamModelAvailability', () => { + it('uses runtime-reported Codex models as the source of truth', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + }); + + it('filters Codex models that are UI-disabled even if runtime reports them', () => { + const providerStatus = createCodexProviderStatus([ + 'gpt-5.4', + 'gpt-5.3-codex-spark', + 'gpt-5.2-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + ]); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + }); + + it('keeps 5.1 Codex Max available outside the ChatGPT subscription path', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.1-codex-max'], { + authMethod: 'api_key', + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'api.openai.com/v1/responses', + }, + }); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.1-codex-max', + ]); + }); + + it('builds Codex model options from the runtime list instead of the hardcoded fallback', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { value: 'gpt-5.4', label: '5.4', availabilityStatus: 'available', availabilityReason: null }, + { + value: 'gpt-5.3-codex', + label: '5.3 Codex', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('clears stale Codex selections when runtime no longer reports that model', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', providerStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + }); + + it('reports an explicit error when a Codex model is unsupported by the current runtime', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getTeamModelSelectionError('codex', 'gpt-5.2-codex', providerStatus)).toContain( + 'Temporarily disabled for team agents' + ); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain( + 'waiting for Codex runtime verification' + ); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + }); + + it('keeps runtime models selectable without per-model verification state', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4']); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('does not require runtime verification for Anthropic curated models', () => { + expect(normalizeTeamModelForUi('anthropic', 'opus')).toBe('opus'); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); + }); +}); diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index d332928d..25d0f55f 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -20,7 +20,6 @@ describe('teamModelCatalog', () => { 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', - 'gpt-5.2-codex', 'gpt-5.1-codex-max', ]); }); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index f24f8896..723e8e85 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -35,4 +35,128 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.compactTitle).toBe('Team launched'); expect(presentation?.compactDetail).toBe('Lead online'); }); + + it('surfaces the failed teammate reason while launch is still active', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-2', + teamName: 'codex-team', + state: 'assembling', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:05.000Z', + message: 'Spawning member jack...', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + hardFailureReason: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.panelMessage).toContain('jack failed to start'); + expect(presentation?.panelMessage).toContain('gpt-5.2-codex'); + expect(presentation?.panelMessageSeverity).toBe('warning'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + expect(presentation?.compactTone).toBe('warning'); + }); + + it('surfaces the failed teammate reason after launch completes with errors', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed with teammate errors - jack failed to start', + messageSeverity: 'warning', + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: 'The requested model is not available for your account.', + hardFailureReason: 'The requested model is not available for your account.', + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['jack'], + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start'); + expect(presentation?.panelMessage).toContain('requested model is not available'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + }); });