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 ToChatResponse_TimestampFolding_MemberData() { - // Base test cases + // Base test cases (first valid timestamp wins) var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[] { (null, null, null), ("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"), (null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), - ("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T11:00:00Z"), - ("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), + ("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z"), // First wins + ("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), // First wins ("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"), - ("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), + ("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), // Unix epoch treated as null, second is first valid + ("1969-12-31T23:59:59Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), // Before Unix epoch treated as null, second is first valid + ("1960-01-01T00:00:00Z", "1965-06-15T12:00:00Z", null), // Both before Unix epoch treated as null }; // Yield each test case twice, once for useAsync = false and once for useAsync = true