From ece2991f965cb9c54674342b1ec7c937b6536135 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:02:33 +0300 Subject: [PATCH] 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. --- .../services/team/TeamProvisioningService.ts | 256 ++++++++++++++---- .../team/dialogs/CreateTeamDialog.tsx | 208 +++++++------- .../team/dialogs/LaunchTeamDialog.tsx | 130 +++++---- .../components/team/members/MemberCard.tsx | 3 +- .../components/team/members/MemberList.tsx | 23 +- src/renderer/store/slices/teamSlice.ts | 1 + src/renderer/utils/memberRuntimeSummary.ts | 41 +++ src/shared/types/team.ts | 2 + .../team/TeamProvisioningService.test.ts | 79 +++++- .../TeamProvisioningServicePrompts.test.ts | 133 +++++++++ .../utils/memberRuntimeSummary.test.ts | 66 +++++ 11 files changed, 719 insertions(+), 223 deletions(-) create mode 100644 src/renderer/utils/memberRuntimeSummary.ts create mode 100644 test/renderer/utils/memberRuntimeSummary.test.ts diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 18b4de9f..9ace3693 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + const envByProvider = new Map>(); + const defaultModelByProvider = new Map>(); + const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId); + + const getProvisioningEnv = (providerId: TeamProviderId): Promise => { + 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 => { + 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; - 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; - 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 + ): 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 { + return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); + } + + private getLiveTeamAgentRuntimeMetadata( + teamName: string + ): Map { 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(); + const metadataByAgent = new Map(); 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 { @@ -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, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0dd81283..1a3037f6 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(); - 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(); + 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 = diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 16d8993b..fb9b0a30 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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 = diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3937e09f..bcf7ebc2 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 && diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index c19f4b10..58db6a84 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 549cad1f..8060f2f1 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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 ); diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts new file mode 100644 index 00000000..937a4f0f --- /dev/null +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -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); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 1d970668..bbdaa950 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 659d5d68..889c74c8 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 8ceb6d05..7eef52f4 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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); + }); }); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts new file mode 100644 index 00000000..dd8ab9b5 --- /dev/null +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -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 { + 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 { + 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'); + }); +});