.Net ChatClientAgent Streaming API Impl. (#69)

* Add Streaming API

* Removing InstructionsRole

* Updating thread notification strategy

* Fix net472 failing

* Address typo
This commit is contained in:
Roger Barreto
2025-06-12 07:18:47 +01:00
committed by GitHub
Unverified
parent 89daf173c4
commit d761c92a52
5 changed files with 587 additions and 206 deletions
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -19,6 +20,7 @@ public sealed class ChatClientAgent : Agent
{
private readonly ChatClientAgentOptions? _agentOptions;
private readonly ILogger _logger;
private readonly Type _chatClientType;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgent"/> class.
@@ -30,27 +32,17 @@ public sealed class ChatClientAgent : Agent
{
Throw.IfNull(chatClient);
this._chatClientType = chatClient.GetType();
this.ChatClient = chatClient.AsAgentInvokingChatClient();
this._agentOptions = options;
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
}
/// <summary>
/// The chat client.
/// The underlying chat client used by the agent to invoke chat completions.
/// </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;
@@ -72,6 +64,110 @@ public sealed class ChatClientAgent : Agent
{
Throw.IfNull(messages);
(ChatClientAgentThread chatClientThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, messages, options, cancellationToken).ConfigureAwait(false);
var agentName = this.GetAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
ChatResponse chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType, 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 as IReadOnlyCollection<ChatMessage> ?? 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 async IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
(ChatClientAgentThread chatClientThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, messages, options, cancellationToken).ConfigureAwait(false);
int messageCount = threadMessages.Count;
var agentName = this.GetAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, agentName, this._chatClientType);
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
var responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, agentName, this._chatClientType);
List<ChatResponseUpdate> responseUpdates = [];
// Ensure we start the streaming request
var hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, messages, cancellationToken).ConfigureAwait(false);
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
responseUpdates.Add(update);
update.AuthorName ??= agentName;
yield return update;
}
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
var chatResponseMessages = chatResponse.Messages as IReadOnlyCollection<ChatMessage> ?? chatResponse.Messages.ToArray();
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
if (options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages(chatResponseMessages).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public override AgentThread GetNewThread() => new ChatClientAgentThread();
#region Private
/// <summary>
/// Prepares the thread, chat options, and messages for agent execution.
/// </summary>
/// <param name="thread">The conversation thread to use or create.</param>
/// <param name="inputMessages">The input messages to use.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(ChatClientAgentThread thread, ChatOptions? chatOptions, List<ChatMessage> threadMessages)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IReadOnlyCollection<ChatMessage> inputMessages,
AgentRunOptions? options,
CancellationToken cancellationToken)
{
// Retrieve chat options from the provided AgentRunOptions if available.
ChatOptions? chatOptions = (options as ChatClientAgentRunOptions)?.ChatOptions;
@@ -87,65 +183,28 @@ public sealed class ChatClientAgent : Agent
}
}
// 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();
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
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;
return (chatClientThread, chatOptions, threadMessages);
}
/// <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 });
threadMessages.Insert(0, new(ChatRole.System, options?.AdditionalInstructions) { AuthorName = this.Name });
}
if (!string.IsNullOrWhiteSpace(this.Instructions))
{
threadMessages.Insert(0, new(this.InstructionsRole, this.Instructions) { AuthorName = this.Name });
threadMessages.Insert(0, new(ChatRole.System, this.Instructions) { AuthorName = this.Name });
}
}
private string GetAgentName() => this.Name ?? "UnnamedAgent";
#endregion
}
@@ -14,26 +14,49 @@ namespace Microsoft.Agents;
public static class ChatClientAgentExtensions
{
/// <summary>
/// Allow running a chat client agent with a <see cref="ChatOptions"/> configuration.
/// Run the agent with the provided message and arguments.
/// </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="messages">The messages to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public static Task<ChatResponse> RunAsync(
this ChatClientAgent agent,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? agentOptions = null,
AgentRunOptions? agentRunOptions = null,
ChatOptions? chatOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(messages);
return agent.RunAsync(messages, thread, new ChatClientAgentRunOptions(agentOptions, chatOptions), cancellationToken);
return agent.RunAsync(messages, thread, new ChatClientAgentRunOptions(agentRunOptions, chatOptions), cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public static IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
this ChatClientAgent agent,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? agentRunOptions = null,
ChatOptions? chatOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(messages);
return agent.RunStreamingAsync(messages, thread, new ChatClientAgentRunOptions(agentRunOptions, chatOptions), cancellationToken);
}
}
@@ -23,13 +23,13 @@ internal static partial class ChatClientAgentLogMessages
[LoggerMessage(
EventId = 0,
Level = LogLevel.Debug,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoking service {ServiceType}.")]
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoking client {ClientType}.")]
public static partial void LogAgentChatClientInvokingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType);
Type clientType);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoked agent (complete).
@@ -37,13 +37,13 @@ internal static partial class ChatClientAgentLogMessages
[LoggerMessage(
EventId = 0,
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked service {ServiceType} with message count: {MessageCount}.")]
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked client {ClientType} with message count: {MessageCount}.")]
public static partial void LogAgentChatClientInvokedAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType,
Type clientType,
int messageCount);
/// <summary>
@@ -52,11 +52,11 @@ internal static partial class ChatClientAgentLogMessages
[LoggerMessage(
EventId = 0,
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked service {ServiceType}.")]
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked client {ClientType}.")]
public static partial void LogAgentChatClientInvokedStreamingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type serviceType);
Type clientType);
}
@@ -16,7 +16,7 @@ public class ChatClientAgentTests
/// Verify the invocation and response of <see cref="ChatClientAgent"/>.
/// </summary>
[Fact]
public void VerifyChatCompletionAgentDefinition()
public void VerifyChatClientAgentDefinition()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
@@ -38,42 +38,6 @@ public class ChatClientAgentTests
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>
@@ -119,47 +83,6 @@ public class ChatClientAgentTests
});
}
/// <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>
@@ -607,77 +530,51 @@ public class ChatClientAgentTests
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.
/// Verify the streaming invocation and response of <see cref="ChatClientAgent"/>.
/// </summary>
[Fact]
public void RunStreamingAsyncThrowsNotImplementedException()
public async Task VerifyChatClientAgentStreamingAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
ChatClientAgent agent = new(chatClient, new() { Instructions = "test instructions" });
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?"),
];
// Act & Assert
Assert.Throws<NotImplementedException>(() =>
{
var result = agent.RunStreamingAsync([new(ChatRole.User, "test")]);
// Force enumeration to trigger the exception
result.GetAsyncEnumerator();
});
}
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
/// <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" });
ChatClientAgent agent =
new(mockService.Object, new()
{
Instructions = "test instructions"
});
// Act & Assert
Assert.Throws<NotImplementedException>(() =>
{
var result = agent.RunStreamingAsync("test message");
// Force enumeration to trigger the exception
result.GetAsyncEnumerator();
});
// Act
ChatResponseUpdate[] result = await agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")]).ToArrayAsync();
// Assert
Assert.Equal(2, result.Length);
Assert.Equal("wh", result[0].Text);
Assert.Equal("at?", result[1].Text);
mockService.Verify(
x =>
x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
@@ -1,11 +1,15 @@
// 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;
#pragma warning disable CS0162 // Unreachable code detected
namespace Microsoft.Agents.UnitTests.ChatCompletion;
public class ChatClientAgentThreadTests
@@ -265,4 +269,402 @@ public class ChatClientAgentThreadTests
}
#endregion
#region RunStreamingAsync Thread Notification Tests
/// <summary>
/// Verify that thread is notified of both input and response messages when invoking the streaming API with RunStreamingAsync.
/// </summary>
[Fact]
public async Task VerifyThreadNotificationDuringStreamingAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello, streaming!");
var assistantMessage = new ChatMessage(ChatRole.Assistant, "Hi there, streaming response!");
// Create streaming response updates
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hi there, "),
new ChatResponseUpdate(role: null, content: "streaming response!"),
];
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(returnUpdates.ToAsyncEnumerable());
// 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();
// Act - Run the agent with streaming to populate the thread with messages
var streamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([userMessage], thread))
{
streamingResults.Add(update);
}
// Assert - Verify streaming worked
Assert.Equal(2, streamingResults.Count);
// Retrieve messages from the thread to verify notification occurred
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread was notified and contains both user and assistant messages
Assert.NotEmpty(retrievedMessages);
Assert.Equal(2, retrievedMessages.Count);
Assert.Contains(retrievedMessages, m => m.Text == "Hello, streaming!" && m.Role == ChatRole.User);
Assert.Contains(retrievedMessages, m => m.Text == "Hi there, streaming response!" && m.Role == ChatRole.Assistant);
}
/// <summary>
/// Verify that thread accumulates both input and response messages across multiple streaming calls.
/// </summary>
[Fact]
public async Task VerifyThreadAccumulatesMessagesAcrossMultipleStreamingCallsAsync()
{
// Arrange
var firstUserMessage = new ChatMessage(ChatRole.User, "First streaming message");
var secondUserMessage = new ChatMessage(ChatRole.User, "Second streaming message");
// Create streaming response updates for first call
ChatResponseUpdate[] firstReturnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "First "),
new ChatResponseUpdate(role: null, content: "response"),
];
// Create streaming response updates for second call
ChatResponseUpdate[] secondReturnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Second "),
new ChatResponseUpdate(role: null, content: "response"),
];
var mockChatClient = new Mock<IChatClient>();
mockChatClient.SetupSequence(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(firstReturnUpdates.ToAsyncEnumerable())
.Returns(secondReturnUpdates.ToAsyncEnumerable());
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act - Make two streaming calls
var firstStreamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([firstUserMessage], thread))
{
firstStreamingResults.Add(update);
}
var secondStreamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([secondUserMessage], thread))
{
secondStreamingResults.Add(update);
}
// Assert - Verify both streaming calls worked
Assert.Equal(2, firstStreamingResults.Count);
Assert.Equal(2, secondStreamingResults.Count);
// Retrieve all messages from the thread
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread contains all messages in order
Assert.Equal(4, retrievedMessages.Count);
Assert.Equal("First streaming message", retrievedMessages[0].Text);
Assert.Equal("First response", retrievedMessages[1].Text);
Assert.Equal("Second streaming message", retrievedMessages[2].Text);
Assert.Equal("Second response", retrievedMessages[3].Text);
}
/// <summary>
/// Verify that thread notification works correctly when streaming with existing thread messages.
/// Both RunAsync and RunStreamingAsync should add both input and response messages to the thread.
/// </summary>
[Fact]
public async Task VerifyStreamingWithExistingThreadMessagesAsync()
{
// Arrange
var initialUserMessage = new ChatMessage(ChatRole.User, "Initial message");
var initialAssistantMessage = new ChatMessage(ChatRole.Assistant, "Initial response");
var newUserMessage = new ChatMessage(ChatRole.User, "New streaming message");
// Setup for initial non-streaming call
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([initialAssistantMessage]));
// Setup for streaming call
ChatResponseUpdate[] streamingUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Streaming "),
new ChatResponseUpdate(role: null, content: "response"),
];
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingUpdates.ToAsyncEnumerable());
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act - First, make a regular call to populate the thread
await agent.RunAsync([initialUserMessage], thread);
// Then make a streaming call
var streamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([newUserMessage], thread))
{
streamingResults.Add(update);
}
// Assert - Verify streaming worked
Assert.Equal(2, streamingResults.Count);
// Retrieve all messages from the thread
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread contains all messages including the new streaming ones
Assert.Equal(4, retrievedMessages.Count);
Assert.Equal("Initial message", retrievedMessages[0].Text);
Assert.Equal("Initial response", retrievedMessages[1].Text);
Assert.Equal("New streaming message", retrievedMessages[2].Text);
Assert.Equal("Streaming response", retrievedMessages[3].Text);
}
/// <summary>
/// Verify that thread is notified of input messages even when zero streaming updates are received.
/// </summary>
[Fact]
public async Task VerifyThreadNotificationWithZeroStreamingUpdatesAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello with no response!");
// Create empty streaming response (no updates)
ChatResponseUpdate[] returnUpdates = [];
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(returnUpdates.ToAsyncEnumerable());
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act - Run the agent with streaming that returns no updates
var streamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([userMessage], thread))
{
streamingResults.Add(update);
}
// Assert - Verify no streaming updates were received
Assert.Empty(streamingResults);
// Retrieve messages from the thread to verify notification occurred
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread was notified of input messages even with zero updates
// The fallback mechanism should ensure input messages are added to the thread
Assert.Single(retrievedMessages);
Assert.Contains(retrievedMessages, m => m.Text == "Hello with no response!" && m.Role == ChatRole.User);
}
/// <summary>
/// Verify that thread is notified of input messages only once even with multiple streaming updates.
/// </summary>
[Fact]
public async Task VerifyThreadNotificationWithMultipleStreamingUpdatesAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello with many updates!");
// Create multiple streaming response updates
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "First "),
new ChatResponseUpdate(role: null, content: "update, "),
new ChatResponseUpdate(role: null, content: "second "),
new ChatResponseUpdate(role: null, content: "update, "),
new ChatResponseUpdate(role: null, content: "third "),
new ChatResponseUpdate(role: null, content: "update!"),
];
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(returnUpdates.ToAsyncEnumerable());
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act - Run the agent with streaming that returns multiple updates
var streamingResults = new List<ChatResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([userMessage], thread))
{
streamingResults.Add(update);
}
// Assert - Verify all streaming updates were received
Assert.Equal(6, streamingResults.Count);
// Retrieve messages from the thread to verify notification occurred
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread contains both input and response messages
// Input message should be added only once despite multiple updates
Assert.Equal(2, retrievedMessages.Count);
Assert.Contains(retrievedMessages, m => m.Text == "Hello with many updates!" && m.Role == ChatRole.User);
Assert.Contains(retrievedMessages, m => m.Text == "First update, second update, third update!" && m.Role == ChatRole.Assistant);
}
/// <summary>
/// Verify that thread is NOT notified of input messages when an exception occurs during streaming.
/// </summary>
[Fact]
public async Task VerifyThreadNotNotifiedWhenStreamingThrowsExceptionAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello that will fail!");
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Streaming failed"));
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act & Assert - Verify that streaming throws an exception
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync([userMessage], thread))
{
Assert.Fail("Should not yield updates.");
}
});
// Retrieve messages from the thread to verify NO notification occurred
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread was NOT notified of any messages due to the exception
// This ensures that failed operations don't leave the thread in an inconsistent state
Assert.Empty(retrievedMessages);
}
/// <summary>
/// Verify that thread is NOT notified of input messages when an exception occurs after some streaming updates.
/// </summary>
[Fact]
public async Task VerifyThreadNotNotifiedWhenStreamingThrowsExceptionAfterUpdatesAsync()
{
// Arrange
var userMessage = new ChatMessage(ChatRole.User, "Hello that will partially fail!");
// Create an async enumerable that yields some updates then throws
static async IAsyncEnumerable<ChatResponseUpdate> GetUpdatesWithExceptionAsync()
{
await Task.CompletedTask; // Simulate async operation
throw new InvalidOperationException("Streaming failed after partial response");
yield break;
}
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(GetUpdatesWithExceptionAsync());
var agent = new ChatClientAgent(mockChatClient.Object, new());
var thread = agent.GetNewThread();
// Act & Assert - Verify that streaming throws an exception after some updates
var streamingResults = new List<ChatResponseUpdate>();
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync([userMessage], thread))
{
streamingResults.Add(update);
}
});
// Verify that some updates were received before the exception
Assert.Empty(streamingResults);
// Retrieve messages from the thread to verify NO notification occurred
var messagesRetrievableThread = (IMessagesRetrievableThread)thread;
var retrievedMessages = new List<ChatMessage>();
await foreach (var message in messagesRetrievableThread.GetMessagesAsync())
{
retrievedMessages.Add(message);
}
// Assert - Verify that the thread was NOT notified of any messages due to the exception
// Even though some updates were received, the exception should prevent thread notification
Assert.Empty(retrievedMessages);
}
#endregion
}