diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
index 87a957130f5..cbc00dd98ec 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
@@ -306,26 +306,19 @@ private static void FinalizeResponse(ChatResponse response)
private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse response)
{
// If there is no message created yet, or if the last update we saw had a different
- // message ID or role than the newest update, create a new message.
- ChatMessage message;
- var isNewMessage = false;
- if (response.Messages.Count == 0)
- {
- isNewMessage = true;
- }
- else if (update.MessageId is { Length: > 0 } updateMessageId
- && response.Messages[response.Messages.Count - 1].MessageId is string lastMessageId
- && updateMessageId != lastMessageId)
- {
- isNewMessage = true;
- }
- else if (update.Role is { } updateRole
- && response.Messages[response.Messages.Count - 1].Role is { } lastRole
- && updateRole != lastRole)
+ // identifying parts, create a new message.
+ bool isNewMessage = true;
+ if (response.Messages.Count != 0)
{
- isNewMessage = true;
+ var lastMessage = response.Messages[response.Messages.Count - 1];
+ isNewMessage =
+ NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) ||
+ NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) ||
+ NotNullOrEqual(update.Role, lastMessage.Role);
}
+ // Get the message to target, either a new one or the last ones.
+ ChatMessage message;
if (isNewMessage)
{
message = new(ChatRole.Assistant, []);
@@ -418,4 +411,12 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
}
}
}
+
+ /// Gets whether both strings are not null/empty and not the same as each other.
+ private static bool NotEmptyOrEqual(string? s1, string? s2) =>
+ s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2;
+
+ /// Gets whether two roles are not null and not the same as each other.
+ private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) =>
+ r1.HasValue && r2.HasValue && r1.Value != r2.Value;
}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs
index 3328d0be083..ee9ae354677 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs
@@ -36,8 +36,8 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync)
];
ChatResponse response = useAsync ?
- updates.ToChatResponse() :
- await YieldAsync(updates).ToChatResponseAsync();
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
Assert.NotNull(response);
Assert.NotNull(response.Usage);
@@ -67,7 +67,7 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync)
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task ToChatResponse_RoleOrIdChangeDictatesMessageChange(bool useAsync)
+ public async Task ToChatResponse_RoleOrIdOrAuthorNameChangeDictatesMessageChange(bool useAsync)
{
ChatResponseUpdate[] updates =
[
@@ -88,8 +88,8 @@ public async Task ToChatResponse_RoleOrIdChangeDictatesMessageChange(bool useAsy
];
ChatResponse response = useAsync ?
- updates.ToChatResponse() :
- await YieldAsync(updates).ToChatResponseAsync();
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
Assert.Equal(9, response.Messages.Count);
Assert.Equal("!a", response.Messages[0].Text);
@@ -120,6 +120,329 @@ public async Task ToChatResponse_RoleOrIdChangeDictatesMessageChange(bool useAsy
Assert.Equal(ChatRole.Assistant, response.Messages[8].Role);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_AuthorNameChangeDictatesMessageBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with AuthorName "Alice"
+ new(ChatRole.Assistant, "Hello ") { AuthorName = "Alice" },
+ new(null, "from ") { AuthorName = "Alice" },
+ new(null, "Alice!"),
+
+ // Second message - AuthorName changes to "Bob"
+ new(null, "Hi ") { AuthorName = "Bob" },
+ new(null, "from ") { AuthorName = "Bob" },
+ new(null, "Bob!"),
+
+ // Third message - AuthorName changes to "Charlie"
+ new(ChatRole.Assistant, "Greetings ") { AuthorName = "Charlie" },
+ new(null, "from Charlie!") { AuthorName = "Charlie" },
+
+ // Fourth message - AuthorName changes back to "Alice"
+ new(null, "Alice again!") { AuthorName = "Alice" },
+
+ // Fifth message - empty/null AuthorName should continue with last message
+ new(null, " Still Alice.") { AuthorName = "" },
+ new(null, " And more."),
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(4, response.Messages.Count);
+
+ Assert.Equal("Hello from Alice!", response.Messages[0].Text);
+ Assert.Equal("Alice", response.Messages[0].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[0].Role);
+
+ Assert.Equal("Hi from Bob!", response.Messages[1].Text);
+ Assert.Equal("Bob", response.Messages[1].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[1].Role);
+
+ Assert.Equal("Greetings from Charlie!", response.Messages[2].Text);
+ Assert.Equal("Charlie", response.Messages[2].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[2].Role);
+
+ Assert.Equal("Alice again! Still Alice. And more.", response.Messages[3].Text);
+ Assert.Equal("Alice", response.Messages[3].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[3].Role);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_AuthorNameWithOtherBoundaries(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // Message 1: Role=Assistant, MessageId="1", AuthorName="Alice"
+ new(ChatRole.Assistant, "A") { MessageId = "1", AuthorName = "Alice" },
+ new(null, "B") { MessageId = "1", AuthorName = "Alice" },
+
+ // Message 2: AuthorName changes to "Bob", same MessageId and Role
+ new(null, "C") { MessageId = "1", AuthorName = "Bob" },
+
+ // Message 3: MessageId changes to "2", AuthorName stays "Bob"
+ new(null, "D") { MessageId = "2", AuthorName = "Bob" },
+ new(null, "E") { MessageId = "2", AuthorName = "Bob" },
+
+ // Message 4: Role changes to User, AuthorName stays "Bob"
+ new(ChatRole.User, "F") { MessageId = "2", AuthorName = "Bob" },
+
+ // Message 5: All three boundaries change
+ new(ChatRole.Tool, "G") { MessageId = "3", AuthorName = "Charlie" },
+ new(null, "H") { MessageId = "3", AuthorName = "Charlie" },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(5, response.Messages.Count);
+
+ Assert.Equal("AB", response.Messages[0].Text);
+ Assert.Equal("Alice", response.Messages[0].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[0].Role);
+ Assert.Equal("1", response.Messages[0].MessageId);
+
+ Assert.Equal("C", response.Messages[1].Text);
+ Assert.Equal("Bob", response.Messages[1].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[1].Role);
+ Assert.Equal("1", response.Messages[1].MessageId);
+
+ Assert.Equal("DE", response.Messages[2].Text);
+ Assert.Equal("Bob", response.Messages[2].AuthorName);
+ Assert.Equal(ChatRole.Assistant, response.Messages[2].Role);
+ Assert.Equal("2", response.Messages[2].MessageId);
+
+ Assert.Equal("F", response.Messages[3].Text);
+ Assert.Equal("Bob", response.Messages[3].AuthorName);
+ Assert.Equal(ChatRole.User, response.Messages[3].Role);
+ Assert.Equal("2", response.Messages[3].MessageId);
+
+ Assert.Equal("GH", response.Messages[4].Text);
+ Assert.Equal("Charlie", response.Messages[4].AuthorName);
+ Assert.Equal(ChatRole.Tool, response.Messages[4].Role);
+ Assert.Equal("3", response.Messages[4].MessageId);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_EmptyOrNullAuthorNameDoesNotCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with AuthorName "Assistant"
+ new(ChatRole.Assistant, "Hello") { AuthorName = "Assistant" },
+
+ // Empty AuthorName should not create new message
+ new(null, " world") { AuthorName = "" },
+
+ // Null AuthorName should not create new message
+ new(null, "!"),
+
+ // Another empty AuthorName
+ new(null, " How") { AuthorName = "" },
+ new(null, " are") { AuthorName = "" },
+
+ // Null again
+ new(null, " you?") { AuthorName = null },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ ChatMessage message = Assert.Single(response.Messages);
+ Assert.Equal("Hello world! How are you?", message.Text);
+ Assert.Equal("Assistant", message.AuthorName);
+ Assert.Equal(ChatRole.Assistant, message.Role);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_AuthorNameNullToNonNullDoesNotCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with no AuthorName
+ new(ChatRole.Assistant, "Hello") { MessageId = "1" },
+ new(null, " there") { MessageId = "1" },
+
+ // AuthorName becomes non-empty but doesn't create boundary
+ new(null, " I'm Bob") { MessageId = "1", AuthorName = "Bob" },
+ new(null, " speaking") { MessageId = "1", AuthorName = "Bob" },
+
+ // Second message - AuthorName changes to "Alice" creates boundary
+ new(null, "Now Alice") { MessageId = "1", AuthorName = "Alice" },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(2, response.Messages.Count);
+
+ Assert.Equal("Hello there I'm Bob speaking", response.Messages[0].Text);
+ Assert.Equal("Bob", response.Messages[0].AuthorName); // Last AuthorName wins
+ Assert.Equal("1", response.Messages[0].MessageId);
+
+ Assert.Equal("Now Alice", response.Messages[1].Text);
+ Assert.Equal("Alice", response.Messages[1].AuthorName);
+ Assert.Equal("1", response.Messages[1].MessageId);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_MessageIdNullToNonNullDoesNotCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with no MessageId
+ new(ChatRole.Assistant, "Hello"),
+ new(null, " there"),
+
+ // MessageId becomes non-empty but doesn't create boundary
+ new(null, " from") { MessageId = "msg1" },
+ new(null, " AI") { MessageId = "msg1" },
+
+ // Second message - MessageId changes to different value creates boundary
+ new(null, "Next message") { MessageId = "msg2" },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(2, response.Messages.Count);
+
+ Assert.Equal("Hello there from AI", response.Messages[0].Text);
+ Assert.Equal("msg1", response.Messages[0].MessageId); // Last MessageId wins
+ Assert.Equal(ChatRole.Assistant, response.Messages[0].Role);
+
+ Assert.Equal("Next message", response.Messages[1].Text);
+ Assert.Equal("msg2", response.Messages[1].MessageId);
+ Assert.Equal(ChatRole.Assistant, response.Messages[1].Role);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_EmptyMessageIdDoesNotCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with MessageId
+ new(ChatRole.Assistant, "Hello") { MessageId = "msg1" },
+ new(null, " world") { MessageId = "msg1" },
+
+ // Empty MessageId should not create new message
+ new(null, "!") { MessageId = "" },
+
+ // Null MessageId should not create new message
+ new(null, " How"),
+
+ // Another message with empty MessageId
+ new(null, " are") { MessageId = "" },
+ new(null, " you?"),
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ ChatMessage message = Assert.Single(response.Messages);
+ Assert.Equal("Hello world! How are you?", message.Text);
+ Assert.Equal("msg1", message.MessageId);
+ Assert.Equal(ChatRole.Assistant, message.Role);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_RoleNullToNonNullDoesNotCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with no explicit Role (will default to Assistant)
+ new(null, "Hello") { MessageId = "1" },
+ new(null, " there") { MessageId = "1" },
+
+ // Role becomes explicit Assistant - shouldn't create boundary
+ new(ChatRole.Assistant, " from") { MessageId = "1" },
+ new(null, " AI") { MessageId = "1" },
+
+ // Second message - Role changes to User creates boundary
+ new(ChatRole.User, "User message") { MessageId = "1" },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(2, response.Messages.Count);
+
+ Assert.Equal("Hello there from AI", response.Messages[0].Text);
+ Assert.Equal(ChatRole.Assistant, response.Messages[0].Role);
+ Assert.Equal("1", response.Messages[0].MessageId);
+
+ Assert.Equal("User message", response.Messages[1].Text);
+ Assert.Equal(ChatRole.User, response.Messages[1].Role);
+ Assert.Equal("1", response.Messages[1].MessageId);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ToChatResponse_CustomRoleChangesCreateBoundary(bool useAsync)
+ {
+ ChatResponseUpdate[] updates =
+ [
+
+ // First message with custom role "agent1"
+ new(new ChatRole("agent1"), "Hello") { MessageId = "1" },
+ new(null, " from") { MessageId = "1" },
+ new(new ChatRole("agent1"), " agent1") { MessageId = "1" },
+
+ // Second message - custom role changes to "agent2"
+ new(new ChatRole("agent2"), "Hi") { MessageId = "1" },
+ new(null, " from") { MessageId = "1" },
+ new(new ChatRole("agent2"), " agent2") { MessageId = "1" },
+
+ // Third message - changes to standard role
+ new(ChatRole.Assistant, "Assistant here") { MessageId = "1" },
+ ];
+
+ ChatResponse response = useAsync ?
+ await YieldAsync(updates).ToChatResponseAsync() :
+ updates.ToChatResponse();
+
+ Assert.Equal(3, response.Messages.Count);
+
+ Assert.Equal("Hello from agent1", response.Messages[0].Text);
+ Assert.Equal(new ChatRole("agent1"), response.Messages[0].Role);
+
+ Assert.Equal("Hi from agent2", response.Messages[1].Text);
+ Assert.Equal(new ChatRole("agent2"), response.Messages[1].Role);
+
+ Assert.Equal("Assistant here", response.Messages[2].Text);
+ Assert.Equal(ChatRole.Assistant, response.Messages[2].Role);
+ }
+
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -128,22 +451,25 @@ public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool use
ChatResponseUpdate[] updates =
[
- // First message - ID "msg1"
+ // First message - ID "msg1", AuthorName "Assistant"
new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" },
new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" },
new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win
- new(null, " AI") { MessageId = "msg1", AuthorName = "AI Assistant" }, // Later AuthorName should win
+ new(null, " AI") { MessageId = "msg1", AuthorName = "Assistant" }, // Keep same AuthorName to avoid creating new message
+
+ // Second message - ID "msg1" changes to "msg2", still AuthorName "Assistant"
+ new(null, "More text") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), AuthorName = "Assistant" },
- // Second message - ID "msg2"
- new(ChatRole.User, "How") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" },
- new(null, " are") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) },
- new(null, " you?") { MessageId = "msg2", AuthorName = "Human User" }, // Later AuthorName should win
+ // Third message - ID "msg3", Role changes to User
+ new(ChatRole.User, "How") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" },
+ new(null, " are") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) },
+ new(null, " you?") { MessageId = "msg3", AuthorName = "User" }, // Keep same AuthorName
- // Third message - ID "msg3"
- new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) },
- new(null, " thank you!") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win
+ // Fourth message - ID "msg4", Role changes back to Assistant
+ new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) },
+ new(null, " thank you!") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win
- // Updates without MessageId should continue the last message (msg3)
+ // Updates without MessageId should continue the last message (msg4)
new(null, " How can I help?"),
];
@@ -152,31 +478,39 @@ await YieldAsync(updates).ToChatResponseAsync() :
updates.ToChatResponse();
Assert.NotNull(response);
- Assert.Equal(3, response.Messages.Count);
+ Assert.Equal(4, response.Messages.Count);
// Verify first message
ChatMessage message1 = response.Messages[0];
Assert.Equal("msg1", message1.MessageId);
Assert.Equal(ChatRole.Assistant, message1.Role);
- Assert.Equal("AI Assistant", message1.AuthorName); // Last value should win
+ Assert.Equal("Assistant", message1.AuthorName);
Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win
Assert.Equal("Hi! Hello from AI", message1.Text);
// Verify second message
ChatMessage message2 = response.Messages[1];
Assert.Equal("msg2", message2.MessageId);
- Assert.Equal(ChatRole.User, message2.Role);
- Assert.Equal("Human User", message2.AuthorName); // Last value should win
- Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message2.CreatedAt); // Last value should win
- Assert.Equal("How are you?", message2.Text);
+ Assert.Equal(ChatRole.Assistant, message2.Role);
+ Assert.Equal("Assistant", message2.AuthorName);
+ Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), message2.CreatedAt);
+ Assert.Equal("More text", message2.Text);
// Verify third message
ChatMessage message3 = response.Messages[2];
Assert.Equal("msg3", message3.MessageId);
- Assert.Equal(ChatRole.Assistant, message3.Role);
- Assert.Null(message3.AuthorName); // No AuthorName set in later updates
- Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win
- Assert.Equal("I'm doing well, thank you! How can I help?", message3.Text);
+ Assert.Equal(ChatRole.User, message3.Role);
+ Assert.Equal("User", message3.AuthorName);
+ Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win
+ Assert.Equal("How are you?", message3.Text);
+
+ // Verify fourth message
+ ChatMessage message4 = response.Messages[3];
+ Assert.Equal("msg4", message4.MessageId);
+ Assert.Equal(ChatRole.Assistant, message4.Role);
+ Assert.Null(message4.AuthorName); // No AuthorName set
+ Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message4.CreatedAt); // Last value should win
+ Assert.Equal("I'm doing well, thank you! How can I help?", message4.Text);
}
public static IEnumerable