From 16cbc446119175c8a1a6f25fe20ad6573077ec38 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:08:11 +0100 Subject: [PATCH] .Net: Adding ChatOptions configuration to the Agent level (#75) * Adding ChatOptions configuration to the Agent level * fix warnings * Address Copilot Feedback * Add UT for new AgentExtensions * Add UnitTests for ChatOptions merging behavior * Fix warning * Address PR Feedback * Update dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgent.cs Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> --------- Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> --- .../GettingStarted/GettingStarted.csproj | 4 - .../GettingStarted/Steps/Step02_UsingTools.cs | 42 +- .../ChatCompletion/ChatClientAgent.cs | 125 ++- .../ChatCompletion/ChatClientAgentOptions.cs | 20 + .../ChatClientAgentExtensionsTests.cs | 861 ++++++++++++++++++ .../ChatClientAgentRunOptionsTests.cs | 105 +++ .../ChatCompletion/ChatClientAgentTests.cs | 399 ++++++++ 7 files changed, 1521 insertions(+), 35 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentRunOptionsTests.cs diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 2d13bb0bc0..908c71111b 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -27,10 +27,6 @@ - - true - - diff --git a/dotnet/samples/GettingStarted/Steps/Step02_UsingTools.cs b/dotnet/samples/GettingStarted/Steps/Step02_UsingTools.cs index 861b409349..a653819591 100644 --- a/dotnet/samples/GettingStarted/Steps/Step02_UsingTools.cs +++ b/dotnet/samples/GettingStarted/Steps/Step02_UsingTools.cs @@ -15,23 +15,22 @@ public sealed class Step02_UsingTools(ITestOutputHelper output) : AgentSample(ou using var chatClient = base.GetOpenAIChatClient(); // Define the agent + var menuTools = new MenuTools(); ChatClientAgent agent = new(chatClient, new() { Name = "Host", Instructions = "Answer questions about the menu.", + ChatOptions = new() + { + Tools = [ + AIFunctionFactory.Create(menuTools.GetMenu), + AIFunctionFactory.Create(menuTools.GetSpecials), + AIFunctionFactory.Create(menuTools.GetItemPrice) + ] + } }); - var menuTools = new MenuTools(); - var chatOptions = new ChatOptions - { - Tools = [ - AIFunctionFactory.Create(menuTools.GetMenu), - AIFunctionFactory.Create(menuTools.GetSpecials), - AIFunctionFactory.Create(menuTools.GetItemPrice), - ], - }; - // Create the chat history thread to capture the agent interaction. var thread = agent.GetNewThread(); @@ -44,7 +43,7 @@ public sealed class Step02_UsingTools(ITestOutputHelper output) : AgentSample(ou async Task InvokeAgentAsync(string input) { this.WriteUserMessage(input); - var response = await agent.RunAsync(input, thread, chatOptions: chatOptions); + var response = await agent.RunAsync(input, thread); this.WriteResponseOutput(response); } } @@ -56,23 +55,22 @@ public sealed class Step02_UsingTools(ITestOutputHelper output) : AgentSample(ou using var chatClient = base.GetOpenAIChatClient(); // Define the agent + var menuTools = new MenuTools(); ChatClientAgent agent = new(chatClient, new() { Name = "Host", Instructions = "Answer questions about the menu.", + ChatOptions = new() + { + Tools = [ + AIFunctionFactory.Create(menuTools.GetMenu), + AIFunctionFactory.Create(menuTools.GetSpecials), + AIFunctionFactory.Create(menuTools.GetItemPrice) + ] + } }); - var menuTools = new MenuTools(); - var chatOptions = new ChatOptions - { - Tools = [ - AIFunctionFactory.Create(menuTools.GetMenu), - AIFunctionFactory.Create(menuTools.GetSpecials), - AIFunctionFactory.Create(menuTools.GetItemPrice), - ], - }; - // Create the chat history thread to capture the agent interaction. var thread = agent.GetNewThread(); @@ -85,7 +83,7 @@ public sealed class Step02_UsingTools(ITestOutputHelper output) : AgentSample(ou async Task InvokeAgentAsync(string input) { this.WriteUserMessage(input); - await foreach (var update in agent.RunStreamingAsync(input, thread, chatOptions: chatOptions)) + await foreach (var update in agent.RunStreamingAsync(input, thread)) { this.WriteAgentOutput(update); } diff --git a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgent.cs index 45cec97c34..1c521863c8 100644 --- a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgent.cs @@ -32,9 +32,14 @@ public sealed class ChatClientAgent : Agent { Throw.IfNull(chatClient); + // Options must be cloned since ChatClientAgentOptions is mutable. + this._agentOptions = options?.Clone(); + + // Get the type of the chat client before wrapping it as an agent invoking chat client. this._chatClientType = chatClient.GetType(); + this.ChatClient = chatClient.AsAgentInvokingChatClient(); - this._agentOptions = options; + this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger(); } @@ -55,6 +60,11 @@ public sealed class ChatClientAgent : Agent /// public override string? Instructions => this._agentOptions?.Instructions; + /// + /// Gets of the default used by the agent. + /// + internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions; + /// public override async Task RunAsync( IReadOnlyCollection messages, @@ -162,22 +172,119 @@ public sealed class ChatClientAgent : Agent #region Private + /// + /// Configures and returns chat options by merging the provided run options with the agent's default chat options. + /// + /// This method prioritizes the chat options provided in over the + /// agent's default chat options. Any unset properties in the run options will be filled using the agent's chat + /// options. If both are , the method returns . + /// Optional run options that may include specific chat configuration settings. + /// A object representing the merged chat configuration, or if + /// neither the run options nor the agent's chat options are available. + private ChatOptions? CreateConfiguredChatOptions(AgentRunOptions? runOptions) + { + ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone(); + + // If no agent chat options were provided, return the request chat options as is. + if (this._agentOptions?.ChatOptions is null) + { + return requestChatOptions; + } + + // If no request chat options were provided, use the agent's chat options clone. + if (requestChatOptions is null) + { + return this._agentOptions?.ChatOptions?.Clone(); + } + + // If both are present, we need to merge them. + + // The merge strategy will prioritize the request options over the agent options, + // and will fill the blanks with agent options where the request options were not set. + + // Merge only the additional properties from the agent if they are not already set in the request options. + if (requestChatOptions.AdditionalProperties is not null && this._agentOptions.ChatOptions.AdditionalProperties is not null) + { + foreach (var property in this._agentOptions.ChatOptions.AdditionalProperties.Keys) + { + requestChatOptions.AdditionalProperties.TryAdd(property, this._agentOptions.ChatOptions.AdditionalProperties[property]); + } + } + else + { + requestChatOptions.AdditionalProperties ??= this._agentOptions.ChatOptions.AdditionalProperties; + } + requestChatOptions.AllowMultipleToolCalls ??= this._agentOptions.ChatOptions.AllowMultipleToolCalls; + requestChatOptions.ConversationId ??= this._agentOptions.ChatOptions.ConversationId; + requestChatOptions.FrequencyPenalty ??= this._agentOptions.ChatOptions.FrequencyPenalty; + requestChatOptions.MaxOutputTokens ??= this._agentOptions.ChatOptions.MaxOutputTokens; + requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId; + requestChatOptions.PresencePenalty ??= this._agentOptions.ChatOptions.PresencePenalty; + + // Chain the raw representation factory from the request options with the agent's factory if available. + if (this._agentOptions.ChatOptions.RawRepresentationFactory is { } agentFactory) + { + requestChatOptions.RawRepresentationFactory = requestChatOptions.RawRepresentationFactory is { } requestFactory + ? chatClient => requestFactory(chatClient) ?? agentFactory(chatClient) + : agentFactory; + } + + requestChatOptions.ResponseFormat ??= this._agentOptions.ChatOptions.ResponseFormat; + requestChatOptions.Seed ??= this._agentOptions.ChatOptions.Seed; + + // We concatenate the request stop sequences with the agent's stop sequences when available. + if (this._agentOptions.ChatOptions.StopSequences is { Count: not 0 }) + { + if (requestChatOptions.StopSequences is null || requestChatOptions.StopSequences.Count == 0) + { + // If the request stop sequences are not set or empty, we use the agent's stop sequences directly. + requestChatOptions.StopSequences = this._agentOptions.ChatOptions.StopSequences.ToArray(); + } + else + { + // If both agent's and request's stop sequences are set, we concatenate them. + requestChatOptions.StopSequences = [.. requestChatOptions.StopSequences, .. this._agentOptions.ChatOptions.StopSequences]; + } + } + + requestChatOptions.Temperature ??= this._agentOptions.ChatOptions.Temperature; + requestChatOptions.TopP ??= this._agentOptions.ChatOptions.TopP; + requestChatOptions.TopK ??= this._agentOptions.ChatOptions.TopK; + requestChatOptions.ToolMode ??= this._agentOptions.ChatOptions.ToolMode; + + // We concatenate the request tools with the agent's tools when available. + if (this._agentOptions.ChatOptions.Tools is { Count: not 0 }) + { + if (requestChatOptions.Tools is not { Count: > 0 }) + { + // If the request tools are not set or empty, we use the agent's tools directly. + requestChatOptions.Tools = this._agentOptions.ChatOptions.Tools; + } + else + { + // If the both agent's and request's tools are set, we concatenate all tools. + requestChatOptions.Tools = [.. requestChatOptions.Tools, .. this._agentOptions.ChatOptions.Tools]; + } + } + + return requestChatOptions; + } + /// /// Prepares the thread, chat options, and messages for agent execution. /// /// The conversation thread to use or create. /// The input messages to use. - /// Optional parameters for agent invocation. + /// Optional parameters for agent invocation. /// The cancellation token. /// A tuple containing the thread, chat options, and thread messages. - private async Task<(ChatClientAgentThread thread, ChatOptions? chatOptions, List threadMessages)> PrepareThreadAndMessagesAsync( + private async Task<(ChatClientAgentThread, ChatOptions?, List)> PrepareThreadAndMessagesAsync( AgentThread? thread, IReadOnlyCollection inputMessages, - AgentRunOptions? options, + AgentRunOptions? runOptions, CancellationToken cancellationToken) { - // Retrieve chat options from the provided AgentRunOptions if available. - ChatOptions? chatOptions = (options as ChatClientAgentRunOptions)?.ChatOptions; + ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions); var chatClientThread = this.ValidateOrCreateThreadType(thread, () => new()); @@ -192,7 +299,7 @@ public sealed class ChatClientAgent : Agent } // Update the messages with agent instructions. - this.UpdateThreadMessagesWithAgentInstructions(threadMessages, options); + this.UpdateThreadMessagesWithAgentInstructions(threadMessages, runOptions); // Add the input messages to the end of thread messages. threadMessages.AddRange(inputMessages); @@ -202,13 +309,13 @@ public sealed class ChatClientAgent : Agent if (!string.IsNullOrWhiteSpace(chatClientThread.Id) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && chatClientThread.Id != chatOptions.ConversationId) { throw new InvalidOperationException( - $"The {nameof(ChatOptions.ConversationId)} provided via {nameof(ChatOptions)} is different to the id of the provided {nameof(AgentThread)}. Only one thread id can be used for a run."); + $"The {nameof(chatOptions.ConversationId)} provided via {nameof(Microsoft.Extensions.AI.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}. Only one thread id can be used for a run."); } // Only clone and update ChatOptions if we have an id on the thread and we don't have the same one already in ChatOptions. if (!string.IsNullOrWhiteSpace(chatClientThread.Id) && chatClientThread.Id != chatOptions?.ConversationId) { - chatOptions = chatOptions is null ? new ChatOptions() : chatOptions.Clone(); + chatOptions ??= new(); chatOptions.ConversationId = chatClientThread.Id; } diff --git a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentOptions.cs index ec4e1e36b1..7e283f0fa3 100644 --- a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentOptions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI; + namespace Microsoft.Agents; /// @@ -30,4 +32,22 @@ public class ChatClientAgentOptions /// Gets or sets the agent description. /// public string? Description { get; set; } + + /// + /// Gets or sets the default chatOptions to use. + /// + public ChatOptions? ChatOptions { get; set; } + + /// + /// Creates a new instance of with the same values as this instance. + /// + internal ChatClientAgentOptions Clone() + => new() + { + Id = this.Id, + Name = this.Name, + Instructions = this.Instructions, + Description = this.Description, + ChatOptions = this.ChatOptions?.Clone() + }; } diff --git a/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentExtensionsTests.cs new file mode 100644 index 0000000000..9a1f7f249d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentExtensionsTests.cs @@ -0,0 +1,861 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.UnitTests.ChatCompletion; + +public class ChatClientAgentExtensionsTests +{ + #region RunAsync with IReadOnlyCollection Tests + + /// + /// Verify that RunAsync extension method with messages works with valid parameters. + /// + [Fact] + public async Task RunAsyncWithMessagesWorksWithValidParametersAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test message") }; + + // Act & Assert - Should not throw + var result = await ChatClientAgentExtensions.RunAsync(agent, messages); + Assert.NotNull(result); + } + + /// + /// Verify that RunAsync extension method with messages throws ArgumentNullException when agent is null. + /// + [Fact] + public async Task RunAsyncWithMessagesThrowsArgumentNullExceptionWhenAgentIsNullAsync() + { + // Arrange + ChatClientAgent agent = null!; + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ChatClientAgentExtensions.RunAsync(agent, messages)); + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verify that RunAsync extension method with messages throws ArgumentNullException when messages is null. + /// + [Fact] + public async Task RunAsyncWithMessagesThrowsArgumentNullExceptionWhenMessagesIsNullAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ChatClientAgentExtensions.RunAsync(agent, (IReadOnlyCollection)null!)); + Assert.Equal("messages", exception.ParamName); + } + + /// + /// Verify that RunAsync extension method with messages works with ChatOptions parameter. + /// + [Fact] + public async Task RunAsyncWithMessagesWorksWithChatOptionsAsync() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act - Call extension method (should not throw) + var result = await ChatClientAgentExtensions.RunAsync(agent, messages, chatOptions: chatOptions); + + // Assert - Extension method completed successfully + Assert.NotNull(result); + Assert.Single(result.Messages); + } + + /// + /// Verify that RunAsync extension method with messages passes AgentRunOptions correctly. + /// + [Fact] + public async Task RunAsyncWithMessagesPassesAgentRunOptionsCorrectlyAsync() + { + // Arrange + Mock mockService = new(); + List capturedMessages = []; + List capturedChatOptions = []; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + { + capturedChatOptions.Add(opts); + capturedMessages.AddRange(msgs); + }) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "base instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + var runOptions = new AgentRunOptions { AdditionalInstructions = "additional instructions" }; + + // Act + await ChatClientAgentExtensions.RunAsync(agent, messages, agentRunOptions: runOptions); + + // Assert + Assert.Contains(capturedMessages, m => m.Text == "base instructions" && m.Role == ChatRole.System); + Assert.Contains(capturedMessages, m => m.Text == "additional instructions" && m.Role == ChatRole.System); + Assert.Contains(capturedMessages, m => m.Text == "test" && m.Role == ChatRole.User); + Assert.All(capturedChatOptions, Assert.Null); + } + + /// + /// Verify that RunAsync extension method with messages works with thread parameter. + /// + [Fact] + public async Task RunAsyncWithMessagesWorksWithThreadParameterAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + var thread = agent.GetNewThread(); + + // Act + var result = await ChatClientAgentExtensions.RunAsync(agent, messages, thread: thread); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + mockService.Verify( + x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunAsync extension method with messages respects cancellation token. + /// + [Fact] + public async Task RunAsyncWithMessagesRespectsCancellationTokenAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ThrowsAsync(new OperationCanceledException()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + await Assert.ThrowsAsync(() => ChatClientAgentExtensions.RunAsync(agent, messages, cancellationToken: cts.Token)); + } + + #endregion + + #region RunAsync with string prompt Tests + + /// + /// Verify that RunAsync extension method with prompt calls the underlying agent method correctly. + /// + [Fact] + public async Task RunAsyncWithPromptCallsUnderlyingAgentMethodAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + var result = await ChatClientAgentExtensions.RunAsync(agent, TestPrompt); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("response", result.Messages[0].Text); + mockService.Verify( + x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunAsync extension method with prompt throws ArgumentNullException when agent is null. + /// + [Fact] + public async Task RunAsyncWithPromptThrowsArgumentNullExceptionWhenAgentIsNullAsync() + { + // Arrange + ChatClientAgent agent = null!; + const string TestPrompt = "test prompt"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ChatClientAgentExtensions.RunAsync(agent, TestPrompt)); + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verify that RunAsync extension method with prompt throws ArgumentNullException when prompt is null. + /// + [Fact] + public async Task RunAsyncWithPromptThrowsArgumentNullExceptionWhenPromptIsNullAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ChatClientAgentExtensions.RunAsync(agent, (string)null!)); + Assert.Equal("prompt", exception.ParamName); + } + + /// + /// Verify that RunAsync extension method with prompt throws ArgumentException when prompt is whitespace. + /// + [Fact] + public async Task RunAsyncWithPromptThrowsArgumentExceptionWhenPromptIsWhitespaceAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ChatClientAgentExtensions.RunAsync(agent, " ")); + Assert.Equal("prompt", exception.ParamName); + } + + /// + /// Verify that RunAsync extension method with prompt converts prompt to ChatMessage correctly. + /// + [Fact] + public async Task RunAsyncWithPromptConvertsPromptToChatMessageCorrectlyAsync() + { + // Arrange + Mock mockService = new(); + List capturedMessages = []; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + await ChatClientAgentExtensions.RunAsync(agent, TestPrompt); + + // Assert + Assert.Contains(capturedMessages, m => m.Text == "test prompt" && m.Role == ChatRole.User); + Assert.Contains(capturedMessages, m => m.Text == "test instructions" && m.Role == ChatRole.System); + } + + /// + /// Verify that RunAsync extension method with prompt passes ChatOptions correctly. + /// + [Fact] + public async Task RunAsyncWithPromptPassesChatOptionsCorrectlyAsync() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 200 }; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 200), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + await ChatClientAgentExtensions.RunAsync(agent, TestPrompt, chatOptions: chatOptions); + + // Assert + mockService.Verify( + x => x.GetResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 200), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunAsync extension method with prompt passes AgentRunOptions correctly. + /// + [Fact] + public async Task RunAsyncWithPromptPassesAgentRunOptionsCorrectlyAsync() + { + // Arrange + Mock mockService = new(); + List capturedMessages = []; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "base instructions" }); + const string TestPrompt = "test prompt"; + var runOptions = new AgentRunOptions { AdditionalInstructions = "additional instructions" }; + + // Act + await ChatClientAgentExtensions.RunAsync(agent, TestPrompt, agentRunOptions: runOptions); + + // Assert + Assert.Contains(capturedMessages, m => m.Text == "base instructions" && m.Role == ChatRole.System); + Assert.Contains(capturedMessages, m => m.Text == "additional instructions" && m.Role == ChatRole.System); + Assert.Contains(capturedMessages, m => m.Text == "test prompt" && m.Role == ChatRole.User); + } + + /// + /// Verify that RunAsync extension method with prompt works with thread parameter. + /// + [Fact] + public async Task RunAsyncWithPromptWorksWithThreadParameterAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + var thread = agent.GetNewThread(); + + // Act + var result = await ChatClientAgentExtensions.RunAsync(agent, TestPrompt, thread: thread); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + mockService.Verify( + x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunAsync extension method with prompt respects cancellation token. + /// + [Fact] + public async Task RunAsyncWithPromptRespectsCancellationTokenAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ThrowsAsync(new OperationCanceledException()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act & Assert + await Assert.ThrowsAsync(() => agent.RunAsync(TestPrompt, cancellationToken: cts.Token)); + } + + #endregion + + #region RunStreamingAsync with IReadOnlyCollection Tests + + /// + /// Verify that RunStreamingAsync extension method with messages calls the underlying agent method correctly. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesCallsUnderlyingAgentMethodAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"), + new ChatResponseUpdate(role: ChatRole.Assistant, content: " World"), + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test message") }; + + // Act + var updates = new List(); + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + Assert.Equal("Hello", updates[0].Text); + Assert.Equal(" World", updates[1].Text); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with messages throws ArgumentNullException when agent is null. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesThrowsArgumentNullExceptionWhenAgentIsNullAsync() + { + // Arrange + ChatClientAgent agent = null!; + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages)) + { + // Should not reach here + } + }); + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verify that RunStreamingAsync extension method with messages throws ArgumentNullException when messages is null. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesThrowsArgumentNullExceptionWhenMessagesIsNullAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, (IReadOnlyCollection)null!)) + { + // Should not reach here + } + }); + Assert.Equal("messages", exception.ParamName); + } + + /// + /// Verify that RunStreamingAsync extension method with messages passes ChatOptions correctly. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesPassesChatOptionsCorrectlyAsync() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 100), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + var updates = new List(); + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages, chatOptions: chatOptions)) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 100), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with messages works with thread parameter. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesWorksWithThreadParameterAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + var thread = agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages, thread: thread)) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with messages respects cancellation token. + /// + [Fact] + public async Task RunStreamingAsyncWithMessagesRespectsCancellationTokenAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Throws(new OperationCanceledException()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages, cancellationToken: cts.Token)) + { + // Should not reach here + } + }); + } + + #endregion + + #region RunStreamingAsync with string prompt Tests + + /// + /// Verify that RunStreamingAsync extension method with prompt calls the underlying agent method correctly. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptCallsUnderlyingAgentMethodAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"), + new ChatResponseUpdate(role: ChatRole.Assistant, content: " World"), + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + var updates = new List(); + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, TestPrompt)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + Assert.Equal("Hello", updates[0].Text); + Assert.Equal(" World", updates[1].Text); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt throws ArgumentNullException when agent is null. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptThrowsArgumentNullExceptionWhenAgentIsNullAsync() + { + // Arrange + ChatClientAgent agent = null!; + const string TestPrompt = "test prompt"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, TestPrompt)) + { + // Should not reach here + } + }); + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt throws ArgumentNullException when prompt is null. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptThrowsArgumentNullExceptionWhenPromptIsNullAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, (string)null!)) + { + // Should not reach here + } + }); + Assert.Equal("prompt", exception.ParamName); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt throws ArgumentException when prompt is whitespace. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptThrowsArgumentExceptionWhenPromptIsWhitespaceAsync() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, " ")) + { + // Should not reach here + } + }); + Assert.Equal("prompt", exception.ParamName); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt converts prompt to ChatMessage correctly. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptConvertsPromptToChatMessageCorrectlyAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")]; + + Mock mockService = new(); + List capturedMessages = []; + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedMessages.AddRange(msgs)) + .Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync(TestPrompt)) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + Assert.Contains(capturedMessages, m => m.Text == "test prompt" && m.Role == ChatRole.User); + Assert.Contains(capturedMessages, m => m.Text == "test instructions" && m.Role == ChatRole.System); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt passes ChatOptions correctly. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptPassesChatOptionsCorrectlyAsync() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 200 }; + ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 200), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync(TestPrompt, chatOptions: chatOptions)) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 200), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt works with thread parameter. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptWorksWithThreadParameterAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + var thread = agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync(TestPrompt, thread: thread)) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + mockService.Verify( + x => x.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verify that RunStreamingAsync extension method with prompt respects cancellation token. + /// + [Fact] + public async Task RunStreamingAsyncWithPromptRespectsCancellationTokenAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Throws(new OperationCanceledException()); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + const string TestPrompt = "test prompt"; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var update in agent.RunStreamingAsync(TestPrompt, cancellationToken: cts.Token)) + { + // Should not reach here + } + }); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentRunOptionsTests.cs new file mode 100644 index 0000000000..8821617865 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentRunOptionsTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.UnitTests.ChatCompletion; + +public class ChatClientAgentRunOptionsTests +{ + /// + /// Verify that ChatClientAgentRunOptions constructor works with null source and null chatOptions. + /// + [Fact] + public void ConstructorWorksWithNullSourceAndNullChatOptions() + { + // Act + var runOptions = new ChatClientAgentRunOptions(); + + // Assert + Assert.Null(runOptions.OnIntermediateMessages); + Assert.Null(runOptions.AdditionalInstructions); + Assert.Null(runOptions.ChatOptions); + } + + /// + /// Verify that ChatClientAgentRunOptions constructor works with null source and provided chatOptions. + /// + [Fact] + public void ConstructorWorksWithNullSourceAndProvidedChatOptions() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + + // Act + var runOptions = new ChatClientAgentRunOptions(null, chatOptions); + + // Assert + Assert.Null(runOptions.OnIntermediateMessages); + Assert.Null(runOptions.AdditionalInstructions); + Assert.Same(chatOptions, runOptions.ChatOptions); + } + + /// + /// Verify that ChatClientAgentRunOptions constructor copies properties from source AgentRunOptions. + /// + [Fact] + public void ConstructorCopiesPropertiesFromSourceAgentRunOptions() + { + // Arrange + var sourceRunOptions = new AgentRunOptions + { + AdditionalInstructions = "additional instructions", + OnIntermediateMessages = messages => Task.CompletedTask + }; + var chatOptions = new ChatOptions { MaxOutputTokens = 200 }; + + // Act + var runOptions = new ChatClientAgentRunOptions(sourceRunOptions, chatOptions); + + // Assert + Assert.Same(sourceRunOptions.OnIntermediateMessages, runOptions.OnIntermediateMessages); + Assert.Equal("additional instructions", runOptions.AdditionalInstructions); + Assert.Same(chatOptions, runOptions.ChatOptions); + } + + /// + /// Verify that ChatClientAgentRunOptions constructor works with source but null chatOptions. + /// + [Fact] + public void ConstructorWorksWithSourceButNullChatOptions() + { + // Arrange + var sourceRunOptions = new AgentRunOptions + { + AdditionalInstructions = "test instructions" + }; + + // Act + var runOptions = new ChatClientAgentRunOptions(sourceRunOptions, null); + + // Assert + Assert.Equal("test instructions", runOptions.AdditionalInstructions); + Assert.Null(runOptions.ChatOptions); + } + + /// + /// Verify that ChatClientAgentRunOptions ChatOptions property is set and mutable. + /// + [Fact] + public void ChatOptionsPropertyIsReadOnly() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + var runOptions = new ChatClientAgentRunOptions(null, chatOptions); + chatOptions.MaxOutputTokens = 200; // Change the property to verify mutability + + // Act & Assert + Assert.Same(chatOptions, runOptions.ChatOptions); + + // Verify that the property doesn't have a setter by checking if it's the same instance + var retrievedOptions = runOptions.ChatOptions!; + Assert.Same(chatOptions, retrievedOptions); + Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected + } +} diff --git a/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs index 3c928c42f9..6e92514ab6 100644 --- a/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs @@ -623,6 +623,405 @@ public class ChatClientAgentTests #endregion + #region ChatOptions Property Tests + + /// + /// Verify that ChatOptions property returns null when agent options are null. + /// + [Fact] + public void ChatOptionsReturnsNullWhenAgentOptionsAreNull() + { + // Arrange + var chatClient = new Mock().Object; + ChatClientAgent agent = new(chatClient, null); + + // Act & Assert + Assert.Null(agent.ChatOptions); + } + + /// + /// Verify that ChatOptions property returns null when agent options ChatOptions is null. + /// + [Fact] + public void ChatOptionsReturnsNullWhenAgentOptionsChatOptionsIsNull() + { + // Arrange + var chatClient = new Mock().Object; + var agentOptions = new ChatClientAgentOptions { ChatOptions = null }; + ChatClientAgent agent = new(chatClient, agentOptions); + + // Act & Assert + Assert.Null(agent.ChatOptions); + } + + /// + /// Verify that ChatOptions property returns a cloned copy when agent options have ChatOptions. + /// + [Fact] + public void ChatOptionsReturnsClonedCopyWhenAgentOptionsHaveChatOptions() + { + // Arrange + var chatClient = new Mock().Object; + var originalChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.5f }; + var agentOptions = new ChatClientAgentOptions { ChatOptions = originalChatOptions }; + ChatClientAgent agent = new(chatClient, agentOptions); + + // Act + var returnedChatOptions = agent.ChatOptions; + + // Assert + Assert.NotNull(returnedChatOptions); + Assert.NotSame(originalChatOptions, returnedChatOptions); // Should be a different instance (cloned) + Assert.Equal(originalChatOptions.MaxOutputTokens, returnedChatOptions.MaxOutputTokens); + Assert.Equal(originalChatOptions.Temperature, returnedChatOptions.Temperature); + } + + #endregion + + #region ChatOptions Merging Tests + + /// + /// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't. + /// + [Fact] + public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() + { + // Arrange + var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f }; + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() + { + Instructions = "test instructions", + ChatOptions = agentChatOptions + }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equal(100, capturedChatOptions.MaxOutputTokens); + Assert.Equal(0.7f, capturedChatOptions.Temperature); + } + + /// + /// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't. + /// + [Fact] + public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync() + { + // Arrange + var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f }; + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages, chatOptions: requestChatOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equivalent(requestChatOptions, capturedChatOptions); // Should be the same instance since no merging needed + Assert.Equal(200, capturedChatOptions.MaxOutputTokens); + Assert.Equal(0.3f, capturedChatOptions.Temperature); + } + + /// + /// Verify that ChatOptions merging prioritizes request options over agent options. + /// + [Fact] + public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsync() + { + // Arrange + var agentChatOptions = new ChatOptions + { + MaxOutputTokens = 100, + Temperature = 0.7f, + TopP = 0.9f, + ModelId = "agent-model" + }; + var requestChatOptions = new ChatOptions + { + MaxOutputTokens = 200, + Temperature = 0.3f + // TopP and ModelId not set, should use agent values + }; + var expectedChatOptionsMerge = new ChatOptions + { + MaxOutputTokens = 200, // Request value takes priority + Temperature = 0.3f, // Request value takes priority + TopP = 0.9f, // Agent value used when request doesn't specify + ModelId = "agent-model" // Agent value used when request doesn't specify + }; + + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() + { + Instructions = "test instructions", + ChatOptions = agentChatOptions + }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages, chatOptions: requestChatOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the same instance (modified in place) + Assert.Equal(200, capturedChatOptions.MaxOutputTokens); // Request value takes priority + Assert.Equal(0.3f, capturedChatOptions.Temperature); // Request value takes priority + Assert.Equal(0.9f, capturedChatOptions.TopP); // Agent value used when request doesn't specify + Assert.Equal("agent-model", capturedChatOptions.ModelId); // Agent value used when request doesn't specify + } + + /// + /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// + [Fact] + public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + { + // Arrange + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages); + + // Assert + Assert.Null(capturedChatOptions); + } + + /// + /// Verify that ChatOptions merging concatenates Tools from agent and request. + /// + [Fact] + public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync() + { + // Arrange + var agentTool = AIFunctionFactory.Create(() => "agent tool"); + var requestTool = AIFunctionFactory.Create(() => "request tool"); + + var agentChatOptions = new ChatOptions + { + Tools = [agentTool] + }; + var requestChatOptions = new ChatOptions + { + Tools = [requestTool] + }; + + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() + { + Instructions = "test instructions", + ChatOptions = agentChatOptions + }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages, chatOptions: requestChatOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.NotNull(capturedChatOptions.Tools); + Assert.Equal(2, capturedChatOptions.Tools.Count); + // Request tools should come first, then agent tools + Assert.Contains(requestTool, capturedChatOptions.Tools); + Assert.Contains(agentTool, capturedChatOptions.Tools); + } + + /// + /// Verify that ChatOptions merging uses agent Tools when request has no Tools. + /// + [Fact] + public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync() + { + // Arrange + var agentTool = AIFunctionFactory.Create(() => "agent tool"); + + var agentChatOptions = new ChatOptions + { + Tools = [agentTool] + }; + var requestChatOptions = new ChatOptions + { + MaxOutputTokens = 100 + // No Tools specified + }; + + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() + { + Instructions = "test instructions", + ChatOptions = agentChatOptions + }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages, chatOptions: requestChatOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.NotNull(capturedChatOptions.Tools); + Assert.Single(capturedChatOptions.Tools); + Assert.Contains(agentTool, capturedChatOptions.Tools); // Should contain the agent's tool + } + + /// + /// Verify that ChatOptions merging handles all scalar properties correctly. + /// + [Fact] + public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync() + { + // Arrange + var agentChatOptions = new ChatOptions + { + MaxOutputTokens = 100, + Temperature = 0.7f, + TopP = 0.9f, + TopK = 50, + PresencePenalty = 0.1f, + FrequencyPenalty = 0.2f, + ModelId = "agent-model", + Seed = 12345, + ConversationId = "agent-conversation", + AllowMultipleToolCalls = true, + StopSequences = ["agent-stop"] + }; + var requestChatOptions = new ChatOptions + { + MaxOutputTokens = 200, + Temperature = 0.3f, + // Other properties not set, should use agent values + StopSequences = ["request-stop"] + }; + + var expectedChatOptionsMerge = new ChatOptions + { + MaxOutputTokens = 200, + Temperature = 0.3f, + + // Agent value used when request doesn't specify + TopP = 0.9f, + TopK = 50, + PresencePenalty = 0.1f, + FrequencyPenalty = 0.2f, + ModelId = "agent-model", + Seed = 12345, + ConversationId = "agent-conversation", + AllowMultipleToolCalls = true, + + // Merged StopSequences + StopSequences = ["request-stop", "agent-stop"] + }; + + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, new() + { + Instructions = "test instructions", + ChatOptions = agentChatOptions + }); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages, chatOptions: requestChatOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the equivalent instance (modified in place) + + // Request values should take priority + Assert.Equal(200, capturedChatOptions.MaxOutputTokens); + Assert.Equal(0.3f, capturedChatOptions.Temperature); + + // Merge StopSequences + Assert.Equal(["request-stop", "agent-stop"], capturedChatOptions.StopSequences); + + // Agent values should be used when request doesn't specify + Assert.Equal(0.9f, capturedChatOptions.TopP); + Assert.Equal(50, capturedChatOptions.TopK); + Assert.Equal(0.1f, capturedChatOptions.PresencePenalty); + Assert.Equal(0.2f, capturedChatOptions.FrequencyPenalty); + Assert.Equal("agent-model", capturedChatOptions.ModelId); + Assert.Equal(12345, capturedChatOptions.Seed); + Assert.Equal("agent-conversation", capturedChatOptions.ConversationId); + Assert.Equal(true, capturedChatOptions.AllowMultipleToolCalls); + } + + #endregion + #region RunStreamingAsync Tests ///