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
///