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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-x-3 top-3 z-20 flex items-start gap-2 pointer-events-none">
|
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 h-8">
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="absolute left-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||||
{onToggleSidebar ? (
|
{onToggleSidebar ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
|
|
@ -165,15 +165,15 @@ export function GraphControls({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 ? (
|
{topToolbarContent ? (
|
||||||
<div className="pointer-events-auto min-w-0 max-w-[min(360px,42vw)]">
|
<div className="pointer-events-auto min-w-0">
|
||||||
{topToolbarContent}
|
{topToolbarContent}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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
|
<div
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from '@shared/utils/idleNotificationSemantics';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildInlineActivityEntries,
|
buildInlineActivityEntries,
|
||||||
|
|
@ -253,10 +254,11 @@ export class TeamGraphAdapter {
|
||||||
const visibleMemberByStableOwnerId = new Map(
|
const visibleMemberByStableOwnerId = new Map(
|
||||||
visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const)
|
visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const)
|
||||||
);
|
);
|
||||||
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(
|
||||||
const configStableOwnerIds = new Set(
|
data.members,
|
||||||
(data.config.members ?? []).map((member) => getGraphStableOwnerId(member))
|
data.config.members ?? []
|
||||||
);
|
);
|
||||||
|
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
||||||
|
|
||||||
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
|
|
@ -270,44 +272,26 @@ export class TeamGraphAdapter {
|
||||||
ownerOrder.push(nodeId);
|
ownerOrder.push(nodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const assignedVisibleMembersOutsideConfig = visibleMembers
|
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||||
.filter((member) => {
|
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||||
const stableOwnerId = getGraphStableOwnerId(member);
|
if (!visibleMember) {
|
||||||
return (
|
continue;
|
||||||
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);
|
|
||||||
if (!assignedStableOwnerIds.has(stableOwnerId)) {
|
if (!assignedStableOwnerIds.has(stableOwnerId)) {
|
||||||
continue;
|
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);
|
pushMember(visibleMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingMembers = visibleMembers.toSorted((left, right) =>
|
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||||
getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right))
|
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||||
);
|
if (!visibleMember) {
|
||||||
|
continue;
|
||||||
for (const member of remainingMembers) {
|
}
|
||||||
pushMember(member);
|
if (assignedStableOwnerIds.has(stableOwnerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushMember(visibleMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedAssignments: Record<string, GraphOwnerSlotAssignment> = {};
|
const normalizedAssignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,18 @@
|
||||||
* Thin wrapper — instantiates the class adapter and calls adapt() with store data.
|
* 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 { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getDefaultTeamGraphSlotAssignmentsForMembers,
|
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
selectResolvedMembersForTeamName,
|
selectResolvedMembersForTeamName,
|
||||||
hasAppliedDefaultTeamGraphSlotAssignments,
|
|
||||||
isTeamGraphSlotPersistenceDisabled,
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
selectTeamMessages,
|
selectTeamMessages,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
@ -40,6 +39,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
slotAssignments,
|
slotAssignments,
|
||||||
|
graphLayoutSession,
|
||||||
ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -56,6 +56,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
||||||
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||||
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||||
|
graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||||
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -90,18 +91,44 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
if (!isTeamGraphSlotPersistenceDisabled()) {
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||||
return slotAssignments;
|
return slotAssignments;
|
||||||
}
|
}
|
||||||
if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) {
|
if (graphLayoutSession?.mode === 'manual') {
|
||||||
return slotAssignments;
|
return slotAssignments;
|
||||||
}
|
}
|
||||||
const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members);
|
const defaultSeed = buildTeamGraphDefaultLayoutSeed(
|
||||||
return Object.keys(defaults).length === 0 ? undefined : defaults;
|
teamData.members,
|
||||||
}, [slotAssignments, teamData, teamName]);
|
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) {
|
if (!teamName || !teamData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ensureTeamGraphSlotAssignments(teamName, teamData.members);
|
ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []);
|
||||||
}, [ensureTeamGraphSlotAssignments, teamData, teamName]);
|
}, [ensureTeamGraphSlotAssignments, teamData, teamName]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
||||||
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||||
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
|
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
|
||||||
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
|
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -12,8 +11,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} 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 { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
@ -40,44 +37,6 @@ function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null
|
||||||
return presentation != 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 {
|
export interface GraphProvisioningHudProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|
@ -91,7 +50,6 @@ export const GraphProvisioningHud = ({
|
||||||
const lastActiveStepRef = useRef(-1);
|
const lastActiveStepRef = useRef(-1);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
|
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
|
||||||
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
|
||||||
const errorStepIndex = presentation?.isFailed
|
const errorStepIndex = presentation?.isFailed
|
||||||
? lastActiveStepRef.current >= 0
|
? lastActiveStepRef.current >= 0
|
||||||
? lastActiveStepRef.current
|
? lastActiveStepRef.current
|
||||||
|
|
@ -109,16 +67,12 @@ export const GraphProvisioningHud = ({
|
||||||
}
|
}
|
||||||
}, [presentation]);
|
}, [presentation]);
|
||||||
|
|
||||||
const compactLabel = useMemo(() => {
|
const ariaLabel = useMemo(() => {
|
||||||
if (!presentation?.compactDetail) {
|
const parts = [presentation?.compactTitle, presentation?.compactDetail].filter(Boolean);
|
||||||
return null;
|
return parts.join(' - ') || 'Open launch details';
|
||||||
}
|
}, [presentation?.compactDetail, presentation?.compactTitle]);
|
||||||
return presentation.compactDetail.length > 54
|
|
||||||
? `${presentation.compactDetail.slice(0, 54)}...`
|
|
||||||
: presentation.compactDetail;
|
|
||||||
}, [presentation?.compactDetail]);
|
|
||||||
|
|
||||||
if (!shouldRender || !presentation || !tone) {
|
if (!shouldRender || !presentation) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,49 +80,19 @@ export const GraphProvisioningHud = ({
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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"
|
||||||
'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
|
|
||||||
)}
|
|
||||||
onClick={() => setDetailsOpen(true)}
|
onClick={() => setDetailsOpen(true)}
|
||||||
aria-label="Open launch details"
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="px-1 py-0.5" style={HUD_STEPPER_STYLE}>
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<StepProgressBar
|
<StepProgressBar
|
||||||
steps={MINI_STEPS}
|
steps={MINI_STEPS}
|
||||||
currentIndex={presentation.currentStepIndex}
|
currentIndex={presentation.currentStepIndex}
|
||||||
errorIndex={errorStepIndex}
|
errorIndex={errorStepIndex}
|
||||||
className="w-full"
|
className="w-full origin-top scale-[0.88]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="sr-only">{ariaLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
|
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
|
@ -49,6 +50,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
|
||||||
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
||||||
ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus);
|
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_INSTALL, handleInstall);
|
||||||
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
||||||
|
|
||||||
|
|
@ -61,6 +63,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
||||||
|
ipcMain.removeHandler(CLI_INSTALLER_VERIFY_PROVIDER_MODELS);
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
||||||
|
|
||||||
|
|
@ -75,7 +78,12 @@ async function handleGetStatus(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<IpcResult<CliInstallationStatus>> {
|
): Promise<IpcResult<CliInstallationStatus>> {
|
||||||
try {
|
try {
|
||||||
|
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
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 };
|
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> {
|
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||||
cachedStatus = null;
|
cachedStatus = null;
|
||||||
providerStatusInFlight.clear();
|
providerStatusInFlight.clear();
|
||||||
ClaudeBinaryResolver.clearCache();
|
ClaudeBinaryResolver.clearCache();
|
||||||
|
service.invalidateStatusCache();
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1389,11 +1389,15 @@ async function handlePrepareProvisioning(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
cwd: unknown,
|
cwd: unknown,
|
||||||
providerId: unknown,
|
providerId: unknown,
|
||||||
providerIds: unknown
|
providerIds: unknown,
|
||||||
|
selectedModels: unknown,
|
||||||
|
limitContext: unknown
|
||||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||||
let validatedCwd: string | undefined;
|
let validatedCwd: string | undefined;
|
||||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||||
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
|
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
|
||||||
|
let validatedSelectedModels: string[] | undefined;
|
||||||
|
let validatedLimitContext: boolean | undefined;
|
||||||
if (cwd !== undefined) {
|
if (cwd !== undefined) {
|
||||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||||
return { success: false, error: 'cwd must be a non-empty string' };
|
return { success: false, error: 'cwd must be a non-empty string' };
|
||||||
|
|
@ -1424,10 +1428,32 @@ async function handlePrepareProvisioning(
|
||||||
}
|
}
|
||||||
validatedProviderIds = normalized;
|
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', () =>
|
return wrapTeamHandler('prepareProvisioning', () =>
|
||||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||||||
providerId: validatedProviderId,
|
providerId: validatedProviderId,
|
||||||
providerIds: validatedProviderIds,
|
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 { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||||
|
|
||||||
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
||||||
|
import {
|
||||||
|
CliProviderModelAvailabilityService,
|
||||||
|
type ProviderModelAvailabilityContext,
|
||||||
|
type ProviderModelAvailabilitySnapshot,
|
||||||
|
} from '../runtime/CliProviderModelAvailabilityService';
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||||
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
|
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
|
||||||
|
|
||||||
|
|
@ -45,6 +50,7 @@ import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
CliPlatform,
|
CliPlatform,
|
||||||
|
CliProviderModelAvailability,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
CliProviderStatus,
|
CliProviderStatus,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -137,6 +143,8 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
||||||
launchError: status.launchError ?? null,
|
launchError: status.launchError ?? null,
|
||||||
providers: status.providers.map((provider) => ({
|
providers: status.providers.map((provider) => ({
|
||||||
...provider,
|
...provider,
|
||||||
|
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||||
|
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: { ...provider.capabilities },
|
||||||
selectedBackendId: provider.selectedBackendId ?? null,
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider.resolvedBackendId ?? 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
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -328,6 +342,13 @@ export class CliInstallerService {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private installing = false;
|
private installing = false;
|
||||||
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
|
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> {
|
private electronMetaForDiag(): Record<string, unknown> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -395,6 +416,16 @@ export class CliInstallerService {
|
||||||
this.mainWindow = window;
|
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.
|
* 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,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'unknown' as const,
|
verificationState: 'unknown' as const,
|
||||||
|
modelVerificationState: 'idle' as const,
|
||||||
statusMessage: 'Checking...',
|
statusMessage: 'Checking...',
|
||||||
models: [],
|
models: [],
|
||||||
|
modelAvailability: [],
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
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
|
// Public: getStatus
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async getStatus(): Promise<CliInstallationStatus> {
|
async getStatus(): Promise<CliInstallationStatus> {
|
||||||
const result = this.createInitialStatus();
|
const result = this.createInitialStatus();
|
||||||
|
this.latestProviderSignatures.clear();
|
||||||
|
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
||||||
|
|
||||||
// Run the actual status gathering with an overall timeout.
|
// Run the actual status gathering with an overall timeout.
|
||||||
// On timeout, return whatever partial result was collected so far.
|
// On timeout, return whatever partial result was collected so far.
|
||||||
|
|
@ -516,7 +684,46 @@ export class CliInstallerService {
|
||||||
return null;
|
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.installedVersion = versionProbe.version;
|
||||||
r.launchError = null;
|
r.launchError = null;
|
||||||
r.authStatusChecking = true;
|
r.authStatusChecking = true;
|
||||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
this.publishStatusSnapshot(r);
|
||||||
|
|
||||||
// Auth and GCS version check are independent — run in parallel.
|
// Auth and GCS version check are independent — run in parallel.
|
||||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||||
|
|
@ -551,6 +758,7 @@ export class CliInstallerService {
|
||||||
this.checkAuthStatus(binaryPath, r, diag),
|
this.checkAuthStatus(binaryPath, r, diag),
|
||||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
|
this.publishStatusSnapshot(r);
|
||||||
} else {
|
} else {
|
||||||
diag.versionError = versionProbe.error;
|
diag.versionError = versionProbe.error;
|
||||||
r.installed = false;
|
r.installed = false;
|
||||||
|
|
@ -567,7 +775,7 @@ export class CliInstallerService {
|
||||||
if (r.supportsSelfUpdate) {
|
if (r.supportsSelfUpdate) {
|
||||||
await this.fetchLatestVersion(r);
|
await this.fetchLatestVersion(r);
|
||||||
}
|
}
|
||||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
this.publishStatusSnapshot(r);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No binary — still check latest version for "install" prompt
|
// No binary — still check latest version for "install" prompt
|
||||||
|
|
@ -577,7 +785,7 @@ export class CliInstallerService {
|
||||||
if (r.supportsSelfUpdate) {
|
if (r.supportsSelfUpdate) {
|
||||||
await this.fetchLatestVersion(r);
|
await this.fetchLatestVersion(r);
|
||||||
}
|
}
|
||||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
this.publishStatusSnapshot(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -641,8 +849,10 @@ export class CliInstallerService {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'error',
|
verificationState: 'error',
|
||||||
|
modelVerificationState: 'idle',
|
||||||
statusMessage: message,
|
statusMessage: message,
|
||||||
models: [],
|
models: [],
|
||||||
|
modelAvailability: [],
|
||||||
canLoginFromUi: false,
|
canLoginFromUi: false,
|
||||||
backend: null,
|
backend: null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -671,7 +881,7 @@ export class CliInstallerService {
|
||||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||||
result.authMethod =
|
result.authMethod =
|
||||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
this.publishStatusSnapshot(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
result.providers = providers;
|
result.providers = providers;
|
||||||
|
|
@ -679,7 +889,7 @@ export class CliInstallerService {
|
||||||
result.authMethod =
|
result.authMethod =
|
||||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||||
result.authStatusChecking = false;
|
result.authStatusChecking = false;
|
||||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
this.publishStatusSnapshot(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
diag.authLastError = msg;
|
diag.authLastError = msg;
|
||||||
|
|
|
||||||
|
|
@ -121,8 +121,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'unknown',
|
verificationState: 'unknown',
|
||||||
|
modelVerificationState: 'idle',
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
models: [],
|
models: [],
|
||||||
|
modelAvailability: [],
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
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 { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
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 { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import {
|
import {
|
||||||
encodePath,
|
encodePath,
|
||||||
|
|
@ -42,12 +42,14 @@ import {
|
||||||
} from '@shared/utils/inboxNoise';
|
} from '@shared/utils/inboxNoise';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
parseAllTeammateMessages,
|
parseAllTeammateMessages,
|
||||||
type ParsedTeammateContent,
|
type ParsedTeammateContent,
|
||||||
} from '@shared/utils/teammateMessageParser';
|
} from '@shared/utils/teammateMessageParser';
|
||||||
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
|
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
extractToolPreview,
|
extractToolPreview,
|
||||||
|
|
@ -66,6 +68,15 @@ import {
|
||||||
type GeminiRuntimeAuthState,
|
type GeminiRuntimeAuthState,
|
||||||
resolveGeminiRuntimeAuth,
|
resolveGeminiRuntimeAuth,
|
||||||
} from '../runtime/geminiRuntimeAuth';
|
} from '../runtime/geminiRuntimeAuth';
|
||||||
|
import {
|
||||||
|
buildProviderPreflightPingArgs,
|
||||||
|
buildProviderModelProbeArgs,
|
||||||
|
classifyProviderModelProbeFailure,
|
||||||
|
getProviderModelProbeExpectedOutput,
|
||||||
|
getProviderModelProbeTimeoutMs,
|
||||||
|
isProviderModelProbeSuccessOutput,
|
||||||
|
normalizeProviderModelProbeFailureReason,
|
||||||
|
} from '../runtime/providerModelProbe';
|
||||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||||
|
|
||||||
|
|
@ -96,6 +107,7 @@ import {
|
||||||
} from './TeamLaunchStateEvaluator';
|
} from './TeamLaunchStateEvaluator';
|
||||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
|
|
@ -185,9 +197,6 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
||||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
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_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
|
|
@ -214,11 +223,6 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
|
||||||
'result',
|
'result',
|
||||||
'system',
|
'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 {
|
function assertAppDeterministicBootstrapEnabled(): void {
|
||||||
if (process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP === '1') {
|
if (process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP === '1') {
|
||||||
throw new Error(
|
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[] {
|
function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||||
return [
|
return buildProviderPreflightPingArgs(providerId);
|
||||||
'-p',
|
|
||||||
PREFLIGHT_PING_PROMPT,
|
|
||||||
'--output-format',
|
|
||||||
'text',
|
|
||||||
'--model',
|
|
||||||
getPreflightPingModel(providerId),
|
|
||||||
'--max-turns',
|
|
||||||
'1',
|
|
||||||
'--no-session-persistence',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
||||||
switch (resolveTeamProviderId(providerId)) {
|
return getProviderModelProbeTimeoutMs(providerId);
|
||||||
case 'codex':
|
}
|
||||||
return PREFLIGHT_CODEX_TIMEOUT_MS;
|
|
||||||
case 'gemini':
|
interface ProviderModelListCommandResponse {
|
||||||
return PREFLIGHT_GEMINI_TIMEOUT_MS;
|
schemaVersion?: number;
|
||||||
case 'anthropic':
|
providers?: Record<
|
||||||
default:
|
string,
|
||||||
return PREFLIGHT_TIMEOUT_MS;
|
{
|
||||||
|
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 {
|
function getTeamProviderLabel(providerId: TeamProviderId): string {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'codex':
|
case 'codex':
|
||||||
|
|
@ -1045,11 +1059,68 @@ function extractBootstrapFailureReason(text: string): string | null {
|
||||||
lower.includes('lookup failure') ||
|
lower.includes('lookup failure') ||
|
||||||
lower.includes('validation error') ||
|
lower.includes('validation error') ||
|
||||||
lower.includes('api 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');
|
lower.includes('please check the provided tool list');
|
||||||
if (!looksLikeBootstrapFailure) return null;
|
if (!looksLikeBootstrapFailure) return null;
|
||||||
return trimmed.slice(0, 280);
|
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 {
|
function normalizeMemberDiagnosticText(memberName: string, text: string): string {
|
||||||
return `${memberName}: ${text.trim()}`;
|
return `${memberName}: ${text.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2090,6 +2161,7 @@ function normalizeSameTeamText(text: string): string {
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
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 RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||||
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
||||||
private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000;
|
private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000;
|
||||||
|
|
@ -2114,6 +2186,7 @@ export class TeamProvisioningService {
|
||||||
NativeSameTeamFingerprint[]
|
NativeSameTeamFingerprint[]
|
||||||
>();
|
>();
|
||||||
private readonly launchStateStore = new TeamLaunchStateStore();
|
private readonly launchStateStore = new TeamLaunchStateStore();
|
||||||
|
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | null = null;
|
private helpOutputCache: string | null = null;
|
||||||
private helpOutputCacheTime = 0;
|
private helpOutputCacheTime = 0;
|
||||||
|
|
@ -2143,7 +2216,13 @@ export class TeamProvisioningService {
|
||||||
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
||||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||||
) {}
|
) {
|
||||||
|
this.memberLogsFinder = new TeamMemberLogsFinder(
|
||||||
|
this.configReader,
|
||||||
|
this.inboxReader,
|
||||||
|
this.membersMetaStore
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setCrossTeamSender(
|
setCrossTeamSender(
|
||||||
sender:
|
sender:
|
||||||
|
|
@ -3447,6 +3526,10 @@ export class TeamProvisioningService {
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!run.expectedMembers || run.expectedMembers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.reconcileBootstrapTranscriptFailures(run);
|
||||||
if (this.shouldSkipMemberSpawnAudit(run)) {
|
if (this.shouldSkipMemberSpawnAudit(run)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3462,6 +3545,33 @@ export class TeamProvisioningService {
|
||||||
await this.auditMemberSpawnStatuses(run);
|
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 CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||||
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
|
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
|
||||||
|
|
||||||
|
|
@ -3507,7 +3617,13 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
async prepareForProvisioning(
|
async prepareForProvisioning(
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
|
opts?: {
|
||||||
|
forceFresh?: boolean;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
providerIds?: TeamProviderId[];
|
||||||
|
modelIds?: string[];
|
||||||
|
limitContext?: boolean;
|
||||||
|
}
|
||||||
): Promise<TeamProvisioningPrepareResult> {
|
): Promise<TeamProvisioningPrepareResult> {
|
||||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||||
await this.validatePrepareCwd(targetCwdForValidation);
|
await this.validatePrepareCwd(targetCwdForValidation);
|
||||||
|
|
@ -3535,7 +3651,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const details: string[] = [];
|
||||||
const blockingMessages: string[] = [];
|
const blockingMessages: string[] = [];
|
||||||
|
const selectedModelIds = Array.from(
|
||||||
|
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
for (const providerId of providerIds) {
|
for (const providerId of providerIds) {
|
||||||
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
|
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
|
||||||
|
|
@ -3555,32 +3675,47 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!probeResult.warning) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefixedWarning =
|
{
|
||||||
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
const prefixedWarning =
|
||||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
||||||
if (authSource === 'configured_api_key_missing') {
|
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||||
blockingMessages.push(prefixedWarning);
|
if (authSource === 'configured_api_key_missing') {
|
||||||
} else if (
|
blockingMessages.push(prefixedWarning);
|
||||||
(authSource === 'none' ||
|
} else if (
|
||||||
authSource === 'codex_runtime' ||
|
(authSource === 'none' ||
|
||||||
authSource === 'gemini_runtime') &&
|
authSource === 'codex_runtime' ||
|
||||||
isAuthFailure
|
authSource === 'gemini_runtime') &&
|
||||||
) {
|
isAuthFailure
|
||||||
blockingMessages.push(prefixedWarning);
|
) {
|
||||||
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
blockingMessages.push(prefixedWarning);
|
||||||
blockingMessages.push(prefixedWarning);
|
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
||||||
} else {
|
blockingMessages.push(prefixedWarning);
|
||||||
// Preflight warnings (including timeouts) should not block provisioning.
|
} else {
|
||||||
warnings.push(prefixedWarning);
|
// Preflight warnings (including timeouts) should not block provisioning.
|
||||||
|
warnings.push(prefixedWarning);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockingMessages.length > 0) {
|
if (blockingMessages.length > 0) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
|
details: details.length > 0 ? details : undefined,
|
||||||
message:
|
message:
|
||||||
blockingMessages.length === 1
|
blockingMessages.length === 1
|
||||||
? blockingMessages[0]
|
? blockingMessages[0]
|
||||||
|
|
@ -3591,6 +3726,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
|
details: details.length > 0 ? details : undefined,
|
||||||
message:
|
message:
|
||||||
providerIds.length > 1
|
providerIds.length > 1
|
||||||
? warnings.length > 0
|
? 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(
|
private getFreshCachedProbeResult(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
providerId: TeamProviderId | undefined
|
providerId: TeamProviderId | undefined
|
||||||
|
|
@ -6699,7 +6998,7 @@ export class TeamProvisioningService {
|
||||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
||||||
const persisted = await this.launchStateStore.read(teamName);
|
const persisted = await this.launchStateStore.read(teamName);
|
||||||
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted);
|
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted);
|
||||||
if (preferredSnapshot) {
|
if (preferredSnapshot && preferredSnapshot === bootstrapSnapshot) {
|
||||||
return {
|
return {
|
||||||
snapshot: preferredSnapshot,
|
snapshot: preferredSnapshot,
|
||||||
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
||||||
|
|
@ -6784,6 +7083,8 @@ export class TeamProvisioningService {
|
||||||
const heartbeatReason = heartbeatMessage
|
const heartbeatReason = heartbeatMessage
|
||||||
? extractBootstrapFailureReason(heartbeatMessage.text)
|
? extractBootstrapFailureReason(heartbeatMessage.text)
|
||||||
: null;
|
: null;
|
||||||
|
const acceptedAtMs =
|
||||||
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
current.runtimeAlive = runtimeAlive;
|
current.runtimeAlive = runtimeAlive;
|
||||||
current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt;
|
current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt;
|
||||||
current.sources = {
|
current.sources = {
|
||||||
|
|
@ -6806,8 +7107,18 @@ export class TeamProvisioningService {
|
||||||
current.hardFailure = false;
|
current.hardFailure = false;
|
||||||
current.hardFailureReason = undefined;
|
current.hardFailureReason = undefined;
|
||||||
}
|
}
|
||||||
const acceptedAtMs =
|
if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) {
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
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 =
|
const graceExpired =
|
||||||
current.agentToolAccepted === true &&
|
current.agentToolAccepted === true &&
|
||||||
Number.isFinite(acceptedAtMs) &&
|
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 {
|
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
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 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) {
|
if (!isPong) {
|
||||||
return {
|
return {
|
||||||
warning:
|
warning:
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,9 @@ export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
|
||||||
/** Get status for a single provider */
|
/** Get status for a single provider */
|
||||||
export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus';
|
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 */
|
/** Start CLI install/update */
|
||||||
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
CLI_INSTALLER_PROGRESS,
|
CLI_INSTALLER_PROGRESS,
|
||||||
|
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||||
CONTEXT_CHANGED,
|
CONTEXT_CHANGED,
|
||||||
CONTEXT_GET_ACTIVE,
|
CONTEXT_GET_ACTIVE,
|
||||||
CONTEXT_LIST,
|
CONTEXT_LIST,
|
||||||
|
|
@ -859,13 +860,17 @@ const electronAPI: ElectronAPI = {
|
||||||
prepareProvisioning: async (
|
prepareProvisioning: async (
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
providerId?: TeamLaunchRequest['providerId'],
|
providerId?: TeamLaunchRequest['providerId'],
|
||||||
providerIds?: TeamLaunchRequest['providerId'][]
|
providerIds?: TeamLaunchRequest['providerId'][],
|
||||||
|
selectedModels?: string[],
|
||||||
|
limitContext?: boolean
|
||||||
) => {
|
) => {
|
||||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
cwd,
|
cwd,
|
||||||
providerId,
|
providerId,
|
||||||
providerIds
|
providerIds,
|
||||||
|
selectedModels,
|
||||||
|
limitContext
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
createTeam: async (request: TeamCreateRequest) => {
|
createTeam: async (request: TeamCreateRequest) => {
|
||||||
|
|
@ -1418,6 +1423,9 @@ const electronAPI: ElectronAPI = {
|
||||||
getProviderStatus: async (providerId: CliProviderId) => {
|
getProviderStatus: async (providerId: CliProviderId) => {
|
||||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||||
},
|
},
|
||||||
|
verifyProviderModels: async (providerId: CliProviderId) => {
|
||||||
|
return invokeIpcWithResult(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, providerId);
|
||||||
|
},
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -718,7 +718,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
prepareProvisioning: async (
|
prepareProvisioning: async (
|
||||||
_cwd?: string,
|
_cwd?: string,
|
||||||
_providerId?: TeamLaunchRequest['providerId'],
|
_providerId?: TeamLaunchRequest['providerId'],
|
||||||
_providerIds?: TeamLaunchRequest['providerId'][]
|
_providerIds?: TeamLaunchRequest['providerId'][],
|
||||||
|
_selectedModels?: string[],
|
||||||
|
_limitContext?: boolean
|
||||||
): Promise<TeamProvisioningPrepareResult> => {
|
): Promise<TeamProvisioningPrepareResult> => {
|
||||||
throw new Error('Team provisioning is not available in browser mode');
|
throw new Error('Team provisioning is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
@ -1126,6 +1128,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
providers: [],
|
providers: [],
|
||||||
}),
|
}),
|
||||||
getProviderStatus: async (): Promise<null> => null,
|
getProviderStatus: async (): Promise<null> => null,
|
||||||
|
verifyProviderModels: async (): Promise<null> => null,
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
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 { api, isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import {
|
import {
|
||||||
formatProviderStatusText,
|
formatProviderStatusText,
|
||||||
getProviderConnectionModeSummary,
|
getProviderConnectionModeSummary,
|
||||||
|
|
@ -32,10 +33,6 @@ import { useStore } from '@renderer/store';
|
||||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||||
import {
|
|
||||||
getTeamModelBadgeLabel,
|
|
||||||
getVisibleTeamProviderModels,
|
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
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 => {
|
const ProviderDetailSkeleton = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 space-y-2">
|
<div className="mt-1 space-y-2">
|
||||||
|
|
@ -659,7 +625,12 @@ const InstalledBanner = ({
|
||||||
</div>
|
</div>
|
||||||
{!showSkeleton && provider.models.length > 0 && (
|
{!showSkeleton && provider.models.length > 0 && (
|
||||||
<div className="col-span-2">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 { isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import {
|
import {
|
||||||
formatProviderStatusText,
|
formatProviderStatusText,
|
||||||
getProviderConnectionModeSummary,
|
getProviderConnectionModeSummary,
|
||||||
|
|
@ -28,10 +29,6 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import {
|
|
||||||
getTeamModelBadgeLabel,
|
|
||||||
getVisibleTeamProviderModels,
|
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -49,37 +46,6 @@ import { SettingsSectionHeader } from '../components';
|
||||||
|
|
||||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
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 => {
|
const ProviderDetailSkeleton = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 space-y-2">
|
<div className="mt-1 space-y-2">
|
||||||
|
|
@ -600,9 +566,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
{!showSkeleton && provider.models.length > 0 && (
|
{!showSkeleton && provider.models.length > 0 && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<ModelBadges
|
<ProviderModelBadges
|
||||||
providerId={provider.providerId}
|
providerId={provider.providerId}
|
||||||
models={provider.models}
|
models={provider.models}
|
||||||
|
modelAvailability={provider.modelAvailability}
|
||||||
|
providerStatus={provider}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,12 @@ import {
|
||||||
normalizeCreateLaunchProviderForUi,
|
normalizeCreateLaunchProviderForUi,
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
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 { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -50,15 +54,21 @@ import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
|
getPrimaryProvisioningFailureDetail,
|
||||||
getProvisioningProviderBackendSummary,
|
getProvisioningProviderBackendSummary,
|
||||||
type ProvisioningProviderCheck,
|
type ProvisioningProviderCheck,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
updateProviderCheck,
|
updateProviderCheck,
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
|
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||||
|
import {
|
||||||
|
getProviderPrepareCachedSnapshot,
|
||||||
|
runProviderPrepareDiagnostics,
|
||||||
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
|
} from './providerPrepareDiagnostics';
|
||||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
|
|
@ -82,7 +92,6 @@ import type {
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
TeamProvisioningMemberInput,
|
TeamProvisioningMemberInput,
|
||||||
TeamProvisioningPrepareResult,
|
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
function getStoredTeamProvider(): TeamProviderId {
|
function getStoredTeamProvider(): TeamProviderId {
|
||||||
|
|
@ -115,6 +124,32 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
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 {
|
export interface TeamCopyData {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -486,6 +521,25 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
return new Map<TeamProviderId, string | null>(entries);
|
return new Map<TeamProviderId, string | null>(entries);
|
||||||
}, [cliStatus?.providers]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
|
|
@ -534,41 +588,110 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const requestSeq = ++prepareRequestSeqRef.current;
|
const requestSeq = ++prepareRequestSeqRef.current;
|
||||||
|
const initialChecks = alignProvisioningChecks(
|
||||||
|
prepareChecksRef.current,
|
||||||
|
selectedMemberProviders
|
||||||
|
);
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Checking selected providers...');
|
setPrepareMessage('Checking selected providers...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
setPrepareChecks(initialChecks);
|
||||||
|
|
||||||
// Defer so file list fetch (triggered by project select) can run first
|
// Defer so file list fetch (triggered by project select) can run first
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let checks = createInitialProviderChecks(selectedMemberProviders);
|
let checks = initialChecks;
|
||||||
let anyFailure = false;
|
let anyFailure = false;
|
||||||
let anyNotes = false;
|
let anyNotes = false;
|
||||||
const collectedWarnings: string[] = [];
|
const collectedWarnings: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const providerId of selectedMemberProviders) {
|
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, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: 'checking',
|
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
||||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
backendSummary,
|
||||||
details: [],
|
details: cachedSnapshot.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
setPrepareChecks(checks);
|
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(
|
const prepResult = await runProviderPrepareDiagnostics({
|
||||||
effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
providerId,
|
providerId,
|
||||||
[providerId]
|
selectedModelIds: selectedModelChecks,
|
||||||
);
|
prepareProvisioning: api.teams.prepareProvisioning,
|
||||||
const detailLines = [
|
limitContext,
|
||||||
...(prepResult.warnings ?? []).filter(Boolean),
|
cachedModelResultsById,
|
||||||
...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
|
onModelProgress: ({ details, completedCount, totalCount }) => {
|
||||||
];
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
if (prepResult.warnings?.length) {
|
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;
|
anyNotes = true;
|
||||||
collectedWarnings.push(
|
collectedWarnings.push(
|
||||||
...prepResult.warnings.map(
|
...prepResult.warnings.map(
|
||||||
|
|
@ -576,23 +699,29 @@ export const CreateTeamDialog = ({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!prepResult.ready) {
|
if (prepResult.status === 'failed') {
|
||||||
anyFailure = true;
|
anyFailure = true;
|
||||||
|
} else if (prepResult.status === 'notes') {
|
||||||
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
|
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
status: prepResult.status,
|
||||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
backendSummary,
|
||||||
details: detailLines,
|
details: prepResult.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
setPrepareChecks(checks);
|
setPrepareChecks(checks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
|
const failureMessage =
|
||||||
|
getPrimaryProvisioningFailureDetail(checks) ??
|
||||||
|
'Some selected providers need attention.';
|
||||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||||
setPrepareMessage(
|
setPrepareMessage(
|
||||||
anyFailure
|
anyFailure
|
||||||
? 'Some selected providers need attention.'
|
? failureMessage
|
||||||
: anyNotes
|
: anyNotes
|
||||||
? 'Selected providers are ready with notes.'
|
? 'Selected providers are ready with notes.'
|
||||||
: 'Selected providers are ready.'
|
: 'Selected providers are ready.'
|
||||||
|
|
@ -619,9 +748,11 @@ export const CreateTeamDialog = ({
|
||||||
canCreate,
|
canCreate,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
|
effectiveMemberDrafts,
|
||||||
|
limitContext,
|
||||||
|
selectedModel,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedMemberProviders,
|
selectedMemberProviders,
|
||||||
runtimeBackendSummaryByProvider,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -809,6 +940,13 @@ export const CreateTeamDialog = ({
|
||||||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||||
[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 sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
const teamNameInlineError = validateTeamNameInline(teamName);
|
const teamNameInlineError = validateTeamNameInline(teamName);
|
||||||
|
|
@ -854,11 +992,76 @@ export const CreateTeamDialog = ({
|
||||||
() => validateRequest(request, { requireCwd: launchTeam }),
|
() => validateRequest(request, { requireCwd: launchTeam }),
|
||||||
[request, 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 =
|
const hasCreateFormErrors =
|
||||||
!!teamNameInlineError ||
|
!!teamNameInlineError ||
|
||||||
isNameTakenByExistingTeam ||
|
isNameTakenByExistingTeam ||
|
||||||
isNameProvisioning ||
|
isNameProvisioning ||
|
||||||
!requestValidation.valid;
|
!requestValidation.valid ||
|
||||||
|
!!modelValidationError;
|
||||||
|
|
||||||
const internalArgs = useMemo(() => {
|
const internalArgs = useMemo(() => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -897,7 +1100,8 @@ export const CreateTeamDialog = ({
|
||||||
[members, setMembers, setSyncModelsWithLead]
|
[members, setMembers, setSyncModelsWithLead]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
const activeError =
|
||||||
|
localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||||
const canOpenExistingTeam =
|
const canOpenExistingTeam =
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||||
|
|
||||||
|
|
@ -928,6 +1132,10 @@ export const CreateTeamDialog = ({
|
||||||
setLocalError(messages.join(' · ') || 'Check form fields');
|
setLocalError(messages.join(' · ') || 'Check form fields');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (modelValidationError) {
|
||||||
|
setLocalError(modelValidationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
@ -1040,45 +1248,6 @@ export const CreateTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{!canCreate ? (
|
||||||
<p
|
<p
|
||||||
className="rounded border p-2 text-xs"
|
className="rounded border p-2 text-xs"
|
||||||
|
|
@ -1162,6 +1331,8 @@ export const CreateTeamDialog = ({
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
|
leadModelIssueText={leadModelIssueText}
|
||||||
|
memberModelIssueById={memberModelIssueById}
|
||||||
headerTop={
|
headerTop={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -1420,6 +1591,48 @@ export const CreateTeamDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,12 @@ import {
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
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 { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -69,15 +73,21 @@ import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
|
getPrimaryProvisioningFailureDetail,
|
||||||
getProvisioningProviderBackendSummary,
|
getProvisioningProviderBackendSummary,
|
||||||
type ProvisioningProviderCheck,
|
type ProvisioningProviderCheck,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
updateProviderCheck,
|
updateProviderCheck,
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
|
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||||
|
import {
|
||||||
|
getProviderPrepareCachedSnapshot,
|
||||||
|
runProviderPrepareDiagnostics,
|
||||||
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
|
} from './providerPrepareDiagnostics';
|
||||||
import {
|
import {
|
||||||
computeEffectiveTeamModel,
|
computeEffectiveTeamModel,
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
|
|
@ -96,10 +106,35 @@ import type {
|
||||||
ScheduleLaunchConfig,
|
ScheduleLaunchConfig,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
TeamProvisioningPrepareResult,
|
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
} from '@shared/types';
|
} 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
|
// Props — discriminated union
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -344,6 +379,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
);
|
);
|
||||||
return new Map<TeamProviderId, string | null>(entries);
|
return new Map<TeamProviderId, string | null>(entries);
|
||||||
}, [cliStatus?.providers]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
|
|
@ -632,6 +691,51 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
||||||
[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(() => {
|
const runtimeChangeNotes = useMemo(() => {
|
||||||
if (!isLaunch) {
|
if (!isLaunch) {
|
||||||
|
|
@ -814,61 +918,95 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const requestSeq = ++prepareRequestSeqRef.current;
|
const requestSeq = ++prepareRequestSeqRef.current;
|
||||||
|
const initialChecks = alignProvisioningChecks(
|
||||||
|
prepareChecksRef.current,
|
||||||
|
selectedMemberProviders
|
||||||
|
);
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Checking selected providers...');
|
setPrepareMessage('Checking selected providers...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
setPrepareChecks(initialChecks);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let checks = createInitialProviderChecks(selectedMemberProviders);
|
let checks = initialChecks;
|
||||||
let anyFailure = false;
|
let anyFailure = false;
|
||||||
let anyNotes = false;
|
let anyNotes = false;
|
||||||
const collectedWarnings: string[] = [];
|
const collectedWarnings: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const providerId of selectedMemberProviders) {
|
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, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: 'checking',
|
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
||||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
backendSummary,
|
||||||
details: [],
|
details: cachedSnapshot.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
setPrepareChecks(checks);
|
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(
|
const prepResult = await runProviderPrepareDiagnostics({
|
||||||
effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
providerId,
|
providerId,
|
||||||
[providerId]
|
selectedModelIds: selectedModelChecks,
|
||||||
);
|
prepareProvisioning: api.teams.prepareProvisioning,
|
||||||
const detailLines = [
|
limitContext,
|
||||||
...(prepResult.warnings ?? []).filter(Boolean),
|
cachedModelResultsById,
|
||||||
...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
|
onModelProgress: ({ details, completedCount, totalCount }) => {
|
||||||
];
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
if (prepResult.warnings?.length) {
|
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;
|
anyNotes = true;
|
||||||
collectedWarnings.push(
|
collectedWarnings.push(
|
||||||
...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
|
...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!prepResult.ready) {
|
if (prepResult.status === 'failed') {
|
||||||
anyFailure = true;
|
anyFailure = true;
|
||||||
|
} else if (prepResult.status === 'notes') {
|
||||||
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
|
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
status: prepResult.status,
|
||||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
backendSummary,
|
||||||
details: detailLines,
|
details: prepResult.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
setPrepareChecks(checks);
|
setPrepareChecks(checks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
|
const failureMessage =
|
||||||
|
getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.';
|
||||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||||
setPrepareMessage(
|
setPrepareMessage(
|
||||||
anyFailure
|
anyFailure
|
||||||
? 'Some selected providers need attention.'
|
? failureMessage
|
||||||
: anyNotes
|
: anyNotes
|
||||||
? 'Selected providers are ready with notes.'
|
? 'Selected providers are ready with notes.'
|
||||||
: 'Selected providers are ready.'
|
: 'Selected providers are ready.'
|
||||||
|
|
@ -894,7 +1032,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedMemberProviders,
|
selectedMemberProviders,
|
||||||
runtimeBackendSummaryByProvider,
|
selectedModelChecksByProvider,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1069,6 +1207,84 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
|
}, [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(
|
const hasInvalidLaunchMemberNames = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isLaunch &&
|
isLaunch &&
|
||||||
|
|
@ -1090,7 +1306,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const provisioningError = isLaunch ? props.provisioningError : null;
|
const provisioningError = isLaunch ? props.provisioningError : null;
|
||||||
const activeError = localError ?? provisioningError;
|
const activeError = localError ?? modelValidationError ?? provisioningError;
|
||||||
const launchInFlight = useStore((s) =>
|
const launchInFlight = useStore((s) =>
|
||||||
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
||||||
);
|
);
|
||||||
|
|
@ -1122,6 +1338,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setLocalError(validationErrors[0]);
|
setLocalError(validationErrors[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (modelValidationError) {
|
||||||
|
setLocalError(modelValidationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isLaunch && !effectiveCwd) {
|
if (isLaunch && !effectiveCwd) {
|
||||||
setLocalError('Select working directory (cwd)');
|
setLocalError('Select working directory (cwd)');
|
||||||
return;
|
return;
|
||||||
|
|
@ -1231,9 +1451,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
? isSubmitting ||
|
? isSubmitting ||
|
||||||
launchInFlight ||
|
launchInFlight ||
|
||||||
validationErrors.length > 0 ||
|
validationErrors.length > 0 ||
|
||||||
|
!!modelValidationError ||
|
||||||
hasInvalidLaunchMemberNames ||
|
hasInvalidLaunchMemberNames ||
|
||||||
hasDuplicateLaunchMemberNames
|
hasDuplicateLaunchMemberNames
|
||||||
: isSubmitting || validationErrors.length > 0;
|
: isSubmitting || validationErrors.length > 0 || !!modelValidationError;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dynamic labels
|
// Dynamic labels
|
||||||
|
|
@ -1321,63 +1542,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="space-y-4">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
Schedule-only: Team selector (standalone mode)
|
Schedule-only: Team selector (standalone mode)
|
||||||
|
|
@ -1556,6 +1720,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
leadWarningText={leadRuntimeWarningText}
|
leadWarningText={leadRuntimeWarningText}
|
||||||
memberWarningById={memberRuntimeWarningById}
|
memberWarningById={memberRuntimeWarningById}
|
||||||
|
leadModelIssueText={leadModelIssueText}
|
||||||
|
memberModelIssueById={memberModelIssueById}
|
||||||
softDeleteMembers
|
softDeleteMembers
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1819,7 +1985,64 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : 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 {
|
function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'checking':
|
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();
|
const lower = detail.toLowerCase();
|
||||||
|
|
||||||
if (lower.includes('spawn ') && lower.includes(' enoent')) {
|
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')) {
|
if (lower.includes('claude cli preflight check failed')) {
|
||||||
return 'CLI preflight 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') {
|
if (status === 'notes') {
|
||||||
return 'Ready with notes';
|
return 'Ready with notes';
|
||||||
|
|
@ -142,13 +188,173 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus
|
||||||
return null;
|
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 {
|
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||||
const summary = check.details.find(Boolean)
|
const modelSummary = getModelDetailSummary(check.details);
|
||||||
? summarizeDetail(check.details[0], check.status)
|
if (modelSummary) {
|
||||||
: null;
|
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);
|
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(
|
export function shouldHideProvisioningProviderStatusList(
|
||||||
checks: ProvisioningProviderCheck[],
|
checks: ProvisioningProviderCheck[],
|
||||||
message: string | null | undefined
|
message: string | null | undefined
|
||||||
|
|
@ -236,7 +442,10 @@ export const ProvisioningProviderStatusList = ({
|
||||||
{visibleDetails.length > 0 ? (
|
{visibleDetails.length > 0 ? (
|
||||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||||
{visibleDetails.map((detail) => (
|
{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}
|
{detail}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,26 @@ import {
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
import {
|
import {
|
||||||
GEMINI_UI_DISABLED_BADGE_LABEL,
|
GEMINI_UI_DISABLED_BADGE_LABEL,
|
||||||
GEMINI_UI_DISABLED_REASON,
|
GEMINI_UI_DISABLED_REASON,
|
||||||
isGeminiUiFrozen,
|
isGeminiUiFrozen,
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
|
import {
|
||||||
|
getAvailableTeamProviderModelOptions,
|
||||||
|
getTeamModelUiDisabledReason,
|
||||||
|
normalizeTeamModelForUi,
|
||||||
|
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||||
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
import {
|
import {
|
||||||
doesTeamModelCarryProviderBrand,
|
doesTeamModelCarryProviderBrand,
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
getTeamModelLabel as getCatalogTeamModelLabel,
|
getTeamModelLabel as getCatalogTeamModelLabel,
|
||||||
getTeamModelUiDisabledReason,
|
|
||||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
||||||
getTeamProviderModelOptions,
|
|
||||||
normalizeTeamModelForUi,
|
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
} from '@renderer/utils/teamModelCatalog';
|
||||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||||
import { Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
|
||||||
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
|
||||||
|
|
@ -105,9 +108,9 @@ export function computeEffectiveTeamModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = extractProviderScopedBaseModel(selectedModel, providerId);
|
const base = extractProviderScopedBaseModel(selectedModel, providerId);
|
||||||
if (limitContext) return base;
|
if (limitContext) return base || getAnthropicDefaultTeamModel(true);
|
||||||
if (base === 'haiku') return base;
|
if (base === 'haiku') return base;
|
||||||
return base ? `${base}[1m]` : 'opus[1m]';
|
return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamModelSelectorProps {
|
export interface TeamModelSelectorProps {
|
||||||
|
|
@ -117,6 +120,7 @@ export interface TeamModelSelectorProps {
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
|
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
|
|
@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
onValueChange,
|
onValueChange,
|
||||||
id,
|
id,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
|
modelIssueReasonByValue,
|
||||||
}) => {
|
}) => {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||||
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator';
|
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator';
|
||||||
|
|
||||||
|
|
@ -135,7 +141,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
||||||
const defaultModelTooltip = useMemo(() => {
|
const defaultModelTooltip = useMemo(() => {
|
||||||
if (effectiveProviderId === 'anthropic') {
|
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.';
|
return 'Uses the runtime default for the selected provider.';
|
||||||
}, [effectiveProviderId]);
|
}, [effectiveProviderId]);
|
||||||
|
|
@ -181,13 +187,20 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
|
|
||||||
return statusBadge;
|
return statusBadge;
|
||||||
};
|
};
|
||||||
const runtimeModels = useMemo(
|
const runtimeProviderStatus = useMemo(
|
||||||
() =>
|
() =>
|
||||||
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId)
|
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null,
|
||||||
?.models ?? [],
|
|
||||||
[cliStatus?.providers, effectiveProviderId]
|
[cliStatus?.providers, effectiveProviderId]
|
||||||
);
|
);
|
||||||
const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value);
|
const shouldAwaitRuntimeModelList =
|
||||||
|
effectiveProviderId !== 'anthropic' &&
|
||||||
|
(cliStatus == null || cliStatusLoading) &&
|
||||||
|
runtimeProviderStatus == null;
|
||||||
|
const normalizedValue = normalizeTeamModelForUi(
|
||||||
|
effectiveProviderId,
|
||||||
|
value,
|
||||||
|
runtimeProviderStatus
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (normalizedValue !== value) {
|
if (normalizedValue !== value) {
|
||||||
|
|
@ -196,22 +209,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
}, [normalizedValue, onValueChange, value]);
|
}, [normalizedValue, onValueChange, value]);
|
||||||
|
|
||||||
const modelOptions = useMemo(() => {
|
const modelOptions = useMemo(() => {
|
||||||
const fallback = getTeamProviderModelOptions(effectiveProviderId);
|
if (shouldAwaitRuntimeModelList) {
|
||||||
if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) {
|
return [{ value: '', label: 'Default', badgeLabel: 'Default' }];
|
||||||
return fallback.map((option) => ({
|
|
||||||
...option,
|
|
||||||
label:
|
|
||||||
option.value === ''
|
|
||||||
? option.label
|
|
||||||
: getProviderScopedTeamModelLabel(effectiveProviderId, option.value),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
const dynamicOptions = runtimeModels.map((model) => ({
|
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
|
||||||
value: model,
|
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
|
||||||
label: getProviderScopedTeamModelLabel(effectiveProviderId, model),
|
|
||||||
}));
|
|
||||||
return [{ value: '', label: 'Default' }, ...dynamicOptions];
|
|
||||||
}, [effectiveProviderId, runtimeModels]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
|
|
@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="p-3">
|
<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
|
<div
|
||||||
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
|
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
|
||||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||||
|
|
@ -300,9 +308,24 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
(() => {
|
(() => {
|
||||||
const modelDisabledReason = getTeamModelUiDisabledReason(
|
const modelDisabledReason = getTeamModelUiDisabledReason(
|
||||||
effectiveProviderId,
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -310,13 +333,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
type="button"
|
type="button"
|
||||||
id={opt.value === normalizedValue ? id : undefined}
|
id={opt.value === normalizedValue ? id : undefined}
|
||||||
aria-disabled={!modelSelectable}
|
aria-disabled={!modelSelectable}
|
||||||
|
title={modelStatusMessage ?? undefined}
|
||||||
className={cn(
|
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',
|
'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
|
hasModelIssue && normalizedValue === opt.value
|
||||||
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
|
||||||
: modelSelectable
|
: hasModelIssue
|
||||||
? '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-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
|
||||||
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
|
: 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',
|
!modelSelectable && 'cursor-not-allowed opacity-45',
|
||||||
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
|
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
|
||||||
)}
|
)}
|
||||||
|
|
@ -349,7 +377,29 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</span>
|
</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
|
<span
|
||||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
|
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
|
||||||
title={modelDisabledReason}
|
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 { Label } from '@renderer/components/ui/label';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
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';
|
import { Button } from '../../ui/button';
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ interface LeadModelRowProps {
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
|
modelIssueText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeadModelRow = ({
|
export const LeadModelRow = ({
|
||||||
|
|
@ -47,6 +49,7 @@ export const LeadModelRow = ({
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
warningText,
|
warningText,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
|
modelIssueText,
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
|
|
@ -55,6 +58,7 @@ export const LeadModelRow = ({
|
||||||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||||
: 'Default';
|
: 'Default';
|
||||||
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
|
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
|
||||||
|
const hasModelIssue = Boolean(modelIssueText);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -99,7 +103,11 @@ export const LeadModelRow = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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}
|
aria-label={modelButtonAriaLabel}
|
||||||
onClick={() => setModelExpanded((prev) => !prev)}
|
onClick={() => setModelExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
|
|
@ -109,7 +117,8 @@ export const LeadModelRow = ({
|
||||||
<ChevronRight className="size-3.5" />
|
<ChevronRight className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,6 +139,7 @@ export const LeadModelRow = ({
|
||||||
onValueChange={onModelChange}
|
onValueChange={onModelChange}
|
||||||
id="lead-model"
|
id="lead-model"
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
|
||||||
/>
|
/>
|
||||||
<EffortLevelSelector
|
<EffortLevelSelector
|
||||||
value={effort ?? ''}
|
value={effort ?? ''}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
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 { MemberDraft } from './membersEditorTypes';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -55,6 +56,7 @@ interface MemberDraftRowProps {
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
|
modelIssueText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
|
|
@ -87,6 +89,7 @@ export const MemberDraftRow = ({
|
||||||
onRestore,
|
onRestore,
|
||||||
warningText,
|
warningText,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
|
modelIssueText,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
const memberColorSet = getTeamColorSet(
|
||||||
|
|
@ -175,6 +178,7 @@ export const MemberDraftRow = ({
|
||||||
const modelTooltipText = forceInheritedModelSettings
|
const modelTooltipText = forceInheritedModelSettings
|
||||||
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
|
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
|
||||||
: modelLockReason;
|
: modelLockReason;
|
||||||
|
const hasModelIssue = Boolean(modelIssueText);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -248,7 +252,11 @@ export const MemberDraftRow = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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}
|
aria-label={modelButtonAriaLabel}
|
||||||
disabled={lockProviderModel || isRemoved}
|
disabled={lockProviderModel || isRemoved}
|
||||||
onClick={() => setModelExpanded((prev) => !prev)}
|
onClick={() => setModelExpanded((prev) => !prev)}
|
||||||
|
|
@ -262,13 +270,21 @@ export const MemberDraftRow = ({
|
||||||
providerId={effectiveProviderId}
|
providerId={effectiveProviderId}
|
||||||
className="size-3.5 shrink-0"
|
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>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{modelTooltipText ? (
|
{modelTooltipText || modelIssueText ? (
|
||||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
<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>
|
</TooltipContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -355,6 +371,9 @@ export const MemberDraftRow = ({
|
||||||
}}
|
}}
|
||||||
id={`member-${member.id}-model`}
|
id={`member-${member.id}-model`}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
modelIssueReasonByValue={
|
||||||
|
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<EffortLevelSelector
|
<EffortLevelSelector
|
||||||
value={effectiveEffort ?? ''}
|
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.'}
|
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export interface MembersEditorSectionProps {
|
||||||
softDeleteMembers?: boolean;
|
softDeleteMembers?: boolean;
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
|
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MembersEditorSection = ({
|
export const MembersEditorSection = ({
|
||||||
|
|
@ -128,6 +129,7 @@ export const MembersEditorSection = ({
|
||||||
softDeleteMembers = false,
|
softDeleteMembers = false,
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
|
memberModelIssueById,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
|
@ -316,6 +318,7 @@ export const MembersEditorSection = ({
|
||||||
modelLockReason={modelLockReason}
|
modelLockReason={modelLockReason}
|
||||||
warningText={memberWarningById?.[member.id] ?? null}
|
warningText={memberWarningById?.[member.id] ?? null}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
modelIssueText={memberModelIssueById?.[member.id] ?? null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{softDeleteMembers && removedMembers.length > 0 ? (
|
{softDeleteMembers && removedMembers.length > 0 ? (
|
||||||
|
|
@ -356,6 +359,7 @@ export const MembersEditorSection = ({
|
||||||
isRemoved
|
isRemoved
|
||||||
warningText={null}
|
warningText={null}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
modelIssueText={null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps {
|
||||||
leadWarningText?: string | null;
|
leadWarningText?: string | null;
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
|
leadModelIssueText?: string | null;
|
||||||
|
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamRosterEditorSection = ({
|
export const TeamRosterEditorSection = ({
|
||||||
|
|
@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({
|
||||||
leadWarningText,
|
leadWarningText,
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
|
leadModelIssueText,
|
||||||
|
memberModelIssueById,
|
||||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<MembersEditorSection
|
<MembersEditorSection
|
||||||
|
|
@ -108,6 +112,7 @@ export const TeamRosterEditorSection = ({
|
||||||
modelLockReason={modelLockReason}
|
modelLockReason={modelLockReason}
|
||||||
softDeleteMembers={softDeleteMembers}
|
softDeleteMembers={softDeleteMembers}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
memberModelIssueById={memberModelIssueById}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{headerTop}
|
{headerTop}
|
||||||
|
|
@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({
|
||||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||||
warningText={leadWarningText}
|
warningText={leadWarningText}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
modelIssueText={leadModelIssueText}
|
||||||
/>
|
/>
|
||||||
{headerBottom}
|
{headerBottom}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function useCliInstaller(): {
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
fetchCliProviderStatus: (
|
fetchCliProviderStatus: (
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
options?: { silent?: boolean; epoch?: number }
|
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
invalidateCliStatus: () => Promise<void>;
|
invalidateCliStatus: () => Promise<void>;
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'unknown' as const,
|
verificationState: 'unknown' as const,
|
||||||
|
modelVerificationState: 'idle' as const,
|
||||||
statusMessage: 'Checking...',
|
statusMessage: 'Checking...',
|
||||||
models: [],
|
models: [],
|
||||||
|
modelAvailability: [],
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
|
|
@ -89,14 +91,14 @@ export interface CliInstallerSlice {
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
fetchCliProviderStatus: (
|
fetchCliProviderStatus: (
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
options?: { silent?: boolean; epoch?: number }
|
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
invalidateCliStatus: () => Promise<void>;
|
invalidateCliStatus: () => Promise<void>;
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliStatusInFlight: Promise<void> | null = null;
|
let cliStatusInFlight: Promise<void> | null = null;
|
||||||
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
|
const cliProviderStatusInFlight = new Map<string, Promise<void>>();
|
||||||
let cliStatusEpoch = 0;
|
let cliStatusEpoch = 0;
|
||||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||||
|
|
||||||
|
|
@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
if (get().cliStatus && !get().cliStatus?.installed) {
|
if (get().cliStatus && !get().cliStatus?.installed) {
|
||||||
return;
|
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;
|
if (inFlight) return inFlight;
|
||||||
|
|
||||||
const requestEpoch = options?.epoch ?? cliStatusEpoch;
|
const requestEpoch = options?.epoch ?? cliStatusEpoch;
|
||||||
|
|
@ -277,7 +281,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providerStatus = await api.cliInstaller.getProviderStatus(providerId);
|
const providerStatus = verifyModels
|
||||||
|
? await api.cliInstaller.verifyProviderModels(providerId)
|
||||||
|
: await api.cliInstaller.getProviderStatus(providerId);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextLoading = silent
|
const nextLoading = silent
|
||||||
? state.cliProviderStatusLoading
|
? state.cliProviderStatusLoading
|
||||||
|
|
@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cliProviderStatusInFlight.delete(providerId);
|
cliProviderStatusInFlight.delete(requestKey);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
cliProviderStatusInFlight.set(providerId, request);
|
cliProviderStatusInFlight.set(requestKey, request);
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -100,33 +101,20 @@ const teamRefreshBurstDiagnostics = new Map<
|
||||||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||||
>();
|
>();
|
||||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||||
const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set<string>();
|
|
||||||
interface RefreshTeamDataOptions {
|
interface RefreshTeamDataOptions {
|
||||||
withDedup?: boolean;
|
withDedup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||||
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||||
|
type TeamGraphConfigMemberSeedInput = Pick<
|
||||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
NonNullable<TeamViewSnapshot['config']['members']>[number],
|
||||||
[],
|
'name' | 'agentId' | 'removedAt'
|
||||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
>;
|
||||||
[
|
type TeamGraphLayoutSessionState = {
|
||||||
{ ringIndex: 0, sectorIndex: 0 },
|
mode: 'default' | 'manual';
|
||||||
{ ringIndex: 0, sectorIndex: 1 },
|
signature: string | null;
|
||||||
],
|
};
|
||||||
[
|
|
||||||
{ 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 isTeamDataRefreshPending(teamName: string): boolean {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -171,7 +159,6 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
resolvedMemberSelectorCache.clear();
|
resolvedMemberSelectorCache.clear();
|
||||||
mergedMessagesSelectorCache.clear();
|
mergedMessagesSelectorCache.clear();
|
||||||
memberMessagesSelectorCache.clear();
|
memberMessagesSelectorCache.clear();
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
|
|
@ -1512,14 +1499,18 @@ function isMemberActivityMetaStale(
|
||||||
|
|
||||||
function seedStableSlotAssignmentsForMembers(
|
function seedStableSlotAssignmentsForMembers(
|
||||||
assignments: TeamGraphSlotAssignments,
|
assignments: TeamGraphSlotAssignments,
|
||||||
members: readonly TeamGraphMemberSeedInput[]
|
members: readonly TeamGraphMemberSeedInput[],
|
||||||
|
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
|
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||||
if (visibleMembers.length === 0 || visibleMembers.length > 4) {
|
if (
|
||||||
|
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
|
||||||
|
Object.keys(defaultSeed.assignments).length === 0
|
||||||
|
) {
|
||||||
return { assignments, changed: false };
|
return { assignments, changed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member));
|
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
|
||||||
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||||
(stableOwnerId) => assignments[stableOwnerId] != null
|
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||||
);
|
);
|
||||||
|
|
@ -1527,14 +1518,9 @@ function seedStableSlotAssignmentsForMembers(
|
||||||
return { assignments, changed: false };
|
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 };
|
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||||
visibleMembers.forEach((member, index) => {
|
visibleStableOwnerIds.forEach((stableOwnerId) => {
|
||||||
nextAssignments[getStableTeamOwnerId(member)] = preset[index]!;
|
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { assignments: nextAssignments, changed: true };
|
return { assignments: nextAssignments, changed: true };
|
||||||
|
|
@ -1564,20 +1550,47 @@ function areTeamGraphSlotAssignmentsEqual(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
members: readonly TeamGraphMemberSeedInput[]
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
): TeamGraphSlotAssignments {
|
): 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 {
|
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||||
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean {
|
|
||||||
return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVisibleInActiveTeamSurface(
|
function isVisibleInActiveTeamSurface(
|
||||||
state: Pick<AppState, 'paneLayout'>,
|
state: Pick<AppState, 'paneLayout'>,
|
||||||
teamName: string | null | undefined
|
teamName: string | null | undefined
|
||||||
|
|
@ -1643,6 +1656,7 @@ export interface TeamSlice {
|
||||||
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
|
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
|
||||||
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
|
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
|
||||||
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||||
|
graphLayoutSessionByTeam: Record<string, TeamGraphLayoutSessionState>;
|
||||||
selectedTeamLoading: boolean;
|
selectedTeamLoading: boolean;
|
||||||
selectedTeamLoadNonce: number;
|
selectedTeamLoadNonce: number;
|
||||||
selectedTeamError: string | null;
|
selectedTeamError: string | null;
|
||||||
|
|
@ -1687,7 +1701,8 @@ export interface TeamSlice {
|
||||||
clearKanbanFilter: () => void;
|
clearKanbanFilter: () => void;
|
||||||
ensureTeamGraphSlotAssignments: (
|
ensureTeamGraphSlotAssignments: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: readonly TeamGraphMemberSeedInput[]
|
members: readonly TeamGraphMemberSeedInput[],
|
||||||
|
configMembers?: readonly TeamGraphConfigMemberSeedInput[]
|
||||||
) => void;
|
) => void;
|
||||||
setTeamGraphOwnerSlotAssignment: (
|
setTeamGraphOwnerSlotAssignment: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -1966,6 +1981,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
slotAssignmentsByTeam: {},
|
slotAssignmentsByTeam: {},
|
||||||
teamMessagesByName: {},
|
teamMessagesByName: {},
|
||||||
memberActivityMetaByTeam: {},
|
memberActivityMetaByTeam: {},
|
||||||
|
graphLayoutSessionByTeam: {},
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamLoadNonce: 0,
|
selectedTeamLoadNonce: 0,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
|
|
@ -2385,33 +2401,72 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({ kanbanFilterQuery: null });
|
set({ kanbanFilterQuery: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
ensureTeamGraphSlotAssignments: (teamName, members) => {
|
ensureTeamGraphSlotAssignments: (teamName, members, configMembers = []) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextState: Partial<TeamSlice> = {};
|
const nextState: Partial<TeamSlice> = {};
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam;
|
let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam;
|
||||||
|
let nextGraphLayoutSessionByTeam = state.graphLayoutSessionByTeam;
|
||||||
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
||||||
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
||||||
nextSlotAssignmentsByTeam = {};
|
nextSlotAssignmentsByTeam = {};
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
nextGraphLayoutSessionByTeam = {};
|
||||||
changed = true;
|
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 (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) {
|
||||||
if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) {
|
if (currentSession?.mode === 'manual') {
|
||||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
if (
|
||||||
const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members);
|
!areTeamGraphSlotAssignmentsEqual(
|
||||||
if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) {
|
nextSlotAssignmentsByTeam[teamName],
|
||||||
|
visibleAssignments
|
||||||
|
)
|
||||||
|
) {
|
||||||
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
|
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
|
||||||
if (Object.keys(defaultAssignments).length === 0) {
|
if (visibleAssignments) {
|
||||||
delete nextSlotAssignmentsByTeam[teamName];
|
nextSlotAssignmentsByTeam[teamName] = visibleAssignments;
|
||||||
} else {
|
} else {
|
||||||
nextSlotAssignmentsByTeam[teamName] = defaultAssignments;
|
delete nextSlotAssignmentsByTeam[teamName];
|
||||||
}
|
}
|
||||||
changed = true;
|
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) {
|
if (!changed) {
|
||||||
|
|
@ -2419,12 +2474,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
||||||
|
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||||
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
||||||
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
|
const seeded = seedStableSlotAssignmentsForMembers(
|
||||||
|
migrated.assignments,
|
||||||
|
members,
|
||||||
|
configMembers
|
||||||
|
);
|
||||||
if (migrated.changed || seeded.changed) {
|
if (migrated.changed || seeded.changed) {
|
||||||
nextSlotAssignmentsByTeam = {
|
nextSlotAssignmentsByTeam = {
|
||||||
...nextSlotAssignmentsByTeam,
|
...nextSlotAssignmentsByTeam,
|
||||||
|
|
@ -2438,6 +2498,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
||||||
|
if (nextGraphLayoutSessionByTeam !== state.graphLayoutSessionByTeam) {
|
||||||
|
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
|
||||||
|
}
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -2475,6 +2538,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
[stableOwnerId]: assignment,
|
[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,
|
...state.slotAssignmentsByTeam,
|
||||||
[teamName]: nextAssignments,
|
[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,
|
[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) => {
|
clearTeamGraphSlotAssignments: (teamName) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!teamName) {
|
if (!teamName) {
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
|
||||||
if (
|
if (
|
||||||
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
|
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 {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
slotAssignmentsByTeam: {},
|
slotAssignmentsByTeam: {},
|
||||||
|
graphLayoutSessionByTeam: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(teamName in state.slotAssignmentsByTeam)) {
|
if (
|
||||||
|
!(teamName in state.slotAssignmentsByTeam) &&
|
||||||
|
!(teamName in state.graphLayoutSessionByTeam)
|
||||||
|
) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||||
|
const nextGraphLayoutSessionByTeam = { ...state.graphLayoutSessionByTeam };
|
||||||
delete nextAssignmentsByTeam[teamName];
|
delete nextAssignmentsByTeam[teamName];
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
delete nextGraphLayoutSessionByTeam[teamName];
|
||||||
return {
|
return {
|
||||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||||
|
graphLayoutSessionByTeam: nextGraphLayoutSessionByTeam,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -2613,36 +2703,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamData = selectTeamDataForName(state, teamName);
|
const teamData = selectTeamDataForName(state, teamName);
|
||||||
const defaultAssignments = teamData
|
const defaultSeed = teamData
|
||||||
? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members)
|
? buildTeamGraphDefaultLayoutSeed(teamData.members, teamData.config.members ?? [])
|
||||||
: {};
|
: { orderedVisibleOwnerIds: [], signature: null, assignments: {} };
|
||||||
const currentAssignments = state.slotAssignmentsByTeam[teamName];
|
const currentAssignments = state.slotAssignmentsByTeam[teamName];
|
||||||
const hasCurrentAssignments =
|
const currentSession = state.graphLayoutSessionByTeam[teamName];
|
||||||
currentAssignments && Object.keys(currentAssignments).length > 0;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) &&
|
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultSeed.assignments) &&
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)
|
currentSession?.mode === 'default' &&
|
||||||
|
currentSession.signature === defaultSeed.signature
|
||||||
) {
|
) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||||
if (Object.keys(defaultAssignments).length === 0) {
|
if (Object.keys(defaultSeed.assignments).length === 0) {
|
||||||
delete nextAssignmentsByTeam[teamName];
|
delete nextAssignmentsByTeam[teamName];
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
|
||||||
} else {
|
} else {
|
||||||
nextAssignmentsByTeam[teamName] = defaultAssignments;
|
nextAssignmentsByTeam[teamName] = defaultSeed.assignments;
|
||||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||||
|
graphLayoutSessionByTeam: {
|
||||||
|
...state.graphLayoutSessionByTeam,
|
||||||
|
[teamName]: {
|
||||||
|
mode: 'default',
|
||||||
|
signature: defaultSeed.signature,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,302 @@
|
||||||
export {
|
import type {
|
||||||
getTeamModelUiDisabledReason,
|
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_MODEL,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
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_MODEL,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
isTeamModelUiDisabled,
|
|
||||||
normalizeTeamModelForUi,
|
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||||
} from './teamModelCatalog';
|
} 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 SupportedProviderId = CliProviderId | TeamProviderId;
|
||||||
|
type RuntimeAwareProviderStatus = Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>;
|
||||||
|
|
||||||
export interface TeamProviderModelOption {
|
export interface TeamProviderModelOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -10,10 +23,12 @@ export interface TeamProviderModelOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled';
|
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 =
|
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.';
|
'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 =
|
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.';
|
'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,
|
uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
},
|
},
|
||||||
{ value: 'gpt-5.2', label: 'GPT-5.2', badgeLabel: '5.2' },
|
{ 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',
|
value: 'gpt-5.1-codex-mini',
|
||||||
label: '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(
|
export function getVisibleTeamProviderModels(
|
||||||
providerId: SupportedProviderId,
|
providerId: SupportedProviderId,
|
||||||
models: readonly string[]
|
models: readonly string[],
|
||||||
|
providerStatus?: RuntimeAwareProviderStatus | null
|
||||||
): string[] {
|
): string[] {
|
||||||
return sortTeamProviderModels(providerId, models).filter(
|
return sortTeamProviderModels(
|
||||||
(model) => !isTeamModelUiDisabled(providerId, model)
|
providerId,
|
||||||
);
|
filterVisibleProviderRuntimeModels(providerId, models)
|
||||||
|
).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTeamModelUiDisabledReason(
|
export function getTeamModelUiDisabledReason(
|
||||||
|
|
@ -213,6 +262,26 @@ export function getTeamModelUiDisabledReason(
|
||||||
return getKnownTeamProviderModelOption(providerId, model)?.uiDisabledReason ?? null;
|
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(
|
export function isTeamModelUiDisabled(
|
||||||
providerId: SupportedProviderId | undefined,
|
providerId: SupportedProviderId | undefined,
|
||||||
model: string | undefined
|
model: string | undefined
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ interface ProvisioningMemberLike {
|
||||||
removedAt?: number;
|
removedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FailedSpawnDetail {
|
||||||
|
name: string;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTIVE_PROVISIONING_STATES = new Set([
|
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||||
'validating',
|
'validating',
|
||||||
'spawning',
|
'spawning',
|
||||||
|
|
@ -30,6 +35,73 @@ const ACTIVE_PROVISIONING_STATES = new Set([
|
||||||
'verifying',
|
'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 {
|
export interface TeamProvisioningPresentation {
|
||||||
progress: TeamProvisioningProgress;
|
progress: TeamProvisioningProgress;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
@ -103,6 +175,9 @@ export function buildTeamProvisioningPresentation({
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
});
|
});
|
||||||
|
const failedSpawnDetails = getFailedSpawnDetails(memberSpawnStatuses);
|
||||||
|
const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
|
||||||
|
const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
|
||||||
|
|
||||||
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
||||||
getLaunchJoinState({
|
getLaunchJoinState({
|
||||||
|
|
@ -139,7 +214,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
hasMembersStillJoining,
|
hasMembersStillJoining,
|
||||||
remainingJoinCount,
|
remainingJoinCount,
|
||||||
panelTitle: 'Launch failed',
|
panelTitle: 'Launch failed',
|
||||||
panelMessage: progress.error ?? null,
|
panelMessage: progress.error ?? failedSpawnPanelMessage ?? null,
|
||||||
panelTone: 'error',
|
panelTone: 'error',
|
||||||
defaultLiveOutputOpen: true,
|
defaultLiveOutputOpen: true,
|
||||||
compactTitle: 'Launch failed',
|
compactTitle: 'Launch failed',
|
||||||
|
|
@ -155,7 +230,8 @@ export function buildTeamProvisioningPresentation({
|
||||||
: `${remainingJoinCount} teammates still joining`;
|
: `${remainingJoinCount} teammates still joining`;
|
||||||
const readyCompactDetail =
|
const readyCompactDetail =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`
|
? (failedSpawnCompactDetail ??
|
||||||
|
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||||
: hasMembersStillJoining
|
: hasMembersStillJoining
|
||||||
? joiningPhrase
|
? joiningPhrase
|
||||||
: expectedTeammateCount === 0
|
: expectedTeammateCount === 0
|
||||||
|
|
@ -163,7 +239,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
: `All ${expectedTeammateCount} teammates joined`;
|
: `All ${expectedTeammateCount} teammates joined`;
|
||||||
const readyDetailMessage =
|
const readyDetailMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? progress.message
|
? (failedSpawnPanelMessage ?? progress.message)
|
||||||
: expectedTeammateCount === 0
|
: expectedTeammateCount === 0
|
||||||
? 'Team provisioned - lead online'
|
? 'Team provisioned - lead online'
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
|
|
@ -233,15 +309,19 @@ export function buildTeamProvisioningPresentation({
|
||||||
hasMembersStillJoining,
|
hasMembersStillJoining,
|
||||||
remainingJoinCount,
|
remainingJoinCount,
|
||||||
panelTitle: 'Launching team',
|
panelTitle: 'Launching team',
|
||||||
panelMessage: progress.message,
|
panelMessage:
|
||||||
panelMessageSeverity: progress.messageSeverity,
|
failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message,
|
||||||
|
panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||||
defaultLiveOutputOpen: true,
|
defaultLiveOutputOpen: true,
|
||||||
compactTitle: 'Launching team',
|
compactTitle: 'Launching team',
|
||||||
compactDetail:
|
compactDetail:
|
||||||
expectedTeammateCount > 0 && progressStepIndex >= 2
|
failedSpawnCount > 0
|
||||||
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
? (failedSpawnCompactDetail ??
|
||||||
: progress.message,
|
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||||
compactTone: 'default',
|
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
||||||
|
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||||
|
: progress.message,
|
||||||
|
compactTone: failedSpawnCount > 0 ? 'warning' : 'default',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,9 @@ export interface TeamsAPI {
|
||||||
prepareProvisioning: (
|
prepareProvisioning: (
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
providerId?: TeamLaunchRequest['providerId'],
|
providerId?: TeamLaunchRequest['providerId'],
|
||||||
providerIds?: TeamLaunchRequest['providerId'][]
|
providerIds?: TeamLaunchRequest['providerId'][],
|
||||||
|
selectedModels?: string[],
|
||||||
|
limitContext?: boolean
|
||||||
) => Promise<TeamProvisioningPrepareResult>;
|
) => Promise<TeamProvisioningPrepareResult>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,19 @@ export interface CliExternalRuntimeDiagnostic {
|
||||||
detailMessage?: string | null;
|
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 {
|
export interface CliProviderStatus {
|
||||||
providerId: CliProviderId;
|
providerId: CliProviderId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -64,8 +77,10 @@ export interface CliProviderStatus {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
authMethod: string | null;
|
authMethod: string | null;
|
||||||
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
|
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
|
||||||
|
modelVerificationState?: 'idle' | 'verifying' | 'verified';
|
||||||
statusMessage?: string | null;
|
statusMessage?: string | null;
|
||||||
models: string[];
|
models: string[];
|
||||||
|
modelAvailability?: CliProviderModelAvailability[];
|
||||||
canLoginFromUi: boolean;
|
canLoginFromUi: boolean;
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: boolean;
|
teamLaunch: boolean;
|
||||||
|
|
@ -172,6 +187,8 @@ export interface CliInstallerAPI {
|
||||||
getStatus: () => Promise<CliInstallationStatus>;
|
getStatus: () => Promise<CliInstallationStatus>;
|
||||||
/** Get current runtime/auth status for a single provider */
|
/** Get current runtime/auth status for a single provider */
|
||||||
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
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. */
|
/** Start install/update flow. Progress sent via onProgress events. */
|
||||||
install: () => Promise<void>;
|
install: () => Promise<void>;
|
||||||
/** Invalidate cached status (forces fresh check on next getStatus) */
|
/** Invalidate cached status (forces fresh check on next getStatus) */
|
||||||
|
|
|
||||||
|
|
@ -1027,6 +1027,7 @@ export interface TeamCreateResponse {
|
||||||
export interface TeamProvisioningPrepareResult {
|
export interface TeamProvisioningPrepareResult {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
details?: string[];
|
||||||
warnings?: 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 {
|
import {
|
||||||
CliInstallerService,
|
CliInstallerService,
|
||||||
isVersionOlder,
|
isVersionOlder,
|
||||||
normalizeVersion,
|
normalizeVersion,
|
||||||
} from '@main/services/infrastructure/CliInstallerService';
|
} from '@main/services/infrastructure/CliInstallerService';
|
||||||
|
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
|
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -96,6 +105,13 @@ describe('CliInstallerService', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
realpathMock.mockReset();
|
realpathMock.mockReset();
|
||||||
realpathMock.mockImplementation(async (value: string) => value);
|
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();
|
service = new CliInstallerService();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -176,6 +192,146 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.installedVersion).toBe('2.1.101');
|
expect(status.installedVersion).toBe('2.1.101');
|
||||||
expect(status.authLoggedIn).toBe(true);
|
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', () => {
|
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('--resume');
|
||||||
expect(launchArgs).toContain(leadSessionId);
|
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 * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
|
|
||||||
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
ClaudeBinaryResolver: { resolve: vi.fn() },
|
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 () => {
|
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -537,4 +537,74 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
await Promise.resolve();
|
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';
|
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import {
|
import {
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
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,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
|
getAvailableTeamProviderModels,
|
||||||
|
getTeamModelSelectionError,
|
||||||
getTeamModelUiDisabledReason,
|
getTeamModelUiDisabledReason,
|
||||||
normalizeTeamModelForUi,
|
normalizeTeamModelForUi,
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
|
|
@ -22,10 +26,13 @@ describe('formatTeamModelSummary', () => {
|
||||||
expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('5.4 · Medium');
|
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(
|
expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe(
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON
|
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(
|
expect(getTeamModelUiDisabledReason('codex', 'gpt-5.3-codex-spark')).toBe(
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON
|
||||||
);
|
);
|
||||||
|
|
@ -33,10 +40,72 @@ describe('formatTeamModelSummary', () => {
|
||||||
expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull();
|
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', () => {
|
it('normalizes disabled Codex model selections back to default', () => {
|
||||||
expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe('');
|
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.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', true, 'anthropic')).toBe('opus');
|
||||||
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
||||||
expect(computeEffectiveTeamModel('opus[1m][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', () => {
|
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', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: unknown) => unknown) =>
|
useStore: (selector: (state: unknown) => unknown) => selector(storeState),
|
||||||
selector({
|
|
||||||
cliStatus: null,
|
|
||||||
appConfig: { general: { multimodelEnabled: true } },
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
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', () => {
|
describe('TeamModelSelector disabled Codex models', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.cliStatusLoading = true;
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
@ -98,37 +101,10 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('5.1 Codex Mini');
|
expect(host.textContent).toContain('Default');
|
||||||
expect(host.textContent).toContain('Disabled');
|
expect(host.textContent).toContain('Explicit models load from the current runtime');
|
||||||
expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON);
|
expect(host.textContent).not.toContain('5.1 Codex Mini');
|
||||||
|
expect(host.textContent).not.toContain('5.3 Codex Spark');
|
||||||
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);
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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 () => {
|
it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getPrimaryProvisioningFailureDetail,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
createInitialProviderChecks,
|
createInitialProviderChecks,
|
||||||
} from '@renderer/components/team/dialogs/ProvisioningProviderStatusList';
|
} from '@renderer/components/team/dialogs/ProvisioningProviderStatusList';
|
||||||
|
|
@ -35,4 +36,96 @@ describe('ProvisioningProviderStatusList', () => {
|
||||||
await Promise.resolve();
|
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();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const openButton = host.querySelector('button[aria-label="Open launch details"]');
|
const openButton = host.querySelector('button[aria-label]');
|
||||||
expect(openButton).not.toBeNull();
|
expect(openButton).not.toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,65 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
vi.useRealTimers();
|
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', () => {
|
it('creates a message particle for a new incoming message from the newest message set', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
const baseline = createBaseTeamData();
|
const baseline = createBaseTeamData();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ vi.mock('@renderer/api', () => ({
|
||||||
cliInstaller: {
|
cliInstaller: {
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
getProviderStatus: vi.fn(),
|
getProviderStatus: vi.fn(),
|
||||||
|
verifyProviderModels: vi.fn(),
|
||||||
invalidateStatus: vi.fn(),
|
invalidateStatus: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
onProgress: vi.fn(() => vi.fn()),
|
onProgress: vi.fn(() => vi.fn()),
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,10 @@ describe('teamSlice actions', () => {
|
||||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
'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', () => {
|
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
|
||||||
|
|
@ -313,6 +317,33 @@ describe('teamSlice actions', () => {
|
||||||
{ name: 'jack', agentId: 'agent-jack' },
|
{ 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({
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
|
@ -335,8 +366,8 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
'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();
|
const store = createSliceStore();
|
||||||
|
|
||||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||||
|
|
@ -426,12 +484,18 @@ describe('teamSlice actions', () => {
|
||||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||||
{ name: 'alice', agentId: 'agent-alice' },
|
{ name: 'alice', agentId: 'agent-alice' },
|
||||||
{ name: 'bob', agentId: 'agent-bob' },
|
{ name: 'bob', agentId: 'agent-bob' },
|
||||||
|
{ name: 'tom', agentId: 'agent-tom' },
|
||||||
|
{ name: 'jack', agentId: 'agent-jack' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
|
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
|
||||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
'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', () => {
|
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
||||||
|
|
@ -466,7 +530,7 @@ describe('teamSlice actions', () => {
|
||||||
'my-team',
|
'my-team',
|
||||||
'agent-alice',
|
'agent-alice',
|
||||||
{ ringIndex: 0, sectorIndex: 2 },
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
'agent-tom',
|
'agent-jack',
|
||||||
{ ringIndex: 0, sectorIndex: 0 }
|
{ ringIndex: 0, sectorIndex: 0 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -475,8 +539,12 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
'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.4-mini',
|
||||||
'gpt-5.3-codex',
|
'gpt-5.3-codex',
|
||||||
'gpt-5.2',
|
'gpt-5.2',
|
||||||
'gpt-5.2-codex',
|
|
||||||
'gpt-5.1-codex-max',
|
'gpt-5.1-codex-max',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,128 @@ describe('buildTeamProvisioningPresentation', () => {
|
||||||
expect(presentation?.compactTitle).toBe('Team launched');
|
expect(presentation?.compactTitle).toBe('Team launched');
|
||||||
expect(presentation?.compactDetail).toBe('Lead online');
|
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