Files
Roger Barreto b12109b7e4 .NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)
* Bump MEAI to 10.5.1 and add per-call x-client header support

Replaces the brittle UserAgentResponsesClient subclass with a clean
per-call x-client-* header pipeline built on the new Microsoft.Extensions.AI
10.5.1 OpenAIRequestPolicies hook.

Public surface (Microsoft.Agents.AI.Foundry, [Experimental(MAAI001)]):
* chatOptions.WithClientHeader(name, value) and .WithClientHeaders(IEnumerable)
  validate the x-client- prefix (case-insensitive), apply all-or-nothing on
  bulk, and throw InvalidOperationException on foreign-typed slot collision
* myAgent.AsBuilder().UseClientHeaders().Build() opts a customer-built agent
  into the pipeline; idempotent via agent.GetService<ClientHeadersAgent>()
* Foundry-built agents (FoundryAgent.Create*) pre-wire automatically

Internals:
* ClientHeadersAgent decorator snapshots the dict at scope-push time so
  concurrent runs sharing a ChatOptions reference do not leak headers
* ClientHeadersScope is an AsyncLocal<IReadOnlyDictionary<string,string>?>
  with LIFO push/dispose semantics
* ClientHeadersPolicy singleton stamps headers via Headers.Set so per-call
  values overwrite any same-name header from earlier policies and so
  duplicate registration is value-stable
* OpenAIRequestPoliciesReflection dedups against MEAI's private _entries
  field and falls back to AddPolicy on any reflection failure; a CI test
  asserts the field shape on every MEAI bump

Hosting cleanup:
* Deleted UserAgentResponsesClient and its dummy throwing pipeline
* HostedAgentUserAgentPolicy is now registered via OpenAIRequestPolicies
  in FoundryHostingExtensions.TryApplyUserAgent

Tests:
* 19 new unit tests in ClientHeadersExtensionsTests.cs covering validation,
  AsyncLocal isolation, snapshot semantics, end-to-end wire stamping, and
  shared-chat-client dedup
* Updated OpenTelemetryAgentTests for MEAI 10.5.1 changes to web_search
  serialization and the reduced tool definition payload when sensitive
  data capture is disabled

Microsoft.Extensions.Compliance.Abstractions stays at 10.5.0 because no
10.5.1 release exists on nuget.org.

* Address PR review: pre-wire AsAIAgent path and dedup TryApplyUserAgent

* FoundryAgent: extract WireClientHeaders helper and call it from the
  internal (AIProjectClient, ChatClientAgent) constructor used by
  AzureAIProjectChatClientExtensions.AsAIAgent so those Foundry-built
  agents also pre-wire the x-client header pipeline.
* Foundry.Hosting TryApplyUserAgent: dedup HostedAgentUserAgentPolicy
  registration per OpenAIRequestPolicies instance via
  ConditionalWeakTable so per-request resolution does not grow the
  policy list unboundedly on singleton agents.

* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup

Backs the PR review fixes from a4c8f91 with regression tests:
* ClientHeadersExtensionsTests: AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent
  asserts the FoundryAgent built via AzureAIProjectChatClientExtensions.AsAIAgent
  contains a ClientHeadersAgent in its delegating chain (catches future
  regressions of the bypass).
* ClientHeadersExtensionsTests: FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent
  covers the public constructor path the same way.
* ClientHeadersExtensionsTests: UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  invokes UseClientHeaders 25 times on a shared chat client and asserts via
  reflection that OpenAIRequestPolicies._entries length is exactly 1.
* HostedTryApplyUserAgentDedupTests: two tests asserting
  FoundryHostingExtensions.TryApplyUserAgent stays at one entry per
  OpenAIRequestPolicies instance after 50 calls on the same agent and across
  distinct agents on different chat clients.

* Move tests next to their SUT

Removes the dedicated HostedTryApplyUserAgentDedupTests.cs test class.
Tests are co-located with the SUT they exercise:

* FoundryAgentTests.cs gains the Constructor_PreWiresClientHeadersAgent
  and Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent
  cases, since FoundryAgent is the SUT for the pre-wire behavior.
* HostedOutboundUserAgentTests.cs gains the two TryApplyUserAgent dedup
  cases, since FoundryHostingExtensions.TryApplyUserAgent is the SUT
  it already covers.
* ClientHeadersExtensionsTests.cs keeps only the
  UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  case, which exercises the public ClientHeadersExtensions surface.

* Remove redundant WithCancellation on inner streaming call

ct is already passed to InnerAgent.RunStreamingAsync, so
.WithCancellation(ct) on the resulting IAsyncEnumerable is a no-op.
Caught by Sergey on PR review.

* Address PR review: surface downstream MEAI experimental ID

* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
  DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
  AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
  instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
  surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
  suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
  pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
  does NOT auto-restore on method return; explicit using/Dispose is what
  gives stack-style LIFO semantics.
2026-05-06 14:43:08 +00:00

402 lines
12 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryAgent"/> class.
/// </summary>
public class FoundryAgentTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Constructor validation tests
[Fact]
public void Constructor_WithNullEndpoint_ThrowsArgumentNullException()
{
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test instructions"));
Assert.Equal("endpoint", exception.ParamName);
}
[Fact]
public void Constructor_WithNullCredential_ThrowsArgumentNullException()
{
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: null!,
model: "gpt-4o-mini",
instructions: "Test instructions"));
Assert.Equal("credential", exception.ParamName);
}
[Fact]
public void Constructor_WithNullModel_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: null!,
instructions: "Test instructions"));
}
[Fact]
public void Constructor_WithEmptyModel_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: string.Empty,
instructions: "Test instructions"));
}
[Fact]
public void Constructor_WithNullInstructions_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: null!));
}
[Fact]
public void Constructor_WithValidParams_CreatesAgent()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "You are a helpful assistant.",
name: "test-agent",
description: "A test agent");
Assert.NotNull(agent);
Assert.Equal("test-agent", agent.Name);
Assert.Equal("A test agent", agent.Description);
}
#endregion
#region Property tests
[Fact]
public void Name_ReturnsConfiguredName()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
name: "my-agent");
Assert.Equal("my-agent", agent.Name);
}
[Fact]
public void Description_ReturnsConfiguredDescription()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
description: "Agent description");
Assert.Equal("Agent description", agent.Description);
}
[Fact]
public void GetService_ReturnsAIProjectClient()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
AIProjectClient? client = agent.GetService<AIProjectClient>();
Assert.NotNull(client);
}
[Fact]
public void GetService_ReturnsChatClientAgent()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
ChatClientAgent? innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
}
[Fact]
public void Constructor_PreWiresClientHeadersAgent()
{
// Arrange / Act: the public FoundryAgent ctor should pre-wire the client-headers
// pipeline so x-client-* headers stamped on ChatClientAgentRunOptions reach the wire.
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
// Assert: ClientHeadersAgent decorator is present in the delegating chain.
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
[Fact]
public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
{
// Arrange: stand up a real AIProjectClient pointed at a fake transport.
using var handler = new NoopHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
private sealed class NoopHandler : HttpClientHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
[Fact]
public void GetService_ReturnsIChatClient()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
IChatClient? chatClient = agent.GetService<IChatClient>();
Assert.NotNull(chatClient);
}
[Fact]
public void GetService_ReturnsChatClientMetadata()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
ChatClientMetadata? metadata = agent.GetService<ChatClientMetadata>();
Assert.NotNull(metadata);
Assert.Equal("microsoft.foundry", metadata.ProviderName);
}
[Fact]
public void GetService_ReturnsNullForUnknownType()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
Assert.Null(agent.GetService<HttpClient>());
}
#endregion
#region CreateSessionAsync tests
[Fact]
public async Task CreateSessionAsync_WithConversationId_ReturnsChatClientAgentSessionAsync()
{
// Arrange
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
const string ConversationId = "test-conversation-id";
// Act
AgentSession session = await agent.CreateSessionAsync(ConversationId);
// Assert
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
Assert.Equal(ConversationId, chatSession.ConversationId);
}
[Fact]
public async Task CreateSessionAsync_WithoutConversationId_ReturnsChatClientAgentSessionWithoutConversationIdAsync()
{
// Arrange
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
// Act
AgentSession session = await agent.CreateSessionAsync();
// Assert
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
Assert.Null(chatSession.ConversationId);
}
#endregion
#region Functional tests
[Fact]
public async Task RunAsync_SendsRequestToResponsesAPIAsync()
{
bool requestTriggered = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
{
requestTriggered = true;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
TestDataUtil.GetOpenAIDefaultResponseJson(),
Encoding.UTF8,
"application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using HttpClient httpClient = new(httpHandler);
#pragma warning restore CA5399
AIProjectClientOptions clientOptions = new()
{
Transport = new HttpClientPipelineTransport(httpClient)
};
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "You are a helpful assistant.",
clientOptions: clientOptions);
AgentSession session = await agent.CreateSessionAsync();
await agent.RunAsync("Hello", session);
Assert.True(requestTriggered);
}
[Fact]
public void Constructor_WithChatClientFactory_AppliesFactory()
{
bool factoryCalled = false;
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
clientFactory: client =>
{
factoryCalled = true;
return client;
});
Assert.True(factoryCalled);
Assert.NotNull(agent);
}
[Fact]
public async Task Constructor_UserAgentHeaderAddedToRequestsAsync()
{
bool userAgentFound = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
{
foreach (string value in values)
{
if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase))
{
userAgentFound = true;
}
}
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
TestDataUtil.GetOpenAIDefaultResponseJson(),
Encoding.UTF8,
"application/json")
};
});
#pragma warning disable CA5399
using HttpClient httpClient = new(httpHandler);
#pragma warning restore CA5399
AIProjectClientOptions clientOptions = new()
{
Transport = new HttpClientPipelineTransport(httpClient)
};
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
clientOptions: clientOptions);
AgentSession session = await agent.CreateSessionAsync();
await agent.RunAsync("Hello", session);
Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests.");
}
#endregion
}