Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ public record VersionCheckResult(
bool UpdateAvailable,
string? CurrentVersion,
string? LatestVersion,
string? UpdateCommand);
string? UpdateCommand,
string? NewerPreviewVersion = null);

/// <summary>
/// On-disk cache envelope for the version check result, keyed by fetch timestamp.
/// </summary>
public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion);
public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion, string? NewerPreviewVersion = null);
7 changes: 7 additions & 0 deletions src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,54 @@ internal static string GetUpdateCommand(string version)
? $"{baseCommand} --prerelease"
: baseCommand;
}

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
internal static (string? Primary, string? NewerPreview) SelectLatestVersions(
IEnumerable<string> 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;
}
Comment thread
sellakumaran marked this conversation as resolved.
}

return (primary, newerPreview);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ public async Task<VersionCheckResult> 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)
Expand All @@ -65,7 +65,7 @@ public async Task<VersionCheckResult> 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)
{
Expand All @@ -79,7 +79,7 @@ public async Task<VersionCheckResult> CheckForUpdatesAsync(CancellationToken can
}
}

private async Task<string?> GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken)
private async Task<(string? Primary, string? NewerPreview)> GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken)
{
try
{
Expand All @@ -89,7 +89,7 @@ public async Task<VersionCheckResult> 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);
Expand All @@ -99,22 +99,15 @@ public async Task<VersionCheckResult> 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);
}
}

Expand All @@ -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<VersionCheckCache>(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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Comment thread
sellakumaran marked this conversation as resolved.
// 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")]
Expand Down
2 changes: 1 addition & 1 deletion src/version.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
Loading