diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 1c5a57eb49..dacbbb7d4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -297,6 +297,7 @@ private AIAgent ResolveAgent(CreateResponse request) var agent = this._serviceProvider.GetKeyedService(agentName); if (agent is not null) { + FoundryHostingExtensions.TryApplyUserAgent(agent); return FoundryHostingExtensions.ApplyOpenTelemetry(agent); } @@ -310,12 +311,13 @@ private AIAgent ResolveAgent(CreateResponse request) var defaultAgent = this._serviceProvider.GetService(); if (defaultAgent is not null) { + FoundryHostingExtensions.TryApplyUserAgent(defaultAgent); return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent); } var errorMessage = string.IsNullOrEmpty(agentName) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." - : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent."; + : $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton(\"{agentName}\", ...)."; throw new InvalidOperationException(errorMessage); } @@ -352,7 +354,7 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) var errorMessage = string.IsNullOrEmpty(agentName) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." - : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore."; + : $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton(\"{agentName}\", ...)."; throw new InvalidOperationException(errorMessage); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs new file mode 100644 index 0000000000..ebf878b49a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001, SCME0001 + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// A subclass that delegates every protocol-level request to a +/// wrapped . Before each call, a +/// is added to the per-call +/// so the wrapped client's pipeline appends the hosted-agent +/// User-Agent segment on the wire. +/// +/// +/// +/// The streaming overloads MEAI binds via reflection (internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions) +/// and internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)) bottom out +/// in calls to the public-virtual non-streaming protocol overloads on . Overriding those +/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic. +/// +/// +/// The base pipeline supplied to +/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class +/// delegates to the inner client BEFORE any code path reaches , so the dummy is +/// never expected to run; the throwing transport surfaces any unexpected escape route loudly. +/// +/// +internal sealed class DelegatingResponsesClient : ResponsesClient +{ + private readonly ResponsesClient _inner; + + public DelegatingResponsesClient(ResponsesClient inner) + : base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint }) + { + this._inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public override async Task CreateResponseAsync(BinaryContent content, RequestOptions? options = null) + => await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null) + => this._inner.CreateResponse(content, AddUserAgentPolicy(options)); + + public override async Task GetResponseAsync(string responseId, IEnumerable? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options) + => await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetResponse(string responseId, IEnumerable? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options) + => this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)); + + public override async Task DeleteResponseAsync(string responseId, RequestOptions options) + => await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult DeleteResponse(string responseId, RequestOptions options) + => this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options)); + + public override async Task CancelResponseAsync(string responseId, RequestOptions options) + => await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CancelResponse(string responseId, RequestOptions options) + => this._inner.CancelResponse(responseId, AddUserAgentPolicy(options)); + + public override async Task GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null) + => await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null) + => this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options)); + + public override async Task CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null) + => await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null) + => this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options)); + + public override async Task GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options) + => await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options) + => this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options)); + + private static RequestOptions AddUserAgentPolicy(RequestOptions? options) + { + options ??= new RequestOptions(); + options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall); + return options; + } + + private static ClientPipeline BuildDummyPipeline() + { + var options = new ClientPipelineOptions + { + Transport = new ThrowingTransport(), + }; + return ClientPipeline.Create(options, default, default, default); + } + + private sealed class ThrowingTransport : PipelineTransport + { + private const string Message = + "DelegatingResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of DelegatingResponsesClient."; + + protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message); + protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message); + protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs new file mode 100644 index 0000000000..8d5d330471 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Pipeline policy that appends the hosted-agent User-Agent segment +/// (e.g. "foundry-hosting/agent-framework-dotnet/{version}") to outgoing requests. +/// +/// +/// +/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting +/// assembly's informational version. The policy is idempotent on retries: if the segment +/// is already present in the User-Agent header, the policy does not append it again. +/// +/// +/// This policy is added at request time (per-call ) +/// by when invoking the wrapped +/// . It is only registered when an agent is +/// resolved by the Foundry hosting layer. +/// +/// +internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy +{ + public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy(); + + private static readonly string s_supplementValue = CreateSupplementValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void AppendHeader(PipelineMessage message) + { + if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) + { + // Guard against double-append on retries or when the policy + // is registered on multiple pipeline positions. + if (existing.Contains(s_supplementValue)) + { + return; + } + + message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}"); + } + else + { + message.Request.Headers.Set("User-Agent", s_supplementValue); + } + } + + private static string CreateSupplementValue() + { + const string Name = "foundry-hosting/agent-framework-dotnet"; + + if (typeof(HostedAgentUserAgentPolicy).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/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index dda822ef66..49eb745b6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -3,16 +3,15 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.Core; using Azure.Identity; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Shared.DiagnosticIds; +using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -36,7 +35,7 @@ public static class FoundryHostingExtensions /// /// Example: /// - /// builder.AddAIAgent("my-agent", ...); + /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); /// builder.Services.AddFoundryResponses(); /// /// var app = builder.Build(); @@ -181,13 +180,6 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); - - if (endpoints is IApplicationBuilder app) - { - // Ensure the middleware is added to the pipeline - app.UseMiddleware(); - } - return endpoints; } @@ -216,46 +208,85 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent) .Build(); } - private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) + /// + /// Attempts to wrap the agent's underlying + /// with a so every outgoing Responses-API request + /// carries the hosted-agent User-Agent segment. + /// + /// + /// + /// Best-effort and idempotent. The method is a no-op when: + /// + /// exposes no ; + /// the chat client is not backed by MEAI's internal OpenAIResponsesChatClient (e.g., a non-OpenAI provider or a custom impl); + /// the inner is already a . + /// + /// + /// + /// Works for any -derived inner client — both the Foundry-specific + /// and the native OpenAI + /// obtained from . The wrapper preserves + /// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId / + /// UserAgentApplicationId, custom policies) because every override delegates to the inner instance. + /// + /// + /// Returns the same instance unchanged. Mutation happens via + /// reflection on MEAI's private _responseClient field; the agent itself is not wrapped. + /// + /// + internal static AIAgent TryApplyUserAgent(AIAgent agent) { - private static readonly string s_userAgentValue = CreateUserAgentValue(); + var chatClient = agent.GetService(); + if (chatClient is null) + { + return agent; + } - public async Task InvokeAsync(HttpContext context) + var meaiType = s_meaiResponsesChatClientType; + if (meaiType is null) { - var headers = context.Request.Headers; - var userAgent = headers.UserAgent.ToString(); + return agent; + } - if (string.IsNullOrEmpty(userAgent)) - { - headers.UserAgent = s_userAgentValue; - } - else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase)) - { - headers.UserAgent = $"{userAgent} {s_userAgentValue}"; - } + var meaiInstance = chatClient.GetService(meaiType); + if (meaiInstance is null) + { + return agent; + } - await next(context).ConfigureAwait(false); + var field = s_meaiResponseClientField; + if (field is null) + { + return agent; } - private static string CreateUserAgentValue() + var current = field.GetValue(meaiInstance) as ResponsesClient; + if (current is null or DelegatingResponsesClient) { - const string Name = "agent-framework-dotnet"; + return agent; + } - if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion is string version) - { - int pos = version.IndexOf('+'); - if (pos >= 0) - { - version = version.Substring(0, pos); - } + field.SetValue(meaiInstance, new DelegatingResponsesClient(current)); + return agent; + } - if (version.Length > 0) - { - return $"{Name}/{version}"; - } - } + /// + /// MEAI's internal OpenAIResponsesChatClient type, resolved once via reflection. + /// if the type cannot be found (e.g., MEAI version drift). + /// + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")] + [UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode", + Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")] + private static readonly Type? s_meaiResponsesChatClientType = + typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); - return Name; - } - } + /// + /// MEAI's internal _responseClient field on OpenAIResponsesChatClient, + /// resolved once via reflection. if the field cannot be found. + /// + [UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers", + Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")] + private static readonly FieldInfo? s_meaiResponseClientField = + s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index 03e48e293b..e00025b7ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -3,7 +3,6 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Reflection; -using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI; @@ -13,20 +12,6 @@ internal static class RequestOptionsExtensions /// Gets the singleton that adds a MEAI user-agent header. internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance; - /// Creates a configured for use with Foundry Agents. - public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) - { - RequestOptions requestOptions = new() - { - CancellationToken = cancellationToken, - BufferResponse = !streaming - }; - - requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); - - return requestOptions; - } - /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. private sealed class MeaiUserAgentPolicy : PipelinePolicy { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs new file mode 100644 index 0000000000..8ec8a7f570 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +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; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Verifies that preserves user-supplied client options +/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the +/// hosted-agent User-Agent supplement on every outgoing request, including streaming. +/// Covers both the Azure-flavored and the native OpenAI +/// . +/// +public sealed partial class DelegatingResponsesClientTests +{ + private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj"; + private const string OpenAIEndpoint = "https://fake-openai.example.com/v1"; + private const string Deployment = "fake-deployment"; + + [System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")] + private static partial System.Text.RegularExpressions.Regex SupplementRegex(); + + [Fact] + public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(TestEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalSseResponse()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + await foreach (var _ in chat.GetStreamingResponseAsync("hello")) + { + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(TestEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, + userAgentApplicationId: "MY_APP_ID", + organizationId: "org_xyz", + projectId: "proj_abc"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync() + { + // Arrange + var retryPolicy = new CountingRetryPolicy(extraAttempts: 2); + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert: retry policy ran (1 + 2 extras = 3 attempts). + Assert.Equal(3, handler.Requests.Count); + Assert.Equal(3, retryPolicy.InvocationCount); + foreach (var req in handler.Requests) + { + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + } + + [Fact] + public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = inner.AsIChatClient(Deployment); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync() + { + // Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved). + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(OpenAIEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalSseResponse()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + await foreach (var _ in chat.GetStreamingResponseAsync("hello")) + { + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(OpenAIEndpoint, req.Uri); + } + + [Theory] + [InlineData("DeleteResponseAsync")] + [InlineData("CancelResponseAsync")] + [InlineData("GetInputTokenCountAsync")] + [InlineData("CompactResponseAsync")] + [InlineData("GetResponseInputItemCollectionPageAsync")] + public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method) + { + // Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that + // grabs the underlying ResponsesClient via chat.GetService() and invokes + // a non-Create/Get protocol method. This is the regression path: without overriding these, + // the wrapper's dummy throwing pipeline would fire. + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var wrapper = new DelegatingResponsesClient(inner); + + // Act + switch (method) + { + case "DeleteResponseAsync": + _ = await wrapper.DeleteResponseAsync("resp_1", options: null!); + break; + case "CancelResponseAsync": + _ = await wrapper.CancelResponseAsync("resp_1", options: null!); + break; + case "GetInputTokenCountAsync": + _ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}"))); + break; + case "CompactResponseAsync": + _ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}"))); + break; + case "GetResponseInputItemCollectionPageAsync": + _ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!); + break; + default: + Assert.Fail($"Unhandled method: {method}"); + break; + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync() + { + // Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message, + // so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers. + // The policy's Contains-guard must prevent the supplement from appearing twice. + var retryPolicy = new CountingRetryPolicy(extraAttempts: 2); + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two. + Assert.Equal(3, handler.Requests.Count); + foreach (var req in handler.Requests) + { + int matches = SupplementRegex().Matches(req.UserAgent).Count; + Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}"); + } + } + + [Fact] + public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync() + { + // Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's + // OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport). + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + IChatClient chatClient = inner.AsIChatClient(Deployment); + AIAgent agent = new ChatClientAgent(chatClient); + + // Act: apply twice. + FoundryHostingExtensions.TryApplyUserAgent(agent); + FoundryHostingExtensions.TryApplyUserAgent(agent); + + // Assert: invoking the agent produces exactly ONE outbound request whose UA contains + // the supplement EXACTLY ONCE (would be twice if the wrapper were nested). + _ = await chatClient.GetResponseAsync("hello"); + var req = Assert.Single(handler.Requests); + int matches = SupplementRegex().Matches(req.UserAgent).Count; + Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}"); + } + + [Fact] + public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard() + { + // Guards the polyfill's reflection target. Failure here means MEAI internals + // changed and the polyfill needs updating. + var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly + .GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); + Assert.NotNull(meaiType); + + var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType), + $"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}."); + } + + [Fact] + public void ResponsesClient_PipelineProperty_ReflectionGuard() + { + // The polyfill design assumes ResponsesClient.Pipeline remains accessible. + var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance); + Assert.NotNull(pipelineProp); + Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType); + } + + private static IChatClient MakeWithDelegating(ResponsesClient inner) + { + IChatClient meai = inner.AsIChatClient(Deployment); + var meaiType = meai.GetType(); + var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!; + field.SetValue(meai, new DelegatingResponsesClient(inner)); + return meai; + } + + private static ProjectResponsesClient BuildInner( + HttpClient httpClient, + string? userAgentApplicationId = null, + string? organizationId = null, + string? projectId = null, + PipelinePolicy? retryPolicy = null) + { + var options = new ProjectResponsesClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + if (userAgentApplicationId is not null) + { + options.UserAgentApplicationId = userAgentApplicationId; + } + if (organizationId is not null) + { + options.OrganizationId = organizationId; + } + if (projectId is not null) + { + options.ProjectId = projectId; + } + if (retryPolicy is not null) + { + options.RetryPolicy = retryPolicy; + } + + return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options); + } + + private static ResponsesClient BuildOpenAIInner( + HttpClient httpClient, + string? userAgentApplicationId = null) + { + var options = new OpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + Endpoint = new Uri(OpenAIEndpoint), + }; + if (userAgentApplicationId is not null) + { + options.UserAgentApplicationId = userAgentApplicationId; + } + + return new ResponsesClient(new ApiKeyCredential("test-key"), options); + } + + private static string MinimalResponseJson() => """ + { + "id":"resp_1","object":"response","created_at":1700000000,"status":"completed", + "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + } + """; + + private static string MinimalSseResponse() + { + var sb = new StringBuilder(); + sb.Append("event: response.completed\n"); + sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n"); + sb.Append("data: [DONE]\n\n"); + return sb.ToString(); + } + + private sealed class RecordingHandler : HttpClientHandler + { + private readonly string _body; + public List Requests { get; } = []; + + public RecordingHandler(string body) + { + this._body = body; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string ua = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : "(none)"; + this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua)); + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } + + private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent); + + private sealed class CountingRetryPolicy : PipelinePolicy + { + private readonly int _extraAttempts; + public int InvocationCount { get; private set; } + + public CountingRetryPolicy(int extraAttempts) + { + this._extraAttempts = extraAttempts; + } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + for (int i = 0; i <= this._extraAttempts; i++) + { + this.InvocationCount++; + ProcessNext(message, pipeline, currentIndex); + } + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + for (int i = 0; i <= this._extraAttempts; i++) + { + this.InvocationCount++; + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs new file mode 100644 index 0000000000..30c4bef9de --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs @@ -0,0 +1,165 @@ +// 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.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline: +/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent → +/// agent invocation → outbound HTTP from inside the hosted environment. +/// Verifies that the hosted-agent User-Agent supplement reaches the outbound wire, +/// not just the inbound request. +/// +public sealed class HostedOutboundUserAgentTests : IAsyncDisposable +{ + private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj"; + private const string Deployment = "fake-deployment"; + + private WebApplication? _app; + private HttpClient? _inboundClient; + private RecordingHandler? _outboundHandler; + + public async ValueTask DisposeAsync() + { + this._inboundClient?.Dispose(); + this._outboundHandler?.Dispose(); + if (this._app is not null) + { + await this._app.DisposeAsync(); + } + } + + [Fact] + public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync() + { + // Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's + // OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the + // exact production stack minus the network: the only thing not real is the wire transport. + await this.StartHostedServerAsync(); + + // Act: send an inbound /openai/v1/responses request as the Foundry runtime would. + using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses") + { + Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"), + }; + using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest); + var inboundBody = await inboundResponse.Content.ReadAsStringAsync(); + + // Assert: at least one OUTBOUND request reached the fake transport, AND it carries the + // foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent. + // (We don't care about the inbound response shape — only that the agent's call to MEAI + // triggered an outbound request whose UA reaches the sandbox boundary correctly.) + Assert.True(this._outboundHandler!.Requests.Count > 0, + $"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}"); + var outbound = this._outboundHandler.Requests[0]; + Assert.StartsWith(TestEndpoint, outbound.Uri); + Assert.Contains("MEAI/", outbound.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent); + } + + private async Task StartHostedServerAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + // Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient + // wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler + // resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper. + this._outboundHandler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + var outboundHttpClient = new HttpClient(this._outboundHandler); +#pragma warning restore CA5399 + + var projectOptions = new ProjectResponsesClientOptions + { + Transport = new HttpClientPipelineTransport(outboundHttpClient), + }; + var projectResponsesClient = new ProjectResponsesClient( + new Uri(TestEndpoint), + new FakeAuthenticationTokenProvider(), + projectOptions); + + IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment); + AIAgent agent = new ChatClientAgent(chatClient); + + builder.Services.AddFoundryResponses(agent); + builder.Services.AddLogging(); + + this._app = builder.Build(); + this._app.MapFoundryResponses(); + + await this._app.StartAsync(); + + var testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._inboundClient = testServer.CreateClient(); + } + + private static string InboundResponsesRequestJson() => """ + { + "model": "fake-deployment", + "input": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "user", + "content": [{ "type": "input_text", "text": "Hello" }] + } + ] + } + """; + + private static string MinimalResponseJson() => """ + { + "id":"resp_1","object":"response","created_at":1700000000,"status":"completed", + "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + } + """; + + private sealed class RecordingHandler : HttpClientHandler + { + private readonly string _body; + public List Requests { get; } = []; + + public RecordingHandler(string body) + { + this._body = body; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string ua = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : "(none)"; + this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua)); + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } + + private readonly record struct RecordedRequest(string Uri, string UserAgent); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index aadca65643..c5b9bf1701 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -4,8 +4,10 @@ using System.Linq; using Azure.AI.AgentServer.Responses; using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Moq; +using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; @@ -93,4 +95,45 @@ public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference() Assert.Same(instrumented, result); } + + [Fact] + public void TryApplyUserAgent_AgentWithoutChatClient_NoOp() + { + // Arrange: agent.GetService() returns null. + var mockAgent = new Mock(); + + // Act + var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + [Fact] + public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp() + { + // Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService. + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c.GetService(It.IsAny(), It.IsAny())).Returns(null!); + + var mockAgent = new Mock(); + mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny())).Returns(mockChatClient.Object); + + // Act + var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + [Fact] + public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard() + { + // Guards the polyfill's reflection target type-name. + var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly + .GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); + Assert.NotNull(meaiType); + Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!), + $"Expected MEAI {meaiType!.FullName} to implement IChatClient."); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs deleted file mode 100644 index 008e3f3347..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -// 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+(-[\w.]+)?"; - - 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/RequestOptionsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs new file mode 100644 index 0000000000..18824c4875 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +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 per-call MeaiUserAgentPolicy exposed via +/// . The policy is reachable through the +/// public constructors (which add it to the internally-built +/// 's pipeline), so its behavior is part of the +/// public API surface. +/// +public sealed class RequestOptionsExtensionsTests +{ + [Fact] + public async Task MeaiUserAgentPolicy_AddsMeaiSegment_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: [RequestOptionsExtensions.UserAgentPolicy], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new System.Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert + Assert.Equal(1, handler.Count); + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("MEAI/", handler.LastUserAgent); + } + + [Fact] + public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync() + { + // 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: [RequestOptionsExtensions.UserAgentPolicy], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new System.Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere + // (by the polyfill DelegatingResponsesClient → HostedAgentUserAgentPolicy). + Assert.NotNull(handler.LastUserAgent); + Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent); + } + + [Fact] + public void UserAgentPolicy_ExposesSingletonInstance() + { + // Two reads of the static property must return the same instance — the policy is stateless and shared. + var first = RequestOptionsExtensions.UserAgentPolicy; + var second = RequestOptionsExtensions.UserAgentPolicy; + Assert.Same(first, second); + } + + [Fact] + public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard() + { + // The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}". + // If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version, + // which is a measurable telemetry regression. + var attr = typeof(RequestOptionsExtensions).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); + } + } +}