Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 })
{
Expand All @@ -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;
}
Expand Down Expand Up @@ -598,4 +600,19 @@ private static bool NotEmptyOrEqual(string? s1, string? s2) =>
/// <summary>Gets whether two roles are not null and not the same as each other.</summary>
private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) =>
r1.HasValue && r2.HasValue && r1.Value != r2.Value;

#if NET
/// <summary>Gets whether the specified <see cref="DateTimeOffset"/> is a valid <c>CreatedAt</c> value.</summary>
/// <remarks>Values that are <see langword="null"/> or less than or equal to the Unix epoch are treated as invalid.</remarks>
private static bool IsValidCreatedAt(DateTimeOffset? createdAt) =>
createdAt > DateTimeOffset.UnixEpoch;
#else
/// <summary>The Unix epoch (1970-01-01T00:00:00Z).</summary>
private static readonly DateTimeOffset _unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);

/// <summary>Gets whether the specified <see cref="DateTimeOffset"/> is a valid <c>CreatedAt</c> value.</summary>
/// <remarks>Values that are <see langword="null"/> or less than or equal to the Unix epoch are treated as invalid.</remarks>
private static bool IsValidCreatedAt(DateTimeOffset? createdAt) =>
createdAt > _unixEpoch;
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 })] },
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand All @@ -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?"),
Expand All @@ -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
Expand All @@ -503,15 +503,15 @@ 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
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(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);
}

Expand Down Expand Up @@ -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 =
[
Expand All @@ -751,45 +752,50 @@ 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 ?
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<object?[]> 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
Expand Down
Loading