// 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.Text; using System.Threading; using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using OpenAI; #pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001 namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; /// /// 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 // combined hosted segment foundry-hosting/agent-framework-dotnet/{version} on its // User-Agent. This matches Python's contract // (foundry-hosting/agent-framework-python/{version}, see // python/packages/core/agent_framework/_telemetry.py): a single combined segment when // hosted, never two separate ones. The bare agent-framework-dotnet/{version} segment // (from AgentFrameworkUserAgentPolicy in FoundryChatClient) must be upgraded in place // by HostedAgentUserAgentPolicy — never appear duplicated. 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); // The bare agent-framework-dotnet/{v} segment must NOT appear separately when the // combined form is present — Python emits a single combined value when the hosted // prefix is registered, and .NET preserves that contract via the in-place upgrade in // HostedAgentUserAgentPolicy. var combinedIdx = outbound.UserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); var beforeCombined = outbound.UserAgent.Substring(0, combinedIdx); var afterCombined = outbound.UserAgent.Substring(combinedIdx + "foundry-hosting/agent-framework-dotnet/".Length); Assert.DoesNotContain("agent-framework-dotnet/", beforeCombined); Assert.DoesNotContain("agent-framework-dotnet/", afterCombined); } 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.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); 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} } """; [Fact] public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce() { // Arrange: hosted resolution calls TryApplyUserAgent on every request. Without per-instance // dedup, each call would append another policy entry to the shared OpenAIRequestPolicies, // producing unbounded growth on singleton agents (one chat client reused across requests). using var http = new HttpClient(new NoopHandler()); var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) }); IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient(); AIAgent agent = new ChatClientAgent(chatClient); // Act for (int i = 0; i < 50; i++) { FoundryHostingExtensions.TryApplyUserAgent(agent); } // Assert: exactly one HostedAgentUserAgentPolicy entry on the shared OpenAIRequestPolicies. var policies = chatClient.GetService(); Assert.NotNull(policies); Assert.Equal(1, EntriesCount(policies!)); } [Fact] public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient() { // Arrange: dedup is per-OpenAIRequestPolicies-instance, not global, so two agents on // different chat clients each get exactly one registration. using var http1 = new HttpClient(new NoopHandler()); using var http2 = new HttpClient(new NoopHandler()); var client1 = new OpenAIClient(new ApiKeyCredential("k1"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http1) }); var client2 = new OpenAIClient(new ApiKeyCredential("k2"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http2) }); IChatClient cc1 = client1.GetResponsesClient().AsIChatClient(); IChatClient cc2 = client2.GetResponsesClient().AsIChatClient(); AIAgent a1 = new ChatClientAgent(cc1); AIAgent a2 = new ChatClientAgent(cc2); // Act for (int i = 0; i < 10; i++) { FoundryHostingExtensions.TryApplyUserAgent(a1); FoundryHostingExtensions.TryApplyUserAgent(a2); } // Assert Assert.Equal(1, EntriesCount(cc1.GetService()!)); Assert.Equal(1, EntriesCount(cc2.GetService()!)); } private static int EntriesCount(OpenAIRequestPolicies policies) { var field = typeof(OpenAIRequestPolicies).GetField("_entries", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var array = (Array?)field?.GetValue(policies); return array?.Length ?? -1; } // ----------------------------------------------------------------------- // Direct unit tests for HostedAgentUserAgentPolicy's in-place upgrade behavior. // These run the policy on a synthetic ClientPipeline (no hosting infrastructure) // so the upgrade logic itself can be asserted in isolation. // ----------------------------------------------------------------------- [Fact] public async Task HostedAgentUserAgentPolicy_UpgradesBareAgentFrameworkSegment_InPlaceAsync() { // Arrange: an upstream per-call policy stamps the bare agent-framework-dotnet/{version} // segment (matching what AgentFrameworkUserAgentPolicy would write in non-hosted code). // Then HostedAgentUserAgentPolicy runs and must REPLACE that segment with the combined // foundry-hosting/agent-framework-dotnet/{version} form, not append a duplicate. using var handler = new InspectingHandler(); #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 SetUserAgentPolicy("agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.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: combined form is present; bare form is gone (no duplicate agent-framework segment). Assert.NotNull(handler.LastUserAgent); Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent); var ua = handler.LastUserAgent!; var firstAgentFramework = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal); Assert.True(firstAgentFramework >= 0, "Expected agent-framework-dotnet segment."); var secondAgentFramework = ua.IndexOf("agent-framework-dotnet/", firstAgentFramework + 1, StringComparison.Ordinal); Assert.Equal(-1, secondAgentFramework); } [Fact] public async Task HostedAgentUserAgentPolicy_AppendsCombined_WhenNoBareSegmentPresentAsync() { // Arrange: nothing upstream stamps the bare segment. Hosted policy should append the // full combined segment to whatever User-Agent is on the wire. using var handler = new InspectingHandler(); #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: [HostedAgentUserAgentPolicy.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.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent); } [Fact] public async Task HostedAgentUserAgentPolicy_IsIdempotent_WhenCombinedSegmentAlreadyPresentAsync() { // Arrange: upstream pre-populates the combined segment (simulating a retry or duplicate // registration). Hosted policy must not re-append. using var handler = new InspectingHandler(); #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 SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.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 "foundry-hosting/agent-framework-dotnet/" segment. Assert.NotNull(handler.LastUserAgent); var first = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); Assert.True(first >= 0); var second = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", first + 1, StringComparison.Ordinal); Assert.Equal(-1, second); } [Fact] public async Task HostedAgentUserAgentPolicy_ReplacesDifferentVersionCombinedSegment_InPlaceAsync() { // Q-D regression: when the User-Agent already carries the COMBINED hosted form with a // different version (e.g. an older registration or caller-supplied baseline), the policy // must replace the entire combined span — not just the bare suffix — so we never emit // the malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` shape. using var handler = new InspectingHandler(); #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 SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/0.0.1 MEAI/10.5.1"), HostedAgentUserAgentPolicy.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: no doubled foundry-hosting/ prefix. Assert.NotNull(handler.LastUserAgent); Assert.DoesNotContain("foundry-hosting/foundry-hosting/", handler.LastUserAgent, StringComparison.Ordinal); // The combined segment must appear exactly once, and the trailing MEAI segment must be // preserved in place (i.e. the policy only rewrote the combined span, not anything after it). var firstCombined = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); Assert.True(firstCombined >= 0); var secondCombined = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", firstCombined + 1, StringComparison.Ordinal); Assert.Equal(-1, secondCombined); Assert.Contains(" MEAI/10.5.1", handler.LastUserAgent, StringComparison.Ordinal); // And the version that survives must be the runtime supplement value's version, not 0.0.1. Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet/0.0.1", handler.LastUserAgent, StringComparison.Ordinal); } private sealed class InspectingHandler : HttpClientHandler { public string? LastUserAgent { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) ? string.Join(",", values) : null; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json"), RequestMessage = request, }); } } private sealed class SetUserAgentPolicy : PipelinePolicy { private readonly string _value; public SetUserAgentPolicy(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); } } private sealed class NoopHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); } 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); }