chore(merge): sync dev into team snapshot split spike
This commit is contained in:
commit
6cf0c0d65e
57 changed files with 5572 additions and 635 deletions
|
|
@ -107,8 +107,8 @@ export function GraphControls({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-x-3 top-3 z-20 flex items-start gap-2 pointer-events-none">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 h-8">
|
||||
<div className="absolute left-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||
{onToggleSidebar ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
|
|
@ -165,15 +165,15 @@ export function GraphControls({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 justify-end px-2">
|
||||
<div className="absolute left-1/2 top-0 w-[min(360px,38vw)] -translate-x-1/2 px-2">
|
||||
{topToolbarContent ? (
|
||||
<div className="pointer-events-auto min-w-0 max-w-[min(360px,42vw)]">
|
||||
<div className="pointer-events-auto min-w-0">
|
||||
{topToolbarContent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<div className="absolute right-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -253,10 +254,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: TeamGraphData['members'][number] | undefined): void => {
|
||||
if (!member) {
|
||||
|
|
@ -270,44 +272,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<string, GraphOwnerSlotAssignment> = {};
|
||||
|
|
|
|||
|
|
@ -3,19 +3,18 @@
|
|||
* 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,
|
||||
selectResolvedMembersForTeamName,
|
||||
hasAppliedDefaultTeamGraphSlotAssignments,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
|
@ -40,6 +39,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
slotAssignments,
|
||||
graphLayoutSession,
|
||||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -56,6 +56,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,
|
||||
}))
|
||||
);
|
||||
|
|
@ -90,18 +91,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(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
|||
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
|
||||
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -12,8 +11,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
|
@ -40,44 +37,6 @@ function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null
|
|||
return presentation != null;
|
||||
}
|
||||
|
||||
function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
||||
border: string;
|
||||
badge: string;
|
||||
icon: React.ReactNode;
|
||||
iconClassName: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'error':
|
||||
return {
|
||||
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
||||
badge: 'border-red-500/30 text-red-300',
|
||||
icon: <AlertTriangle size={12} />,
|
||||
iconClassName: 'text-red-400',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
||||
badge: 'border-amber-500/30 text-amber-200',
|
||||
icon: <AlertTriangle size={12} />,
|
||||
iconClassName: 'text-amber-400',
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
||||
badge: 'border-emerald-500/30 text-emerald-200',
|
||||
icon: <CheckCircle2 size={12} />,
|
||||
iconClassName: 'text-emerald-400',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
||||
badge: 'border-cyan-500/20 text-cyan-200',
|
||||
icon: <Loader2 size={12} className="animate-spin" />,
|
||||
iconClassName: 'text-cyan-300',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface GraphProvisioningHudProps {
|
||||
teamName: string;
|
||||
enabled?: boolean;
|
||||
|
|
@ -91,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
|
||||
|
|
@ -109,16 +67,12 @@ 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) {
|
||||
if (!shouldRender || !presentation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -126,49 +80,19 @@ export const GraphProvisioningHud = ({
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full rounded-xl border px-3 py-2 text-left text-slate-100 shadow-[0_14px_34px_rgba(5,5,16,0.24)] backdrop-blur-xl transition-colors hover:bg-[rgba(12,18,32,0.96)]',
|
||||
tone.border
|
||||
)}
|
||||
className="focus-visible:ring-white/18 w-full rounded-xl bg-transparent px-1 py-0.5 text-left text-slate-100 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-1"
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
aria-label="Open launch details"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate text-[11px] font-semibold text-slate-50">
|
||||
{presentation.compactTitle}
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
||||
{presentation.isFailed
|
||||
? 'Issue'
|
||||
: presentation.hasMembersStillJoining
|
||||
? 'Joining'
|
||||
: presentation.isActive
|
||||
? 'Live'
|
||||
: 'Ready'}
|
||||
</Badge>
|
||||
</div>
|
||||
{compactLabel ? (
|
||||
<div className="mt-0.5 truncate text-[10px] leading-4 text-slate-300">
|
||||
{compactLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border-cyan-300/12 mt-2 overflow-hidden rounded-lg border bg-[rgba(4,10,20,0.58)] px-2 py-1.5"
|
||||
style={HUD_STEPPER_STYLE}
|
||||
>
|
||||
<div className="px-1 py-0.5" style={HUD_STEPPER_STYLE}>
|
||||
<StepProgressBar
|
||||
steps={MINI_STEPS}
|
||||
currentIndex={presentation.currentStepIndex}
|
||||
errorIndex={errorStepIndex}
|
||||
className="w-full"
|
||||
className="w-full origin-top scale-[0.88]"
|
||||
/>
|
||||
</div>
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
</button>
|
||||
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
|
|
@ -49,6 +50,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
|
|||
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
||||
ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus);
|
||||
ipcMain.handle(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, handleVerifyProviderModels);
|
||||
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
|
||||
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
|||
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_VERIFY_PROVIDER_MODELS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
||||
|
||||
|
|
@ -75,7 +78,12 @@ async function handleGetStatus(
|
|||
_event: IpcMainInvokeEvent
|
||||
): Promise<IpcResult<CliInstallationStatus>> {
|
||||
try {
|
||||
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot) {
|
||||
cachedStatus = { value: latestSnapshot, at: Date.now() };
|
||||
return { success: true, data: latestSnapshot };
|
||||
}
|
||||
return { success: true, data: cachedStatus.value };
|
||||
}
|
||||
|
||||
|
|
@ -172,9 +180,25 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
|||
}
|
||||
}
|
||||
|
||||
async function handleVerifyProviderModels(
|
||||
_event: IpcMainInvokeEvent,
|
||||
providerId: CliProviderId
|
||||
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||
try {
|
||||
const status = await service.verifyProviderModels(providerId);
|
||||
patchCachedProviderStatus(status);
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logger.error(`Error in cliInstaller:verifyProviderModels(${providerId}):`, msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
cachedStatus = null;
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
service.invalidateStatusCache();
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1389,11 +1389,15 @@ async function handlePrepareProvisioning(
|
|||
_event: IpcMainInvokeEvent,
|
||||
cwd: unknown,
|
||||
providerId: unknown,
|
||||
providerIds: unknown
|
||||
providerIds: unknown,
|
||||
selectedModels: unknown,
|
||||
limitContext: unknown
|
||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
let validatedCwd: string | undefined;
|
||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
|
||||
let validatedSelectedModels: string[] | undefined;
|
||||
let validatedLimitContext: boolean | undefined;
|
||||
if (cwd !== undefined) {
|
||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string' };
|
||||
|
|
@ -1424,10 +1428,32 @@ async function handlePrepareProvisioning(
|
|||
}
|
||||
validatedProviderIds = normalized;
|
||||
}
|
||||
if (selectedModels !== undefined) {
|
||||
if (!Array.isArray(selectedModels)) {
|
||||
return { success: false, error: 'selectedModels must be an array when provided' };
|
||||
}
|
||||
const normalized = Array.from(
|
||||
new Set(
|
||||
selectedModels
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
)
|
||||
);
|
||||
validatedSelectedModels = normalized;
|
||||
}
|
||||
if (limitContext !== undefined) {
|
||||
if (typeof limitContext !== 'boolean') {
|
||||
return { success: false, error: 'limitContext must be a boolean when provided' };
|
||||
}
|
||||
validatedLimitContext = limitContext;
|
||||
}
|
||||
return wrapTeamHandler('prepareProvisioning', () =>
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||||
providerId: validatedProviderId,
|
||||
providerIds: validatedProviderIds,
|
||||
modelIds: validatedSelectedModels,
|
||||
limitContext: validatedLimitContext,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import { tmpdir } from 'os';
|
|||
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
||||
import {
|
||||
CliProviderModelAvailabilityService,
|
||||
type ProviderModelAvailabilityContext,
|
||||
type ProviderModelAvailabilitySnapshot,
|
||||
} from '../runtime/CliProviderModelAvailabilityService';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
|
||||
|
||||
|
|
@ -45,6 +50,7 @@ import type {
|
|||
CliInstallationStatus,
|
||||
CliInstallerProgress,
|
||||
CliPlatform,
|
||||
CliProviderModelAvailability,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
|
@ -137,6 +143,8 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
launchError: status.launchError ?? null,
|
||||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||
capabilities: { ...provider.capabilities },
|
||||
selectedBackendId: provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||
|
|
@ -149,6 +157,12 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
};
|
||||
}
|
||||
|
||||
function cloneProviderModelAvailability(
|
||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||
): CliProviderModelAvailability[] {
|
||||
return modelAvailability?.map((item) => ({ ...item })) ?? [];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -328,6 +342,13 @@ export class CliInstallerService {
|
|||
private mainWindow: BrowserWindow | null = null;
|
||||
private installing = false;
|
||||
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
|
||||
private readonly modelAvailabilityService = new CliProviderModelAvailabilityService(
|
||||
(providerId, signature, snapshot) => {
|
||||
this.handleProviderModelAvailabilityUpdate(providerId, signature, snapshot);
|
||||
}
|
||||
);
|
||||
private latestStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
|
||||
|
||||
private electronMetaForDiag(): Record<string, unknown> {
|
||||
try {
|
||||
|
|
@ -395,6 +416,16 @@ export class CliInstallerService {
|
|||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
getLatestStatusSnapshot(): CliInstallationStatus | null {
|
||||
return this.latestStatusSnapshot ? cloneCliInstallationStatus(this.latestStatusSnapshot) : null;
|
||||
}
|
||||
|
||||
invalidateStatusCache(): void {
|
||||
this.latestStatusSnapshot = null;
|
||||
this.latestProviderSignatures.clear();
|
||||
this.modelAvailabilityService.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app.
|
||||
*/
|
||||
|
|
@ -428,8 +459,10 @@ export class CliInstallerService {
|
|||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
modelVerificationState: 'idle' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
|
|
@ -457,12 +490,147 @@ export class CliInstallerService {
|
|||
};
|
||||
}
|
||||
|
||||
private publishStatusSnapshot(status: CliInstallationStatus): void {
|
||||
this.latestStatusSnapshot = cloneCliInstallationStatus(status);
|
||||
for (const provider of this.latestStatusSnapshot.providers) {
|
||||
if (
|
||||
provider.modelVerificationState === 'verifying' ||
|
||||
(provider.modelVerificationState === 'verified' &&
|
||||
(provider.modelAvailability?.length ?? 0) > 0)
|
||||
) {
|
||||
this.latestProviderSignatures.set(
|
||||
provider.providerId,
|
||||
this.latestProviderSignatures.get(provider.providerId) ?? null
|
||||
);
|
||||
} else {
|
||||
this.latestProviderSignatures.set(provider.providerId, null);
|
||||
}
|
||||
}
|
||||
this.sendProgress({
|
||||
type: 'status',
|
||||
status: cloneCliInstallationStatus(this.latestStatusSnapshot),
|
||||
});
|
||||
}
|
||||
|
||||
private buildProviderModelAvailabilityContext(
|
||||
binaryPath: string,
|
||||
installedVersion: string | null,
|
||||
provider: CliProviderStatus
|
||||
): ProviderModelAvailabilityContext {
|
||||
return {
|
||||
binaryPath,
|
||||
installedVersion,
|
||||
provider: {
|
||||
providerId: provider.providerId,
|
||||
models: [...provider.models],
|
||||
supported: provider.supported,
|
||||
authenticated: provider.authenticated,
|
||||
authMethod: provider.authMethod,
|
||||
selectedBackendId: provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||
capabilities: { ...provider.capabilities },
|
||||
backend: provider.backend ? { ...provider.backend } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private applyProviderModelAvailability(
|
||||
binaryPath: string,
|
||||
installedVersion: string | null,
|
||||
providers: CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
return providers.map((provider) => {
|
||||
const snapshot = this.modelAvailabilityService.getSnapshot(
|
||||
this.buildProviderModelAvailabilityContext(binaryPath, installedVersion, provider)
|
||||
);
|
||||
this.latestProviderSignatures.set(provider.providerId, snapshot.signature);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
modelVerificationState: snapshot.modelVerificationState,
|
||||
modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private applyProviderModelAvailabilityToProvider(
|
||||
binaryPath: string,
|
||||
installedVersion: string | null,
|
||||
provider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
return this.applyProviderModelAvailability(binaryPath, installedVersion, [provider])[0];
|
||||
}
|
||||
|
||||
private handleProviderModelAvailabilityUpdate(
|
||||
providerId: CliProviderId,
|
||||
signature: string,
|
||||
snapshot: ProviderModelAvailabilitySnapshot
|
||||
): void {
|
||||
if (!this.latestStatusSnapshot) {
|
||||
return;
|
||||
}
|
||||
if (this.latestProviderSignatures.get(providerId) !== signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerIndex = this.latestStatusSnapshot.providers.findIndex(
|
||||
(provider) => provider.providerId === providerId
|
||||
);
|
||||
if (providerIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProviders = [...this.latestStatusSnapshot.providers];
|
||||
nextProviders[providerIndex] = {
|
||||
...nextProviders[providerIndex],
|
||||
modelVerificationState: snapshot.modelVerificationState,
|
||||
modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability),
|
||||
};
|
||||
this.latestStatusSnapshot = {
|
||||
...this.latestStatusSnapshot,
|
||||
providers: nextProviders,
|
||||
};
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
|
||||
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
|
||||
if (
|
||||
providerStatus.modelVerificationState !== 'verifying' &&
|
||||
!((providerStatus.modelAvailability?.length ?? 0) > 0)
|
||||
) {
|
||||
this.latestProviderSignatures.set(providerStatus.providerId, null);
|
||||
}
|
||||
|
||||
if (!this.latestStatusSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasProvider = this.latestStatusSnapshot.providers.some(
|
||||
(provider) => provider.providerId === providerStatus.providerId
|
||||
);
|
||||
const nextProviders = hasProvider
|
||||
? this.latestStatusSnapshot.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
)
|
||||
: [...this.latestStatusSnapshot.providers, providerStatus];
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
|
||||
this.latestStatusSnapshot = {
|
||||
...this.latestStatusSnapshot,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const result = this.createInitialStatus();
|
||||
this.latestProviderSignatures.clear();
|
||||
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
||||
|
||||
// Run the actual status gathering with an overall timeout.
|
||||
// On timeout, return whatever partial result was collected so far.
|
||||
|
|
@ -516,7 +684,46 @@ export class CliInstallerService {
|
|||
return null;
|
||||
}
|
||||
|
||||
return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
|
||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
);
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
if (flavor !== 'agent_teams_orchestrator') {
|
||||
return this.getProviderStatus(providerId);
|
||||
}
|
||||
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
);
|
||||
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
||||
binaryPath,
|
||||
versionProbe.version,
|
||||
providerStatus
|
||||
);
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -543,7 +750,7 @@ export class CliInstallerService {
|
|||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
this.publishStatusSnapshot(r);
|
||||
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
|
|
@ -551,6 +758,7 @@ export class CliInstallerService {
|
|||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
this.publishStatusSnapshot(r);
|
||||
} else {
|
||||
diag.versionError = versionProbe.error;
|
||||
r.installed = false;
|
||||
|
|
@ -567,7 +775,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
this.publishStatusSnapshot(r);
|
||||
}
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
|
|
@ -577,7 +785,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
this.publishStatusSnapshot(r);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -641,8 +849,10 @@ export class CliInstallerService {
|
|||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: message,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
backend: null,
|
||||
}));
|
||||
|
|
@ -671,7 +881,7 @@ export class CliInstallerService {
|
|||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
this.publishStatusSnapshot(result);
|
||||
}
|
||||
);
|
||||
result.providers = providers;
|
||||
|
|
@ -679,7 +889,7 @@ export class CliInstallerService {
|
|||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
this.publishStatusSnapshot(result);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
|
|
|
|||
|
|
@ -121,8 +121,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
|
|
|
|||
292
src/main/services/runtime/CliProviderModelAvailabilityService.ts
Normal file
292
src/main/services/runtime/CliProviderModelAvailabilityService.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||
|
||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
import {
|
||||
buildProviderModelProbeArgs,
|
||||
classifyProviderModelProbeFailure,
|
||||
getProviderModelProbeTimeoutMs,
|
||||
isProviderModelProbeSuccessOutput,
|
||||
normalizeProviderModelProbeFailureReason,
|
||||
} from './providerModelProbe';
|
||||
|
||||
import type { CliProviderId, CliProviderModelAvailability, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const logger = createLogger('CliProviderModelAvailabilityService');
|
||||
const MODEL_PROBE_CONCURRENCY = 3;
|
||||
|
||||
export interface ProviderModelAvailabilityContext {
|
||||
binaryPath: string;
|
||||
installedVersion: string | null;
|
||||
provider: Pick<
|
||||
CliProviderStatus,
|
||||
| 'providerId'
|
||||
| 'models'
|
||||
| 'supported'
|
||||
| 'authenticated'
|
||||
| 'authMethod'
|
||||
| 'selectedBackendId'
|
||||
| 'resolvedBackendId'
|
||||
| 'capabilities'
|
||||
| 'backend'
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ProviderModelAvailabilitySnapshot {
|
||||
signature: string | null;
|
||||
modelVerificationState: 'idle' | 'verifying' | 'verified';
|
||||
modelAvailability: CliProviderModelAvailability[];
|
||||
}
|
||||
|
||||
interface ProviderModelAvailabilityCacheEntry {
|
||||
providerId: CliProviderId;
|
||||
signature: string;
|
||||
snapshot: ProviderModelAvailabilitySnapshot;
|
||||
envPromise: Promise<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
type ProviderAvailabilityUpdateHandler = (
|
||||
providerId: CliProviderId,
|
||||
signature: string,
|
||||
snapshot: ProviderModelAvailabilitySnapshot
|
||||
) => void;
|
||||
|
||||
function cloneModelAvailabilitySnapshot(
|
||||
snapshot: ProviderModelAvailabilitySnapshot
|
||||
): ProviderModelAvailabilitySnapshot {
|
||||
return {
|
||||
signature: snapshot.signature,
|
||||
modelVerificationState: snapshot.modelVerificationState,
|
||||
modelAvailability: snapshot.modelAvailability.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function createIdleSnapshot(): ProviderModelAvailabilitySnapshot {
|
||||
return {
|
||||
signature: null,
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createCheckingSnapshot(
|
||||
signature: string,
|
||||
models: string[]
|
||||
): ProviderModelAvailabilitySnapshot {
|
||||
return {
|
||||
signature,
|
||||
modelVerificationState: models.length > 0 ? 'verifying' : 'verified',
|
||||
modelAvailability: models.map((modelId) => ({
|
||||
modelId,
|
||||
status: 'checking',
|
||||
reason: null,
|
||||
checkedAt: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isFinalModelAvailabilityStatus(status: CliProviderModelAvailability['status']): boolean {
|
||||
return status !== 'checking';
|
||||
}
|
||||
|
||||
function buildProviderSignature(
|
||||
context: ProviderModelAvailabilityContext,
|
||||
visibleModels: string[]
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
binaryPath: context.binaryPath,
|
||||
installedVersion: context.installedVersion ?? null,
|
||||
providerId: context.provider.providerId,
|
||||
authMethod: context.provider.authMethod ?? null,
|
||||
selectedBackendId: context.provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: context.provider.resolvedBackendId ?? null,
|
||||
endpointLabel: context.provider.backend?.endpointLabel ?? null,
|
||||
models: visibleModels,
|
||||
});
|
||||
}
|
||||
|
||||
function isProviderEligibleForModelVerification(
|
||||
context: ProviderModelAvailabilityContext,
|
||||
visibleModels: string[]
|
||||
): boolean {
|
||||
return (
|
||||
(context.provider.providerId === 'codex' || context.provider.providerId === 'gemini') &&
|
||||
visibleModels.length > 0 &&
|
||||
context.provider.supported === true &&
|
||||
context.provider.authenticated === true &&
|
||||
context.provider.capabilities.oneShot === true
|
||||
);
|
||||
}
|
||||
|
||||
function classifyFailedProbe(
|
||||
modelId: string,
|
||||
error: unknown
|
||||
): Pick<CliProviderModelAvailability, 'status' | 'reason'> {
|
||||
const message = getErrorMessage(error).trim();
|
||||
const normalizedReason = normalizeProviderModelProbeFailureReason(message);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (classifyProviderModelProbeFailure(message) === 'unavailable') {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
reason: normalizedReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('500') ||
|
||||
lower.includes('502') ||
|
||||
lower.includes('503') ||
|
||||
lower.includes('504')
|
||||
) {
|
||||
return {
|
||||
status: 'unknown',
|
||||
reason: normalizedReason,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`Model probe inconclusive providerModel=${modelId}: ${message}`);
|
||||
return {
|
||||
status: 'unknown',
|
||||
reason: normalizedReason,
|
||||
};
|
||||
}
|
||||
|
||||
export class CliProviderModelAvailabilityService {
|
||||
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
||||
private readonly queue: Array<() => void> = [];
|
||||
private activeProbeCount = 0;
|
||||
|
||||
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.cache.clear();
|
||||
this.queue.length = 0;
|
||||
}
|
||||
|
||||
getSnapshot(context: ProviderModelAvailabilityContext): ProviderModelAvailabilitySnapshot {
|
||||
const visibleModels = filterVisibleProviderRuntimeModels(
|
||||
context.provider.providerId,
|
||||
context.provider.models
|
||||
);
|
||||
if (!isProviderEligibleForModelVerification(context, visibleModels)) {
|
||||
return createIdleSnapshot();
|
||||
}
|
||||
|
||||
const signature = buildProviderSignature(context, visibleModels);
|
||||
const existing = this.cache.get(signature);
|
||||
if (existing) {
|
||||
return cloneModelAvailabilitySnapshot(existing.snapshot);
|
||||
}
|
||||
|
||||
const entry: ProviderModelAvailabilityCacheEntry = {
|
||||
providerId: context.provider.providerId,
|
||||
signature,
|
||||
snapshot: createCheckingSnapshot(signature, visibleModels),
|
||||
envPromise: buildProviderAwareCliEnv({
|
||||
binaryPath: context.binaryPath,
|
||||
providerId: context.provider.providerId,
|
||||
}).then((result) => result.env),
|
||||
};
|
||||
this.cache.set(signature, entry);
|
||||
this.startProbes(context, entry);
|
||||
|
||||
return cloneModelAvailabilitySnapshot(entry.snapshot);
|
||||
}
|
||||
|
||||
private startProbes(
|
||||
context: ProviderModelAvailabilityContext,
|
||||
entry: ProviderModelAvailabilityCacheEntry
|
||||
): void {
|
||||
for (const modelId of entry.snapshot.modelAvailability.map((item) => item.modelId)) {
|
||||
this.enqueue(async () => {
|
||||
const result = await this.probeModel(context, entry, modelId);
|
||||
const index = entry.snapshot.modelAvailability.findIndex(
|
||||
(item) => item.modelId === modelId
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.snapshot.modelAvailability[index] = {
|
||||
modelId,
|
||||
checkedAt: new Date().toISOString(),
|
||||
...result,
|
||||
};
|
||||
if (
|
||||
entry.snapshot.modelAvailability.every((item) =>
|
||||
isFinalModelAvailabilityStatus(item.status)
|
||||
)
|
||||
) {
|
||||
entry.snapshot.modelVerificationState = 'verified';
|
||||
}
|
||||
|
||||
this.onUpdate?.(
|
||||
entry.providerId,
|
||||
entry.signature,
|
||||
cloneModelAvailabilitySnapshot(entry.snapshot)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>): void {
|
||||
this.queue.push(() => {
|
||||
this.activeProbeCount += 1;
|
||||
void task()
|
||||
.catch((error) => {
|
||||
logger.warn(`Model verification task failed: ${getErrorMessage(error)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeProbeCount = Math.max(0, this.activeProbeCount - 1);
|
||||
this.drainQueue();
|
||||
});
|
||||
});
|
||||
this.drainQueue();
|
||||
}
|
||||
|
||||
private drainQueue(): void {
|
||||
while (this.activeProbeCount < MODEL_PROBE_CONCURRENCY) {
|
||||
const next = this.queue.shift();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
private async probeModel(
|
||||
context: ProviderModelAvailabilityContext,
|
||||
entry: ProviderModelAvailabilityCacheEntry,
|
||||
modelId: string
|
||||
): Promise<Pick<CliProviderModelAvailability, 'status' | 'reason'>> {
|
||||
try {
|
||||
const env = await entry.envPromise;
|
||||
const { stdout } = await execCli(context.binaryPath, buildProviderModelProbeArgs(modelId), {
|
||||
timeout: getProviderModelProbeTimeoutMs(context.provider.providerId),
|
||||
env,
|
||||
});
|
||||
const output = stdout.trim();
|
||||
if (isProviderModelProbeSuccessOutput(output)) {
|
||||
return {
|
||||
status: 'available',
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unknown',
|
||||
reason: output || 'Model verification returned an unexpected response.',
|
||||
};
|
||||
} catch (error) {
|
||||
return classifyFailedProbe(modelId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/main/services/runtime/providerModelProbe.ts
Normal file
119
src/main/services/runtime/providerModelProbe.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
||||
|
||||
const PROVIDER_MODEL_PROBE_TIMEOUT_MS = 60_000;
|
||||
const PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS = 60_000;
|
||||
const PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_MODEL_PROBE_PROMPT = 'Output only the single word PONG.';
|
||||
|
||||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||
|
||||
function resolveProbeProviderId(providerId: SupportedProviderId | undefined): SupportedProviderId {
|
||||
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
}
|
||||
|
||||
export function getProviderModelProbePrompt(): string {
|
||||
return PROVIDER_MODEL_PROBE_PROMPT;
|
||||
}
|
||||
|
||||
export function getProviderModelProbeExpectedOutput(): string {
|
||||
return 'PONG';
|
||||
}
|
||||
|
||||
export function isProviderModelProbeSuccessOutput(output: string): boolean {
|
||||
return new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test(output.trim());
|
||||
}
|
||||
|
||||
export function classifyProviderModelProbeFailure(message: string): 'unavailable' | 'unknown' {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('model is not supported') ||
|
||||
lower.includes('model not supported') ||
|
||||
lower.includes('unsupported model') ||
|
||||
lower.includes('model is not available') ||
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('model unavailable') ||
|
||||
lower.includes('model not found') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('invalid model')
|
||||
) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isProviderModelProbeTimeoutMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes('timeout running:') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('did not complete')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeProviderModelProbeFailureReason(message: string): string {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) {
|
||||
return 'Model verification failed';
|
||||
}
|
||||
|
||||
if (
|
||||
/The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed)
|
||||
) {
|
||||
return 'Not available with Codex ChatGPT subscription';
|
||||
}
|
||||
if (/The requested model is not available for your account\./i.test(trimmed)) {
|
||||
return 'Not available for this account';
|
||||
}
|
||||
if (isProviderModelProbeTimeoutMessage(trimmed)) {
|
||||
return 'Model verification timed out';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function buildProviderModelProbeArgs(modelId: string): string[] {
|
||||
return [
|
||||
'-p',
|
||||
getProviderModelProbePrompt(),
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
modelId,
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
];
|
||||
}
|
||||
|
||||
export function getProviderModelProbeTimeoutMs(
|
||||
providerId: SupportedProviderId | undefined
|
||||
): number {
|
||||
switch (resolveProbeProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS;
|
||||
case 'gemini':
|
||||
return PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS;
|
||||
case 'anthropic':
|
||||
default:
|
||||
return PROVIDER_MODEL_PROBE_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderPreflightModel(providerId: TeamProviderId | undefined): string {
|
||||
switch (resolveProbeProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return 'gpt-5.4-mini';
|
||||
case 'gemini':
|
||||
return 'gemini-2.5-flash-lite';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'haiku';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildProviderPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||
return buildProviderModelProbeArgs(getProviderPreflightModel(providerId));
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/mai
|
|||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import {
|
||||
encodePath,
|
||||
|
|
@ -42,12 +42,14 @@ import {
|
|||
} from '@shared/utils/inboxNoise';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
parseAllTeammateMessages,
|
||||
type ParsedTeammateContent,
|
||||
} from '@shared/utils/teammateMessageParser';
|
||||
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
extractToolPreview,
|
||||
|
|
@ -66,6 +68,15 @@ import {
|
|||
type GeminiRuntimeAuthState,
|
||||
resolveGeminiRuntimeAuth,
|
||||
} from '../runtime/geminiRuntimeAuth';
|
||||
import {
|
||||
buildProviderPreflightPingArgs,
|
||||
buildProviderModelProbeArgs,
|
||||
classifyProviderModelProbeFailure,
|
||||
getProviderModelProbeExpectedOutput,
|
||||
getProviderModelProbeTimeoutMs,
|
||||
isProviderModelProbeSuccessOutput,
|
||||
normalizeProviderModelProbeFailureReason,
|
||||
} from '../runtime/providerModelProbe';
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||
|
||||
|
|
@ -96,6 +107,7 @@ import {
|
|||
} from './TeamLaunchStateEvaluator';
|
||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
|
|
@ -185,9 +197,6 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
|||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
||||
const PREFLIGHT_CODEX_TIMEOUT_MS = 45000;
|
||||
const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000;
|
||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
|
|
@ -214,11 +223,6 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
|
|||
'result',
|
||||
'system',
|
||||
]);
|
||||
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini';
|
||||
const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
function assertAppDeterministicBootstrapEnabled(): void {
|
||||
if (process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP === '1') {
|
||||
throw new Error(
|
||||
|
|
@ -260,41 +264,36 @@ function classifyDeterministicBootstrapFailure(reason: string): {
|
|||
};
|
||||
}
|
||||
|
||||
function getPreflightPingModel(providerId: TeamProviderId | undefined): string {
|
||||
switch (resolveTeamProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return PREFLIGHT_CODEX_MODEL;
|
||||
case 'gemini':
|
||||
return PREFLIGHT_GEMINI_MODEL;
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'haiku';
|
||||
}
|
||||
}
|
||||
|
||||
function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||
return [
|
||||
'-p',
|
||||
PREFLIGHT_PING_PROMPT,
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
getPreflightPingModel(providerId),
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
];
|
||||
return buildProviderPreflightPingArgs(providerId);
|
||||
}
|
||||
|
||||
function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
||||
switch (resolveTeamProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return PREFLIGHT_CODEX_TIMEOUT_MS;
|
||||
case 'gemini':
|
||||
return PREFLIGHT_GEMINI_TIMEOUT_MS;
|
||||
case 'anthropic':
|
||||
default:
|
||||
return PREFLIGHT_TIMEOUT_MS;
|
||||
return getProviderModelProbeTimeoutMs(providerId);
|
||||
}
|
||||
|
||||
interface ProviderModelListCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
defaultModel?: string | null;
|
||||
models?: (string | { id?: string; label?: string; description?: string })[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
function extractJsonObjectFromCli<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
||||
}
|
||||
throw new Error('No JSON object found in CLI output');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -308,6 +307,21 @@ function isProbeTimeoutMessage(message: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isTransientModelProbeMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('500') ||
|
||||
lower.includes('502') ||
|
||||
lower.includes('503') ||
|
||||
lower.includes('504')
|
||||
);
|
||||
}
|
||||
|
||||
function getTeamProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
|
|
@ -1045,11 +1059,68 @@ function extractBootstrapFailureReason(text: string): string | null {
|
|||
lower.includes('lookup failure') ||
|
||||
lower.includes('validation error') ||
|
||||
lower.includes('api error'))) ||
|
||||
lower.includes('model is not supported') ||
|
||||
lower.includes('model is not available') ||
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('model unavailable') ||
|
||||
lower.includes('model not found') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unsupported model') ||
|
||||
lower.includes('not supported when using codex with a chatgpt account') ||
|
||||
lower.includes('please check the provided tool list');
|
||||
if (!looksLikeBootstrapFailure) return null;
|
||||
return trimmed.slice(0, 280);
|
||||
}
|
||||
|
||||
function extractTranscriptTextContent(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const record = item as { type?: unknown; text?: unknown; content?: unknown };
|
||||
if (record.type === 'text' && typeof record.text === 'string' && record.text.trim()) {
|
||||
parts.push(record.text.trim());
|
||||
continue;
|
||||
}
|
||||
parts.push(...extractTranscriptTextContent(record.content));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function extractTranscriptMessageText(record: unknown): string | null {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const normalizedRecord = record as {
|
||||
text?: unknown;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
toolUseResult?: unknown;
|
||||
};
|
||||
if (typeof normalizedRecord.text === 'string' && normalizedRecord.text.trim()) {
|
||||
return normalizedRecord.text.trim();
|
||||
}
|
||||
const fromContent = extractTranscriptTextContent(normalizedRecord.content);
|
||||
if (fromContent.length > 0) {
|
||||
return fromContent.join('\n');
|
||||
}
|
||||
const fromToolUseResult = extractTranscriptTextContent(normalizedRecord.toolUseResult);
|
||||
if (fromToolUseResult.length > 0) {
|
||||
return fromToolUseResult.join('\n');
|
||||
}
|
||||
if (normalizedRecord.message) {
|
||||
return extractTranscriptMessageText(normalizedRecord.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMemberDiagnosticText(memberName: string, text: string): string {
|
||||
return `${memberName}: ${text.trim()}`;
|
||||
}
|
||||
|
|
@ -2090,6 +2161,7 @@ function normalizeSameTeamText(text: string): string {
|
|||
|
||||
export class TeamProvisioningService {
|
||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||
private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024;
|
||||
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
||||
private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000;
|
||||
|
|
@ -2114,6 +2186,7 @@ export class TeamProvisioningService {
|
|||
NativeSameTeamFingerprint[]
|
||||
>();
|
||||
private readonly launchStateStore = new TeamLaunchStateStore();
|
||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||
private helpOutputCache: string | null = null;
|
||||
private helpOutputCacheTime = 0;
|
||||
|
|
@ -2143,7 +2216,13 @@ export class TeamProvisioningService {
|
|||
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||
) {}
|
||||
) {
|
||||
this.memberLogsFinder = new TeamMemberLogsFinder(
|
||||
this.configReader,
|
||||
this.inboxReader,
|
||||
this.membersMetaStore
|
||||
);
|
||||
}
|
||||
|
||||
setCrossTeamSender(
|
||||
sender:
|
||||
|
|
@ -3447,6 +3526,10 @@ export class TeamProvisioningService {
|
|||
run: ProvisioningRun,
|
||||
options?: { force?: boolean }
|
||||
): Promise<void> {
|
||||
if (!run.expectedMembers || run.expectedMembers.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.reconcileBootstrapTranscriptFailures(run);
|
||||
if (this.shouldSkipMemberSpawnAudit(run)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -3462,6 +3545,33 @@ export class TeamProvisioningService {
|
|||
await this.auditMemberSpawnStatuses(run);
|
||||
}
|
||||
|
||||
private async reconcileBootstrapTranscriptFailures(run: ProvisioningRun): Promise<void> {
|
||||
for (const memberName of run.expectedMembers ?? []) {
|
||||
const current = run.memberSpawnStatuses.get(memberName);
|
||||
if (
|
||||
!current ||
|
||||
current.launchState === 'failed_to_start' ||
|
||||
current.launchState === 'confirmed_alive' ||
|
||||
current.runtimeAlive === true ||
|
||||
current.hardFailure === true ||
|
||||
current.agentToolAccepted !== true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
|
||||
run.teamName,
|
||||
memberName,
|
||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||
);
|
||||
if (!transcriptFailureReason) {
|
||||
continue;
|
||||
}
|
||||
this.setMemberSpawnStatus(run, memberName, 'error', transcriptFailureReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
|
||||
|
||||
|
|
@ -3507,7 +3617,13 @@ export class TeamProvisioningService {
|
|||
|
||||
async prepareForProvisioning(
|
||||
cwd?: string,
|
||||
opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
|
||||
opts?: {
|
||||
forceFresh?: boolean;
|
||||
providerId?: TeamProviderId;
|
||||
providerIds?: TeamProviderId[];
|
||||
modelIds?: string[];
|
||||
limitContext?: boolean;
|
||||
}
|
||||
): Promise<TeamProvisioningPrepareResult> {
|
||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||
await this.validatePrepareCwd(targetCwdForValidation);
|
||||
|
|
@ -3535,7 +3651,11 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const details: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
const selectedModelIds = Array.from(
|
||||
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||
);
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
|
||||
|
|
@ -3555,32 +3675,47 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (!probeResult.warning) {
|
||||
if (selectedModelIds.length > 0) {
|
||||
const modelVerification = await this.verifySelectedProviderModels({
|
||||
claudePath: probeResult.claudePath,
|
||||
cwd: targetCwd,
|
||||
providerId,
|
||||
modelIds: selectedModelIds,
|
||||
limitContext: opts?.limitContext === true,
|
||||
});
|
||||
details.push(...modelVerification.details);
|
||||
warnings.push(...modelVerification.warnings);
|
||||
blockingMessages.push(...modelVerification.blockingMessages);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefixedWarning =
|
||||
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (authSource === 'configured_api_key_missing') {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (
|
||||
(authSource === 'none' ||
|
||||
authSource === 'codex_runtime' ||
|
||||
authSource === 'gemini_runtime') &&
|
||||
isAuthFailure
|
||||
) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else {
|
||||
// Preflight warnings (including timeouts) should not block provisioning.
|
||||
warnings.push(prefixedWarning);
|
||||
{
|
||||
const prefixedWarning =
|
||||
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (authSource === 'configured_api_key_missing') {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (
|
||||
(authSource === 'none' ||
|
||||
authSource === 'codex_runtime' ||
|
||||
authSource === 'gemini_runtime') &&
|
||||
isAuthFailure
|
||||
) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else {
|
||||
// Preflight warnings (including timeouts) should not block provisioning.
|
||||
warnings.push(prefixedWarning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blockingMessages.length > 0) {
|
||||
return {
|
||||
ready: false,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
message:
|
||||
blockingMessages.length === 1
|
||||
? blockingMessages[0]
|
||||
|
|
@ -3591,6 +3726,7 @@ export class TeamProvisioningService {
|
|||
|
||||
return {
|
||||
ready: true,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
message:
|
||||
providerIds.length > 1
|
||||
? warnings.length > 0
|
||||
|
|
@ -3603,6 +3739,169 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private async verifySelectedProviderModels({
|
||||
claudePath,
|
||||
cwd,
|
||||
providerId,
|
||||
modelIds,
|
||||
limitContext,
|
||||
}: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
providerId: TeamProviderId;
|
||||
modelIds: string[];
|
||||
limitContext: boolean;
|
||||
}): Promise<{
|
||||
details: string[];
|
||||
warnings: string[];
|
||||
blockingMessages: string[];
|
||||
}> {
|
||||
const details: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
return { details, warnings, blockingMessages };
|
||||
}
|
||||
|
||||
const { env } = await this.buildProvisioningEnv(providerId);
|
||||
const probeOutcomeByResolvedModelId = new Map<
|
||||
string,
|
||||
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||
>();
|
||||
let resolvedDefaultModelId: string | null | undefined;
|
||||
|
||||
const recordOutcome = (
|
||||
requestedModelId: string,
|
||||
outcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||
): void => {
|
||||
if (outcome.kind === 'ready') {
|
||||
details.push(`Selected model ${requestedModelId} verified for launch.`);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'unavailable') {
|
||||
blockingMessages.push(
|
||||
`Selected model ${requestedModelId} is unavailable. ${outcome.reason ?? 'Model verification failed'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
warnings.push(
|
||||
`Selected model ${requestedModelId} could not be verified. ${outcome.reason ?? 'Model verification failed'}`
|
||||
);
|
||||
};
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
const label = modelId.trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetModelId = label;
|
||||
if (isDefaultProviderModelSelection(label)) {
|
||||
if (resolvedDefaultModelId === undefined) {
|
||||
try {
|
||||
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
|
||||
claudePath,
|
||||
cwd,
|
||||
providerId,
|
||||
env,
|
||||
limitContext
|
||||
);
|
||||
} catch {
|
||||
resolvedDefaultModelId = null;
|
||||
}
|
||||
}
|
||||
if (!resolvedDefaultModelId) {
|
||||
recordOutcome(label, {
|
||||
kind: 'warning',
|
||||
reason: 'Could not resolve the runtime default model',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
targetModelId = resolvedDefaultModelId;
|
||||
}
|
||||
|
||||
const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId);
|
||||
if (cachedOutcome) {
|
||||
recordOutcome(label, cachedOutcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.spawnProbe(
|
||||
claudePath,
|
||||
buildProviderModelProbeArgs(targetModelId),
|
||||
cwd,
|
||||
env,
|
||||
getProviderModelProbeTimeoutMs(providerId),
|
||||
{
|
||||
resolveOnOutputMatch: ({ stdout, stderr }) =>
|
||||
isProviderModelProbeSuccessOutput(`${stdout}\n${stderr}`),
|
||||
}
|
||||
);
|
||||
const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim();
|
||||
if (result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput)) {
|
||||
const outcome = { kind: 'ready' as const };
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
recordOutcome(label, outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason = combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`;
|
||||
const normalizedReason = normalizeProviderModelProbeFailureReason(reason);
|
||||
if (classifyProviderModelProbeFailure(reason) === 'unavailable') {
|
||||
const outcome = { kind: 'unavailable' as const, reason: normalizedReason };
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
recordOutcome(label, outcome);
|
||||
} else {
|
||||
const outcome = { kind: 'warning' as const, reason: normalizedReason };
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
recordOutcome(label, outcome);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.trim() : String(error).trim();
|
||||
const normalizedMessage = normalizeProviderModelProbeFailureReason(message);
|
||||
if (
|
||||
classifyProviderModelProbeFailure(message) === 'unavailable' &&
|
||||
!isTransientModelProbeMessage(message)
|
||||
) {
|
||||
const outcome = { kind: 'unavailable' as const, reason: normalizedMessage };
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
recordOutcome(label, outcome);
|
||||
} else {
|
||||
const outcome = { kind: 'warning' as const, reason: normalizedMessage };
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
recordOutcome(label, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { details, warnings, blockingMessages };
|
||||
}
|
||||
|
||||
private async resolveProviderDefaultModel(
|
||||
claudePath: string,
|
||||
cwd: string,
|
||||
providerId: TeamProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
limitContext: boolean
|
||||
): Promise<string | null> {
|
||||
if (providerId === 'anthropic') {
|
||||
return getAnthropicDefaultTeamModel(limitContext);
|
||||
}
|
||||
|
||||
const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], {
|
||||
cwd,
|
||||
env,
|
||||
timeout: 10_000,
|
||||
});
|
||||
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
|
||||
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
|
||||
return typeof defaultModel === 'string' && defaultModel.trim().length > 0
|
||||
? defaultModel.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private getFreshCachedProbeResult(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId | undefined
|
||||
|
|
@ -6699,7 +6998,7 @@ export class TeamProvisioningService {
|
|||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
||||
const persisted = await this.launchStateStore.read(teamName);
|
||||
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted);
|
||||
if (preferredSnapshot) {
|
||||
if (preferredSnapshot && preferredSnapshot === bootstrapSnapshot) {
|
||||
return {
|
||||
snapshot: preferredSnapshot,
|
||||
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
||||
|
|
@ -6784,6 +7083,8 @@ export class TeamProvisioningService {
|
|||
const heartbeatReason = heartbeatMessage
|
||||
? extractBootstrapFailureReason(heartbeatMessage.text)
|
||||
: null;
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
current.runtimeAlive = runtimeAlive;
|
||||
current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt;
|
||||
current.sources = {
|
||||
|
|
@ -6806,8 +7107,18 @@ export class TeamProvisioningService {
|
|||
current.hardFailure = false;
|
||||
current.hardFailureReason = undefined;
|
||||
}
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) {
|
||||
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
|
||||
teamName,
|
||||
expected,
|
||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||
);
|
||||
if (transcriptFailureReason) {
|
||||
current.hardFailure = true;
|
||||
current.hardFailureReason = transcriptFailureReason;
|
||||
current.sources.hardFailureSignal = true;
|
||||
}
|
||||
}
|
||||
const graceExpired =
|
||||
current.agentToolAccepted === true &&
|
||||
Number.isFinite(acceptedAtMs) &&
|
||||
|
|
@ -6851,6 +7162,139 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private async findBootstrapTranscriptFailureReason(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
sinceMs: number | null
|
||||
): Promise<string | null> {
|
||||
let summaries: Awaited<ReturnType<TeamMemberLogsFinder['findMemberLogs']>>;
|
||||
try {
|
||||
summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const summary of summaries) {
|
||||
if (!summary.filePath) continue;
|
||||
const reason = await this.readRecentBootstrapFailureReason(
|
||||
summary.filePath,
|
||||
sinceMs,
|
||||
memberName
|
||||
);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
return this.findBootstrapFailureReasonInProjectRoot(teamName, memberName, sinceMs);
|
||||
}
|
||||
|
||||
private async readRecentBootstrapFailureReason(
|
||||
filePath: string,
|
||||
sinceMs: number | null,
|
||||
memberName?: string
|
||||
): Promise<string | null> {
|
||||
let handle: fs.promises.FileHandle | null = null;
|
||||
const normalizedMemberName = memberName?.trim().toLowerCase() || null;
|
||||
try {
|
||||
handle = await fs.promises.open(filePath, 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return null;
|
||||
}
|
||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(stat.size - start);
|
||||
if (buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
await handle.read(buffer, 0, buffer.length, start);
|
||||
const lines = buffer.toString('utf8').split('\n');
|
||||
if (start > 0) {
|
||||
lines.shift();
|
||||
}
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index]?.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { timestamp?: unknown } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as { timestamp?: unknown };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const timestampMs =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
if (sinceMs != null && Number.isFinite(timestampMs) && timestampMs < sinceMs) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedMemberName) {
|
||||
const parsedAgentName =
|
||||
typeof (parsed as { agentName?: unknown }).agentName === 'string'
|
||||
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
|
||||
: null;
|
||||
if (parsedAgentName && parsedAgentName !== normalizedMemberName) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const text = extractTranscriptMessageText(parsed);
|
||||
if (!text) continue;
|
||||
const reason = extractBootstrapFailureReason(text);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findBootstrapFailureReasonInProjectRoot(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
sinceMs: number | null
|
||||
): Promise<string | null> {
|
||||
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>>;
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const projectPath = config?.projectPath?.trim();
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectDir = path.join(getProjectsBasePath(), extractBaseDir(encodePath(projectPath)));
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonlFiles = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
|
||||
.sort((left, right) => right.name.localeCompare(left.name));
|
||||
for (const entry of jsonlFiles) {
|
||||
if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) {
|
||||
continue;
|
||||
}
|
||||
const reason = await this.readRecentBootstrapFailureReason(
|
||||
path.join(projectDir, entry.name),
|
||||
sinceMs,
|
||||
memberName
|
||||
);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||
for (const part of content) {
|
||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
||||
|
|
@ -11562,7 +12006,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
|
||||
const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate);
|
||||
const isPong = new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test(
|
||||
pongCandidate
|
||||
);
|
||||
if (!isPong) {
|
||||
return {
|
||||
warning:
|
||||
|
|
|
|||
|
|
@ -441,6 +441,9 @@ export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
|
|||
/** Get status for a single provider */
|
||||
export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus';
|
||||
|
||||
/** Trigger on-demand model verification for a single provider */
|
||||
export const CLI_INSTALLER_VERIFY_PROVIDER_MODELS = 'cliInstaller:verifyProviderModels';
|
||||
|
||||
/** Start CLI install/update */
|
||||
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_PROGRESS,
|
||||
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||
CONTEXT_CHANGED,
|
||||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
|
|
@ -859,13 +860,17 @@ const electronAPI: ElectronAPI = {
|
|||
prepareProvisioning: async (
|
||||
cwd?: string,
|
||||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][]
|
||||
providerIds?: TeamLaunchRequest['providerId'][],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
cwd,
|
||||
providerId,
|
||||
providerIds
|
||||
providerIds,
|
||||
selectedModels,
|
||||
limitContext
|
||||
);
|
||||
},
|
||||
createTeam: async (request: TeamCreateRequest) => {
|
||||
|
|
@ -1418,6 +1423,9 @@ const electronAPI: ElectronAPI = {
|
|||
getProviderStatus: async (providerId: CliProviderId) => {
|
||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||
},
|
||||
verifyProviderModels: async (providerId: CliProviderId) => {
|
||||
return invokeIpcWithResult(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, providerId);
|
||||
},
|
||||
install: async (): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -718,7 +718,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
prepareProvisioning: async (
|
||||
_cwd?: string,
|
||||
_providerId?: TeamLaunchRequest['providerId'],
|
||||
_providerIds?: TeamLaunchRequest['providerId'][]
|
||||
_providerIds?: TeamLaunchRequest['providerId'][],
|
||||
_selectedModels?: string[],
|
||||
_limitContext?: boolean
|
||||
): Promise<TeamProvisioningPrepareResult> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
|
|
@ -1126,6 +1128,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
providers: [],
|
||||
}),
|
||||
getProviderStatus: async (): Promise<null> => null,
|
||||
verifyProviderModels: async (): Promise<null> => null,
|
||||
install: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import {
|
||||
formatProviderStatusText,
|
||||
getProviderConnectionModeSummary,
|
||||
|
|
@ -32,10 +33,6 @@ import { useStore } from '@renderer/store';
|
|||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import {
|
||||
getTeamModelBadgeLabel,
|
||||
getVisibleTeamProviderModels,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -266,37 +263,6 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||
return getTeamModelBadgeLabel(providerId, model) ?? model;
|
||||
}
|
||||
|
||||
const ModelBadges = ({
|
||||
providerId,
|
||||
models,
|
||||
}: {
|
||||
readonly providerId: CliProviderId;
|
||||
readonly models: string[];
|
||||
}): React.JSX.Element => {
|
||||
const visibleModels = getVisibleTeamProviderModels(providerId, models);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{formatModelBadgeLabel(providerId, model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProviderDetailSkeleton = (): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
|
|
@ -659,7 +625,12 @@ const InstalledBanner = ({
|
|||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ModelBadges providerId={provider.providerId} models={provider.models} />
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
97
src/renderer/components/runtime/ProviderModelBadges.tsx
Normal file
97
src/renderer/components/runtime/ProviderModelBadges.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
getTeamModelBadgeLabel,
|
||||
getVisibleTeamProviderModels,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import type {
|
||||
CliProviderId,
|
||||
CliProviderModelAvailability,
|
||||
CliProviderModelAvailabilityStatus,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||
return getTeamModelBadgeLabel(providerId, model) ?? model;
|
||||
}
|
||||
|
||||
function getAvailabilityStatus(
|
||||
model: string,
|
||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||
): CliProviderModelAvailabilityStatus | null {
|
||||
return modelAvailability?.find((item) => item.modelId === model)?.status ?? null;
|
||||
}
|
||||
|
||||
function getAvailabilityReason(
|
||||
model: string,
|
||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||
): string | null {
|
||||
return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null;
|
||||
}
|
||||
|
||||
function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'Checking';
|
||||
case 'unavailable':
|
||||
return 'Unavailable';
|
||||
case 'unknown':
|
||||
return 'Check failed';
|
||||
case 'available':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderModelBadges({
|
||||
providerId,
|
||||
models,
|
||||
modelAvailability,
|
||||
providerStatus,
|
||||
}: {
|
||||
readonly providerId: CliProviderId;
|
||||
readonly models: string[];
|
||||
readonly modelAvailability?: CliProviderModelAvailability[];
|
||||
readonly providerStatus?: Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null;
|
||||
}): React.JSX.Element {
|
||||
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleModels.map((model) => {
|
||||
const availabilityStatus = getAvailabilityStatus(model, modelAvailability);
|
||||
const availabilityReason = getAvailabilityReason(model, modelAvailability);
|
||||
const availabilityChip = getAvailabilityChip(availabilityStatus);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
title={availabilityReason ?? availabilityChip ?? undefined}
|
||||
>
|
||||
<span>{formatModelBadgeLabel(providerId, model)}</span>
|
||||
{availabilityChip ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
|
||||
availabilityStatus === 'checking'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
|
||||
: availabilityStatus === 'unavailable'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
|
||||
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
|
||||
)}
|
||||
>
|
||||
{availabilityChip}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import {
|
||||
formatProviderStatusText,
|
||||
getProviderConnectionModeSummary,
|
||||
|
|
@ -28,10 +29,6 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import {
|
||||
getTeamModelBadgeLabel,
|
||||
getVisibleTeamProviderModels,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -49,37 +46,6 @@ import { SettingsSectionHeader } from '../components';
|
|||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||
return getTeamModelBadgeLabel(providerId, model) ?? model;
|
||||
}
|
||||
|
||||
const ModelBadges = ({
|
||||
providerId,
|
||||
models,
|
||||
}: {
|
||||
readonly providerId: CliProviderId;
|
||||
readonly models: string[];
|
||||
}): React.JSX.Element => {
|
||||
const visibleModels = getVisibleTeamProviderModels(providerId, models);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{formatModelBadgeLabel(providerId, model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProviderDetailSkeleton = (): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
|
|
@ -600,9 +566,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ModelBadges
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,12 @@ import {
|
|||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
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, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -50,15 +54,21 @@ import { AdvancedCliSection } from './AdvancedCliSection';
|
|||
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 { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
|
@ -82,7 +92,6 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
TeamProvisioningPrepareResult,
|
||||
} from '@shared/types';
|
||||
|
||||
function getStoredTeamProvider(): TeamProviderId {
|
||||
|
|
@ -115,6 +124,32 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
|||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
||||
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: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface TeamCopyData {
|
||||
teamName: string;
|
||||
description?: string;
|
||||
|
|
@ -486,6 +521,25 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
return new Map<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
}, [runtimeBackendSummaryByProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
prepareChecksRef.current = prepareChecks;
|
||||
}, [prepareChecks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
prepareModelResultsCacheRef.current.clear();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (multimodelEnabled) {
|
||||
|
|
@ -534,41 +588,110 @@ export const CreateTeamDialog = ({
|
|||
|
||||
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);
|
||||
|
||||
// Defer so file list fetch (triggered by project select) can run first
|
||||
const timer = setTimeout(() => {
|
||||
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 = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const memberProviderId =
|
||||
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
||||
if (memberProviderId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const memberModel = member.model?.trim();
|
||||
if (memberModel) {
|
||||
next.add(memberModel);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
}
|
||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||
}
|
||||
return Array.from(next);
|
||||
})();
|
||||
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(
|
||||
|
|
@ -576,23 +699,29 @@ export const CreateTeamDialog = ({
|
|||
)
|
||||
);
|
||||
}
|
||||
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.'
|
||||
|
|
@ -619,9 +748,11 @@ export const CreateTeamDialog = ({
|
|||
canCreate,
|
||||
launchTeam,
|
||||
effectiveCwd,
|
||||
effectiveMemberDrafts,
|
||||
limitContext,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
runtimeBackendSummaryByProvider,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -809,6 +940,13 @@ export const CreateTeamDialog = ({
|
|||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||
[selectedModel, limitContext, selectedProviderId]
|
||||
);
|
||||
const runtimeProviderStatusById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const)
|
||||
),
|
||||
[cliStatus?.providers]
|
||||
);
|
||||
|
||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||
const teamNameInlineError = validateTeamNameInline(teamName);
|
||||
|
|
@ -854,11 +992,76 @@ export const CreateTeamDialog = ({
|
|||
() => validateRequest(request, { requireCwd: launchTeam }),
|
||||
[request, launchTeam]
|
||||
);
|
||||
const modelValidationError = useMemo(() => {
|
||||
const leadError = getTeamModelSelectionError(
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
);
|
||||
if (leadError) {
|
||||
return leadError;
|
||||
}
|
||||
|
||||
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, runtimeProviderStatusById, selectedModel, selectedProviderId]);
|
||||
const leadModelIssueText = useMemo(() => {
|
||||
const issue = getProvisioningModelIssue(
|
||||
prepareChecks,
|
||||
selectedProviderId,
|
||||
effectiveModel ?? selectedModel
|
||||
);
|
||||
return issue?.reason ?? issue?.detail ?? null;
|
||||
}, [effectiveModel, prepareChecks, selectedModel, selectedProviderId]);
|
||||
const memberModelIssueById = useMemo(() => {
|
||||
const next: Record<string, string> = {};
|
||||
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,
|
||||
leadModelIssueText,
|
||||
prepareChecks,
|
||||
selectedProviderId,
|
||||
syncModelsWithLead,
|
||||
]);
|
||||
const hasCreateFormErrors =
|
||||
!!teamNameInlineError ||
|
||||
isNameTakenByExistingTeam ||
|
||||
isNameProvisioning ||
|
||||
!requestValidation.valid;
|
||||
!requestValidation.valid ||
|
||||
!!modelValidationError;
|
||||
|
||||
const internalArgs = useMemo(() => {
|
||||
const args: string[] = [];
|
||||
|
|
@ -897,7 +1100,8 @@ export const CreateTeamDialog = ({
|
|||
[members, setMembers, setSyncModelsWithLead]
|
||||
);
|
||||
|
||||
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||
const activeError =
|
||||
localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||
const canOpenExistingTeam =
|
||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
|
|
@ -928,6 +1132,10 @@ export const CreateTeamDialog = ({
|
|||
setLocalError(messages.join(' · ') || 'Check form fields');
|
||||
return;
|
||||
}
|
||||
if (modelValidationError) {
|
||||
setLocalError(modelValidationError);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setLocalError(null);
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -1040,45 +1248,6 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
CLI environment is not available — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-1"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!canCreate ? (
|
||||
<p
|
||||
className="rounded border p-2 text-xs"
|
||||
|
|
@ -1162,6 +1331,8 @@ export const CreateTeamDialog = ({
|
|||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
headerTop={
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
|
@ -1420,6 +1591,48 @@ export const CreateTeamDialog = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
CLI environment is not available - launch is blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Pre-flight check to catch errors before launch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-2"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-1 space-y-0.5 pl-6">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -46,8 +46,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,
|
||||
|
|
@ -69,15 +73,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,
|
||||
|
|
@ -96,10 +106,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
|
||||
// =============================================================================
|
||||
|
|
@ -344,6 +379,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
return new Map<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
|
||||
);
|
||||
|
||||
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) {
|
||||
|
|
@ -632,6 +691,51 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
||||
[selectedModel, limitContext, selectedProviderId]
|
||||
);
|
||||
const selectedModelChecksByProvider = useMemo(() => {
|
||||
const modelsByProvider = new Map<TeamProviderId, string[]>();
|
||||
const defaultSelectionByProvider = new Map<TeamProviderId, boolean>();
|
||||
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) {
|
||||
|
|
@ -814,61 +918,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.'
|
||||
|
|
@ -894,7 +1032,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
effectiveCwd,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
runtimeBackendSummaryByProvider,
|
||||
selectedModelChecksByProvider,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1069,6 +1207,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<string, string> = {};
|
||||
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 &&
|
||||
|
|
@ -1090,7 +1306,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
|
||||
);
|
||||
|
|
@ -1122,6 +1338,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;
|
||||
|
|
@ -1231,9 +1451,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
|
||||
|
|
@ -1321,63 +1542,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Launch-only: CLI env failed */}
|
||||
{isLaunch && prepareState === 'failed' ? (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
CLI environment is not available — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-1"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
|
||||
prepareChecks.some((check) =>
|
||||
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
|
||||
) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => {
|
||||
closeDialog();
|
||||
openDashboard();
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Schedule-only: Team selector (standalone mode)
|
||||
|
|
@ -1556,6 +1720,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
softDeleteMembers
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
/>
|
||||
|
|
@ -1819,7 +1985,64 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'failed' ? <div /> : null}
|
||||
{prepareState === 'failed' ? (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
CLI environment is not available - launch is blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Pre-flight check to catch errors before launch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-2"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-1 space-y-0.5 pl-6">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 pl-6">
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
|
||||
prepareChecks.some((check) =>
|
||||
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
|
||||
) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => {
|
||||
closeDialog();
|
||||
openDashboard();
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||
{visibleDetails.map((detail) => (
|
||||
<p key={detail} className="text-[10px] text-[var(--color-text-muted)]">
|
||||
<p
|
||||
key={detail}
|
||||
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string | null | undefined>>;
|
||||
}
|
||||
|
||||
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||
|
|
@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
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<TeamModelSelectorProps> = ({
|
|||
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<TeamModelSelectorProps> = ({
|
|||
|
||||
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<TeamModelSelectorProps> = ({
|
|||
}, [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 (
|
||||
<div className="mb-5">
|
||||
|
|
@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
) : null}
|
||||
|
||||
<div className="p-3">
|
||||
{shouldAwaitRuntimeModelList ? (
|
||||
<p className="mb-2 text-[11px] text-[var(--color-text-muted)]">
|
||||
Explicit models load from the current runtime. Default remains available while the
|
||||
list is syncing.
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
|
|
@ -300,9 +308,24 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
(() => {
|
||||
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 (
|
||||
<button
|
||||
|
|
@ -310,13 +333,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
type="button"
|
||||
id={opt.value === normalizedValue ? id : undefined}
|
||||
aria-disabled={!modelSelectable}
|
||||
title={modelStatusMessage ?? undefined}
|
||||
className={cn(
|
||||
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
|
||||
normalizedValue === opt.value
|
||||
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: modelSelectable
|
||||
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
|
||||
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
|
||||
hasModelIssue && normalizedValue === opt.value
|
||||
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
|
||||
: hasModelIssue
|
||||
? 'border-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
|
||||
: normalizedValue === opt.value
|
||||
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: modelSelectable
|
||||
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
|
||||
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
|
||||
!modelSelectable && 'cursor-not-allowed opacity-45',
|
||||
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
|
||||
)}
|
||||
|
|
@ -349,7 +377,29 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</TooltipProvider>
|
||||
</span>
|
||||
)}
|
||||
{modelDisabledReason && (
|
||||
{hasModelIssue && (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
|
||||
title={modelIssueReason ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
<span>Issue</span>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="size-3 shrink-0 opacity-50 transition-opacity hover:opacity-80" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
{modelIssueReason}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
)}
|
||||
{!hasModelIssue && modelDisabledReason && (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
|
||||
title={modelDisabledReason}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,378 @@
|
|||
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
|
||||
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
|
||||
|
||||
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
|
||||
|
||||
interface PrepareProvisioningFn {
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
): Promise<TeamProvisioningPrepareResult>;
|
||||
}
|
||||
|
||||
interface ProviderPrepareDiagnosticsProgress {
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface ProviderPrepareDiagnosticsModelResult {
|
||||
status: 'ready' | 'notes' | 'failed';
|
||||
line: string;
|
||||
warningLine?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderPrepareDiagnosticsCachedSnapshot {
|
||||
status: ProviderPrepareCheckStatus | 'checking';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface ProviderPrepareDiagnosticsResult {
|
||||
status: ProviderPrepareCheckStatus;
|
||||
details: string[];
|
||||
warnings: string[];
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getModelLabel(providerId: TeamProviderId, modelId: string): string {
|
||||
if (isDefaultProviderModelSelection(modelId)) {
|
||||
return 'Default';
|
||||
}
|
||||
return getProviderScopedTeamModelLabel(providerId, modelId) ?? modelId;
|
||||
}
|
||||
|
||||
export function buildProviderPrepareModelCheckingLine(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string
|
||||
): string {
|
||||
return `${getModelLabel(providerId, modelId)} - checking...`;
|
||||
}
|
||||
|
||||
function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): string {
|
||||
return `${getModelLabel(providerId, modelId)} - verified`;
|
||||
}
|
||||
|
||||
export function getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds,
|
||||
cachedModelResultsById,
|
||||
}: {
|
||||
providerId: TeamProviderId;
|
||||
selectedModelIds: string[];
|
||||
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): ProviderPrepareDiagnosticsCachedSnapshot {
|
||||
const reusableModelResultsById = cachedModelResultsById ?? {};
|
||||
const orderedModelIds = Array.from(
|
||||
new Set(selectedModelIds.map((modelId) => modelId.trim()).filter(Boolean))
|
||||
);
|
||||
|
||||
let completedCount = 0;
|
||||
let hasFailure = false;
|
||||
let hasNotes = false;
|
||||
let hasChecking = false;
|
||||
|
||||
const details = orderedModelIds.map((modelId) => {
|
||||
const cachedResult = reusableModelResultsById[modelId];
|
||||
if (!cachedResult) {
|
||||
hasChecking = true;
|
||||
return buildProviderPrepareModelCheckingLine(providerId, modelId);
|
||||
}
|
||||
|
||||
completedCount += 1;
|
||||
if (cachedResult.status === 'failed') {
|
||||
hasFailure = true;
|
||||
} else if (cachedResult.status === 'notes') {
|
||||
hasNotes = true;
|
||||
}
|
||||
return cachedResult.line;
|
||||
});
|
||||
|
||||
return {
|
||||
status: hasChecking ? 'checking' : hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
|
||||
details,
|
||||
completedCount,
|
||||
totalCount: orderedModelIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
function stripSelectedModelPrefix(modelId: string, message: string): string {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} could not be verified\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} verified for launch\\.\\s*`, 'i'),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(trimmed)) {
|
||||
return trimmed.replace(pattern, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function decodeQuotedJsonString(value: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) as string;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModelReason(rawReason: string | null | undefined): string | null {
|
||||
const trimmed = rawReason?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
/The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed)
|
||||
) {
|
||||
return 'Not available with Codex ChatGPT subscription';
|
||||
}
|
||||
if (/The requested model is not available for your account\./i.test(trimmed)) {
|
||||
return 'Not available for this account';
|
||||
}
|
||||
if (
|
||||
trimmed.toLowerCase().includes('timeout running:') ||
|
||||
trimmed.toLowerCase().includes('timed out') ||
|
||||
trimmed.toLowerCase().includes('etimedout')
|
||||
) {
|
||||
return 'Model verification timed out';
|
||||
}
|
||||
|
||||
const detailMatch = trimmed.match(/"detail":"((?:\\"|[^"])*)"/i);
|
||||
if (detailMatch?.[1]) {
|
||||
return normalizeModelReason(detailMatch[1].replace(/\\"/g, '"').trim());
|
||||
}
|
||||
|
||||
const messageMatch = trimmed.match(/"message":"((?:\\"|[^"])*)"/i);
|
||||
if (messageMatch?.[1]) {
|
||||
const decodedMessage = messageMatch[1].replace(/\\"/g, '"');
|
||||
const nestedDetailMatch = decodedMessage.match(/"detail":"([^"]+)"/i);
|
||||
if (nestedDetailMatch?.[1]) {
|
||||
return normalizeModelReason(nestedDetailMatch[1].trim());
|
||||
}
|
||||
return normalizeModelReason(decodeQuotedJsonString(decodedMessage).trim());
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null {
|
||||
const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const stripped = stripSelectedModelPrefix(modelId, candidate);
|
||||
if (stripped) {
|
||||
return normalizeModelReason(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildModelFailureLine(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
kind: 'unavailable' | 'check failed',
|
||||
reason: string | null
|
||||
): string {
|
||||
const label = getModelLabel(providerId, modelId);
|
||||
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`;
|
||||
}
|
||||
|
||||
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
|
||||
return [...(result.details ?? []), ...(result.warnings ?? [])];
|
||||
}
|
||||
|
||||
export async function runProviderPrepareDiagnostics({
|
||||
cwd,
|
||||
providerId,
|
||||
selectedModelIds,
|
||||
prepareProvisioning,
|
||||
limitContext,
|
||||
onModelProgress,
|
||||
cachedModelResultsById,
|
||||
}: {
|
||||
cwd: string;
|
||||
providerId: TeamProviderId;
|
||||
selectedModelIds: string[];
|
||||
prepareProvisioning: PrepareProvisioningFn;
|
||||
limitContext?: boolean;
|
||||
onModelProgress?: (progress: ProviderPrepareDiagnosticsProgress) => void;
|
||||
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): Promise<ProviderPrepareDiagnosticsResult> {
|
||||
const runtimeResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
undefined,
|
||||
limitContext
|
||||
);
|
||||
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
const runtimeWarnings = [...(runtimeResult.warnings ?? [])];
|
||||
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedModelIds.length === 0) {
|
||||
return {
|
||||
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: runtimeDetailLines,
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
|
||||
const orderedModelIds = Array.from(
|
||||
new Set(selectedModelIds.map((modelId) => modelId.trim()).filter(Boolean))
|
||||
);
|
||||
const reusableModelResultsById = cachedModelResultsById ?? {};
|
||||
const modelResultsById = new Map<string, ProviderPrepareDiagnosticsModelResult>();
|
||||
const modelLines = new Map<string, string>();
|
||||
let completedCount = 0;
|
||||
let hasFailure = false;
|
||||
let hasNotes = runtimeWarnings.length > 0;
|
||||
const modelWarnings: string[] = [];
|
||||
|
||||
for (const modelId of orderedModelIds) {
|
||||
const cachedResult = reusableModelResultsById[modelId];
|
||||
if (cachedResult) {
|
||||
modelResultsById.set(modelId, cachedResult);
|
||||
modelLines.set(modelId, cachedResult.line);
|
||||
completedCount += 1;
|
||||
if (cachedResult.status === 'failed') {
|
||||
hasFailure = true;
|
||||
} else if (cachedResult.status === 'notes') {
|
||||
hasNotes = true;
|
||||
}
|
||||
if (cachedResult.warningLine) {
|
||||
modelWarnings.push(cachedResult.warningLine);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
modelLines.set(modelId, buildProviderPrepareModelCheckingLine(providerId, modelId));
|
||||
}
|
||||
|
||||
const emitProgress = (): void => {
|
||||
onModelProgress?.({
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
completedCount,
|
||||
totalCount: orderedModelIds.length,
|
||||
});
|
||||
};
|
||||
|
||||
emitProgress();
|
||||
|
||||
await Promise.all(
|
||||
orderedModelIds
|
||||
.filter((modelId) => !modelResultsById.has(modelId))
|
||||
.map(async (modelId) => {
|
||||
try {
|
||||
const modelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
[modelId],
|
||||
limitContext
|
||||
);
|
||||
if (!modelResult.ready) {
|
||||
hasFailure = true;
|
||||
const line = buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'unavailable',
|
||||
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
|
||||
);
|
||||
modelLines.set(modelId, line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'failed',
|
||||
line,
|
||||
warningLine: null,
|
||||
});
|
||||
} else if ((modelResult.warnings?.length ?? 0) > 0) {
|
||||
hasNotes = true;
|
||||
const reason = getResultReason(modelId, modelResult);
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
} else {
|
||||
const line = buildModelSuccessLine(providerId, modelId);
|
||||
modelLines.set(modelId, line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'ready',
|
||||
line,
|
||||
warningLine: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
} finally {
|
||||
completedCount += 1;
|
||||
emitProgress();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
|
||||
const selectedModelResultsById = Object.fromEntries(
|
||||
orderedModelIds
|
||||
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
|
||||
.filter((entry): entry is [string, ProviderPrepareDiagnosticsModelResult] =>
|
||||
Boolean(entry[1])
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
}
|
||||
124
src/renderer/components/team/dialogs/provisioningModelIssues.ts
Normal file
124
src/renderer/components/team/dialogs/provisioningModelIssues.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||
|
||||
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
export interface ProvisioningModelIssue {
|
||||
providerId: TeamProviderId;
|
||||
modelId: string;
|
||||
kind: 'unavailable' | 'check failed';
|
||||
reason: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function extractReason(detail: string, prefix: string): string | null {
|
||||
if (!detail.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = detail.slice(prefix.length).trim();
|
||||
if (!suffix) {
|
||||
return null;
|
||||
}
|
||||
return suffix.startsWith('- ') ? suffix.slice(2).trim() : suffix;
|
||||
}
|
||||
|
||||
function buildIssueFromFormattedDetail(
|
||||
detail: string,
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
label: string
|
||||
): ProvisioningModelIssue | null {
|
||||
const unavailablePrefix = `${label} - unavailable`;
|
||||
const unavailableReason = extractReason(detail, unavailablePrefix);
|
||||
if (detail.startsWith(unavailablePrefix)) {
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
kind: 'unavailable',
|
||||
reason: unavailableReason,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
const checkFailedPrefix = `${label} - check failed`;
|
||||
const checkFailedReason = extractReason(detail, checkFailedPrefix);
|
||||
if (detail.startsWith(checkFailedPrefix)) {
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
kind: 'check failed',
|
||||
reason: checkFailedReason,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildIssueFromLegacyDetail(
|
||||
detail: string,
|
||||
providerId: TeamProviderId,
|
||||
modelId: string
|
||||
): ProvisioningModelIssue | null {
|
||||
const unavailablePrefix = `Selected model ${modelId} is unavailable.`;
|
||||
if (detail.startsWith(unavailablePrefix)) {
|
||||
const reason = detail.slice(unavailablePrefix.length).trim() || null;
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
kind: 'unavailable',
|
||||
reason,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
const checkFailedPrefix = `Selected model ${modelId} could not be verified.`;
|
||||
if (detail.startsWith(checkFailedPrefix)) {
|
||||
const reason = detail.slice(checkFailedPrefix.length).trim() || null;
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
kind: 'check failed',
|
||||
reason,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getProvisioningModelIssue(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
providerId: TeamProviderId,
|
||||
modelId: string | null | undefined
|
||||
): ProvisioningModelIssue | null {
|
||||
const trimmedModelId = modelId?.trim() ?? '';
|
||||
if (!trimmedModelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = getProviderScopedTeamModelLabel(providerId, trimmedModelId) ?? trimmedModelId;
|
||||
const providerChecks = checks.filter((check) => check.providerId === providerId);
|
||||
|
||||
for (const check of providerChecks) {
|
||||
for (const detail of check.details) {
|
||||
const formattedIssue = buildIssueFromFormattedDetail(
|
||||
detail,
|
||||
providerId,
|
||||
trimmedModelId,
|
||||
label
|
||||
);
|
||||
if (formattedIssue) {
|
||||
return formattedIssue;
|
||||
}
|
||||
|
||||
const legacyIssue = buildIssueFromLegacyDetail(detail, providerId, trimmedModelId);
|
||||
if (legacyIssue) {
|
||||
return legacyIssue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -12,8 +12,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
|
|||
import { Label } from '@renderer/components/ui/label';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ interface LeadModelRowProps {
|
|||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
}
|
||||
|
||||
export const LeadModelRow = ({
|
||||
|
|
@ -47,6 +49,7 @@ export const LeadModelRow = ({
|
|||
onSyncModelsWithTeammatesChange,
|
||||
warningText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
|
@ -55,6 +58,7 @@ export const LeadModelRow = ({
|
|||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||
: 'Default';
|
||||
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
|
||||
const hasModelIssue = Boolean(modelIssueText);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -99,7 +103,11 @@ export const LeadModelRow = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
|
||||
className={cn(
|
||||
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
|
||||
hasModelIssue &&
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
|
||||
)}
|
||||
aria-label={modelButtonAriaLabel}
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
>
|
||||
|
|
@ -109,7 +117,8 @@ export const LeadModelRow = ({
|
|||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{modelButtonLabel}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
|
||||
{hasModelIssue ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,6 +139,7 @@ export const LeadModelRow = ({
|
|||
onValueChange={onModelChange}
|
||||
id="lead-model"
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={effort ?? ''}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -55,6 +56,7 @@ interface MemberDraftRowProps {
|
|||
onRestore?: (id: string) => 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 (
|
||||
<div
|
||||
|
|
@ -248,7 +252,11 @@ export const MemberDraftRow = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
|
||||
className={cn(
|
||||
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
|
||||
hasModelIssue &&
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
|
||||
)}
|
||||
aria-label={modelButtonAriaLabel}
|
||||
disabled={lockProviderModel || isRemoved}
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
|
|
@ -262,13 +270,21 @@ export const MemberDraftRow = ({
|
|||
providerId={effectiveProviderId}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{modelButtonLabel}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
|
||||
{hasModelIssue ? (
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-300" />
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{modelTooltipText ? (
|
||||
{modelTooltipText || modelIssueText ? (
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{modelTooltipText}
|
||||
{modelIssueText ? <p className="text-red-300">{modelIssueText}</p> : null}
|
||||
{modelTooltipText ? (
|
||||
<p className={modelIssueText ? 'mt-1 border-t border-white/10 pt-1' : ''}>
|
||||
{modelTooltipText}
|
||||
</p>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
|
|
@ -355,6 +371,9 @@ export const MemberDraftRow = ({
|
|||
}}
|
||||
id={`member-${member.id}-model`}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueReasonByValue={
|
||||
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
|
||||
}
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={effectiveEffort ?? ''}
|
||||
|
|
@ -370,13 +389,6 @@ export const MemberDraftRow = ({
|
|||
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
If this teammate uses a different provider than the lead, they will be started in a
|
||||
separate process automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export interface MembersEditorSectionProps {
|
|||
softDeleteMembers?: boolean;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps {
|
|||
leadWarningText?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
|
|
@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({
|
|||
leadWarningText,
|
||||
memberWarningById,
|
||||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
memberModelIssueById,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<MembersEditorSection
|
||||
|
|
@ -108,6 +112,7 @@ export const TeamRosterEditorSection = ({
|
|||
modelLockReason={modelLockReason}
|
||||
softDeleteMembers={softDeleteMembers}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
headerExtra={
|
||||
<div className="space-y-3">
|
||||
{headerTop}
|
||||
|
|
@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({
|
|||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
warningText={leadWarningText}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={leadModelIssueText}
|
||||
/>
|
||||
{headerBottom}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function useCliInstaller(): {
|
|||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
options?: { silent?: boolean; epoch?: number }
|
||||
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
|
||||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
options?: { silent?: boolean; epoch?: number }
|
||||
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
|
||||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
}
|
||||
|
||||
let cliStatusInFlight: Promise<void> | null = null;
|
||||
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
|
||||
const cliProviderStatusInFlight = new Map<string, Promise<void>>();
|
||||
let cliStatusEpoch = 0;
|
||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||
|
||||
|
|
@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
if (get().cliStatus && !get().cliStatus?.installed) {
|
||||
return;
|
||||
}
|
||||
const inFlight = cliProviderStatusInFlight.get(providerId);
|
||||
const verifyModels = options?.verifyModels === true;
|
||||
const requestKey = `${providerId}:${verifyModels ? 'verify' : 'status'}`;
|
||||
const inFlight = cliProviderStatusInFlight.get(requestKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const requestEpoch = options?.epoch ?? cliStatusEpoch;
|
||||
|
|
@ -277,7 +281,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
try {
|
||||
const providerStatus = await api.cliInstaller.getProviderStatus(providerId);
|
||||
const providerStatus = verifyModels
|
||||
? await api.cliInstaller.verifyProviderModels(providerId)
|
||||
: await api.cliInstaller.getProviderStatus(providerId);
|
||||
set((state) => {
|
||||
const nextLoading = silent
|
||||
? state.cliProviderStatusLoading
|
||||
|
|
@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
};
|
||||
});
|
||||
} finally {
|
||||
cliProviderStatusInFlight.delete(providerId);
|
||||
cliProviderStatusInFlight.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
cliProviderStatusInFlight.set(providerId, request);
|
||||
cliProviderStatusInFlight.set(requestKey, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
|
@ -100,33 +101,20 @@ const teamRefreshBurstDiagnostics = new Map<
|
|||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||
>();
|
||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||
const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set<string>();
|
||||
interface RefreshTeamDataOptions {
|
||||
withDedup?: boolean;
|
||||
}
|
||||
|
||||
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||
|
||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
||||
[],
|
||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 0, sectorIndex: 3 },
|
||||
],
|
||||
];
|
||||
type TeamGraphConfigMemberSeedInput = Pick<
|
||||
NonNullable<TeamViewSnapshot['config']['members']>[number],
|
||||
'name' | 'agentId' | 'removedAt'
|
||||
>;
|
||||
type TeamGraphLayoutSessionState = {
|
||||
mode: 'default' | 'manual';
|
||||
signature: string | null;
|
||||
};
|
||||
|
||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||
return (
|
||||
|
|
@ -171,7 +159,6 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
|||
resolvedMemberSelectorCache.clear();
|
||||
mergedMessagesSelectorCache.clear();
|
||||
memberMessagesSelectorCache.clear();
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
|
|
@ -1512,14 +1499,18 @@ function isMemberActivityMetaStale(
|
|||
|
||||
function seedStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
|
||||
if (visibleMembers.length === 0 || visibleMembers.length > 4) {
|
||||
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||
if (
|
||||
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
|
||||
Object.keys(defaultSeed.assignments).length === 0
|
||||
) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member));
|
||||
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
|
||||
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||
);
|
||||
|
|
@ -1527,14 +1518,9 @@ function seedStableSlotAssignmentsForMembers(
|
|||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[visibleMembers.length];
|
||||
if (!preset || preset.length !== visibleMembers.length) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
visibleMembers.forEach((member, index) => {
|
||||
nextAssignments[getStableTeamOwnerId(member)] = preset[index]!;
|
||||
visibleStableOwnerIds.forEach((stableOwnerId) => {
|
||||
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
|
||||
});
|
||||
|
||||
return { assignments: nextAssignments, changed: true };
|
||||
|
|
@ -1564,20 +1550,47 @@ function areTeamGraphSlotAssignmentsEqual(
|
|||
return true;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
return seedStableSlotAssignmentsForMembers({}, members).assignments;
|
||||
if (visibleOwnerIds.length === 0 || !assignments) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = {};
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
normalizedAssignments[stableOwnerId] = assignment;
|
||||
}
|
||||
return normalizedAssignments;
|
||||
}
|
||||
|
||||
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments | undefined {
|
||||
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments,
|
||||
visibleOwnerIds
|
||||
);
|
||||
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): TeamGraphSlotAssignments {
|
||||
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
|
||||
}
|
||||
|
||||
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||
}
|
||||
|
||||
export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean {
|
||||
return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName);
|
||||
}
|
||||
|
||||
function isVisibleInActiveTeamSurface(
|
||||
state: Pick<AppState, 'paneLayout'>,
|
||||
teamName: string | null | undefined
|
||||
|
|
@ -1643,6 +1656,7 @@ export interface TeamSlice {
|
|||
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
|
||||
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
|
||||
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||
graphLayoutSessionByTeam: Record<string, TeamGraphLayoutSessionState>;
|
||||
selectedTeamLoading: boolean;
|
||||
selectedTeamLoadNonce: number;
|
||||
selectedTeamError: string | null;
|
||||
|
|
@ -1687,7 +1701,8 @@ export interface TeamSlice {
|
|||
clearKanbanFilter: () => void;
|
||||
ensureTeamGraphSlotAssignments: (
|
||||
teamName: string,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers?: readonly TeamGraphConfigMemberSeedInput[]
|
||||
) => void;
|
||||
setTeamGraphOwnerSlotAssignment: (
|
||||
teamName: string,
|
||||
|
|
@ -1966,6 +1981,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
slotAssignmentsByTeam: {},
|
||||
teamMessagesByName: {},
|
||||
memberActivityMetaByTeam: {},
|
||||
graphLayoutSessionByTeam: {},
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamLoadNonce: 0,
|
||||
selectedTeamError: null,
|
||||
|
|
@ -2385,33 +2401,72 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set({ kanbanFilterQuery: null });
|
||||
},
|
||||
|
||||
ensureTeamGraphSlotAssignments: (teamName, members) => {
|
||||
ensureTeamGraphSlotAssignments: (teamName, members, configMembers = []) => {
|
||||
set((state) => {
|
||||
const nextState: Partial<TeamSlice> = {};
|
||||
let changed = false;
|
||||
|
||||
let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam;
|
||||
let nextGraphLayoutSessionByTeam = state.graphLayoutSessionByTeam;
|
||||
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
||||
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
||||
nextSlotAssignmentsByTeam = {};
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
nextGraphLayoutSessionByTeam = {};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||
const visibleAssignments = pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
nextSlotAssignmentsByTeam[teamName],
|
||||
defaultSeed.orderedVisibleOwnerIds
|
||||
);
|
||||
const currentSession = nextGraphLayoutSessionByTeam[teamName];
|
||||
|
||||
if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) {
|
||||
if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) {
|
||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||
const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members);
|
||||
if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) {
|
||||
if (currentSession?.mode === 'manual') {
|
||||
if (
|
||||
!areTeamGraphSlotAssignmentsEqual(
|
||||
nextSlotAssignmentsByTeam[teamName],
|
||||
visibleAssignments
|
||||
)
|
||||
) {
|
||||
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
|
||||
if (Object.keys(defaultAssignments).length === 0) {
|
||||
delete nextSlotAssignmentsByTeam[teamName];
|
||||
if (visibleAssignments) {
|
||||
nextSlotAssignmentsByTeam[teamName] = visibleAssignments;
|
||||
} else {
|
||||
nextSlotAssignmentsByTeam[teamName] = defaultAssignments;
|
||||
delete nextSlotAssignmentsByTeam[teamName];
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
|
||||
} else {
|
||||
if (
|
||||
!areTeamGraphSlotAssignmentsEqual(
|
||||
nextSlotAssignmentsByTeam[teamName],
|
||||
visibleAssignments
|
||||
) ||
|
||||
!areTeamGraphSlotAssignmentsEqual(visibleAssignments, defaultSeed.assignments)
|
||||
) {
|
||||
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
|
||||
if (Object.keys(defaultSeed.assignments).length === 0) {
|
||||
delete nextSlotAssignmentsByTeam[teamName];
|
||||
} else {
|
||||
nextSlotAssignmentsByTeam[teamName] = defaultSeed.assignments;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
currentSession?.mode !== 'default' ||
|
||||
currentSession?.signature !== defaultSeed.signature
|
||||
) {
|
||||
nextGraphLayoutSessionByTeam = {
|
||||
...nextGraphLayoutSessionByTeam,
|
||||
[teamName]: {
|
||||
mode: 'default',
|
||||
signature: defaultSeed.signature,
|
||||
},
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
|
|
@ -2419,12 +2474,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
|
||||
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
||||
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
||||
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
|
||||
const seeded = seedStableSlotAssignmentsForMembers(
|
||||
migrated.assignments,
|
||||
members,
|
||||
configMembers
|
||||
);
|
||||
if (migrated.changed || seeded.changed) {
|
||||
nextSlotAssignmentsByTeam = {
|
||||
...nextSlotAssignmentsByTeam,
|
||||
|
|
@ -2438,6 +2498,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
|
||||
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
||||
if (nextGraphLayoutSessionByTeam !== state.graphLayoutSessionByTeam) {
|
||||
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
|
|
@ -2475,6 +2538,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
[stableOwnerId]: assignment,
|
||||
},
|
||||
},
|
||||
graphLayoutSessionByTeam: {
|
||||
...state.graphLayoutSessionByTeam,
|
||||
[teamName]: {
|
||||
mode: 'manual',
|
||||
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -2535,6 +2605,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
...state.slotAssignmentsByTeam,
|
||||
[teamName]: nextAssignments,
|
||||
},
|
||||
graphLayoutSessionByTeam: {
|
||||
...state.graphLayoutSessionByTeam,
|
||||
[teamName]: {
|
||||
mode: 'manual',
|
||||
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -2562,6 +2639,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
[otherStableOwnerId]: left,
|
||||
},
|
||||
},
|
||||
graphLayoutSessionByTeam: {
|
||||
...state.graphLayoutSessionByTeam,
|
||||
[teamName]: {
|
||||
mode: 'manual',
|
||||
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -2569,29 +2653,35 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
clearTeamGraphSlotAssignments: (teamName) => {
|
||||
set((state) => {
|
||||
if (!teamName) {
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
if (
|
||||
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
|
||||
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
|
||||
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION &&
|
||||
Object.keys(state.graphLayoutSessionByTeam).length === 0
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: {},
|
||||
graphLayoutSessionByTeam: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!(teamName in state.slotAssignmentsByTeam)) {
|
||||
if (
|
||||
!(teamName in state.slotAssignmentsByTeam) &&
|
||||
!(teamName in state.graphLayoutSessionByTeam)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||
const nextGraphLayoutSessionByTeam = { ...state.graphLayoutSessionByTeam };
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
||||
delete nextGraphLayoutSessionByTeam[teamName];
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||
graphLayoutSessionByTeam: nextGraphLayoutSessionByTeam,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -2613,36 +2703,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
|
||||
const teamData = selectTeamDataForName(state, teamName);
|
||||
const defaultAssignments = teamData
|
||||
? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members)
|
||||
: {};
|
||||
const defaultSeed = teamData
|
||||
? buildTeamGraphDefaultLayoutSeed(teamData.members, teamData.config.members ?? [])
|
||||
: { orderedVisibleOwnerIds: [], signature: null, assignments: {} };
|
||||
const currentAssignments = state.slotAssignmentsByTeam[teamName];
|
||||
const hasCurrentAssignments =
|
||||
currentAssignments && Object.keys(currentAssignments).length > 0;
|
||||
const currentSession = state.graphLayoutSessionByTeam[teamName];
|
||||
|
||||
if (
|
||||
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) &&
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)
|
||||
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultSeed.assignments) &&
|
||||
currentSession?.mode === 'default' &&
|
||||
currentSession.signature === defaultSeed.signature
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||
if (Object.keys(defaultAssignments).length === 0) {
|
||||
if (Object.keys(defaultSeed.assignments).length === 0) {
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
||||
} else {
|
||||
nextAssignmentsByTeam[teamName] = defaultAssignments;
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
|
||||
}
|
||||
|
||||
if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) {
|
||||
return {};
|
||||
nextAssignmentsByTeam[teamName] = defaultSeed.assignments;
|
||||
}
|
||||
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||
graphLayoutSessionByTeam: {
|
||||
...state.graphLayoutSessionByTeam,
|
||||
[teamName]: {
|
||||
mode: 'default',
|
||||
signature: defaultSeed.signature,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,302 @@
|
|||
export {
|
||||
getTeamModelUiDisabledReason,
|
||||
import type {
|
||||
CliProviderId,
|
||||
CliProviderModelAvailability,
|
||||
CliProviderModelAvailabilityStatus,
|
||||
CliProviderStatus,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
import {
|
||||
getProviderScopedTeamModelLabel,
|
||||
getRuntimeAwareTeamModelUiDisabledReason,
|
||||
getTeamProviderLabel,
|
||||
getTeamProviderModelOptions,
|
||||
sortTeamProviderModels,
|
||||
getVisibleTeamProviderModels,
|
||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
||||
GPT_5_2_CODEX_UI_DISABLED_REASON,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
type TeamProviderModelOption,
|
||||
} from './teamModelCatalog';
|
||||
|
||||
export {
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
||||
GPT_5_2_CODEX_UI_DISABLED_REASON,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||
isTeamModelUiDisabled,
|
||||
normalizeTeamModelForUi,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
} from './teamModelCatalog';
|
||||
|
||||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||
|
||||
export type TeamModelRuntimeProviderStatus = Pick<
|
||||
CliProviderStatus,
|
||||
| 'providerId'
|
||||
| 'models'
|
||||
| 'modelAvailability'
|
||||
| 'modelVerificationState'
|
||||
| 'authMethod'
|
||||
| 'backend'
|
||||
| 'authenticated'
|
||||
| 'supported'
|
||||
>;
|
||||
|
||||
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<string, CliProviderModelAvailability> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>;
|
||||
|
||||
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<SupportedProviderId, readonly TeamProv
|
|||
uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||
},
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2', badgeLabel: '5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', badgeLabel: '5.2-codex' },
|
||||
{
|
||||
value: 'gpt-5.2-codex',
|
||||
label: 'GPT-5.2 Codex',
|
||||
badgeLabel: '5.2-codex',
|
||||
uiDisabledReason: GPT_5_2_CODEX_UI_DISABLED_REASON,
|
||||
},
|
||||
{
|
||||
value: 'gpt-5.1-codex-mini',
|
||||
label: 'GPT-5.1 Codex Mini',
|
||||
|
|
@ -197,13 +217,42 @@ export function sortTeamProviderModels(
|
|||
});
|
||||
}
|
||||
|
||||
export function isCodexChatGptSubscriptionProviderStatus(
|
||||
providerStatus?: RuntimeAwareProviderStatus | null
|
||||
): boolean {
|
||||
if (providerStatus?.providerId !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpointLabel = providerStatus.backend?.endpointLabel?.toLowerCase() ?? '';
|
||||
return (
|
||||
providerStatus.authMethod === 'oauth_token' &&
|
||||
(providerStatus.backend?.kind === 'adapter' ||
|
||||
endpointLabel.includes('chatgpt.com/backend-api/codex/responses'))
|
||||
);
|
||||
}
|
||||
|
||||
function isRuntimeHiddenTeamModel(
|
||||
providerId: SupportedProviderId,
|
||||
model: string,
|
||||
providerStatus?: RuntimeAwareProviderStatus | null
|
||||
): boolean {
|
||||
return (
|
||||
providerId === 'codex' &&
|
||||
model === 'gpt-5.1-codex-max' &&
|
||||
isCodexChatGptSubscriptionProviderStatus(providerStatus)
|
||||
);
|
||||
}
|
||||
|
||||
export function getVisibleTeamProviderModels(
|
||||
providerId: SupportedProviderId,
|
||||
models: readonly string[]
|
||||
models: readonly string[],
|
||||
providerStatus?: RuntimeAwareProviderStatus | null
|
||||
): string[] {
|
||||
return sortTeamProviderModels(providerId, models).filter(
|
||||
(model) => !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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ interface ProvisioningMemberLike {
|
|||
removedAt?: number;
|
||||
}
|
||||
|
||||
interface FailedSpawnDetail {
|
||||
name: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||
'validating',
|
||||
'spawning',
|
||||
|
|
@ -30,6 +35,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;
|
||||
|
|
@ -103,6 +175,9 @@ export function buildTeamProvisioningPresentation({
|
|||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
});
|
||||
const failedSpawnDetails = getFailedSpawnDetails(memberSpawnStatuses);
|
||||
const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
|
||||
const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
|
||||
|
||||
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
||||
getLaunchJoinState({
|
||||
|
|
@ -139,7 +214,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',
|
||||
|
|
@ -155,7 +230,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
|
||||
|
|
@ -163,7 +239,7 @@ export function buildTeamProvisioningPresentation({
|
|||
: `All ${expectedTeammateCount} teammates joined`;
|
||||
const readyDetailMessage =
|
||||
failedSpawnCount > 0
|
||||
? progress.message
|
||||
? (failedSpawnPanelMessage ?? progress.message)
|
||||
: expectedTeammateCount === 0
|
||||
? 'Team provisioned - lead online'
|
||||
: allTeammatesConfirmedAlive
|
||||
|
|
@ -233,15 +309,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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,7 +439,9 @@ export interface TeamsAPI {
|
|||
prepareProvisioning: (
|
||||
cwd?: string,
|
||||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][]
|
||||
providerIds?: TeamLaunchRequest['providerId'][],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
) => Promise<TeamProvisioningPrepareResult>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
|
|
|
|||
|
|
@ -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<CliInstallationStatus>;
|
||||
/** Get current runtime/auth status for a single provider */
|
||||
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||
/** Start on-demand model verification for a single runtime provider */
|
||||
verifyProviderModels: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||
/** Start install/update flow. Progress sent via onProgress events. */
|
||||
install: () => Promise<void>;
|
||||
/** Invalidate cached status (forces fresh check on next getStatus) */
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,7 @@ export interface TeamCreateResponse {
|
|||
export interface TeamProvisioningPrepareResult {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
3
src/shared/utils/anthropicModelDefaults.ts
Normal file
3
src/shared/utils/anthropicModelDefaults.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function getAnthropicDefaultTeamModel(limitContext: boolean): string {
|
||||
return limitContext ? 'opus' : 'opus[1m]';
|
||||
}
|
||||
5
src/shared/utils/providerModelSelection.ts
Normal file
5
src/shared/utils/providerModelSelection.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
47
src/shared/utils/providerModelVisibility.ts
Normal file
47
src/shared/utils/providerModelVisibility.ts
Normal file
|
|
@ -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<Record<SupportedProviderId, readonly string[]>> = {
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
97
src/shared/utils/teamGraphDefaultLayout.ts
Normal file
97
src/shared/utils/teamGraphDefaultLayout.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
|
||||
export interface TeamGraphDefaultLayoutMemberInput {
|
||||
name: string;
|
||||
agentId?: string | null;
|
||||
removedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface TeamGraphDefaultLayoutSeed {
|
||||
orderedVisibleOwnerIds: string[];
|
||||
signature: string | null;
|
||||
assignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
||||
[],
|
||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 0, sectorIndex: 3 },
|
||||
],
|
||||
];
|
||||
|
||||
export function buildOrderedVisibleTeamGraphOwnerIds(
|
||||
members: readonly TeamGraphDefaultLayoutMemberInput[],
|
||||
configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = []
|
||||
): string[] {
|
||||
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
|
||||
if (visibleMembers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visibleMemberByStableOwnerId = new Map(
|
||||
visibleMembers.map((member) => [getStableTeamOwnerId(member), member] as const)
|
||||
);
|
||||
const orderedVisibleOwnerIds: string[] = [];
|
||||
const seenVisibleOwnerIds = new Set<string>();
|
||||
|
||||
for (const configMember of configMembers) {
|
||||
if (configMember.removedAt || isLeadMember(configMember)) {
|
||||
continue;
|
||||
}
|
||||
const stableOwnerId = getStableTeamOwnerId(configMember);
|
||||
if (
|
||||
!visibleMemberByStableOwnerId.has(stableOwnerId) ||
|
||||
seenVisibleOwnerIds.has(stableOwnerId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
orderedVisibleOwnerIds.push(stableOwnerId);
|
||||
seenVisibleOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
const remainingVisibleOwnerIds = visibleMembers
|
||||
.map((member) => getStableTeamOwnerId(member))
|
||||
.filter((stableOwnerId) => !seenVisibleOwnerIds.has(stableOwnerId))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
orderedVisibleOwnerIds.push(...remainingVisibleOwnerIds);
|
||||
return orderedVisibleOwnerIds;
|
||||
}
|
||||
|
||||
export function buildTeamGraphDefaultLayoutSeed(
|
||||
members: readonly TeamGraphDefaultLayoutMemberInput[],
|
||||
configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = []
|
||||
): TeamGraphDefaultLayoutSeed {
|
||||
const orderedVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(members, configMembers);
|
||||
const signature = orderedVisibleOwnerIds.length > 0 ? orderedVisibleOwnerIds.join('|') : null;
|
||||
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
|
||||
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||
|
||||
if (preset && preset.length === orderedVisibleOwnerIds.length) {
|
||||
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
|
||||
assignments[stableOwnerId] = preset[index]!;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
orderedVisibleOwnerIds,
|
||||
signature,
|
||||
assignments,
|
||||
};
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof execCliMock>) => execCliMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<TeamProvisioningPrepareResult>
|
||||
>().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<TeamProvisioningPrepareResult>();
|
||||
const deferred52 = createDeferred<TeamProvisioningPrepareResult>();
|
||||
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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,65 @@ describe('TeamGraphAdapter particles', () => {
|
|||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('builds ownerOrder from config member order instead of transient member array order', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }, { name: 'tom' }],
|
||||
projectPath: '/repo',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'tom',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'my-team',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new Set()
|
||||
);
|
||||
|
||||
expect(graph.layout?.ownerOrder).toEqual([
|
||||
'member:my-team:alice',
|
||||
'member:my-team:bob',
|
||||
'member:my-team:tom',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a message particle for a new incoming message from the newest message set', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const baseline = createBaseTeamData();
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -279,6 +279,10 @@ describe('teamSlice actions', () => {
|
|||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||
mode: 'manual',
|
||||
signature: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
|
||||
|
|
@ -313,6 +317,33 @@ describe('teamSlice actions', () => {
|
|||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses config member order instead of transient visible member array order for defaults', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments(
|
||||
'my-team',
|
||||
[
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
],
|
||||
[
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
|
|
@ -335,8 +366,8 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -410,7 +441,34 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not reseed a team again after defaults were applied once in the session', () => {
|
||||
it('reseeds defaults again while the team remains in default mode and visible owners change', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
]);
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||
mode: 'default',
|
||||
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reshuffle existing owners after the team enters manual mode', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
|
|
@ -426,12 +484,18 @@ describe('teamSlice actions', () => {
|
|||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||
mode: 'manual',
|
||||
signature: 'agent-alice|agent-bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
||||
|
|
@ -466,7 +530,7 @@ describe('teamSlice actions', () => {
|
|||
'my-team',
|
||||
'agent-alice',
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom',
|
||||
'agent-jack',
|
||||
{ ringIndex: 0, sectorIndex: 0 }
|
||||
);
|
||||
|
||||
|
|
@ -475,8 +539,12 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||
mode: 'default',
|
||||
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
119
test/renderer/utils/teamModelAvailability.test.ts
Normal file
119
test/renderer/utils/teamModelAvailability.test.ts
Normal file
|
|
@ -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> = {}
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,4 +29,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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue