.Net: ChatClientAgent (Non-Streaming initial impl) (#65)

* Add non-streaming impl

* Add missing UT

* AgentThread UT + ensuring behavior

* Fix warnings

* Increase code coverage

* Add UT

* Updated abstractions fixes

* Adding AgentInvokingChatClient for instruction handling logic

* Moving AsAgentInvokingClient to ChatClientExtensions

* Address PR Feedback

* Address PR feedback

* Address PR feedback

* Signature updates for chat run options
This commit is contained in:
Roger Barreto
2025-06-11 11:21:30 +01:00
committed by GitHub
Unverified
parent 46a117d581
commit 89daf173c4
18 changed files with 1386 additions and 29 deletions
+1
View File
@@ -8,6 +8,7 @@
<!-- System.* -->
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.5.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -28,7 +29,7 @@ public class AgentRunOptions
Throw.IfNull(options);
this.AdditionalInstructions = options.AdditionalInstructions;
this.OnIntermediateMessage = options.OnIntermediateMessage;
this.OnIntermediateMessages = options.OnIntermediateMessages;
}
/// <summary>
@@ -46,5 +47,5 @@ public class AgentRunOptions
/// when invoking the agent with streaming.
/// </para>
/// </remarks>
public Func<ChatMessage, Task>? OnIntermediateMessage { get; set; } = null;
public Func<IReadOnlyCollection<ChatMessage>, Task>? OnIntermediateMessages { get; set; } = null;
}
@@ -1,10 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents;
/// <summary>
/// Placeholder class.
/// </summary>
public class AgentImplementation
{
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents;
/// <summary>Provides extensions for configuring <see cref="AgentInvokingChatClient"/> instances.</summary>
public static class AgentChatClientBuilderExtensions
{
/// <summary>
/// Enables automatic function call invocation on the chat pipeline.
/// </summary>
/// <remarks>This works by adding an instance of <see cref="AgentInvokingChatClient"/> with default options.</remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> being used to build the chat pipeline.</param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static ChatClientBuilder UseAgentInvocation(
this ChatClientBuilder builder)
{
_ = Throw.IfNull(builder);
return builder.Use((innerClient, services) =>
{
return new AgentInvokingChatClient(innerClient);
});
}
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
/// <summary>
/// Internal chat client that handle agent invocation details for the chat client pipeline.
/// </summary>
internal sealed class AgentInvokingChatClient : DelegatingChatClient
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentInvokingChatClient"/> class.
/// </summary>
/// <param name="chatClient">The chat client to invoke agents.</param>
internal AgentInvokingChatClient(IChatClient chatClient)
: base(chatClient)
{
}
}
@@ -0,0 +1,151 @@
// 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 Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents;
/// <summary>
/// Represents an agent that can be invoked using a chat client.
/// </summary>
public sealed class ChatClientAgent : Agent
{
private readonly ChatClientAgentOptions? _agentOptions;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgent"/> class.
/// </summary>
/// <param name="chatClient">The chat client to use for invoking the agent.</param>
/// <param name="options">Optional agent options to configure the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options = null, ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(chatClient);
this.ChatClient = chatClient.AsAgentInvokingChatClient();
this._agentOptions = options;
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
}
/// <summary>
/// The chat client.
/// </summary>
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the role used for agent instructions. Defaults to "system".
/// </summary>
/// <remarks>
/// Certain versions of "O*" series (deep reasoning) models require the instructions
/// to be provided as "developer" role. Other versions support neither role and
/// an agent targeting such a model cannot provide instructions. Agent functionality
/// will be dictated entirely by the provided plugins.
/// </remarks>
public ChatRole InstructionsRole { get; set; } = ChatRole.System;
/// <inheritdoc/>
public override string Id => this._agentOptions?.Id ?? base.Id;
/// <inheritdoc/>
public override string? Name => this._agentOptions?.Name;
/// <inheritdoc/>
public override string? Description => this._agentOptions?.Description;
/// <inheritdoc/>
public override string? Instructions => this._agentOptions?.Instructions;
/// <inheritdoc/>
public override async Task<ChatResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
// Retrieve chat options from the provided AgentRunOptions if available.
ChatOptions? chatOptions = (options as ChatClientAgentRunOptions)?.ChatOptions;
var chatClientThread = this.ValidateOrCreateThreadType<ChatClientAgentThread>(thread, () => new());
// Add any existing messages from the thread to the messages to be sent to the chat client.
List<ChatMessage> threadMessages = [];
if (chatClientThread is IMessagesRetrievableThread messagesRetrievableThread)
{
await foreach (ChatMessage message in messagesRetrievableThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false))
{
threadMessages.Add(message);
}
}
// Append to the existing thread messages the messages that were passed in to this call.
threadMessages.AddRange(messages);
// Update the messages with agent instructions.
this.UpdateThreadMessagesWithAgentInstructions(threadMessages, options);
var agentName = this.Name ?? "UnnamedAgent";
Type serviceType = this.ChatClient.GetType();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, serviceType);
ChatResponse chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, serviceType, messages.Count);
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent messages state in the thread.
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, messages, cancellationToken).ConfigureAwait(false);
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
{
chatResponseMessage.AuthorName ??= agentName;
}
// Convert the chat response messages to a valid IReadOnlyCollection for notification signatures below.
var chatResponseMessages = chatResponse.Messages.ToArray();
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
if (options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages(chatResponseMessages).ConfigureAwait(false);
}
return chatResponse;
}
/// <inheritdoc/>
public override IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new System.NotImplementedException();
}
/// <inheritdoc/>
public override AgentThread GetNewThread() => new ChatClientAgentThread();
#region Private
private void UpdateThreadMessagesWithAgentInstructions(List<ChatMessage> threadMessages, AgentRunOptions? options)
{
if (!string.IsNullOrWhiteSpace(options?.AdditionalInstructions))
{
threadMessages.Insert(0, new(this.InstructionsRole, options?.AdditionalInstructions) { AuthorName = this.Name });
}
if (!string.IsNullOrWhiteSpace(this.Instructions))
{
threadMessages.Insert(0, new(this.InstructionsRole, this.Instructions) { AuthorName = this.Name });
}
}
#endregion
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents;
/// <summary>
/// Extensions for <see cref="ChatClientAgent"/> agent types.
/// </summary>
public static class ChatClientAgentExtensions
{
/// <summary>
/// Allow running a chat client agent with a <see cref="ChatOptions"/> configuration.
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="messages">Messages to send to the agent.</param>
/// <param name="thread">Optional thread to use for the agent.</param>
/// <param name="agentOptions">Optional agent run options.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>A task representing the asynchronous operation, with the chat response.</returns>
public static Task<ChatResponse> RunAsync(
this ChatClientAgent agent,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? agentOptions = null,
ChatOptions? chatOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(messages);
return agent.RunAsync(messages, thread, new ChatClientAgentRunOptions(agentOptions, chatOptions), cancellationToken);
}
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents;
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
/// <summary>
/// Extensions for logging <see cref="ChatClientAgent"/> invocations.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class ChatClientAgentLogMessages
{
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoking agent (started).
/// </summary>
[LoggerMessage(
EventId = 0,
Level = LogLevel.Debug,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoking service {ServiceType}.")]
public static partial void LogAgentChatClientInvokingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoked agent (complete).
/// </summary>
[LoggerMessage(
EventId = 0,
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked service {ServiceType} with message count: {MessageCount}.")]
public static partial void LogAgentChatClientInvokedAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType,
int messageCount);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoked streaming agent (complete).
/// </summary>
[LoggerMessage(
EventId = 0,
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked service {ServiceType}.")]
public static partial void LogAgentChatClientInvokedStreamingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType);
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents;
/// <summary>
/// Represents metadata for a chat client agent, including its identifier, name, instructions, and description.
/// </summary>
/// <remarks>
/// This class is used to encapsulate information about a chat client agent, such as its unique
/// identifier, display name, operational instructions, and a descriptive summary. It can be used to store and transfer
/// agent-related metadata within a chat application.
/// </remarks>
public class ChatClientAgentOptions
{
/// <summary>
/// Gets or sets the agent id.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Gets or sets the agent name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the agent instructions.
/// </summary>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
/// <summary>
/// Chat client agent run options.
/// </summary>
internal sealed class ChatClientAgentRunOptions : AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class.
/// </summary>
/// <param name="source">Optional source <see cref="AgentRunOptions"/> to clone.</param>
/// <param name="chatOptions">Optional chat options to pass to the agent's invocation.</param>
internal ChatClientAgentRunOptions(AgentRunOptions? source = null, ChatOptions? chatOptions = null)
{
this.OnIntermediateMessages = source?.OnIntermediateMessages;
this.AdditionalInstructions = source?.AdditionalInstructions;
this.ChatOptions = chatOptions;
}
/// <summary>
/// Gets or sets optional chat options to pass to the agent's invocation
/// </summary>
internal ChatOptions? ChatOptions { get; }
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
/// <summary>
/// Chat client agent thread.
/// </summary>
public sealed class ChatClientAgentThread : AgentThread, IMessagesRetrievableThread
{
private readonly List<ChatMessage> _chatMessages = [];
/// <inheritdoc/>
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
public async IAsyncEnumerable<ChatMessage> GetMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var message in this._chatMessages)
{
yield return message;
}
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc/>
protected override Task OnNewMessagesAsync(IReadOnlyCollection<ChatMessage> newMessages, CancellationToken cancellationToken = default)
{
this._chatMessages.AddRange(newMessages);
return Task.CompletedTask;
}
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
internal static class ChatClientExtensions
{
internal static IChatClient AsAgentInvokingChatClient(this IChatClient chatClient)
{
var chatBuilder = chatClient.AsBuilder();
if (chatClient is not AgentInvokingChatClient agentInvokingChatClient)
{
chatBuilder.UseAgentInvocation();
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.UseFunctionInvocation();
}
return chatBuilder.Build();
}
}
@@ -5,6 +5,10 @@
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<TargetFrameworks>$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
@@ -13,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.Abstractions\Microsoft.Agents.Abstractions.csproj" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<PropertyGroup>
@@ -17,7 +17,7 @@ public class AgentRunOptionsTests
var options = new AgentRunOptions
{
AdditionalInstructions = "Test instructions",
OnIntermediateMessage = msg => Task.CompletedTask
OnIntermediateMessages = msg => Task.CompletedTask
};
// Act
@@ -25,7 +25,7 @@ public class AgentRunOptionsTests
// Assert
Assert.Equal(options.AdditionalInstructions, clone.AdditionalInstructions);
Assert.Equal(options.OnIntermediateMessage, clone.OnIntermediateMessage);
Assert.Equal(options.OnIntermediateMessages, clone.OnIntermediateMessages);
}
[Fact]
@@ -272,7 +272,7 @@ public class AgentTests
public override AgentThread GetNewThread()
{
throw new System.NotImplementedException();
throw new NotImplementedException();
}
public override Task<ChatResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
@@ -0,0 +1,684 @@
// 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 ChatClientAgentTests
{
/// <summary>
/// Verify the invocation and response of <see cref="ChatClientAgent"/>.
/// </summary>
[Fact]
public void VerifyChatCompletionAgentDefinition()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent =
new(chatClient,
new()
{
Id = "test-agent-id",
Name = "test name",
Description = "test description",
Instructions = "test instructions",
});
// Assert
Assert.NotNull(agent.Id);
Assert.Equal("test-agent-id", agent.Id);
Assert.Equal("test name", agent.Name);
Assert.Equal("test description", agent.Description);
Assert.Equal("test instructions", agent.Instructions);
Assert.NotNull(agent.ChatClient);
Assert.Equal("AgentInvokingChatClient", agent.ChatClient.GetType().Name);
Assert.Equal(ChatRole.System, agent.InstructionsRole);
}
/// <summary>
/// Verify the invocation and response of <see cref="ChatClientAgent"/>.
/// </summary>
[Fact]
public async Task VerifyChatCompletionAgentInvocationAsync()
{
// 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, "what?")]));
ChatClientAgent agent =
new(mockService.Object, new()
{
Instructions = "test instructions"
});
// Act
ChatResponse result = await agent.RunAsync([]);
// Assert
Assert.Single(result.Messages);
mockService.Verify(
x =>
x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify the invocation and response of <see cref="ChatClientAgent"/> using <see cref="IChatClient"/>.
/// </summary>
[Fact]
public async Task VerifyChatClientAgentInvocationAsync()
{
// 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, "I'm here!")]));
ChatClientAgent agent =
new(mockService.Object, new()
{
Instructions = "test instructions"
});
// Act
ChatResponse result = await agent.RunAsync([new(ChatRole.User, "Where are you?")]);
// Assert
Assert.Single(result.Messages);
mockService.Verify(
x =>
x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.Single(result.Messages);
Assert.Collection(result.Messages,
message =>
{
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("I'm here!", message.Text);
});
}
/// <summary>
/// Verify the streaming invocation and response of <see cref="ChatClientAgent"/>.
/// </summary>
[Fact(Skip = "Not implemented yet")]
public async Task VerifyChatClientAgentStreamingAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"),
new ChatResponseUpdate(role: null, content: "at?"),
];
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"
});
// Act
ChatResponseUpdate[] result = await agent.RunStreamingAsync([]).ToArrayAsync();
// Assert
Assert.Equal(2, result.Length);
mockService.Verify(
x =>
x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync throws ArgumentNullException when messages parameter is null.
/// </summary>
[Fact]
public async Task RunAsyncThrowsArgumentNullExceptionWhenMessagesIsNullAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => agent.RunAsync((IReadOnlyCollection<ChatMessage>)null!));
}
/// <summary>
/// Verify that RunAsync passes ChatOptions when using ChatClientAgentRunOptions.
/// </summary>
[Fact]
public async Task RunAsyncPassesChatOptionsWhenUsingChatClientAgentRunOptionsAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
// Act
await agent.RunAsync([new(ChatRole.User, "test")], chatOptions: chatOptions);
// Assert
mockService.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
/// </summary>
[Fact]
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
null,
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions" });
var runOptions = new AgentRunOptions();
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
// Assert
mockService.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
null,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that RunAsync includes additional instructions when provided in options.
/// </summary>
[Fact]
public async Task RunAsyncIncludesAdditionalInstructionsWhenProvidedInOptionsAsync()
{
// 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" });
var runOptions = new AgentRunOptions { AdditionalInstructions = "additional instructions" };
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: 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);
}
/// <summary>
/// Verify that RunAsync calls OnIntermediateMessage callback for each response message.
/// </summary>
[Fact]
public async Task RunAsyncCallsOnIntermediateMessageForEachResponseMessageAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
var responseMessages = new[]
{
new ChatMessage(ChatRole.Assistant, "first response"),
new ChatMessage(ChatRole.Assistant, "second response")
};
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse(responseMessages));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions", Name = "TestAgent" });
var callbackMessages = new List<ChatMessage>();
var runOptions = new AgentRunOptions
{
OnIntermediateMessages = messages =>
{
callbackMessages.AddRange(messages);
return Task.CompletedTask;
}
};
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
// Assert
Assert.Equal(2, callbackMessages.Count);
Assert.Equal("first response", callbackMessages[0].Text);
Assert.Equal("second response", callbackMessages[1].Text);
Assert.All(callbackMessages, msg => Assert.Equal("TestAgent", msg.AuthorName));
}
/// <summary>
/// Verify that RunAsync sets AuthorName on all response messages.
/// </summary>
[Fact]
public async Task RunAsyncSetsAuthorNameOnAllResponseMessagesAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
var responseMessages = new[]
{
new ChatMessage(ChatRole.Assistant, "response 1"),
new ChatMessage(ChatRole.Assistant, "response 2")
};
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse(responseMessages));
ChatClientAgent agent = new(mockService.Object, new() { Instructions = "test instructions", Name = "TestAgent" });
// Act
var result = await agent.RunAsync([new(ChatRole.User, "test")]);
// Assert
Assert.All(result.Messages, msg => Assert.Equal("TestAgent", msg.AuthorName));
}
/// <summary>
/// Verify that RunAsync works with existing thread and retrieves messages from IMessagesRetrievableThread.
/// </summary>
[Fact]
public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadImplementsIMessagesRetrievableThreadAsync()
{
// 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" });
// Create a thread using the agent's GetNewThread method
var thread = agent.GetNewThread();
// Act
await agent.RunAsync([new(ChatRole.User, "new message")], thread: thread);
// Assert
// Should contain: instructions + new message
Assert.Contains(capturedMessages, m => m.Text == "test instructions");
Assert.Contains(capturedMessages, m => m.Text == "new message");
}
/// <summary>
/// Verify that RunAsync works without instructions.
/// </summary>
[Fact]
public async Task RunAsyncWorksWithoutInstructionsWhenInstructionsAreNullOrEmptyAsync()
{
// 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 = null });
// Act
await agent.RunAsync([new(ChatRole.User, "test message")]);
// Assert
// Should only contain the user message, no system instructions
Assert.Single(capturedMessages);
Assert.Equal("test message", capturedMessages[0].Text);
Assert.Equal(ChatRole.User, capturedMessages[0].Role);
}
/// <summary>
/// Verify that RunAsync works with empty message collection.
/// </summary>
[Fact]
public async Task RunAsyncWorksWithEmptyMessagesWhenNoMessagesProvidedAsync()
{
// 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" });
// Act
await agent.RunAsync([]);
// Assert
// Should only contain the instructions
Assert.Single(capturedMessages);
Assert.Equal("test instructions", capturedMessages[0].Text);
Assert.Equal(ChatRole.System, capturedMessages[0].Role);
}
#region Property Override Tests
/// <summary>
/// Verify that Id property returns metadata Id when provided, otherwise falls back to base implementation.
/// </summary>
[Fact]
public void IdReturnsMetadataIdWhenMetadataProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Id = "custom-agent-id" };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Equal("custom-agent-id", agent.Id);
}
/// <summary>
/// Verify that Id property falls back to base implementation when metadata is null.
/// </summary>
[Fact]
public void IdFallsBackToBaseImplementationWhenMetadataIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, null);
// Act & Assert
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
// Base implementation returns a GUID, so it should be parseable as a GUID
Assert.True(Guid.TryParse(agent.Id, out _));
}
/// <summary>
/// Verify that Id property falls back to base implementation when metadata Id is null.
/// </summary>
[Fact]
public void IdFallsBackToBaseImplementationWhenMetadataIdIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Id = null };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
// Base implementation returns a GUID, so it should be parseable as a GUID
Assert.True(Guid.TryParse(agent.Id, out _));
}
/// <summary>
/// Verify that Name property returns metadata Name when provided.
/// </summary>
[Fact]
public void NameReturnsMetadataNameWhenMetadataProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Name = "Test Agent" };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Equal("Test Agent", agent.Name);
}
/// <summary>
/// Verify that Name property returns null when metadata is null.
/// </summary>
[Fact]
public void NameReturnsNullWhenMetadataIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, null);
// Act & Assert
Assert.Null(agent.Name);
}
/// <summary>
/// Verify that Name property returns null when metadata Name is null.
/// </summary>
[Fact]
public void NameReturnsNullWhenMetadataNameIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Name = null };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Null(agent.Name);
}
/// <summary>
/// Verify that Description property returns metadata Description when provided.
/// </summary>
[Fact]
public void DescriptionReturnsMetadataDescriptionWhenMetadataProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Description = "A helpful test agent" };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Equal("A helpful test agent", agent.Description);
}
/// <summary>
/// Verify that Description property returns null when metadata is null.
/// </summary>
[Fact]
public void DescriptionReturnsNullWhenMetadataIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, null);
// Act & Assert
Assert.Null(agent.Description);
}
/// <summary>
/// Verify that Description property returns null when metadata Description is null.
/// </summary>
[Fact]
public void DescriptionReturnsNullWhenMetadataDescriptionIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Description = null };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Null(agent.Description);
}
/// <summary>
/// Verify that Instructions property returns metadata Instructions when provided.
/// </summary>
[Fact]
public void InstructionsReturnsMetadataInstructionsWhenMetadataProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Instructions = "You are a helpful assistant" };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Equal("You are a helpful assistant", agent.Instructions);
}
/// <summary>
/// Verify that Instructions property returns null when metadata is null.
/// </summary>
[Fact]
public void InstructionsReturnsNullWhenMetadataIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, null);
// Act & Assert
Assert.Null(agent.Instructions);
}
/// <summary>
/// Verify that Instructions property returns null when metadata Instructions is null.
/// </summary>
[Fact]
public void InstructionsReturnsNullWhenMetadataInstructionsIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var metadata = new ChatClientAgentOptions { Instructions = null };
ChatClientAgent agent = new(chatClient, metadata);
// Act & Assert
Assert.Null(agent.Instructions);
}
/// <summary>
/// Verify that InstructionsRole property has default value of System.
/// </summary>
[Fact]
public void InstructionsRoleHasDefaultValueOfSystem()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new());
// Act & Assert
Assert.Equal(ChatRole.System, agent.InstructionsRole);
}
/// <summary>
/// Verify that InstructionsRole property can be set to custom values.
/// </summary>
[Fact]
public void InstructionsRoleCanBeSetToCustomValue()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new());
// Act
agent.InstructionsRole = ChatRole.User;
// Assert
Assert.Equal(ChatRole.User, agent.InstructionsRole);
}
#endregion
#region RunStreamingAsync Tests
/// <summary>
/// Verify that RunStreamingAsync throws NotImplementedException.
/// </summary>
[Fact]
public void RunStreamingAsyncThrowsNotImplementedException()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
Assert.Throws<NotImplementedException>(() =>
{
var result = agent.RunStreamingAsync([new(ChatRole.User, "test")]);
// Force enumeration to trigger the exception
result.GetAsyncEnumerator();
});
}
/// <summary>
/// Verify that RunStreamingAsync with string message throws NotImplementedException.
/// </summary>
[Fact]
public void RunStreamingAsyncWithStringMessageThrowsNotImplementedException()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
// Act & Assert
Assert.Throws<NotImplementedException>(() =>
{
var result = agent.RunStreamingAsync("test message");
// Force enumeration to trigger the exception
result.GetAsyncEnumerator();
});
}
#endregion
}
@@ -0,0 +1,268 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.UnitTests.ChatCompletion;
public class ChatClientAgentThreadTests
{
/// <summary>
/// Verify that <see cref="ChatClientAgentThread"/> implements <see cref="IMessagesRetrievableThread"/>.
/// </summary>
[Fact]
public void VerifyChatClientAgentThreadImplementsIMessagesRetrievableThread()
{
// Arrange & Act
var thread = new ChatClientAgentThread();
// Assert
Assert.IsAssignableFrom<IMessagesRetrievableThread>(thread);
Assert.IsAssignableFrom<AgentThread>(thread);
}
/// <summary>
/// Verify that <see cref="ChatClientAgentThread"/> can retrieve messages through <see cref="IMessagesRetrievableThread.GetMessagesAsync"/>.
/// This test verifies the interface works correctly when no messages have been added.
/// </summary>
[Fact]
public async Task VerifyIMessagesRetrievableThreadGetMessagesAsyncWhenEmptyAsync()
{
// Arrange
var thread = new ChatClientAgentThread();
// Act - Retrieve messages when thread is empty
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in thread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert
Assert.Empty(retrievedMessages);
}
/// <summary>
/// Verify that <see cref="ChatClientAgentThread"/> can retrieve messages through <see cref="IMessagesRetrievableThread.GetMessagesAsync"/>.
/// This test verifies the interface works correctly when messages have been added via ChatClientAgent.
/// </summary>
[Fact]
public async Task VerifyIMessagesRetrievableThreadGetMessagesAsyncWhenNotEmptyAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello, how are you?");
var assistantMessage = new ChatMessage(ChatRole.Assistant, "I'm doing well, thank you!");
// Mock IChatClient to return the assistant message
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([assistantMessage]));
// Create ChatClientAgent with the mocked client
var agent = new ChatClientAgent(mockChatClient.Object, new()
{
Instructions = "You are a helpful assistant"
});
// Get a new thread from the agent
var thread = agent.GetNewThread();
// Run the agent again with the thread to populate it with messages
var responseWithThread = await agent.RunAsync([userMessage], thread);
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
// Retrieve messages through the interface
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert
Assert.NotEmpty(retrievedMessages);
// Verify that the messages include the assistant response
Assert.Collection(retrievedMessages,
m => Assert.Equal(ChatRole.User, m.Role),
m => Assert.Equal(ChatRole.Assistant, m.Role));
// Verify the content matches what we expect
Assert.Contains(retrievedMessages, m => m.Text == "Hello, how are you?" && m.Role == ChatRole.User);
Assert.Contains(retrievedMessages, m => m.Text == "I'm doing well, thank you!" && m.Role == ChatRole.Assistant);
}
/// <summary>
/// Verify that <see cref="ChatClientAgentThread.GetMessagesAsync"/> works with cancellation token.
/// </summary>
[Fact]
public async Task VerifyGetMessagesAsyncWithCancellationTokenAsync()
{
// Arrange
var thread = new ChatClientAgentThread();
using var cts = new CancellationTokenSource();
// Act - Test that GetMessagesAsync accepts cancellation token without throwing
var retrievedMessages = new List<ChatMessage>();
await foreach (var msg in thread.GetMessagesAsync(cts.Token))
{
retrievedMessages.Add(msg);
}
// Assert - Should return empty list when no messages
Assert.Empty(retrievedMessages);
}
/// <summary>
/// Verify that <see cref="ChatClientAgentThread"/> initializes with expected default values.
/// </summary>
[Fact]
public void VerifyThreadInitialState()
{
// Arrange & Act
var thread = new ChatClientAgentThread();
// Assert
Assert.Null(thread.Id); // Id should be null until created
}
#region Core Override Method Tests
/// <summary>
/// Verify that thread creation generates a valid thread ID through integration with ChatClientAgent.
/// </summary>
[Fact]
public void ThreadCreationGeneratesValidThreadId()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")]));
var agent = new ChatClientAgent(mockChatClient.Object, new());
// Act
var thread = agent.GetNewThread();
// Assert
Assert.NotNull(thread);
Assert.IsType<ChatClientAgentThread>(thread);
Assert.Null(thread.Id); // Id should be null until the thread is actually used
}
/// <summary>
/// Verify that thread creation generates unique instances.
/// </summary>
[Fact]
public void ThreadCreationGeneratesUniqueInstances()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new());
// Act
var thread1 = agent.GetNewThread();
var thread2 = agent.GetNewThread();
// Assert
Assert.NotSame(thread1, thread2);
Assert.IsType<ChatClientAgentThread>(thread1);
Assert.IsType<ChatClientAgentThread>(thread2);
}
/// <summary>
/// Verify that messages are properly stored and retrieved through the thread lifecycle.
/// </summary>
[Fact]
public async Task ThreadLifecycleStoresAndRetrievesMessagesAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello");
var assistantMessage = new ChatMessage(ChatRole.Assistant, "Hi there!");
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([assistantMessage]));
var agent = new ChatClientAgent(mockChatClient.Object, new() { Instructions = "Test instructions" });
// Act
var thread = agent.GetNewThread();
// Run the agent to populate the thread with messages
await agent.RunAsync([userMessage], thread);
// Retrieve messages from the thread
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in ((IMessagesRetrievableThread)thread).GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert
Assert.Equal(2, retrievedMessages.Count);
Assert.Contains(retrievedMessages, m => m.Text == "Hello" && m.Role == ChatRole.User);
Assert.Contains(retrievedMessages, m => m.Text == "Hi there!" && m.Role == ChatRole.Assistant);
}
/// <summary>
/// Verify that multiple messages can be added and retrieved in order.
/// </summary>
[Fact]
public async Task ThreadMessageHandlingHandlesMultipleMessagesInOrderAsync()
{
// Arrange
var messages = new[]
{
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "First response"),
new ChatMessage(ChatRole.User, "Second message"),
new ChatMessage(ChatRole.Assistant, "Second response")
};
var mockChatClient = new Mock<IChatClient>();
mockChatClient.SetupSequence(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([messages[1]]))
.ReturnsAsync(new ChatResponse([messages[3]]));
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act - Add messages through multiple agent runs
await agent.RunAsync([messages[0]], thread);
await agent.RunAsync([messages[2]], thread);
// Assert - Verify all messages are stored in order
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in ((IMessagesRetrievableThread)thread).GetMessagesAsync())
{
retrievedMessages.Add(message);
}
Assert.Equal(4, retrievedMessages.Count);
Assert.Equal("First message", retrievedMessages[0].Text);
Assert.Equal("First response", retrievedMessages[1].Text);
Assert.Equal("Second message", retrievedMessages[2].Text);
Assert.Equal("Second response", retrievedMessages[3].Text);
}
#endregion
}
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Tests;
/// <summary>
/// Placeholder.
/// </summary>
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}