From 9687e6e6a50c696f9c4e3670a030d669277ab512 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:32:42 +0000 Subject: [PATCH] .NET: Updates to Foundry Agents Package (#2125) * Remove the conversation creation always * Update unit tests + address IL + refactor * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Internalize unused methods --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...Extensions.cs => AgentClientExtensions.cs} | 4 +- .../AzureAIAgentChatClient.cs | 41 ++-- .../StatefulExecutor.cs | 2 +- ...Tests.cs => AgentClientExtensionsTests.cs} | 181 +++------------ .../AzureAIChatClientTests.cs | 212 ++++++++++++++++++ .../FakeAuthenticationTokenProvider.cs | 28 +++ .../HttpHandlerAssert.cs | 40 ++++ ...crosoft.Agents.AI.AzureAI.UnitTests.csproj | 12 + .../TestData/AgentResponse.json | 17 ++ .../TestData/AgentVersionResponse.json | 9 + .../TestData/OpenAIDefaultResponse.json | 68 ++++++ .../TestDataUtil.cs | 101 +++++++++ 12 files changed, 533 insertions(+), 182 deletions(-) rename dotnet/src/Microsoft.Agents.AI.AzureAI/{AgentsClientExtensions.cs => AgentClientExtensions.cs} (99%) rename dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/{AgentsClientExtensionsTests.cs => AgentClientExtensionsTests.cs} (92%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIChatClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentClientExtensions.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.AzureAI/AgentClientExtensions.cs index 38f33bf230..fc495bb797 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentClientExtensions.cs @@ -564,7 +564,7 @@ public static class AgentClientExtensions /// private static AgentVersion CreateAgentVersionWithProtocol(AgentClient agentClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) { - using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions)); + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIAgentsContext.Default)); ClientResult protocolResponse = agentClient.CreateAgentVersion(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)); return ClientResult.FromValue((AgentVersion)protocolResponse, protocolResponse.GetRawResponse()).Value; } @@ -574,7 +574,7 @@ public static class AgentClientExtensions /// private static async Task CreateAgentVersionWithProtocolAsync(AgentClient agentClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) { - using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions)); + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIAgentsContext.Default)); ClientResult protocolResponse = await agentClient.CreateAgentVersionAsync(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); return ClientResult.FromValue((AgentVersion)protocolResponse, protocolResponse.GetRawResponse()).Value; } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIAgentChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIAgentChatClient.cs index 5ea96ec0c8..4b208e29b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIAgentChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIAgentChatClient.cs @@ -72,42 +72,41 @@ internal sealed class AzureAIAgentChatClient : DelegatingChatClient /// public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - var conversationId = await this.GetOrCreateConversationAsync(options, cancellationToken).ConfigureAwait(false); - var conversationChatOptions = this.GetConversationEnabledChatOptions(options, conversationId); + var agentOptions = this.GetAgentEnabledChatOptions(options); - return await base.GetResponseAsync(messages, conversationChatOptions, cancellationToken).ConfigureAwait(false); + return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false); } /// public async override IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var conversation = await this.GetOrCreateConversationAsync(options, cancellationToken).ConfigureAwait(false); - var conversationOptions = this.GetConversationEnabledChatOptions(options, conversation); + var agentOptions = this.GetAgentEnabledChatOptions(options); - await foreach (var chunk in base.GetStreamingResponseAsync(messages, conversationOptions, cancellationToken).ConfigureAwait(false)) + await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false)) { yield return chunk; } } - private async Task GetOrCreateConversationAsync(ChatOptions? options, CancellationToken cancellationToken) - => string.IsNullOrWhiteSpace(options?.ConversationId) - ? (await this._agentClient.GetConversationClient().CreateConversationAsync(cancellationToken: cancellationToken).ConfigureAwait(false)).Value.Id - : options.ConversationId; - - private ChatOptions GetConversationEnabledChatOptions(ChatOptions? chatOptions, string conversationId) + private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options) { // Start with a clone of the base chat options defined for the agent, if any. - ChatOptions conversationChatOptions = this._chatOptions?.Clone() ?? new(); + ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new(); // Ignore per-request all options that can't be overridden. - conversationChatOptions.Instructions = null; - conversationChatOptions.Tools = null; + agentEnabledChatOptions.Instructions = null; + agentEnabledChatOptions.Tools = null; + agentEnabledChatOptions.Temperature = null; + agentEnabledChatOptions.TopP = null; + agentEnabledChatOptions.PresencePenalty = null; + + // Use the conversation from the request, or the one defined at the client level. + agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId; // Preserve the original RawRepresentationFactory - var originalFactory = chatOptions?.RawRepresentationFactory; + var originalFactory = options?.RawRepresentationFactory; - conversationChatOptions.RawRepresentationFactory = (client) => + agentEnabledChatOptions.RawRepresentationFactory = (client) => { if (originalFactory?.Invoke(this) is not ResponseCreationOptions responseCreationOptions) { @@ -115,12 +114,11 @@ internal sealed class AzureAIAgentChatClient : DelegatingChatClient } SetAgentReference(responseCreationOptions, this._agentVersion); - SetConversationReference(responseCreationOptions, conversationId); return responseCreationOptions; }; - return conversationChatOptions; + return agentEnabledChatOptions; } // Since the SetAdditionalProperty/SetAgentReference/SetConversationReference extensions in Azure.AI.Agents does not yet support the recent updates in OpenAI 2.6.0 @@ -139,10 +137,5 @@ internal sealed class AzureAIAgentChatClient : DelegatingChatClient SetAdditionalProperty(responseCreationOptions, "agent", ModelReaderWriter.Write(agentReference, new ModelReaderWriterOptions("W"), AzureAIAgentsContext.Default)); responseCreationOptions.Patch.Remove([.. "$."u8, .. Encoding.UTF8.GetBytes("model")]); } - - private static void SetConversationReference(ResponseCreationOptions responseCreationOptions, string conversationId) - { - SetAdditionalProperty(responseCreationOptions, "conversation", BinaryData.FromString($"\"{conversationId}\"")); - } #pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 344134369d..12079289a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -110,7 +110,7 @@ public abstract class StatefulExecutor : Executor { if (!skipCache && !context.ConcurrentRunsEnabled) { - TState newState = await invocation(this._stateCache ?? (this._initialStateFactory()), + TState newState = await invocation(this._stateCache ?? this._initialStateFactory(), context, cancellationToken).ConfigureAwait(false) ?? this._initialStateFactory(); diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentClientExtensionsTests.cs similarity index 92% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentClientExtensionsTests.cs index d989bdc166..65bb9da391 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentClientExtensionsTests.cs @@ -992,7 +992,7 @@ public sealed class AgentClientExtensionsTests Assert.Contains("required_tool", requestBody); } - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentVersionTestJsonObject, Encoding.UTF8, "application/json") }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; }); #pragma warning disable CA5399 @@ -1034,7 +1034,7 @@ public sealed class AgentClientExtensionsTests Assert.Contains("required_tool", requestBody); } - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentVersionTestJsonObject, Encoding.UTF8, "application/json") }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; }); #pragma warning disable CA5399 @@ -1788,7 +1788,7 @@ public sealed class AgentClientExtensionsTests mockAgentClient .Setup(x => x.GetAgent(It.IsAny(), It.IsAny())) .Callback((name, options) => capturedRequestOptions = options) - .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject)))); + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentResponseJson())))); mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); @@ -1813,7 +1813,7 @@ public sealed class AgentClientExtensionsTests mockAgentClient .Setup(x => x.GetAgentAsync(It.IsAny(), It.IsAny())) .Callback((name, options) => capturedRequestOptions = options) - .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject))))); + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))))); mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); @@ -1838,7 +1838,7 @@ public sealed class AgentClientExtensionsTests mockAgentClient .Setup(x => x.CreateAgentVersion(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((name, content, options) => capturedRequestOptions = options) - .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject)))); + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson())))); mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); @@ -1865,7 +1865,7 @@ public sealed class AgentClientExtensionsTests mockAgentClient .Setup(x => x.CreateAgentVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((name, content, options) => capturedRequestOptions = options) - .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject))))); + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))))); mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); @@ -1891,7 +1891,7 @@ public sealed class AgentClientExtensionsTests 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") }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; }); #pragma warning disable CA5399 @@ -1923,7 +1923,7 @@ public sealed class AgentClientExtensionsTests 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") }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; }); #pragma warning disable CA5399 @@ -1959,50 +1959,9 @@ public sealed class AgentClientExtensionsTests /// private AgentRecord CreateTestAgentRecord() { - return ModelReaderWriter.Read(BinaryData.FromString(AgentTestJsonObject))!; + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))!; } - private const string AgentDefinitionPlaceholder = """ - { - "kind": "prompt", - "model": "gpt-5-mini", - "instructions": "You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", - "tools": [] - } - """; - - private const string AgentTestJsonObject = $$""" - { - "object": "agent", - "id": "agent_abc123", - "name": "agent_abc123", - "versions": { - "latest": { - "metadata": {}, - "object": "agent.version", - "id": "agent_abc123:1", - "name": "agent_abc123", - "version": "1", - "description": "", - "created_at": 1761771936, - "definition": {{AgentDefinitionPlaceholder}} - } - } - } - """; - - private const string AgentVersionTestJsonObject = $$""" - { - "object": "agent.version", - "id": "agent_abc123:1", - "name": "agent_abc123", - "version": "1", - "description": "", - "created_at": 1761771936, - "definition": {{AgentDefinitionPlaceholder}} - } - """; - private const string OpenAPISpec = """ { "openapi": "3.0.3", @@ -2038,7 +1997,7 @@ public sealed class AgentClientExtensionsTests /// private AgentVersion CreateTestAgentVersion() { - return ModelReaderWriter.Read(BinaryData.FromString(AgentVersionTestJsonObject))!; + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; } /// @@ -2066,88 +2025,50 @@ public sealed class AgentClientExtensionsTests public override ClientResult GetAgent(string agentName, RequestOptions options) { - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))); + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); } public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) { - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200)); + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); } public override Task GetAgentAsync(string agentName, RequestOptions options) { - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject))))); + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); } public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) { - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200))); + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); } public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null) { - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))); + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); } public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) { - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200)); + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); } public override Task CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null) { - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject))))); + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); } public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) { - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200))); - } - - private static string TryApplyAgentDefinition(string json, AgentDefinition? definition) - { - if (definition is not null) - { - json = json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString()); - } - return json; - } - - private static string TryApplyAgentName(string json, string? agentName) - { - if (!string.IsNullOrEmpty(agentName)) - { - return json.Replace("\"agent_abc123\"", $"\"{agentName}\""); - } - return json; - } - - private static string TryApplyInstructions(string json, string? instructions) - { - if (!string.IsNullOrEmpty(instructions)) - { - return json.Replace("You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", instructions); - } - return json; - } - - private static string TryApplyDescription(string json, string? description) - { - if (!string.IsNullOrEmpty(description)) - { - return json.Replace("\"description\": \"\"", $"\"description\": \"{description}\""); - } - return json; - } - - private string ApplyResponseChanges(string json) - { - var modifiedJson = TryApplyAgentName(json, this._agentName); - modifiedJson = TryApplyAgentDefinition(modifiedJson, this._agentDefinition); - modifiedJson = TryApplyInstructions(modifiedJson, this._instructions); - modifiedJson = TryApplyDescription(modifiedJson, this._description); - - return modifiedJson; + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); } } @@ -2247,58 +2168,8 @@ public sealed class AgentClientExtensionsTests } } - private sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider - { - public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary properties) - { - return new GetTokenOptions(new Dictionary()); - } - - public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken) - { - return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1)); - } - - public override ValueTask GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken) - { - return new ValueTask(this.GetToken(options, cancellationToken)); - } - } - #endregion - private sealed class HttpHandlerAssert : HttpClientHandler - { - private readonly Func? _assertion; - private readonly Func>? _assertionAsync; - - public HttpHandlerAssert(Func assertion) - { - this._assertion = assertion; - } - public HttpHandlerAssert(Func> assertionAsync) - { - this._assertionAsync = assertionAsync; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (this._assertionAsync is not null) - { - return await this._assertionAsync.Invoke(request); - } - - return this._assertion!.Invoke(request); - } - -#if NET - protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) - { - return this._assertion!(request); - } -#endif - } - /// /// Helper method to access internal ChatOptions property via reflection. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIChatClientTests.cs new file mode 100644 index 0000000000..8e5be6065a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIChatClientTests.cs @@ -0,0 +1,212 @@ +// 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.Tasks; +using Azure.AI.Agents; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +public class AzureAIChatClientTests +{ + /// + /// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client + /// + [Fact] + public async Task ChatClient_UsesDefaultConversationIdAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + ChatOptions = new() { ConversationId = "conv_12345" } + }, openAIClientOptions: new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + }, openAIClientOptions: new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + ChatOptions = new() { ConversationId = "conv_should_not_use_default" } + }, openAIClientOptions: new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("resp_0888a", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + }, openAIClientOptions: new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientThread.ConversationId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs new file mode 100644 index 0000000000..d37ed881ff --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider +{ + public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary properties) + { + return new GetTokenOptions(new Dictionary()); + } + + public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken) + { + return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken) + { + return new ValueTask(this.GetToken(options, cancellationToken)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs new file mode 100644 index 0000000000..3b8025ed9e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +internal sealed class HttpHandlerAssert : HttpClientHandler +{ + private readonly Func? _assertion; + private readonly Func>? _assertionAsync; + + public HttpHandlerAssert(Func assertion) + { + this._assertion = assertion; + } + public HttpHandlerAssert(Func> assertionAsync) + { + this._assertionAsync = assertionAsync; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (this._assertionAsync is not null) + { + return await this._assertionAsync.Invoke(request); + } + + return this._assertion!.Invoke(request); + } + +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + return this._assertion!(request); + } +#endif +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj index a96251098b..79bc577661 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj @@ -8,4 +8,16 @@ + + + Always + + + Always + + + Always + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json new file mode 100644 index 0000000000..6e93dd65c4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json @@ -0,0 +1,17 @@ +{ + "object": "agent", + "id": "agent_abc123", + "name": "agent_abc123", + "versions": { + "latest": { + "metadata": {}, + "object": "agent.version", + "id": "agent_abc123:1", + "name": "agent_abc123", + "version": "1", + "description": "", + "created_at": 1761771936, + "definition": "agent-definition-placeholder" + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json new file mode 100644 index 0000000000..26e5b335ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json @@ -0,0 +1,9 @@ +{ + "object": "agent.version", + "id": "agent_abc123:1", + "name": "agent_abc123", + "version": "1", + "description": "", + "created_at": 1761771936, + "definition": "agent-definition-placeholder" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json new file mode 100644 index 0000000000..a270ebf4d4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json @@ -0,0 +1,68 @@ +{ + "id": "resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", + "object": "response", + "created_at": 1762941294, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0888a46cbf2b1ff3006914596f814481958e8cf500a6dabbec", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "Hello! How can I assist you today?" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 9, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 19 + }, + "user": null, + "metadata": {} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs new file mode 100644 index 0000000000..6305e58b89 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.IO; +using Azure.AI.Agents; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Utility class for loading and processing test data files. +/// +internal static class TestDataUtil +{ + private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json"); + private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json"); + private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json"); + + private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\""; + + private const string DefaultAgentDefinition = """ + { + "kind": "prompt", + "model": "gpt-5-mini", + "instructions": "You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", + "tools": [] + } + """; + + /// + /// Gets the agent response JSON with optional placeholder replacements applied. + /// + public static string GetAgentResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_agentResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + /// + /// Gets the agent version response JSON with optional placeholder replacements applied. + /// + public static string GetAgentVersionResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_agentVersionResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + /// + /// Gets the OpenAI default response JSON with optional placeholder replacements applied. + /// + public static string GetOpenAIDefaultResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_openAIDefaultResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + private static string ApplyAgentName(string json, string? agentName) + { + if (!string.IsNullOrEmpty(agentName)) + { + return json.Replace("\"agent_abc123\"", $"\"{agentName}\""); + } + return json; + } + + private static string ApplyAgentDefinition(string json, AgentDefinition? definition) + { + return (definition is not null) + ? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString()) + : json.Replace(AgentDefinitionPlaceholder, DefaultAgentDefinition); + } + + private static string ApplyInstructions(string json, string? instructions) + { + if (!string.IsNullOrEmpty(instructions)) + { + return json.Replace("You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", instructions); + } + return json; + } + + private static string ApplyDescription(string json, string? description) + { + if (!string.IsNullOrEmpty(description)) + { + return json.Replace("\"description\": \"\"", $"\"description\": \"{description}\""); + } + return json; + } +}