diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
index e7b535e6995..34e882a77aa 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
@@ -509,16 +509,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
}
// Some members on ChatResponseUpdate map to members of ChatMessage.
- // Incorporate those into the latest message; in cases where the message
- // stores a single value, prefer the latest update's value over anything
- // stored in the message.
+ // Incorporate those into the latest message. In most cases the message
+ // stores a single value, and we prefer the latest update's value over
+ // anything stored in the message, except for CreatedAt which prefers
+ // the first valid value.
if (update.AuthorName is not null)
{
message.AuthorName = update.AuthorName;
}
- if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt))
+ if (message.CreatedAt is null && IsValidCreatedAt(update.CreatedAt))
{
message.CreatedAt = update.CreatedAt;
}
@@ -551,7 +552,8 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
}
// Other members on a ChatResponseUpdate map to members of the ChatResponse.
- // Update the response object with those, preferring the values from later updates.
+ // Update the response object with those, preferring the values from later updates
+ // except for CreatedAt which prefers the first valid value.
if (update.ResponseId is { Length: > 0 })
{
@@ -563,7 +565,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
response.ConversationId = update.ConversationId;
}
- if (response.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > response.CreatedAt))
+ if (response.CreatedAt is null && IsValidCreatedAt(update.CreatedAt))
{
response.CreatedAt = update.CreatedAt;
}
@@ -598,4 +600,19 @@ private static bool NotEmptyOrEqual(string? s1, string? s2) =>
/// 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;
+
+#if NET
+ /// Gets whether the specified is a valid CreatedAt value.
+ /// Values that are or less than or equal to the Unix epoch are treated as invalid.
+ private static bool IsValidCreatedAt(DateTimeOffset? createdAt) =>
+ createdAt > DateTimeOffset.UnixEpoch;
+#else
+ /// The Unix epoch (1970-01-01T00:00:00Z).
+ private static readonly DateTimeOffset _unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ /// Gets whether the specified is a valid CreatedAt value.
+ /// Values that are or less than or equal to the Unix epoch are treated as invalid.
+ private static bool IsValidCreatedAt(DateTimeOffset? createdAt) =>
+ createdAt > _unixEpoch;
+#endif
}
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 59acc1c0991..a921f75d580 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs
@@ -29,9 +29,9 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync)
{
ChatResponseUpdate[] updates =
[
- new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" },
+ new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" },
new(ChatRole.Assistant, ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } },
- new(null, "world!") { CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), ConversationId = "123", AdditionalProperties = new() { ["c"] = "d" } },
+ new(null, "world!") { CreatedAt = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero), ConversationId = "123", AdditionalProperties = new() { ["c"] = "d" } },
new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] },
new() { Contents = [new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 })] },
@@ -47,7 +47,7 @@ await YieldAsync(updates).ToChatResponseAsync() :
Assert.Equal(7, response.Usage.OutputTokenCount);
Assert.Equal("someResponse", response.ResponseId);
- Assert.Equal(new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
+ Assert.Equal(new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
Assert.Equal("model123", response.ModelId);
Assert.Equal("123", response.ConversationId);
@@ -456,7 +456,7 @@ public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool use
// 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, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should not overwrite first
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"
@@ -469,7 +469,7 @@ public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool use
// 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
+ new(null, " thank you!") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should not overwrite first
// Updates without MessageId should continue the last message (msg4)
new(null, " How can I help?"),
@@ -487,7 +487,7 @@ await YieldAsync(updates).ToChatResponseAsync() :
Assert.Equal("msg1", message1.MessageId);
Assert.Equal(ChatRole.Assistant, message1.Role);
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(new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), message1.CreatedAt); // First value should win
Assert.Equal("Hi! Hello from AI", message1.Text);
// Verify second message
@@ -503,7 +503,7 @@ await YieldAsync(updates).ToChatResponseAsync() :
Assert.Equal("msg3", message3.MessageId);
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(new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), message3.CreatedAt); // First value should win
Assert.Equal("How are you?", message3.Text);
// Verify fourth message
@@ -511,7 +511,7 @@ await YieldAsync(updates).ToChatResponseAsync() :
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(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), message4.CreatedAt); // First value should win
Assert.Equal("I'm doing well, thank you! How can I help?", message4.Text);
}
@@ -741,6 +741,7 @@ public async Task ToChatResponse_AlternativeTimestamps(bool useAsync)
DateTimeOffset middle = new(2024, 1, 1, 11, 0, 0, TimeSpan.Zero);
DateTimeOffset late = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
DateTimeOffset unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ DateTimeOffset beforeEpoch = new(1969, 12, 31, 23, 59, 59, TimeSpan.Zero);
ChatResponseUpdate[] updates =
[
@@ -751,20 +752,23 @@ public async Task ToChatResponse_AlternativeTimestamps(bool useAsync)
// Unix epoch (as "null") should not overwrite
new(null, "b") { CreatedAt = unixEpoch },
- // Newer timestamp should overwrite
- new(null, "c") { CreatedAt = middle },
+ // Before Unix epoch (as "null") should not overwrite
+ new(null, "c") { CreatedAt = beforeEpoch },
+
+ // Newer timestamp should not overwrite (first value wins)
+ new(null, "d") { CreatedAt = middle },
// Older timestamp should not overwrite
- new(null, "d") { CreatedAt = early },
+ new(null, "e") { CreatedAt = early },
- // Even newer timestamp should overwrite
- new(null, "e") { CreatedAt = late },
+ // Even newer timestamp should not overwrite (first value wins)
+ new(null, "f") { CreatedAt = late },
// Unix epoch should not overwrite again
- new(null, "f") { CreatedAt = unixEpoch },
+ new(null, "g") { CreatedAt = unixEpoch },
// null should not overwrite
- new(null, "g") { CreatedAt = null },
+ new(null, "h") { CreatedAt = null },
];
ChatResponse response = useAsync ?
@@ -772,24 +776,26 @@ await YieldAsync(updates).ToChatResponseAsync() :
updates.ToChatResponse();
Assert.Single(response.Messages);
- Assert.Equal("abcdefg", response.Messages[0].Text);
+ Assert.Equal("abcdefgh", response.Messages[0].Text);
Assert.Equal(ChatRole.Tool, response.Messages[0].Role);
- Assert.Equal(late, response.Messages[0].CreatedAt);
- Assert.Equal(late, response.CreatedAt);
+ Assert.Equal(early, response.Messages[0].CreatedAt);
+ Assert.Equal(early, response.CreatedAt);
}
public static IEnumerable