Skip to content

Commit 7798311

Browse files
authored
Add ChatMessage.CreatedAt (#6657)
We currently have it at the ChatResponse{Update} level, but for more agentic scenarios, it's helpful to have a timestamp per message.
1 parent 93bebed commit 7798311

File tree

10 files changed

+158
-9
lines changed

10 files changed

+158
-9
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public ChatMessage Clone() =>
5353
AdditionalProperties = AdditionalProperties,
5454
_authorName = _authorName,
5555
_contents = _contents,
56+
CreatedAt = CreatedAt,
5657
RawRepresentation = RawRepresentation,
5758
Role = Role,
5859
MessageId = MessageId,
@@ -65,6 +66,9 @@ public string? AuthorName
6566
set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value;
6667
}
6768

69+
/// <summary>Gets or sets a timestamp for the chat message.</summary>
70+
public DateTimeOffset? CreatedAt { get; set; }
71+
6872
/// <summary>Gets or sets the role of the author of the message.</summary>
6973
public ChatRole Role { get; set; } = ChatRole.User;
7074

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,19 @@ public ChatResponseUpdate[] ToChatResponseUpdates()
130130
ChatMessage message = _messages![i];
131131
updates[i] = new ChatResponseUpdate
132132
{
133-
ConversationId = ConversationId,
134-
135133
AdditionalProperties = message.AdditionalProperties,
136134
AuthorName = message.AuthorName,
137135
Contents = message.Contents,
136+
MessageId = message.MessageId,
138137
RawRepresentation = message.RawRepresentation,
139138
Role = message.Role,
140139

141-
ResponseId = ResponseId,
142-
MessageId = message.MessageId,
143-
CreatedAt = CreatedAt,
140+
ConversationId = ConversationId,
144141
FinishReason = FinishReason,
145-
ModelId = ModelId
142+
ModelId = ModelId,
143+
ResponseId = ResponseId,
144+
145+
CreatedAt = message.CreatedAt ?? CreatedAt,
146146
};
147147
}
148148

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ public static void AddMessages(this IList<ChatMessage> list, ChatResponseUpdate
8484
var contentsList = filter is null ? update.Contents : update.Contents.Where(filter).ToList();
8585
if (contentsList.Count > 0)
8686
{
87-
list.Add(new ChatMessage(update.Role ?? ChatRole.Assistant, contentsList)
87+
list.Add(new(update.Role ?? ChatRole.Assistant, contentsList)
8888
{
8989
AuthorName = update.AuthorName,
90+
CreatedAt = update.CreatedAt,
9091
RawRepresentation = update.RawRepresentation,
9192
AdditionalProperties = update.AdditionalProperties,
9293
});
@@ -268,7 +269,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
268269

269270
if (isNewMessage)
270271
{
271-
message = new ChatMessage(ChatRole.Assistant, []);
272+
message = new(ChatRole.Assistant, []);
272273
response.Messages.Add(message);
273274
}
274275
else
@@ -280,11 +281,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon
280281
// Incorporate those into the latest message; in cases where the message
281282
// stores a single value, prefer the latest update's value over anything
282283
// stored in the message.
284+
283285
if (update.AuthorName is not null)
284286
{
285287
message.AuthorName = update.AuthorName;
286288
}
287289

290+
if (update.CreatedAt is not null)
291+
{
292+
message.CreatedAt = update.CreatedAt;
293+
}
294+
288295
if (update.Role is ChatRole role)
289296
{
290297
message.Role = role;

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,10 @@
883883
"Member": "System.Collections.Generic.IList<Microsoft.Extensions.AI.AIContent> Microsoft.Extensions.AI.ChatMessage.Contents { get; set; }",
884884
"Stage": "Stable"
885885
},
886+
{
887+
"Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatMessage.CreatedAt { get; set; }",
888+
"Stage": "Stable"
889+
},
886890
{
887891
"Member": "string? Microsoft.Extensions.AI.ChatMessage.MessageId { get; set; }",
888892
"Stage": "Stable"

src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public async Task<ChatResponse> GetResponseAsync(
9595
// Create the return message.
9696
ChatMessage message = new(ToChatRole(response.Role), response.Content)
9797
{
98+
CreatedAt = response.Created,
9899
MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID
99100
RawRepresentation = response,
100101
};

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl
438438
// Create the return message.
439439
ChatMessage returnMessage = new()
440440
{
441+
CreatedAt = openAICompletion.CreatedAt,
441442
MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID
442443
RawRepresentation = openAICompletion,
443444
Role = FromOpenAIChatRole(openAICompletion.Role),

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R
162162
}
163163
}
164164

165+
foreach (var message in response.Messages)
166+
{
167+
message.CreatedAt = openAIResponse.CreatedAt;
168+
}
169+
165170
return response;
166171
}
167172

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public void Constructor_Parameterless_PropsDefaulted()
1818
ChatMessage message = new();
1919
Assert.Null(message.AuthorName);
2020
Assert.Empty(message.Contents);
21+
Assert.Null(message.CreatedAt);
2122
Assert.Equal(ChatRole.User, message.Role);
2223
Assert.Empty(message.Text);
2324
Assert.NotNull(message.Contents);
@@ -50,6 +51,7 @@ public void Constructor_RoleString_PropsRoundtrip(string? text)
5051
}
5152

5253
Assert.Null(message.AuthorName);
54+
Assert.Null(message.CreatedAt);
5355
Assert.Null(message.RawRepresentation);
5456
Assert.Null(message.AdditionalProperties);
5557
Assert.Equal(text ?? string.Empty, message.ToString());
@@ -113,6 +115,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount)
113115
}
114116

115117
Assert.Null(message.AuthorName);
118+
Assert.Null(message.CreatedAt);
116119
Assert.Null(message.RawRepresentation);
117120
Assert.Null(message.AdditionalProperties);
118121
}
@@ -230,6 +233,20 @@ public void AdditionalProperties_Roundtrips()
230233
Assert.Same(props, message.AdditionalProperties);
231234
}
232235

236+
[Fact]
237+
public void CreatedAt_Roundtrips()
238+
{
239+
ChatMessage message = new();
240+
Assert.Null(message.CreatedAt);
241+
242+
DateTimeOffset now = DateTimeOffset.Now;
243+
message.CreatedAt = now;
244+
Assert.Equal(now, message.CreatedAt);
245+
246+
message.CreatedAt = null;
247+
Assert.Null(message.CreatedAt);
248+
}
249+
233250
[Fact]
234251
public void ItCanBeSerializeAndDeserialized()
235252
{

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ public void ToString_OutputsText()
125125
}
126126

127127
[Fact]
128-
public void ToChatResponseUpdates()
128+
public void ToChatResponseUpdates_SingleMessage()
129129
{
130130
ChatResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" })
131131
{
@@ -153,4 +153,55 @@ public void ToChatResponseUpdates()
153153
Assert.Equal("value1", update1.AdditionalProperties?["key1"]);
154154
Assert.Equal(42, update1.AdditionalProperties?["key2"]);
155155
}
156+
157+
[Fact]
158+
public void ToChatResponseUpdates_MultipleMessages()
159+
{
160+
ChatResponse response = new(
161+
[
162+
new ChatMessage(new ChatRole("customRole"), "Text")
163+
{
164+
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
165+
MessageId = "someMessage"
166+
},
167+
new ChatMessage(new ChatRole("secondRole"), "Another message")
168+
{
169+
CreatedAt = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero),
170+
MessageId = "anotherMessage"
171+
}
172+
])
173+
{
174+
ResponseId = "12345",
175+
ModelId = "someModel",
176+
FinishReason = ChatFinishReason.ContentFilter,
177+
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
178+
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
179+
};
180+
181+
ChatResponseUpdate[] updates = response.ToChatResponseUpdates();
182+
Assert.NotNull(updates);
183+
Assert.Equal(3, updates.Length);
184+
185+
ChatResponseUpdate update0 = updates[0];
186+
Assert.Equal("12345", update0.ResponseId);
187+
Assert.Equal("someMessage", update0.MessageId);
188+
Assert.Equal("someModel", update0.ModelId);
189+
Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason);
190+
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
191+
Assert.Equal("customRole", update0.Role?.Value);
192+
Assert.Equal("Text", update0.Text);
193+
194+
ChatResponseUpdate update1 = updates[1];
195+
Assert.Equal("12345", update1.ResponseId);
196+
Assert.Equal("anotherMessage", update1.MessageId);
197+
Assert.Equal("someModel", update1.ModelId);
198+
Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason);
199+
Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), update1.CreatedAt);
200+
Assert.Equal("secondRole", update1.Role?.Value);
201+
Assert.Equal("Another message", update1.Text);
202+
203+
ChatResponseUpdate update2 = updates[2];
204+
Assert.Equal("value1", update2.AdditionalProperties?["key1"]);
205+
Assert.Equal(42, update2.AdditionalProperties?["key2"]);
206+
}
156207
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,65 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync)
6565
Assert.Equal("Hello, world!", response.Text);
6666
}
6767

68+
[Theory]
69+
[InlineData(false)]
70+
[InlineData(true)]
71+
public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool useAsync)
72+
{
73+
ChatResponseUpdate[] updates =
74+
[
75+
76+
// First message - ID "msg1"
77+
new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" },
78+
new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" },
79+
new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win
80+
new(null, " AI") { MessageId = "msg1", AuthorName = "AI Assistant" }, // Later AuthorName should win
81+
82+
// Second message - ID "msg2"
83+
new(ChatRole.User, "How") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" },
84+
new(null, " are") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) },
85+
new(null, " you?") { MessageId = "msg2", AuthorName = "Human User" }, // Later AuthorName should win
86+
87+
// Third message - ID "msg3"
88+
new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) },
89+
new(null, " thank you!") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win
90+
91+
// Updates without MessageId should continue the last message (msg3)
92+
new(null, " How can I help?"),
93+
];
94+
95+
ChatResponse response = useAsync ?
96+
await YieldAsync(updates).ToChatResponseAsync() :
97+
updates.ToChatResponse();
98+
99+
Assert.NotNull(response);
100+
Assert.Equal(3, response.Messages.Count);
101+
102+
// Verify first message
103+
ChatMessage message1 = response.Messages[0];
104+
Assert.Equal("msg1", message1.MessageId);
105+
Assert.Equal(ChatRole.Assistant, message1.Role);
106+
Assert.Equal("AI Assistant", message1.AuthorName); // Last value should win
107+
Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win
108+
Assert.Equal("Hi! Hello from AI", message1.Text);
109+
110+
// Verify second message
111+
ChatMessage message2 = response.Messages[1];
112+
Assert.Equal("msg2", message2.MessageId);
113+
Assert.Equal(ChatRole.User, message2.Role);
114+
Assert.Equal("Human User", message2.AuthorName); // Last value should win
115+
Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message2.CreatedAt); // Last value should win
116+
Assert.Equal("How are you?", message2.Text);
117+
118+
// Verify third message
119+
ChatMessage message3 = response.Messages[2];
120+
Assert.Equal("msg3", message3.MessageId);
121+
Assert.Equal(ChatRole.Assistant, message3.Role);
122+
Assert.Null(message3.AuthorName); // No AuthorName set in later updates
123+
Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win
124+
Assert.Equal("I'm doing well, thank you! How can I help?", message3.Text);
125+
}
126+
68127
public static IEnumerable<object[]> ToChatResponse_Coalescing_VariousSequenceAndGapLengths_MemberData()
69128
{
70129
foreach (bool useAsync in new[] { false, true })

0 commit comments

Comments
 (0)