Skip to content

Commit 680b915

Browse files
authored
.Net: Fix 11820 OpenAIChatMessageContent Serialization (#12352)
### Motivation and Context - Fixes #11820
1 parent c00e729 commit 680b915

File tree

3 files changed

+401
-1
lines changed

3 files changed

+401
-1
lines changed

dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIChatMessageContentTests.cs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ public void ConstructorsWorkCorrectly()
3131
this.AssertChatMessageContent(AuthorRole.User, "content2", "model-id2", toolCalls, content2);
3232
}
3333

34+
[Fact]
35+
public void InternalConstructorInitializesCorrectlyForSerialization()
36+
{
37+
// Arrange & Act - Test that serialization/deserialization works with internal constructor
38+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "Test message", "gpt-4", []);
39+
40+
var json = JsonSerializer.Serialize(originalContent);
41+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
42+
43+
// Assert - Verify that deserialization properly initializes the object
44+
Assert.NotNull(deserializedContent);
45+
Assert.NotNull(deserializedContent.ToolCalls);
46+
Assert.Empty(deserializedContent.ToolCalls);
47+
Assert.Equal("assistant", deserializedContent.Role.Label);
48+
Assert.Equal("Test message", deserializedContent.Content);
49+
Assert.Equal("gpt-4", deserializedContent.ModelId);
50+
}
51+
3452
[Fact]
3553
public void GetOpenAIFunctionToolCallsReturnsCorrectList()
3654
{
@@ -94,6 +112,263 @@ public void MetadataIsInitializedCorrectly(bool readOnlyMetadata)
94112
Assert.Equal("id2", actualToolCalls[1].Id);
95113
}
96114

115+
[Fact]
116+
public void SerializationWithoutToolCallsWorksCorrectly()
117+
{
118+
// Arrange
119+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "Hello, world!", "gpt-4", [])
120+
{
121+
AuthorName = "Assistant"
122+
};
123+
124+
// Act
125+
var json = JsonSerializer.Serialize(originalContent);
126+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
127+
128+
// Assert
129+
Assert.NotNull(deserializedContent);
130+
Assert.Equal(originalContent.Role.Label, deserializedContent.Role.Label);
131+
Assert.Equal(originalContent.Content, deserializedContent.Content);
132+
Assert.Equal(originalContent.AuthorName, deserializedContent.AuthorName);
133+
Assert.Equal(originalContent.ModelId, deserializedContent.ModelId);
134+
Assert.NotNull(deserializedContent.ToolCalls);
135+
Assert.Empty(deserializedContent.ToolCalls);
136+
}
137+
138+
[Fact]
139+
public void SerializationWithoutToolCallsWorksCorrectlyForBasicScenario()
140+
{
141+
// Arrange - Test the basic scenario without tool calls which is the main use case for serialization
142+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "I'll help you with that.", "gpt-4", [])
143+
{
144+
AuthorName = "Assistant"
145+
};
146+
147+
// Act
148+
var json = JsonSerializer.Serialize(originalContent);
149+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
150+
151+
// Assert
152+
Assert.NotNull(deserializedContent);
153+
Assert.Equal(originalContent.Role.Label, deserializedContent.Role.Label);
154+
Assert.Equal(originalContent.Content, deserializedContent.Content);
155+
Assert.Equal(originalContent.AuthorName, deserializedContent.AuthorName);
156+
Assert.Equal(originalContent.ModelId, deserializedContent.ModelId);
157+
Assert.NotNull(deserializedContent.ToolCalls);
158+
Assert.Empty(deserializedContent.ToolCalls);
159+
}
160+
161+
[Fact]
162+
public void SerializationWithToolRoleWorksCorrectly()
163+
{
164+
// Arrange - This simulates the scenario from the issue where Tool role messages need to be serialized
165+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Tool, "Function result data", "gpt-4", []);
166+
167+
// Act
168+
var json = JsonSerializer.Serialize(originalContent);
169+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
170+
171+
// Assert
172+
Assert.NotNull(deserializedContent);
173+
Assert.Equal(AuthorRole.Tool.Label, deserializedContent.Role.Label);
174+
Assert.Equal(originalContent.Content, deserializedContent.Content);
175+
Assert.Equal(originalContent.ModelId, deserializedContent.ModelId);
176+
Assert.NotNull(deserializedContent.ToolCalls);
177+
Assert.Empty(deserializedContent.ToolCalls);
178+
}
179+
180+
[Fact]
181+
public void SerializationPreservesAllProperties()
182+
{
183+
// Arrange - Test that all properties are properly preserved during serialization/deserialization
184+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "Test content", "gpt-4", [])
185+
{
186+
AuthorName = "TestBot"
187+
};
188+
189+
// Act
190+
var json = JsonSerializer.Serialize(originalContent);
191+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
192+
193+
// Assert
194+
Assert.NotNull(deserializedContent);
195+
Assert.Equal("assistant", deserializedContent.Role.Label);
196+
Assert.Equal("gpt-4", deserializedContent.ModelId);
197+
Assert.Equal("Test content", deserializedContent.Content);
198+
Assert.Equal("TestBot", deserializedContent.AuthorName);
199+
Assert.NotNull(deserializedContent.ToolCalls);
200+
Assert.Empty(deserializedContent.ToolCalls);
201+
}
202+
203+
[Fact]
204+
public void SerializationWithNonEmptyToolCallsWorksCorrectlyWithJsonConverter()
205+
{
206+
// Arrange - Test that serialization with actual tool calls works with custom JsonConverter
207+
// Note: ToolCalls property now uses a custom JsonConverter to handle ChatToolCall serialization
208+
var args = JsonSerializer.Serialize(new Dictionary<string, object?> { { "location", "Seattle" }, { "unit", "celsius" } });
209+
List<ChatToolCall> toolCalls = [
210+
ChatToolCall.CreateFunctionToolCall("tool-call-1", "get_weather", BinaryData.FromString(args)),
211+
ChatToolCall.CreateFunctionToolCall("tool-call-2", "get_time", BinaryData.FromString("{\"timezone\":\"PST\"}")),
212+
ChatToolCall.CreateFunctionToolCall("tool-call-3", "get_current_user", BinaryData.FromString("{}")) // No arguments
213+
];
214+
215+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "I'll get the weather and time for you.", "gpt-4", toolCalls)
216+
{
217+
AuthorName = "WeatherBot"
218+
};
219+
220+
// Act - Serialization and deserialization should work now
221+
var json = JsonSerializer.Serialize(originalContent);
222+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
223+
224+
// Assert - Verify that serialization works and ToolCalls are properly serialized/deserialized
225+
Assert.NotNull(json);
226+
Assert.Contains("ToolCalls", json); // ToolCalls should be serialized
227+
228+
Assert.NotNull(deserializedContent);
229+
Assert.Equal("assistant", deserializedContent.Role.Label);
230+
Assert.Equal("gpt-4", deserializedContent.ModelId);
231+
Assert.Equal("I'll get the weather and time for you.", deserializedContent.Content);
232+
Assert.Equal("WeatherBot", deserializedContent.AuthorName);
233+
234+
// ToolCalls should be properly deserialized
235+
Assert.NotNull(deserializedContent.ToolCalls);
236+
Assert.Equal(3, deserializedContent.ToolCalls.Count);
237+
238+
// Verify first tool call (with arguments)
239+
Assert.Equal("tool-call-1", deserializedContent.ToolCalls[0].Id);
240+
Assert.Equal("get_weather", deserializedContent.ToolCalls[0].FunctionName);
241+
Assert.Equal(args, deserializedContent.ToolCalls[0].FunctionArguments.ToString());
242+
243+
// Verify second tool call (with arguments)
244+
Assert.Equal("tool-call-2", deserializedContent.ToolCalls[1].Id);
245+
Assert.Equal("get_time", deserializedContent.ToolCalls[1].FunctionName);
246+
Assert.Equal("{\"timezone\":\"PST\"}", deserializedContent.ToolCalls[1].FunctionArguments.ToString());
247+
248+
// Verify third tool call (without arguments)
249+
Assert.Equal("tool-call-3", deserializedContent.ToolCalls[2].Id);
250+
Assert.Equal("get_current_user", deserializedContent.ToolCalls[2].FunctionName);
251+
Assert.Equal("{}", deserializedContent.ToolCalls[2].FunctionArguments.ToString());
252+
}
253+
254+
[Fact]
255+
public void SerializationWithToolCallsEdgeCasesWorksCorrectly()
256+
{
257+
// Arrange - Test edge cases for tool call serialization
258+
List<ChatToolCall> toolCalls = [
259+
ChatToolCall.CreateFunctionToolCall("tool-1", "no_args_function", BinaryData.FromString("{}")), // Empty object
260+
ChatToolCall.CreateFunctionToolCall("tool-2", "minimal_function", BinaryData.FromString("")), // Empty string
261+
ChatToolCall.CreateFunctionToolCall("tool-3", "null_args_function", BinaryData.FromString("null")) // Null value
262+
];
263+
264+
var originalContent = new OpenAIChatMessageContent(AuthorRole.Assistant, "Calling functions with various argument types.", "gpt-4", toolCalls);
265+
266+
// Act
267+
var json = JsonSerializer.Serialize(originalContent);
268+
var deserializedContent = JsonSerializer.Deserialize<OpenAIChatMessageContent>(json);
269+
270+
// Assert
271+
Assert.NotNull(deserializedContent);
272+
Assert.Equal(3, deserializedContent.ToolCalls.Count);
273+
274+
// Verify empty object arguments
275+
Assert.Equal("tool-1", deserializedContent.ToolCalls[0].Id);
276+
Assert.Equal("no_args_function", deserializedContent.ToolCalls[0].FunctionName);
277+
Assert.Equal("{}", deserializedContent.ToolCalls[0].FunctionArguments.ToString());
278+
279+
// Verify empty string arguments
280+
Assert.Equal("tool-2", deserializedContent.ToolCalls[1].Id);
281+
Assert.Equal("minimal_function", deserializedContent.ToolCalls[1].FunctionName);
282+
Assert.Equal("", deserializedContent.ToolCalls[1].FunctionArguments.ToString());
283+
284+
// Verify null arguments
285+
Assert.Equal("tool-3", deserializedContent.ToolCalls[2].Id);
286+
Assert.Equal("null_args_function", deserializedContent.ToolCalls[2].FunctionName);
287+
Assert.Equal("null", deserializedContent.ToolCalls[2].FunctionArguments.ToString());
288+
}
289+
290+
[Fact]
291+
public void SerializationWorksForMostCommonScenarios()
292+
{
293+
// Arrange - Test the most common serialization scenarios that work
294+
// This covers the main use case from issue #11820: saving chat history without active tool calls
295+
296+
var chatHistory = new List<OpenAIChatMessageContent>
297+
{
298+
// User message
299+
new(AuthorRole.User, "What's the weather like?", "gpt-4", []),
300+
301+
// Assistant message without tool calls (most common case for serialization)
302+
new(AuthorRole.Assistant, "I'll check the weather for you.", "gpt-4", []),
303+
304+
// Tool message (result of a tool call)
305+
new(AuthorRole.Tool, "Weather data: 72°F, sunny", "gpt-4", [])
306+
};
307+
308+
// Act
309+
var json = JsonSerializer.Serialize(chatHistory);
310+
var deserializedHistory = JsonSerializer.Deserialize<List<OpenAIChatMessageContent>>(json);
311+
312+
// Assert
313+
Assert.NotNull(deserializedHistory);
314+
Assert.Equal(3, deserializedHistory.Count);
315+
316+
// Verify all messages were properly serialized and deserialized
317+
Assert.Equal("user", deserializedHistory[0].Role.Label);
318+
Assert.Equal("What's the weather like?", deserializedHistory[0].Content);
319+
320+
Assert.Equal("assistant", deserializedHistory[1].Role.Label);
321+
Assert.Equal("I'll check the weather for you.", deserializedHistory[1].Content);
322+
323+
Assert.Equal("tool", deserializedHistory[2].Role.Label);
324+
Assert.Equal("Weather data: 72°F, sunny", deserializedHistory[2].Content);
325+
326+
// All should have empty tool calls (which is serializable)
327+
Assert.All(deserializedHistory, msg => Assert.Empty(msg.ToolCalls));
328+
}
329+
330+
[Fact]
331+
public void ToolRoleMessageSerializationScenario()
332+
{
333+
// Arrange - This test specifically addresses the scenario described in issue #11820
334+
// where Tool role messages with ToolCalls need to be serialized/deserialized for chat history persistence
335+
336+
// Create a list of OpenAIChatMessageContent objects simulating a chat history with tool calls
337+
var chatHistory = new List<OpenAIChatMessageContent>
338+
{
339+
// User message
340+
new(AuthorRole.User, "What's the weather like?", "gpt-4", []),
341+
342+
// Assistant message (this would normally have tool calls, but we'll keep it simple for serialization)
343+
new(AuthorRole.Assistant, "I'll check the weather for you.", "gpt-4", []),
344+
345+
// Tool message - this is the specific scenario that was failing in the issue
346+
new(AuthorRole.Tool, "Weather data: 72°F, sunny", "gpt-4", [])
347+
};
348+
349+
// Act - Serialize and deserialize the entire chat history
350+
var json = JsonSerializer.Serialize(chatHistory);
351+
var deserializedHistory = JsonSerializer.Deserialize<List<OpenAIChatMessageContent>>(json);
352+
353+
// Assert - Verify that all messages were properly serialized and deserialized
354+
Assert.NotNull(deserializedHistory);
355+
Assert.Equal(3, deserializedHistory.Count);
356+
357+
// Verify user message
358+
Assert.Equal("user", deserializedHistory[0].Role.Label);
359+
Assert.Equal("What's the weather like?", deserializedHistory[0].Content);
360+
361+
// Verify assistant message
362+
Assert.Equal("assistant", deserializedHistory[1].Role.Label);
363+
Assert.Equal("I'll check the weather for you.", deserializedHistory[1].Content);
364+
365+
// Verify tool message - this was the problematic scenario in issue #11820
366+
Assert.Equal("tool", deserializedHistory[2].Role.Label);
367+
Assert.Equal("Weather data: 72°F, sunny", deserializedHistory[2].Content);
368+
Assert.NotNull(deserializedHistory[2].ToolCalls);
369+
Assert.Empty(deserializedHistory[2].ToolCalls);
370+
}
371+
97372
private void AssertChatMessageContent(
98373
AuthorRole expectedRole,
99374
string expectedContent,

0 commit comments

Comments
 (0)