diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index e3ff61ad0c..7f01e5904c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -89,6 +93,56 @@ public static class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); + + if (endpoints is IApplicationBuilder app) + { + // Ensure the middleware is added to the pipeline + app.UseMiddleware(); + } + return endpoints; } + + private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) + { + private static readonly string s_userAgentValue = CreateUserAgentValue(); + + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Request.Headers; + var userAgent = headers.UserAgent.ToString(); + + if (string.IsNullOrEmpty(userAgent)) + { + headers.UserAgent = s_userAgentValue; + } + else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase)) + { + headers.UserAgent = $"{userAgent} {s_userAgentValue}"; + } + + await next(context).ConfigureAwait(false); + } + + private static string CreateUserAgentValue() + { + const string Name = "agent-framework-dotnet"; + + if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs new file mode 100644 index 0000000000..64f7458852 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Tests for the AgentFrameworkUserAgentMiddleware registered by +/// . +/// +public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable +{ + private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+"; + + private WebApplication? _app; + private HttpClient? _httpClient; + + public async ValueTask DisposeAsync() + { + this._httpClient?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } + + [Fact] + public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.StartsWith("MyApp/1.0", userAgent); + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + // First request to capture the actual middleware-generated value + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + var firstResponse = await this._httpClient!.SendAsync(firstRequest); + var middlewareValue = await firstResponse.Content.ReadAsStringAsync(); + + // Act: send a second request that already contains the middleware value + using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}"); + var secondResponse = await this._httpClient!.SendAsync(secondRequest); + var userAgent = await secondResponse.Content.ReadAsStringAsync(); + + // Assert: should remain unchanged (no duplication) + Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent); + Assert.Single(VersionedUserAgentRegex().Matches(userAgent)); + } + + [Fact] + public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert: should match "agent-framework-dotnet/x.y.z" pattern + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + private async Task CreateTestServerAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockAgent = new Mock(); + builder.Services.AddFoundryResponses(mockAgent.Object); + + this._app = builder.Build(); + this._app.MapFoundryResponses(); + + // Test endpoint that echoes the User-Agent header after middleware processing + this._app.MapGet("/test-ua", (HttpContext ctx) => + Results.Text(ctx.Request.Headers.UserAgent.ToString())); + + await this._app.StartAsync(); + + var testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._httpClient = testServer.CreateClient(); + } + + [GeneratedRegex(VersionedUserAgentPattern)] + private static partial Regex VersionedUserAgentRegex(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 6473b799bf..f006096208 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -12,6 +12,7 @@ +