.NET: Feature foundry agent + user agent (#2058)

* Update unit tests

* Add user-agent protocol calls

* Update unit tests

* Update unit tests with http handler confirmation

* UT fix

* Fix xmldoc

* Apply suggestions from code review

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

* Address copilot feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2025-11-10 18:34:32 +00:00
committed by GitHub
co-authored by Copilot
parent c07e6afe21
commit 105dc82c39
3 changed files with 457 additions and 106 deletions
@@ -6,6 +6,9 @@ using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -38,7 +41,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.GetAIAgent(agentRecord));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -116,7 +119,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.GetAIAgent(agentVersion));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -234,7 +237,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.GetAIAgent(options));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -329,7 +332,7 @@ public sealed class AgentClientExtensionsTests
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
client!.GetAIAgentAsync(options));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -383,7 +386,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.GetAIAgent("test-agent"));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -426,8 +429,11 @@ public sealed class AgentClientExtensionsTests
{
// Arrange
var mockClient = new Mock<AgentClient>();
mockClient.Setup(c => c.GetAgent(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200)));
mockClient.Setup(c => c.GetAgent(It.IsAny<string>(), It.IsAny<RequestOptions>()))
.Returns(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null"))));
mockClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
@@ -453,7 +459,7 @@ public sealed class AgentClientExtensionsTests
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
client!.GetAIAgentAsync("test-agent"));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -480,8 +486,11 @@ public sealed class AgentClientExtensionsTests
{
// Arrange
var mockClient = new Mock<AgentClient>();
mockClient.Setup(c => c.GetAgentAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200)));
mockClient.Setup(c => c.GetAgentAsync(It.IsAny<string>(), It.IsAny<RequestOptions>()))
.ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null"))));
mockClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -580,7 +589,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.CreateAIAgent("test-agent", "model", "instructions"));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -618,7 +627,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.CreateAIAgent("test-agent", options));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -673,7 +682,7 @@ public sealed class AgentClientExtensionsTests
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.CreateAIAgent("model", options));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -847,7 +856,7 @@ public sealed class AgentClientExtensionsTests
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
client!.CreateAIAgentAsync("agent-name", options));
Assert.Equal("AgentClient", exception.ParamName);
Assert.Equal("agentClient", exception.ParamName);
}
/// <summary>
@@ -1672,6 +1681,176 @@ public sealed class AgentClientExtensionsTests
#endregion
#region User-Agent Header Tests
/// <summary>
/// Verify that GetAIAgent(string name) passes RequestOptions to the Protocol method.
/// </summary>
[Fact]
public void GetAIAgent_WithStringName_PassesRequestOptionsToProtocol()
{
// Arrange
RequestOptions? capturedRequestOptions = null;
var mockAgentClient = new Mock<AgentClient>(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider());
mockAgentClient
.Setup(x => x.GetAgent(It.IsAny<string>(), It.IsAny<RequestOptions>()))
.Callback<string, RequestOptions>((name, options) => capturedRequestOptions = options)
.Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject))));
mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
// Act
var agent = mockAgentClient.Object.GetAIAgent("test-agent");
// Assert
Assert.NotNull(agent);
Assert.NotNull(capturedRequestOptions);
}
/// <summary>
/// Verify that GetAIAgentAsync(string name) passes RequestOptions to the Protocol method.
/// </summary>
[Fact]
public async Task GetAIAgentAsync_WithStringName_PassesRequestOptionsToProtocolAsync()
{
// Arrange
RequestOptions? capturedRequestOptions = null;
var mockAgentClient = new Mock<AgentClient>(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider());
mockAgentClient
.Setup(x => x.GetAgentAsync(It.IsAny<string>(), It.IsAny<RequestOptions>()))
.Callback<string, RequestOptions>((name, options) => capturedRequestOptions = options)
.Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject)))));
mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
// Act
var agent = await mockAgentClient.Object.GetAIAgentAsync("test-agent");
// Assert
Assert.NotNull(agent);
Assert.NotNull(capturedRequestOptions);
}
/// <summary>
/// Verify that CreateAIAgent(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method.
/// </summary>
[Fact]
public void CreateAIAgent_WithChatClientAgentOptions_PassesRequestOptionsToProtocol()
{
// Arrange
RequestOptions? capturedRequestOptions = null;
var mockAgentClient = new Mock<AgentClient>(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider());
mockAgentClient
.Setup(x => x.CreateAgentVersion(It.IsAny<string>(), It.IsAny<BinaryContent>(), It.IsAny<RequestOptions>()))
.Callback<string, BinaryContent, RequestOptions>((name, content, options) => capturedRequestOptions = options)
.Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject))));
mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
var agentOptions = new ChatClientAgentOptions { Name = "test-agent" };
// Act
var agent = mockAgentClient.Object.CreateAIAgent("gpt-4", agentOptions);
// Assert
Assert.NotNull(agent);
Assert.NotNull(capturedRequestOptions);
}
/// <summary>
/// Verify that CreateAIAgentAsync(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method.
/// </summary>
[Fact]
public async Task CreateAIAgentAsync_WithChatClientAgentOptions_PassesRequestOptionsToProtocolAsync()
{
// Arrange
RequestOptions? capturedRequestOptions = null;
var mockAgentClient = new Mock<AgentClient>(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider());
mockAgentClient
.Setup(x => x.CreateAgentVersionAsync(It.IsAny<string>(), It.IsAny<BinaryContent>(), It.IsAny<RequestOptions>()))
.Callback<string, BinaryContent, RequestOptions>((name, content, options) => capturedRequestOptions = options)
.Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject)))));
mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny<OpenAIClientOptions?>()))
.Returns(new OpenAIClient(new ApiKeyCredential("test-key")));
var agentOptions = new ChatClientAgentOptions { Name = "test-agent" };
// Act
var agent = await mockAgentClient.Object.CreateAIAgentAsync("gpt-4", agentOptions);
// Assert
Assert.NotNull(agent);
Assert.NotNull(capturedRequestOptions);
}
/// <summary>
/// Verifies that the user-agent header is added to both synchronous and asynchronous requests made by agent creation methods.
/// </summary>
[Fact]
public async Task CreateAIAgent_UserAgentHeaderAddedToRequestsAsync()
{
using var httpHandler = new HttpHandlerAssert(request =>
{
Assert.Equal("POST", request.Method.Method);
Assert.Contains("MEAI", request.Headers.UserAgent.ToString());
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentTestJsonObject, Encoding.UTF8, "application/json") };
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
// Arrange
var agentClient = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
var agentOptions = new ChatClientAgentOptions { Name = "test-agent" };
// Act
var agent1 = agentClient.CreateAIAgent("test", agentOptions);
var agent2 = await agentClient.CreateAIAgentAsync("test", agentOptions);
// Assert
Assert.NotNull(agent1);
Assert.NotNull(agent2);
}
/// <summary>
/// Verifies that the user-agent header is added to both synchronous and asynchronous GetAIAgent requests.
/// </summary>
[Fact]
public async Task GetAIAgent_UserAgentHeaderAddedToRequestsAsync()
{
using var httpHandler = new HttpHandlerAssert(request =>
{
Assert.Equal("GET", request.Method.Method);
Assert.Contains("MEAI", request.Headers.UserAgent.ToString());
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentTestJsonObject, Encoding.UTF8, "application/json") };
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
// Arrange
var agentClient = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
// Act
var agent1 = agentClient.GetAIAgent("test");
var agent2 = await agentClient.GetAIAgentAsync("test");
// Assert
Assert.NotNull(agent1);
Assert.NotNull(agent2);
}
#endregion
#region Helper Methods
/// <summary>
@@ -1792,21 +1971,41 @@ public sealed class AgentClientExtensionsTests
return new OpenAIClient(new ApiKeyCredential("test-key"), options);
}
public override ClientResult GetAgent(string agentName, RequestOptions options)
{
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject))));
}
public override ClientResult<AgentRecord> GetAgent(string agentName, CancellationToken cancellationToken = default)
{
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200));
}
public override Task<ClientResult> GetAgentAsync(string agentName, RequestOptions options)
{
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))));
}
public override Task<ClientResult<AgentRecord>> GetAgentAsync(string agentName, CancellationToken cancellationToken = default)
{
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200)));
}
public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null)
{
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject))));
}
public override ClientResult<AgentVersion> CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
{
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200));
}
public override Task<ClientResult> CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null)
{
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))));
}
public override Task<ClientResult<AgentVersion>> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200)));
@@ -1889,10 +2088,14 @@ public sealed class AgentClientExtensionsTests
private sealed class MockPipelineResponse : PipelineResponse
{
private readonly int _status;
private readonly BinaryData _content;
private readonly MockPipelineResponseHeaders _headers;
public MockPipelineResponse(int status)
public MockPipelineResponse(int status, BinaryData? content = null)
{
this._status = status;
this._content = content ?? BinaryData.Empty;
this._headers = new MockPipelineResponseHeaders();
}
public override int Status => this._status;
@@ -1905,9 +2108,9 @@ public sealed class AgentClientExtensionsTests
set { }
}
public override BinaryData Content => BinaryData.Empty;
public override BinaryData Content => this._content;
protected override PipelineResponseHeaders HeadersCore => new EmptyPipelineResponseHeaders();
protected override PipelineResponseHeaders HeadersCore => this._headers;
public override BinaryData BufferContent(CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Buffering content is not supported for mock responses.");
@@ -1919,26 +2122,71 @@ public sealed class AgentClientExtensionsTests
{
}
private sealed class EmptyPipelineResponseHeaders : PipelineResponseHeaders
private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders
{
private readonly Dictionary<string, string> _headers = new(StringComparer.OrdinalIgnoreCase)
{
{ "Content-Type", "application/json" },
{ "x-ms-request-id", "test-request-id" }
};
public override bool TryGetValue(string name, out string? value)
{
value = null;
return false;
return this._headers.TryGetValue(name, out value);
}
public override bool TryGetValues(string name, out IEnumerable<string>? values)
{
if (this._headers.TryGetValue(name, out var value))
{
values = new[] { value };
return true;
}
values = null;
return false;
}
public override IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
yield break;
return this._headers.GetEnumerator();
}
}
}
private sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
{
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
{
return new GetTokenOptions(new Dictionary<string, object>());
}
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
{
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
}
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
{
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
}
}
#endregion
private sealed class HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion) : HttpClientHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = assertion(request);
return Task.FromResult(response);
}
#if NET
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
return assertion(request);
}
#endif
}
}