// Copyright (c) Microsoft. All rights reserved. using System; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Verifies the framework-wide . The policy stamps /// agent-framework-dotnet/{version} onto the outgoing User-Agent header of every /// request made through a Foundry chat client and is registered automatically by /// FoundryChatClient via the MEAI OpenAIRequestPolicies hook. /// public sealed class AgentFrameworkUserAgentPolicyTests { [Fact] public async Task AgentFrameworkUserAgentPolicy_AddsAgentFrameworkSegment_ToOutgoingRequestAsync() { // Arrange using var handler = new RecordingHandler(); #pragma warning disable CA5399 using var httpClient = new HttpClient(handler); #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], perTryPolicies: default, beforeTransportPolicies: default); // Act var message = pipeline.CreateMessage(); message.Request.Method = "POST"; message.Request.Uri = new Uri("https://example.test/anything"); await pipeline.SendAsync(message); // Assert Assert.Equal(1, handler.Count); Assert.NotNull(handler.LastUserAgent); Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent); } [Fact] public async Task AgentFrameworkUserAgentPolicy_DoesNotStampMeaiSegmentAsync() { // Arrange: the AF policy must only contribute the agent-framework-dotnet segment. // The MEAI/{version} segment is contributed by the MEAI-shipped policy at a different // layer; this policy must not duplicate or replace it. using var handler = new RecordingHandler(); #pragma warning disable CA5399 using var httpClient = new HttpClient(handler); #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], perTryPolicies: default, beforeTransportPolicies: default); // Act var message = pipeline.CreateMessage(); message.Request.Method = "POST"; message.Request.Uri = new Uri("https://example.test/anything"); await pipeline.SendAsync(message); // Assert Assert.NotNull(handler.LastUserAgent); Assert.DoesNotContain("MEAI/", handler.LastUserAgent); Assert.DoesNotContain("foundry-hosting/", handler.LastUserAgent); } [Fact] public async Task AgentFrameworkUserAgentPolicy_PreservesExistingUserAgent_WhenAppendingAsync() { // Arrange: a per-call policy upstream that pre-populates the User-Agent header. The AF // policy must read the existing value and append (not overwrite) the agent-framework // segment so both stay reachable on the wire. (The exact separator the HTTP transport // emits between multi-value User-Agent entries is comma per RFC 7230; this test does // not assert on the separator character because that is a transport detail.) using var handler = new RecordingHandler(); #pragma warning disable CA5399 using var httpClient = new HttpClient(handler); #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), AgentFrameworkUserAgentPolicy.Instance], perTryPolicies: default, beforeTransportPolicies: default); // Act var message = pipeline.CreateMessage(); message.Request.Method = "POST"; message.Request.Uri = new Uri("https://example.test/anything"); await pipeline.SendAsync(message); // Assert: both segments survive to the wire. Assert.NotNull(handler.LastUserAgent); Assert.Contains("existing-app/1.0", handler.LastUserAgent); Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent); } [Fact] public async Task AgentFrameworkUserAgentPolicy_IsIdempotent_DoesNotDoubleStampAsync() { // Arrange: register the same policy twice on the same pipeline. The second application // must detect the segment is already present and not append it again. Guards against // double-stamping on retries or duplicate registration. using var handler = new RecordingHandler(); #pragma warning disable CA5399 using var httpClient = new HttpClient(handler); #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance, AgentFrameworkUserAgentPolicy.Instance], perTryPolicies: default, beforeTransportPolicies: default); // Act var message = pipeline.CreateMessage(); message.Request.Method = "POST"; message.Request.Uri = new Uri("https://example.test/anything"); await pipeline.SendAsync(message); // Assert: exactly one occurrence of "agent-framework-dotnet/". Assert.NotNull(handler.LastUserAgent); var ua = handler.LastUserAgent!; var first = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal); Assert.True(first >= 0, "Expected at least one agent-framework-dotnet segment."); var second = ua.IndexOf("agent-framework-dotnet/", first + 1, StringComparison.Ordinal); Assert.Equal(-1, second); } [Fact] public void AgentFrameworkUserAgentPolicy_ExposesSingletonInstance() { // Two reads of the static property must return the same instance. The policy is stateless // and shared; allocating a fresh instance per registration site would bloat memory and // defeat the dedup logic in OpenAIRequestPoliciesReflection.AddPolicyIfMissing. var first = AgentFrameworkUserAgentPolicy.Instance; var second = AgentFrameworkUserAgentPolicy.Instance; Assert.Same(first, second); } [Fact] public void AgentFrameworkUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard() { // The policy emits "agent-framework-dotnet/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}". // If the assembly metadata stops being readable, the policy falls back to "agent-framework-dotnet" // without a version, which is a measurable telemetry regression. var attr = typeof(AgentFrameworkUserAgentPolicy).Assembly .GetCustomAttribute(); Assert.NotNull(attr); Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion)); } private sealed class RecordingHandler : HttpClientHandler { public int Count { get; private set; } public string? LastUserAgent { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { this.Count++; this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) ? string.Join(",", values) : null; var resp = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json"), RequestMessage = request, }; return Task.FromResult(resp); } } private sealed class SeedUserAgentPolicy : PipelinePolicy { private readonly string _value; public SeedUserAgentPolicy(string value) => this._value = value; public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) { message.Request.Headers.Set("User-Agent", this._value); ProcessNext(message, pipeline, currentIndex); } public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) { message.Request.Headers.Set("User-Agent", this._value); return ProcessNextAsync(message, pipeline, currentIndex); } } }