diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2801d6..a28119c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- Version check: stable-channel users now see an informational notice when a newer preview release exists above the current stable version, without triggering the update-required banner. - `setup requirements` Global Administrator path: when the well-known CLI client app is not found in a new tenant, Global Admins are prompted to create the app and grant admin consent automatically (enter an app ID or type `C` to create). - `--authmode obo|s2s|both` option on `setup all` — controls how the agent identity service principal receives permissions: - `obo` (default): principal-scoped delegated grants (`consentType: "Principal"`); no Global Administrator required. @@ -38,6 +39,10 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - Messaging endpoint row added to `a365 setup all` summary output, with "registered"/"reused"/"skipped (non-M365)"/"manual config required"/"failed" states. When registration can't complete, the summary surfaces an "Action Required" entry with the Teams Developer Portal URL so the user knows exactly what to do next. - Defensive fallback when the server rejects the new request with a known contract-mismatch signature — the CLI logs `"Automated messaging endpoint registration is not available for this tenant yet. You'll need to configure it manually."` and directs the user to the Teams Developer Portal. Same user-facing path is reused when registration fails because the signed-in user is not a blueprint owner. +### Fixed +- `setup all --agent-name` re-runs no longer create a duplicate agent registration: the CLI now reads `agentRegistrationId` from `a365.generated.config.json` (when present) and checks for an existing registration before posting a new one. +- `setup all` now skips agent registration with a clear warning when the agent identity ID is not available, instead of silently sending an invalid request. Retry with `a365 setup all --agent-registration-only` once the identity is ready. + ### Removed - `a365 config` command family (`config init`, `config display`, `config permissions`) — replaced by `a365 setup all --agent-name` and `a365 setup permissions custom`. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 3ba09fa9..84304820 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -258,6 +258,28 @@ public static Command CreateCommand( else await WriteBootstrapConfigFileAsync(nonDwConfig, config.FullName, logger); } + + // Merge AgentRegistrationId from the existing generated config (if present) so + // re-running with --agent-name does not create a duplicate registration. Blueprint + // and identity are found by display name, but registration has no lookup endpoint — + // the stored ID is the only idempotency key available for the registration step. + var bootstrapGenPath = Path.Combine( + config.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + if (File.Exists(bootstrapGenPath) && string.IsNullOrWhiteSpace(nonDwConfig.AgentRegistrationId)) + { + try + { + var genConfig = await configService.LoadAsync(config.FullName, bootstrapGenPath); + if (!string.IsNullOrWhiteSpace(genConfig.AgentRegistrationId)) + nonDwConfig.AgentRegistrationId = genConfig.AgentRegistrationId; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not merge generated config in bootstrap mode; proceeding without stored registration ID."); + } + } } } else diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index a900330e..2a550fef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -445,6 +445,19 @@ private static async Task ExecuteAgentIdentityAndRegistrationAsync( ctx.Logger.LogInformation(""); ctx.Logger.LogInformation("Registering agent..."); + if (string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId)) + { + var registrationSkippedMessage = + "Agent registration failed: agent identity ID is not available. " + + "Ensure the agent identity was created successfully, then retry with: a365 setup all --agent-registration-only"; + ctx.Results.Warnings.Add(registrationSkippedMessage); + using (ctx.Logger.Indent()) + ctx.Logger.LogWarning(registrationSkippedMessage); + ctx.Results.AgentRegistrationFailed = true; + } + else + { + // If a registration ID is already stored, verify it still exists before skipping creation. string? registrationId = null; bool registrationAlreadyExisted = false; @@ -521,6 +534,8 @@ private static async Task ExecuteAgentIdentityAndRegistrationAsync( ctx.Logger.LogWarning("Agent registration failed via Graph copilot/agentRegistrations API."); } + } // end else (AgenticAppId present) + // Step 6.5: Messaging endpoint registration — --m365 gated; no-op for non-M365 agents. await AllSubcommand.ExecuteMessagingEndpointStepAsync(ctx); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs index e5be9467..d7c40d10 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs @@ -15,9 +15,10 @@ public record VersionCheckResult( bool UpdateAvailable, string? CurrentVersion, string? LatestVersion, - string? UpdateCommand); + string? UpdateCommand, + string? NewerPreviewVersion = null); /// /// On-disk cache envelope for the version check result, keyed by fetch timestamp. /// -public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion); +public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion, string? NewerPreviewVersion = null); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 75b5c1d0..e5341a3d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -105,6 +105,13 @@ await Task.WhenAll( startupLogger.LogWarning("To update, run: {Command}", result.UpdateCommand); startupLogger.LogWarning(""); } + else if (result.NewerPreviewVersion is not null) + { + startupLogger.LogInformation(""); + startupLogger.LogInformation("A preview release is also available: {Preview}", result.NewerPreviewVersion); + startupLogger.LogInformation("To try it: {Command}", Services.Internal.VersionCheckHelper.GetUpdateCommand(result.NewerPreviewVersion)); + startupLogger.LogInformation(""); + } } catch (OperationCanceledException) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs index caaddd89..df6b258f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs @@ -115,4 +115,54 @@ internal static string GetUpdateCommand(string version) ? $"{baseCommand} --prerelease" : baseCommand; } + + /// + /// Selects the primary latest version and an optional newer preview version from a list of + /// all NuGet versions, applying channel-aware filtering based on the current version. + /// + /// Stable users see only stable versions as their primary update target. If a newer preview + /// exists above the latest stable, it is returned separately as an informational nudge. + /// Preview users see the globally highest version (preview or stable) with no secondary nudge. + /// + /// + internal static (string? Primary, string? NewerPreview) SelectLatestVersions( + IEnumerable allVersions, string currentVersion) + { + bool currentIsPreview = currentVersion.Contains("preview", StringComparison.OrdinalIgnoreCase); + + var allParsed = allVersions + .Select(v => new { Original = v, Parsed = TryParseVersion(v) }) + .Where(v => v.Parsed != null) + .OrderByDescending(v => v.Parsed) + .ToList(); + + // Primary: stable-only for stable users; unrestricted for preview users + var primary = allParsed + .Where(v => currentIsPreview || !v.Original.Contains("preview", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault()?.Original; + + // Informational nudge: surface the latest preview when a stable user is already on the + // latest stable but a newer preview exists above it. + // Use base-version comparison (not TryParseVersion) so that a preview of the same base + // (e.g., "1.1.0-preview.50") is never treated as newer than its GA ("1.1.0"). + string? newerPreview = null; + if (!currentIsPreview && primary != null) + { + var latestPreview = allParsed + .Where(v => v.Original.Contains("preview", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault()?.Original; + + if (latestPreview != null) + { + var primaryBase = TryParseVersion(primary.Split('-')[0]); + var previewBase = TryParseVersion(latestPreview.Split('-')[0]); + // Only nudge when the preview's base version is strictly higher than the GA base. + // This prevents "1.1.0-preview.50" from appearing as newer than "1.1.0". + if (primaryBase != null && previewBase != null && previewBase > primaryBase) + newerPreview = latestPreview; + } + } + + return (primary, newerPreview); + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs index 1de90fd4..f6bbcf7f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs @@ -43,12 +43,12 @@ public async Task CheckForUpdatesAsync(CancellationToken can _logger.LogDebug("Checking for updates..."); - var latestVersion = GetCachedLatestVersion(); + var (latestVersion, newerPreviewVersion) = GetCachedLatestVersion(); if (latestVersion == null) { - latestVersion = await GetLatestVersionFromNuGetAsync(cancellationToken); + (latestVersion, newerPreviewVersion) = await GetLatestVersionFromNuGetAsync(cancellationToken); if (latestVersion != null) - SaveCache(new VersionCheckCache(DateTimeOffset.UtcNow, latestVersion)); + SaveCache(new VersionCheckCache(DateTimeOffset.UtcNow, latestVersion, newerPreviewVersion)); } if (latestVersion == null) @@ -65,7 +65,7 @@ public async Task CheckForUpdatesAsync(CancellationToken can _logger.LogDebug("Running latest version: {Current}", _currentVersion); return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion, - VersionCheckHelper.GetUpdateCommand(latestVersion)); + VersionCheckHelper.GetUpdateCommand(latestVersion), newerPreviewVersion); } catch (OperationCanceledException) { @@ -79,7 +79,7 @@ public async Task CheckForUpdatesAsync(CancellationToken can } } - private async Task GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken) + private async Task<(string? Primary, string? NewerPreview)> GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken) { try { @@ -89,7 +89,7 @@ public async Task CheckForUpdatesAsync(CancellationToken can if (!response.IsSuccessStatusCode) { _logger.LogDebug("NuGet API returned {StatusCode}", response.StatusCode); - return null; + return (null, null); } var content = await response.Content.ReadAsStringAsync(cancellationToken); @@ -99,22 +99,15 @@ public async Task CheckForUpdatesAsync(CancellationToken can if (versionResponse?.Versions == null || versionResponse.Versions.Length == 0) { _logger.LogDebug("No versions found in NuGet response"); - return null; + return (null, null); } - // Sort semantically — NuGet returns chronological order, but we sort to be safe - var sorted = versionResponse.Versions - .Select(v => new { Original = v, Parsed = VersionCheckHelper.TryParseVersion(v) }) - .Where(v => v.Parsed != null) - .OrderByDescending(v => v.Parsed) - .ToList(); - - return sorted.Count == 0 ? null : sorted[0].Original; + return VersionCheckHelper.SelectLatestVersions(versionResponse.Versions, _currentVersion); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Failed to query NuGet API"); - return null; + return (null, null); } } @@ -131,33 +124,33 @@ private bool IsNewerVersion(string current, string latest) } } - private string? GetCachedLatestVersion() + private (string? Primary, string? NewerPreview) GetCachedLatestVersion() { try { var path = GetCacheFilePath(); if (!File.Exists(path)) - return null; + return (null, null); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var cache = JsonSerializer.Deserialize(File.ReadAllText(path), options); if (cache == null) - return null; + return (null, null); if (DateTimeOffset.UtcNow - cache.CachedAt >= TimeSpan.FromHours(CacheTtlHours)) { _logger.LogDebug("Version cache expired (cached at {CachedAt})", cache.CachedAt); - return null; + return (null, null); } _logger.LogDebug("Using cached version {Version} (cached at {CachedAt})", cache.LatestVersion, cache.CachedAt); - return cache.LatestVersion; + return (cache.LatestVersion, cache.NewerPreviewVersion); } catch (Exception ex) { _logger.LogDebug(ex, "Could not load version cache"); - return null; + return (null, null); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs index c9c79151..f1d6d608 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs @@ -105,6 +105,33 @@ public void ParseVersion_ComparesVersionsCorrectly(string current, string latest isNewer.Should().Be(expectedNewerAvailable); } + [Theory] + // Stable user — no preview above GA: latest stable is 1.2.0, preview is older → no nudge + [InlineData("1.2.0", new[] { "1.1.165-preview", "1.2.0" }, "1.2.0", null)] + // Stable user — newer preview above GA: 1.3.0-preview.1 exists → nudge with preview version + [InlineData("1.2.0", new[] { "1.2.0", "1.3.0-preview.1" }, "1.2.0", "1.3.0-preview.1")] + // Preview user — GA is above preview: pick GA, no secondary nudge + [InlineData("1.1.165-preview", new[] { "1.1.165-preview", "1.2.0" }, "1.2.0", null)] + // Preview user — newer preview above GA: pick highest preview, no secondary nudge + [InlineData("1.1.165-preview", new[] { "1.1.165-preview", "1.2.0", "1.3.0-preview.1" }, "1.3.0-preview.1", null)] + // Stable user — preview of same base as GA exists (e.g., "1.1.0-preview.50" alongside "1.1.0") + // → GA is primary, no nudge (same-base preview must not sort above its own GA) + [InlineData("1.1.0", new[] { "1.1.0", "1.1.0-preview.50" }, "1.1.0", null)] + // Stable user on GA with both same-base preview and a higher-base preview → nudge the higher-base preview only + [InlineData("1.1.0", new[] { "1.1.0", "1.1.0-preview.50", "1.2.0-preview.1" }, "1.1.0", "1.2.0-preview.1")] + public void SelectLatestVersions_AppliesChannelAwareFiltering( + string currentVersion, string[] nugetVersions, string expectedPrimary, string? expectedNewerPreview) + { + // Act + var (primary, newerPreview) = VersionCheckHelper.SelectLatestVersions(nugetVersions, currentVersion); + + // Assert + primary.Should().Be(expectedPrimary, + because: "the primary latest should respect the channel of the current version"); + newerPreview.Should().Be(expectedNewerPreview, + because: "the informational preview nudge should only appear for stable users when a newer preview exists"); + } + [Theory] [InlineData("CI")] [InlineData("TF_BUILD")] diff --git a/src/version.json b/src/version.json index 3a3b3829..a301f6e9 100644 --- a/src/version.json +++ b/src/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.1-preview", + "version": "1.1", "assemblyVersion": { "precision": "revision" },