.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>
This commit is contained in:
Roger Barreto
2025-06-17 10:08:11 +01:00
committed by GitHub
Unverified
parent 293cdb1846
commit 16cbc44611
7 changed files with 1521 additions and 35 deletions
@@ -27,10 +27,6 @@
<ProjectReference Include="..\..\src\Microsoft.Agents\Microsoft.Agents.csproj" />
</ItemGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<Using Include="GettingStarted" />
<Using Include="Microsoft.Shared.SampleUtilities" />
@@ -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);
}
@@ -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<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
}
@@ -55,6 +60,11 @@ public sealed class ChatClientAgent : Agent
/// <inheritdoc/>
public override string? Instructions => this._agentOptions?.Instructions;
/// <summary>
/// Gets of the default <see cref="Microsoft.Extensions.AI.ChatOptions"/> used by the agent.
/// </summary>
internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions;
/// <inheritdoc/>
public override async Task<ChatResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
@@ -162,22 +172,119 @@ public sealed class ChatClientAgent : Agent
#region Private
/// <summary>
/// Configures and returns chat options by merging the provided run options with the agent's default chat options.
/// </summary>
/// <remarks>This method prioritizes the chat options provided in <paramref name="runOptions"/> 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 <see langword="null"/>, the method returns <see langword="null"/>.</remarks>
/// <param name="runOptions">Optional run options that may include specific chat configuration settings.</param>
/// <returns>A <see cref="ChatOptions"/> object representing the merged chat configuration, or <see langword="null"/> if
/// neither the run options nor the agent's chat options are available.</returns>
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;
}
/// <summary>
/// Prepares the thread, chat options, and messages for agent execution.
/// </summary>
/// <param name="thread">The conversation thread to use or create.</param>
/// <param name="inputMessages">The input messages to use.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(ChatClientAgentThread thread, ChatOptions? chatOptions, List<ChatMessage> threadMessages)> PrepareThreadAndMessagesAsync(
private async Task<(ChatClientAgentThread, ChatOptions?, List<ChatMessage>)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IReadOnlyCollection<ChatMessage> 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<ChatClientAgentThread>(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;
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
/// <summary>
@@ -30,4 +32,22 @@ public class ChatClientAgentOptions
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the default chatOptions to use.
/// </summary>
public ChatOptions? ChatOptions { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
internal ChatClientAgentOptions Clone()
=> new()
{
Id = this.Id,
Name = this.Name,
Instructions = this.Instructions,
Description = this.Description,
ChatOptions = this.ChatOptions?.Clone()
};
}
@@ -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<ChatMessage> Tests
/// <summary>
/// Verify that RunAsync extension method with messages works with valid parameters.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesWorksWithValidParametersAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test message") };
// Act & Assert - Should not throw
var result = await ChatClientAgentExtensions.RunAsync(agent, messages);
Assert.NotNull(result);
}
/// <summary>
/// Verify that RunAsync extension method with messages throws ArgumentNullException when agent is null.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesThrowsArgumentNullExceptionWhenAgentIsNullAsync()
{
// Arrange
ChatClientAgent agent = null!;
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
ChatClientAgentExtensions.RunAsync(agent, messages));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verify that RunAsync extension method with messages throws ArgumentNullException when messages is null.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesThrowsArgumentNullExceptionWhenMessagesIsNullAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
ChatClientAgentExtensions.RunAsync(agent, (IReadOnlyCollection<ChatMessage>)null!));
Assert.Equal("messages", exception.ParamName);
}
/// <summary>
/// Verify that RunAsync extension method with messages works with ChatOptions parameter.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesWorksWithChatOptionsAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { 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);
}
/// <summary>
/// Verify that RunAsync extension method with messages passes AgentRunOptions correctly.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesPassesAgentRunOptionsCorrectlyAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
List<ChatMessage> capturedMessages = [];
List<ChatOptions> capturedChatOptions = [];
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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);
}
/// <summary>
/// Verify that RunAsync extension method with messages works with thread parameter.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesWorksWithThreadParameterAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { 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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync extension method with messages respects cancellation token.
/// </summary>
[Fact]
public async Task RunAsyncWithMessagesRespectsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ThrowsAsync(new OperationCanceledException());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => ChatClientAgentExtensions.RunAsync(agent, messages, cancellationToken: cts.Token));
}
#endregion
#region RunAsync with string prompt Tests
/// <summary>
/// Verify that RunAsync extension method with prompt calls the underlying agent method correctly.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptCallsUnderlyingAgentMethodAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync extension method with prompt throws ArgumentNullException when agent is null.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptThrowsArgumentNullExceptionWhenAgentIsNullAsync()
{
// Arrange
ChatClientAgent agent = null!;
const string TestPrompt = "test prompt";
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
ChatClientAgentExtensions.RunAsync(agent, TestPrompt));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verify that RunAsync extension method with prompt throws ArgumentNullException when prompt is null.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptThrowsArgumentNullExceptionWhenPromptIsNullAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
ChatClientAgentExtensions.RunAsync(agent, (string)null!));
Assert.Equal("prompt", exception.ParamName);
}
/// <summary>
/// Verify that RunAsync extension method with prompt throws ArgumentException when prompt is whitespace.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptThrowsArgumentExceptionWhenPromptIsWhitespaceAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() =>
ChatClientAgentExtensions.RunAsync(agent, " "));
Assert.Equal("prompt", exception.ParamName);
}
/// <summary>
/// Verify that RunAsync extension method with prompt converts prompt to ChatMessage correctly.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptConvertsPromptToChatMessageCorrectlyAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
List<ChatMessage> capturedMessages = [];
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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);
}
/// <summary>
/// Verify that RunAsync extension method with prompt passes ChatOptions correctly.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptPassesChatOptionsCorrectlyAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 200 };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 200),
It.IsAny<CancellationToken>())).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<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 200),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync extension method with prompt passes AgentRunOptions correctly.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptPassesAgentRunOptionsCorrectlyAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
List<ChatMessage> capturedMessages = [];
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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);
}
/// <summary>
/// Verify that RunAsync extension method with prompt works with thread parameter.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptWorksWithThreadParameterAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync extension method with prompt respects cancellation token.
/// </summary>
[Fact]
public async Task RunAsyncWithPromptRespectsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ThrowsAsync(new OperationCanceledException());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
const string TestPrompt = "test prompt";
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => agent.RunAsync(TestPrompt, cancellationToken: cts.Token));
}
#endregion
#region RunStreamingAsync with IReadOnlyCollection<ChatMessage> Tests
/// <summary>
/// Verify that RunStreamingAsync extension method with messages calls the underlying agent method correctly.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesCallsUnderlyingAgentMethodAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " World"),
];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test message") };
// Act
var updates = new List<ChatResponseUpdate>();
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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with messages throws ArgumentNullException when agent is null.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesThrowsArgumentNullExceptionWhenAgentIsNullAsync()
{
// Arrange
ChatClientAgent agent = null!;
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages))
{
// Should not reach here
}
});
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with messages throws ArgumentNullException when messages is null.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesThrowsArgumentNullExceptionWhenMessagesIsNullAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, (IReadOnlyCollection<ChatMessage>)null!))
{
// Should not reach here
}
});
Assert.Equal("messages", exception.ParamName);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with messages passes ChatOptions correctly.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesPassesChatOptionsCorrectlyAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
var updates = new List<ChatResponseUpdate>();
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<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with messages works with thread parameter.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesWorksWithThreadParameterAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
var thread = agent.GetNewThread();
// Act
var updates = new List<ChatResponseUpdate>();
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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with messages respects cancellation token.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithMessagesRespectsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Throws(new OperationCanceledException());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, messages, cancellationToken: cts.Token))
{
// Should not reach here
}
});
}
#endregion
#region RunStreamingAsync with string prompt Tests
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt calls the underlying agent method correctly.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptCallsUnderlyingAgentMethodAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " World"),
];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
const string TestPrompt = "test prompt";
// Act
var updates = new List<ChatResponseUpdate>();
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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt throws ArgumentNullException when agent is null.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptThrowsArgumentNullExceptionWhenAgentIsNullAsync()
{
// Arrange
ChatClientAgent agent = null!;
const string TestPrompt = "test prompt";
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, TestPrompt))
{
// Should not reach here
}
});
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt throws ArgumentNullException when prompt is null.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptThrowsArgumentNullExceptionWhenPromptIsNullAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, (string)null!))
{
// Should not reach here
}
});
Assert.Equal("prompt", exception.ParamName);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt throws ArgumentException when prompt is whitespace.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptThrowsArgumentExceptionWhenPromptIsWhitespaceAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(async () =>
{
await foreach (var update in ChatClientAgentExtensions.RunStreamingAsync(agent, " "))
{
// Should not reach here
}
});
Assert.Equal("prompt", exception.ParamName);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt converts prompt to ChatMessage correctly.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptConvertsPromptToChatMessageCorrectlyAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")];
Mock<IChatClient> mockService = new();
List<ChatMessage> capturedMessages = [];
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatResponseUpdate>();
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);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt passes ChatOptions correctly.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptPassesChatOptionsCorrectlyAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 200 };
ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 200),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
const string TestPrompt = "test prompt";
// Act
var updates = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(TestPrompt, chatOptions: chatOptions))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
mockService.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 200),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt works with thread parameter.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptWorksWithThreadParameterAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates = [new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(TestPrompt, thread: thread))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
mockService.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunStreamingAsync extension method with prompt respects cancellation token.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWithPromptRespectsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Throws(new OperationCanceledException());
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
const string TestPrompt = "test prompt";
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(TestPrompt, cancellationToken: cts.Token))
{
// Should not reach here
}
});
}
#endregion
}
@@ -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
{
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor works with null source and null chatOptions.
/// </summary>
[Fact]
public void ConstructorWorksWithNullSourceAndNullChatOptions()
{
// Act
var runOptions = new ChatClientAgentRunOptions();
// Assert
Assert.Null(runOptions.OnIntermediateMessages);
Assert.Null(runOptions.AdditionalInstructions);
Assert.Null(runOptions.ChatOptions);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor works with null source and provided chatOptions.
/// </summary>
[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);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor copies properties from source AgentRunOptions.
/// </summary>
[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);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor works with source but null chatOptions.
/// </summary>
[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);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions ChatOptions property is set and mutable.
/// </summary>
[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
}
}
@@ -623,6 +623,405 @@ public class ChatClientAgentTests
#endregion
#region ChatOptions Property Tests
/// <summary>
/// Verify that ChatOptions property returns null when agent options are null.
/// </summary>
[Fact]
public void ChatOptionsReturnsNullWhenAgentOptionsAreNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, null);
// Act & Assert
Assert.Null(agent.ChatOptions);
}
/// <summary>
/// Verify that ChatOptions property returns null when agent options ChatOptions is null.
/// </summary>
[Fact]
public void ChatOptionsReturnsNullWhenAgentOptionsChatOptionsIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var agentOptions = new ChatClientAgentOptions { ChatOptions = null };
ChatClientAgent agent = new(chatClient, agentOptions);
// Act & Assert
Assert.Null(agent.ChatOptions);
}
/// <summary>
/// Verify that ChatOptions property returns a cloned copy when agent options have ChatOptions.
/// </summary>
[Fact]
public void ChatOptionsReturnsClonedCopyWhenAgentOptionsHaveChatOptions()
{
// Arrange
var chatClient = new Mock<IChatClient>().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
/// <summary>
/// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync()
{
// Arrange
var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equal(100, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.7f, capturedChatOptions.Temperature);
}
/// <summary>
/// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync()
{
// Arrange
var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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);
}
/// <summary>
/// Verify that ChatOptions merging prioritizes request options over agent options.
/// </summary>
[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<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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
}
/// <summary>
/// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.Null(capturedChatOptions);
}
/// <summary>
/// Verify that ChatOptions merging concatenates Tools from agent and request.
/// </summary>
[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<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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);
}
/// <summary>
/// Verify that ChatOptions merging uses agent Tools when request has no Tools.
/// </summary>
[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<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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
}
/// <summary>
/// Verify that ChatOptions merging handles all scalar properties correctly.
/// </summary>
[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<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> { 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
/// <summary>