Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs
T
Ben Thomas 6cd81286a9 .NET: dotnet: Add hosted-agent User-Agent supplement to outgoing requests (#5453)
* dotnet: Add hosted-agent User-Agent supplement to outgoing requests

When an agent runs inside a Foundry Hosted Agent, the outgoing
User-Agent header now includes 'agent-framework-hosted/{version}'
alongside the existing 'MEAI/{version}' segment.

- Add HostedAgentContext with AsyncLocal<string?> property
- MeaiUserAgentPolicy reads the supplement per-call
- AgentFrameworkResponseHandler sets/restores the context

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: update hosted UA format to foundry-hosting/agent-framework-dotnet/{version}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Trying to get UA flowing, no luck yet.

* .NET: Polyfill MEAI OpenAIResponsesChatClient to add hosted-agent User-Agent supplement

When AgentFrameworkResponseHandler resolves an agent (i.e. we are running in a
hosted context), TryApplyUserAgent walks the agent's IChatClient decorator chain
to find MEAI's internal OpenAIResponsesChatClient and reflectively swaps its
inner _responseClient field with a DelegatingResponsesClient wrapper. The
wrapper overrides the public-virtual protocol methods to add a per-call
HostedAgentUserAgentPolicy to the RequestOptions and delegate to the inner
ResponsesClient. The OpenAI SDK's internal streaming overloads bottom out in
calls to the public-virtual non-streaming overloads via virtual dispatch on
this, so streaming is covered without overriding any non-virtual member.

The wrapper accepts any ResponsesClient-derived inner — both the Foundry
ProjectResponsesClient and the native OpenAI ResponsesClient — and preserves
the inner client's full pipeline (Transport, RetryPolicy, NetworkTimeout,
OrganizationId / ProjectId / UserAgentApplicationId, custom policies).

- Add DelegatingResponsesClient + HostedAgentUserAgentPolicy in Microsoft.Agents.AI.Foundry.Hosting.
- Add TryApplyUserAgent next to ApplyOpenTelemetry in FoundryHostingExtensions; wire it into AgentFrameworkResponseHandler.GetAgent for both keyed and default-agent paths.
- Drop earlier-iteration dead code: AddHostedAgentTelemetry extension, HostedUserAgentPolicy class, HostedAgentContext.cs, and the never-called ToRequestOptions helper.
- Revert RequestOptionsExtensions.MeaiUserAgentPolicy to MEAI-only (the supplement is now injected by the polyfill).
- Revert unrelated whitespace change in Agent_Step25_ToolboxServerSideTools sample.
- Tests cover streaming AND non-streaming, retry policy preservation, OrganizationId/ProjectId/UserAgentApplicationId pass-through, idempotency, native OpenAI ResponsesClient, and reflection guards for MEAI/OpenAI shape drift.

* .NET: Address review feedback on hosted-agent User-Agent polyfill

- TryApplyUserAgent: replace silent null-return with ArgumentNullException to match the codebase's convention.
- Add idempotency test (TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrap) — runs the polyfill twice on the same agent and asserts the wire UA contains exactly one foundry-hosting segment, proving the 'current is DelegatingResponsesClient' guard prevents nested wrapping.
- Add retry-double-append test (Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgent) — exercises the HostedAgentUserAgentPolicy Contains-guard via a custom retry policy that re-runs the inner pipeline on the same message.
- Replace TryApplyUserAgent_NullAgent_ReturnsNullWithoutThrowing with TryApplyUserAgent_NullAgent_ThrowsArgumentNullException to match the new contract.

* .NET: Drop null check from TryApplyUserAgent and its now-redundant test

The two call sites in AgentFrameworkResponseHandler.GetAgent already null-check the agent before invoking TryApplyUserAgent, so the defensive ArgumentNullException is unreachable. Remove it and the corresponding test.

* .NET: Remove unused Microsoft.Shared.Diagnostics import in ServiceCollectionExtensions

The Throw.IfNull helper from this namespace was used by the now-removed null check in TryApplyUserAgent. Drop the unused import to satisfy IDE0005 in CI's full-project dotnet format run.

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-04-30 16:37:54 +00:00

140 lines
4.4 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
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;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddFoundryResponses_RegistersResponseHandler()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddFoundryResponses();
var descriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(ResponseHandler));
Assert.NotNull(descriptor);
Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType);
}
[Fact]
public void AddFoundryResponses_CalledTwice_RegistersOnce()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddFoundryResponses();
services.AddFoundryResponses();
var count = services.Count(d => d.ServiceType == typeof(ResponseHandler));
Assert.Equal(1, count);
}
[Fact]
public void AddFoundryResponses_NullServices_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(
() => FoundryHostingExtensions.AddFoundryResponses(null!));
}
[Fact]
public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler()
{
var services = new ServiceCollection();
services.AddLogging();
var mockAgent = new Mock<AIAgent>();
services.AddFoundryResponses(mockAgent.Object);
var handlerDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(ResponseHandler));
Assert.NotNull(handlerDescriptor);
var agentDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
}
[Fact]
public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddFoundryResponses(null!));
}
[Fact]
public void ApplyOpenTelemetry_NonInstrumentedAgent_WrapsWithOpenTelemetryAgent()
{
var mockAgent = new Mock<AIAgent>();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(mockAgent.Object);
Assert.NotNull(result.GetService<OpenTelemetryAgent>());
}
[Fact]
public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference()
{
var mockAgent = new Mock<AIAgent>();
var instrumented = mockAgent.Object.AsBuilder()
.UseOpenTelemetry()
.Build();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(instrumented);
Assert.Same(instrumented, result);
}
[Fact]
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
{
// Arrange: agent.GetService<IChatClient>() returns null.
var mockAgent = new Mock<AIAgent>();
// 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<IChatClient>();
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).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.");
}
}