feat(team): enhance team provisioning with runtime model handling
- Added support for live runtime model metadata in team provisioning. - Implemented functions to extract and manage CLI flag values for team members. - Updated member specifications to include effective models based on provider defaults. - Enhanced UI dialogs to check selected providers in parallel, improving responsiveness. - Added tests for handling model unavailability during team bootstrap and launch processes.
This commit is contained in:
parent
53c4204d89
commit
ece2991f96
11 changed files with 719 additions and 223 deletions
|
|
@ -783,6 +783,36 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
|
|||
};
|
||||
}
|
||||
|
||||
interface LiveTeamAgentRuntimeMetadata {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
const unwrapped = trimmed.slice(1, -1).trim();
|
||||
return unwrapped.length > 0 ? unwrapped : undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractCliFlagValue(command: string, flagName: string): string | undefined {
|
||||
const escapedFlag = flagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = new RegExp(`(?:^|\\s)${escapedFlag}\\s+("([^"]*)"|'([^']*)'|([^\\s]+))`).exec(
|
||||
command
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return stripWrappedCliFlagValue(match[2] ?? match[3] ?? match[4] ?? match[1]);
|
||||
}
|
||||
|
||||
export function shouldAcceptDeterministicBootstrapEvent(params: {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
|
|
@ -911,11 +941,17 @@ function buildEffectiveTeamMemberSpec(
|
|||
): TeamMemberInput {
|
||||
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
|
||||
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
|
||||
const model = member.model?.trim() || defaults.model?.trim() || undefined;
|
||||
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
|
||||
const model =
|
||||
member.model?.trim() ||
|
||||
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||
? defaults.model?.trim()
|
||||
: undefined) ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
...member,
|
||||
providerId: memberProviderId ?? defaultProviderId ?? 'anthropic',
|
||||
providerId: effectiveProviderId,
|
||||
model,
|
||||
effort: member.effort ?? defaults.effort,
|
||||
};
|
||||
|
|
@ -3396,16 +3432,19 @@ export class TeamProvisioningService {
|
|||
}> {
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) {
|
||||
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({
|
||||
statuses,
|
||||
runId: null,
|
||||
teamLaunchState: snapshot?.teamLaunchState,
|
||||
launchPhase: snapshot?.launchPhase,
|
||||
expectedMembers: snapshot?.expectedMembers,
|
||||
updatedAt: snapshot?.updatedAt,
|
||||
summary: snapshot?.summary,
|
||||
source: snapshot ? 'persisted' : 'persisted',
|
||||
}));
|
||||
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => {
|
||||
this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
|
||||
return {
|
||||
statuses,
|
||||
runId: null,
|
||||
teamLaunchState: snapshot?.teamLaunchState,
|
||||
launchPhase: snapshot?.launchPhase,
|
||||
expectedMembers: snapshot?.expectedMembers,
|
||||
updatedAt: snapshot?.updatedAt,
|
||||
summary: snapshot?.summary,
|
||||
source: snapshot ? 'persisted' : 'persisted',
|
||||
};
|
||||
});
|
||||
}
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
|
|
@ -3426,6 +3465,7 @@ export class TeamProvisioningService {
|
|||
});
|
||||
const snapshot = persisted ?? liveSnapshot;
|
||||
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
||||
this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
|
||||
return {
|
||||
statuses,
|
||||
runId,
|
||||
|
|
@ -3552,7 +3592,6 @@ export class TeamProvisioningService {
|
|||
!current ||
|
||||
current.launchState === 'failed_to_start' ||
|
||||
current.launchState === 'confirmed_alive' ||
|
||||
current.runtimeAlive === true ||
|
||||
current.hardFailure === true ||
|
||||
current.agentToolAccepted !== true
|
||||
) {
|
||||
|
|
@ -3902,6 +3941,89 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
}
|
||||
|
||||
private async materializeEffectiveTeamMemberSpecs(params: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
members: TeamCreateRequest['members'];
|
||||
defaults: {
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamCreateRequest['effort'];
|
||||
};
|
||||
primaryProviderId?: TeamProviderId;
|
||||
primaryEnv?: ProvisioningEnvResolution;
|
||||
limitContext?: boolean;
|
||||
}): Promise<TeamCreateRequest['members']> {
|
||||
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
|
||||
const defaultModelByProvider = new Map<TeamProviderId, Promise<string>>();
|
||||
const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId);
|
||||
|
||||
const getProvisioningEnv = (providerId: TeamProviderId): Promise<ProvisioningEnvResolution> => {
|
||||
if (normalizedPrimaryProviderId === providerId && params.primaryEnv != null) {
|
||||
return Promise.resolve(params.primaryEnv);
|
||||
}
|
||||
|
||||
const cached = envByProvider.get(providerId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const created = this.buildProvisioningEnv(providerId);
|
||||
envByProvider.set(providerId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const getResolvedDefaultModel = (providerId: TeamProviderId): Promise<string> => {
|
||||
const cached = defaultModelByProvider.get(providerId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const created = (async () => {
|
||||
const envResolution = await getProvisioningEnv(providerId);
|
||||
if (envResolution.warning) {
|
||||
throw new Error(envResolution.warning);
|
||||
}
|
||||
|
||||
const resolvedDefaultModel = await this.resolveProviderDefaultModel(
|
||||
params.claudePath,
|
||||
params.cwd,
|
||||
providerId,
|
||||
envResolution.env,
|
||||
params.limitContext === true
|
||||
);
|
||||
const normalized = resolvedDefaultModel?.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(
|
||||
`Could not resolve the runtime default model for ${providerLabel} teammates. Select an explicit model and retry.`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
})();
|
||||
|
||||
defaultModelByProvider.set(providerId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const effectiveMembers: TeamCreateRequest['members'] = [];
|
||||
for (const member of params.members) {
|
||||
const effectiveMember = buildEffectiveTeamMemberSpec(member, params.defaults);
|
||||
const providerId = normalizeTeamMemberProviderId(effectiveMember.providerId) ?? 'anthropic';
|
||||
if (providerId === 'anthropic' || effectiveMember.model?.trim()) {
|
||||
effectiveMembers.push(effectiveMember);
|
||||
continue;
|
||||
}
|
||||
|
||||
effectiveMembers.push({
|
||||
...effectiveMember,
|
||||
model: await getResolvedDefaultModel(providerId),
|
||||
});
|
||||
}
|
||||
|
||||
return effectiveMembers;
|
||||
}
|
||||
|
||||
private getFreshCachedProbeResult(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId | undefined
|
||||
|
|
@ -4756,10 +4878,23 @@ export class TeamProvisioningService {
|
|||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||
}
|
||||
|
||||
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, {
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||
claudePath,
|
||||
cwd: request.cwd,
|
||||
members: request.members,
|
||||
defaults: {
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
},
|
||||
primaryProviderId: request.providerId,
|
||||
primaryEnv: provisioningEnv,
|
||||
limitContext: request.limitContext,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
const startedAt = nowIso();
|
||||
|
|
@ -4864,14 +4999,6 @@ export class TeamProvisioningService {
|
|||
const initialUserPrompt = request.prompt?.trim() ?? '';
|
||||
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
warning: envWarning,
|
||||
} = await this.buildProvisioningEnv(request.providerId);
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||
if (teammateModeDecision.forceProcessTeammates) {
|
||||
|
|
@ -4963,10 +5090,16 @@ export class TeamProvisioningService {
|
|||
});
|
||||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
request.members.map((m) => ({
|
||||
effectiveMemberSpecs.map((m) => ({
|
||||
name: m.name.trim(),
|
||||
role: m.role?.trim() || undefined,
|
||||
workflow: m.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||
model: m.model?.trim() || undefined,
|
||||
effort:
|
||||
m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
|
||||
? m.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose' as const,
|
||||
color: getMemberColorByName(m.name.trim()),
|
||||
joinedAt: Date.now(),
|
||||
|
|
@ -5300,16 +5433,30 @@ export class TeamProvisioningService {
|
|||
const runId = randomUUID();
|
||||
const startedAt = nowIso();
|
||||
|
||||
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, {
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
|
||||
const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||
claudePath,
|
||||
cwd: request.cwd,
|
||||
members: expectedMemberSpecs,
|
||||
defaults: {
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
},
|
||||
primaryProviderId: request.providerId,
|
||||
primaryEnv: provisioningEnv,
|
||||
limitContext: request.limitContext,
|
||||
});
|
||||
|
||||
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
||||
const syntheticRequest: TeamCreateRequest = {
|
||||
teamName: request.teamName,
|
||||
members: expectedMemberSpecs,
|
||||
members: effectiveMemberSpecs,
|
||||
cwd: request.cwd,
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
|
|
@ -5448,14 +5595,6 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const promptSize = getPromptSizeSummary(prompt);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
warning: envWarning,
|
||||
} = await this.buildProvisioningEnv(request.providerId);
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||
if (teammateModeDecision.forceProcessTeammates) {
|
||||
|
|
@ -6842,12 +6981,34 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean {
|
||||
return this.getLiveTeamAgentNames(teamName).has(memberName);
|
||||
return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName);
|
||||
}
|
||||
|
||||
private attachLiveRuntimeMetadataToStatuses(
|
||||
teamName: string,
|
||||
statuses: Record<string, MemberSpawnStatusEntry>
|
||||
): void {
|
||||
for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) {
|
||||
const current = statuses[memberName];
|
||||
if (!current || !metadata.model) {
|
||||
continue;
|
||||
}
|
||||
statuses[memberName] = {
|
||||
...current,
|
||||
runtimeModel: metadata.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getLiveTeamAgentNames(teamName: string): Set<string> {
|
||||
return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys());
|
||||
}
|
||||
|
||||
private getLiveTeamAgentRuntimeMetadata(
|
||||
teamName: string
|
||||
): Map<string, LiveTeamAgentRuntimeMetadata> {
|
||||
if (process.platform === 'win32') {
|
||||
return new Set();
|
||||
return new Map();
|
||||
}
|
||||
|
||||
let output = '';
|
||||
|
|
@ -6857,11 +7018,11 @@ export class TeamProvisioningService {
|
|||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch {
|
||||
return new Set();
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const teamMarker = `--team-name ${teamName}`;
|
||||
const names = new Set<string>();
|
||||
const metadataByAgent = new Map<string, LiveTeamAgentRuntimeMetadata>();
|
||||
for (const line of output.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.includes(teamMarker)) continue;
|
||||
|
|
@ -6869,10 +7030,13 @@ export class TeamProvisioningService {
|
|||
if (!match) continue;
|
||||
const agentName = match[1]?.trim();
|
||||
if (agentName) {
|
||||
names.add(agentName);
|
||||
const model = extractCliFlagValue(trimmed, '--model');
|
||||
metadataByAgent.set(agentName, {
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return names;
|
||||
return metadataByAgent;
|
||||
}
|
||||
|
||||
private async clearPersistedLaunchState(teamName: string): Promise<void> {
|
||||
|
|
@ -7107,7 +7271,7 @@ export class TeamProvisioningService {
|
|||
current.hardFailure = false;
|
||||
current.hardFailureReason = undefined;
|
||||
}
|
||||
if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) {
|
||||
if (!current.bootstrapConfirmed && !current.hardFailure) {
|
||||
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
|
||||
teamName,
|
||||
expected,
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ export const CreateTeamDialog = ({
|
|||
selectedMemberProviders
|
||||
);
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Checking selected providers...');
|
||||
setPrepareMessage('Checking selected providers in parallel...');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(initialChecks);
|
||||
|
||||
|
|
@ -601,118 +601,128 @@ export const CreateTeamDialog = ({
|
|||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
let checks = initialChecks;
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
|
||||
try {
|
||||
for (const providerId of selectedMemberProviders) {
|
||||
const selectedModelChecks = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
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;
|
||||
}
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const memberProviderId =
|
||||
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
||||
if (memberProviderId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const memberModel = member.model?.trim();
|
||||
if (memberModel) {
|
||||
next.add(memberModel);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
}
|
||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||
}
|
||||
return Array.from(next);
|
||||
})();
|
||||
const backendSummary =
|
||||
runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
cachedModelResultsById,
|
||||
});
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
||||
backendSummary,
|
||||
details: cachedSnapshot.details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(
|
||||
selectedModelChecks.length > 0
|
||||
? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
|
||||
: `Checking ${getProviderLabel(providerId)} runtime...`
|
||||
);
|
||||
}
|
||||
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,
|
||||
});
|
||||
return {
|
||||
providerId,
|
||||
selectedModelChecks,
|
||||
backendSummary,
|
||||
cacheKey,
|
||||
cachedModelResultsById,
|
||||
cachedSnapshot,
|
||||
};
|
||||
});
|
||||
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById,
|
||||
onModelProgress: ({ details, completedCount, totalCount }) => {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(
|
||||
`Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
|
||||
);
|
||||
}
|
||||
},
|
||||
try {
|
||||
for (const plan of providerPlans) {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
});
|
||||
if (prepResult.warnings.length > 0) {
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { ...plan, prepResult };
|
||||
})
|
||||
);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
for (const plan of providerResults) {
|
||||
if (plan.prepResult.warnings.length > 0) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(providerId)}: ${warning}`
|
||||
...plan.prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (prepResult.status === 'failed') {
|
||||
if (plan.prepResult.status === 'failed') {
|
||||
anyFailure = true;
|
||||
} else if (prepResult.status === 'notes') {
|
||||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: prepResult.status,
|
||||
backendSummary,
|
||||
details: prepResult.details,
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
plan.prepResult.modelResultsById
|
||||
);
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
|
|
|
|||
|
|
@ -920,82 +920,92 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedMemberProviders
|
||||
);
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Checking selected providers...');
|
||||
setPrepareMessage('Checking selected providers in parallel...');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(initialChecks);
|
||||
|
||||
void (async () => {
|
||||
let checks = initialChecks;
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
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,
|
||||
});
|
||||
return {
|
||||
providerId,
|
||||
selectedModelChecks,
|
||||
backendSummary,
|
||||
cacheKey,
|
||||
cachedModelResultsById,
|
||||
cachedSnapshot,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
for (const providerId of selectedMemberProviders) {
|
||||
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
cachedModelResultsById,
|
||||
for (const plan of providerPlans) {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
});
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
||||
backendSummary,
|
||||
details: cachedSnapshot.details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(
|
||||
selectedModelChecks.length > 0
|
||||
? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
|
||||
: `Checking ${getProviderLabel(providerId)} runtime...`
|
||||
);
|
||||
}
|
||||
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById,
|
||||
onModelProgress: ({ details, completedCount, totalCount }) => {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(
|
||||
`Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (prepResult.warnings.length > 0) {
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { ...plan, prepResult };
|
||||
})
|
||||
);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
for (const plan of providerResults) {
|
||||
if (plan.prepResult.warnings.length > 0) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
|
||||
...plan.prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (prepResult.status === 'failed') {
|
||||
if (plan.prepResult.status === 'failed') {
|
||||
anyFailure = true;
|
||||
} else if (prepResult.status === 'notes') {
|
||||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: prepResult.status,
|
||||
backendSummary,
|
||||
details: prepResult.details,
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ export const MemberCard = ({
|
|||
!isRemoved &&
|
||||
presenceLabel === 'starting' &&
|
||||
spawnLaunchState !== 'failed_to_start' &&
|
||||
!activityTask;
|
||||
!activityTask &&
|
||||
!runtimeSummary;
|
||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatTeamModelSummary,
|
||||
getTeamEffortLabel,
|
||||
getTeamModelLabel,
|
||||
getTeamProviderLabel,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
|
|
@ -152,6 +147,7 @@ function areMemberSpawnStatusesEquivalent(
|
|||
leftEntry.launchState !== rightEntry.launchState ||
|
||||
leftEntry.error !== rightEntry.error ||
|
||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
||||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
|
||||
) {
|
||||
return false;
|
||||
|
|
@ -242,12 +238,11 @@ export const MemberList = memo(function MemberList({
|
|||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(member: ResolvedTeamMember): string | undefined => {
|
||||
const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
||||
const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||
const effectiveEffort = member.effort ?? launchParams?.effort;
|
||||
|
||||
return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
|
||||
(
|
||||
member: ResolvedTeamMember,
|
||||
spawnEntry: MemberSpawnStatusEntry | undefined
|
||||
): string | undefined => {
|
||||
return resolveMemberRuntimeSummary(member, launchParams, spawnEntry);
|
||||
},
|
||||
[launchParams]
|
||||
);
|
||||
|
|
@ -293,7 +288,7 @@ export const MemberList = memo(function MemberList({
|
|||
reviewTask={isRemoved ? null : reviewTask}
|
||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
||||
runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ function areMemberSpawnStatusEntriesEqual(
|
|||
left.error === right.error &&
|
||||
left.livenessSource === right.livenessSource &&
|
||||
left.runtimeAlive === right.runtimeAlive &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||
left.hardFailure === right.hardFailure
|
||||
);
|
||||
|
|
|
|||
41
src/renderer/utils/memberRuntimeSummary.ts
Normal file
41
src/renderer/utils/memberRuntimeSummary.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types';
|
||||
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||
|
||||
function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean {
|
||||
if (!spawnEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
spawnEntry.launchState === 'starting' ||
|
||||
spawnEntry.launchState === 'runtime_pending_bootstrap' ||
|
||||
spawnEntry.status === 'waiting' ||
|
||||
spawnEntry.status === 'spawning'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMemberRuntimeSummary(
|
||||
member: ResolvedTeamMember,
|
||||
launchParams: TeamLaunchParams | undefined,
|
||||
spawnEntry: MemberSpawnStatusEntry | undefined
|
||||
): string | undefined {
|
||||
const configuredProvider: TeamProviderId =
|
||||
member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
||||
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||
const configuredEffort = member.effort ?? launchParams?.effort;
|
||||
const runtimeModel = spawnEntry?.runtimeModel?.trim();
|
||||
|
||||
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
||||
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
||||
return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
|
||||
}
|
||||
|
||||
if (isMemberLaunchPending(spawnEntry)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
|
||||
}
|
||||
|
|
@ -908,6 +908,8 @@ export interface MemberSpawnStatusEntry {
|
|||
firstSpawnAcceptedAt?: string;
|
||||
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
|
||||
lastHeartbeatAt?: string;
|
||||
/** Live runtime model observed from the teammate process, when available. */
|
||||
runtimeModel?: string;
|
||||
/** ISO timestamp of the last status change. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -752,7 +752,7 @@ describe('TeamProvisioningService', () => {
|
|||
expect(result.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
||||
it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
||||
it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-live-bootstrap-model-unavailable';
|
||||
const leadSessionId = 'lead-session';
|
||||
|
|
@ -816,8 +816,8 @@ describe('TeamProvisioningService', () => {
|
|||
launchState: 'runtime_pending_bootstrap',
|
||||
error: undefined,
|
||||
updatedAt: acceptedAt,
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
|
|
@ -846,4 +846,77 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available');
|
||||
});
|
||||
|
||||
it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-persisted-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']);
|
||||
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, `${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();
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
expect(result.statuses.jack).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: true,
|
||||
});
|
||||
expect(result.statuses.jack?.error).toContain('requested model is not available');
|
||||
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
||||
expect(result.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,6 +276,76 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
const { child } = createFakeChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { PATH: '/usr/bin' },
|
||||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
}));
|
||||
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName: 'codex-default-team',
|
||||
cwd: process.cwd(),
|
||||
providerId: 'codex',
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const bootstrapSpec = extractBootstrapSpec();
|
||||
expect(bootstrapSpec.members).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
}),
|
||||
]);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
vi.mocked(spawnCli).mockReset();
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { PATH: '/usr/bin' },
|
||||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
}));
|
||||
(svc as any).resolveProviderDefaultModel = vi.fn(async () => null);
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
|
||||
await expect(
|
||||
svc.createTeam(
|
||||
{
|
||||
teamName: 'codex-default-missing',
|
||||
cwd: process.cwd(),
|
||||
providerId: 'codex',
|
||||
members: [{ name: 'alice', providerId: 'codex' }],
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.'
|
||||
);
|
||||
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
|
||||
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
||||
name: 'alice',
|
||||
|
|
@ -429,4 +499,67 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => {
|
||||
const teamName = 'codex-default-launch';
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
const { child } = createFakeChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { PATH: '/usr/bin' },
|
||||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
}));
|
||||
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||
source: 'config-fallback',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
|
||||
const { runId } = await svc.launchTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: process.cwd(),
|
||||
providerId: 'codex',
|
||||
clearContext: true,
|
||||
} as any,
|
||||
() => {}
|
||||
);
|
||||
|
||||
const bootstrapSpec = extractBootstrapSpec();
|
||||
expect(bootstrapSpec.members).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
}),
|
||||
]);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
test/renderer/utils/memberRuntimeSummary.test.ts
Normal file
66
test/renderer/utils/memberRuntimeSummary.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
|
||||
import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
function createMember(overrides: Partial<ResolvedTeamMember> = {}): ResolvedTeamMember {
|
||||
return {
|
||||
name: 'alice',
|
||||
agentId: 'alice@test-team',
|
||||
agentType: 'general-purpose',
|
||||
role: 'developer',
|
||||
providerId: 'codex',
|
||||
effort: 'medium',
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
color: 'blue',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSpawnEntry(overrides: Partial<MemberSpawnStatusEntry> = {}): MemberSpawnStatusEntry {
|
||||
return {
|
||||
status: 'waiting',
|
||||
launchState: 'starting',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
updatedAt: '2026-04-16T17:10:48.646Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveMemberRuntimeSummary', () => {
|
||||
it('shows the live runtime model for loading members when available', () => {
|
||||
const member = createMember();
|
||||
const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-6', runtimeAlive: true });
|
||||
|
||||
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe(
|
||||
'Anthropic · Opus 4.6 · Medium'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the loading skeleton when a pending member has no live runtime model yet', () => {
|
||||
const member = createMember();
|
||||
const spawnEntry = createSpawnEntry();
|
||||
|
||||
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the live runtime model as a fallback when config has no explicit model', () => {
|
||||
const member = createMember({ providerId: 'codex', model: undefined });
|
||||
const spawnEntry = createSpawnEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
runtimeModel: 'gpt-5.4-mini',
|
||||
});
|
||||
|
||||
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue