mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
46a117d581
commit
89daf173c4
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user