diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index a7ca8c08d95..ade51651826 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -154,12 +154,36 @@ internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, Chat input.Role == ChatRole.User || input.Role == OpenAIClientExtensions.ChatRoleDeveloper) { - var parts = ToOpenAIChatContent(input.Contents); - string? name = SanitizeAuthorName(input.AuthorName); - yield return - input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = name } : - input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = name } : - new UserChatMessage(parts) { ParticipantName = name }; + // FunctionResultContent can be in User/System/Developer messages when the client wants + // to provide function results in a non-Tool message context. + bool hasFunctionResult = false; + bool hasNonFunctionResultContent = false; + foreach (AIContent item in input.Contents) + { + if (item is FunctionResultContent resultContent) + { + hasFunctionResult = true; + yield return CreateToolChatMessage(resultContent); + } + else + { + hasNonFunctionResultContent = true; + } + } + + // Yield the System/User/Developer message only if there's non-FunctionResultContent content, + // or if there was no FunctionResultContent at all (to preserve original behavior for normal messages). + // Note: ToOpenAIChatContent ignores FunctionResultContent (returns null for them via ToChatMessageContentPart), + // so only non-function-result content is included in the message. + if (hasNonFunctionResultContent || !hasFunctionResult) + { + var parts = ToOpenAIChatContent(input.Contents); + string? name = SanitizeAuthorName(input.AuthorName); + yield return + input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = name } : + input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = name } : + new UserChatMessage(parts) { ParticipantName = name }; + } } else if (input.Role == ChatRole.Tool) { @@ -167,20 +191,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, Chat { if (item is FunctionResultContent resultContent) { - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); + yield return CreateToolChatMessage(resultContent); } } } @@ -753,6 +764,25 @@ internal static void ConvertContentParts(ChatMessageContent content, IList new ChatFinishReason(s), }; + /// Creates a ToolChatMessage from a FunctionResultContent. + private static ToolChatMessage CreateToolChatMessage(FunctionResultContent resultContent) + { + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is not null) + { + try + { + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); + } + /// Sanitizes the author name to be appropriate for including as an OpenAI participant name. private static string? SanitizeAuthorName(string? name) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 1aa7e1e4d0f..e16e0554fcf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -655,6 +655,139 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) Assert.Null(m4.ParticipantName); } + [Fact] + public void AsOpenAIChatMessages_FunctionResultContentInUserMessage_ProducesToolMessage() + { + // When a User message contains a FunctionResultContent, it should produce: + // 1. A ToolChatMessage for each FunctionResultContent + // 2. A UserChatMessage with the remaining content + + List messages = + [ + new(ChatRole.User, + [ + new TextContent("Here is the result from the function"), + new FunctionResultContent("callid123", "theresult"), + ]), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(2, convertedMessages.Length); + + // First message should be the ToolChatMessage from the FunctionResultContent + ToolChatMessage toolMsg = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("callid123", toolMsg.ToolCallId); + Assert.Equal("theresult", Assert.Single(toolMsg.Content).Text); + + // Second message should be the UserChatMessage with the text content + UserChatMessage userMsg = Assert.IsType(convertedMessages[1], exactMatch: false); + Assert.Equal("Here is the result from the function", Assert.Single(userMsg.Content).Text); + } + + [Fact] + public void AsOpenAIChatMessages_MultipleFunctionResultContentInUserMessage_ProducesMultipleToolMessages() + { + // When a User message contains multiple FunctionResultContent items, each should produce a ToolChatMessage + + List messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("callid1", "result1"), + new FunctionResultContent("callid2", "result2"), + new TextContent("All done"), + ]), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(3, convertedMessages.Length); + + // First two messages should be ToolChatMessages + ToolChatMessage toolMsg1 = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("callid1", toolMsg1.ToolCallId); + Assert.Equal("result1", Assert.Single(toolMsg1.Content).Text); + + ToolChatMessage toolMsg2 = Assert.IsType(convertedMessages[1], exactMatch: false); + Assert.Equal("callid2", toolMsg2.ToolCallId); + Assert.Equal("result2", Assert.Single(toolMsg2.Content).Text); + + // Last message should be the UserChatMessage + UserChatMessage userMsg = Assert.IsType(convertedMessages[2], exactMatch: false); + Assert.Equal("All done", Assert.Single(userMsg.Content).Text); + } + + [Fact] + public void AsOpenAIChatMessages_FunctionResultContentOnlyInUserMessage_ProducesOnlyToolMessage() + { + // When a User message contains only FunctionResultContent (no other content), + // it should only produce a ToolChatMessage, not an additional empty UserChatMessage + + List messages = + [ + new(ChatRole.User, [new FunctionResultContent("callid123", "theresult")]), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + ToolChatMessage toolMsg = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("callid123", toolMsg.ToolCallId); + Assert.Equal("theresult", Assert.Single(toolMsg.Content).Text); + } + + [Fact] + public void AsOpenAIChatMessages_FunctionResultContentInSystemMessage_ProducesToolMessage() + { + // When a System message contains a FunctionResultContent, it should produce: + // 1. A ToolChatMessage for each FunctionResultContent + // 2. A SystemChatMessage with the remaining content (if any) + + List messages = + [ + new(ChatRole.System, + [ + new TextContent("System instructions"), + new FunctionResultContent("syscallid", "sysresult"), + ]), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(2, convertedMessages.Length); + + // First message should be the ToolChatMessage from the FunctionResultContent + ToolChatMessage toolMsg = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("syscallid", toolMsg.ToolCallId); + Assert.Equal("sysresult", Assert.Single(toolMsg.Content).Text); + + // Second message should be the SystemChatMessage with the text content + SystemChatMessage sysMsg = Assert.IsType(convertedMessages[1], exactMatch: false); + Assert.Equal("System instructions", Assert.Single(sysMsg.Content).Text); + } + + [Fact] + public void AsOpenAIChatMessages_FunctionResultContentOnlyInSystemMessage_ProducesOnlyToolMessage() + { + // When a System message contains only FunctionResultContent (no other content), + // it should only produce a ToolChatMessage, not an additional empty SystemChatMessage + + List messages = + [ + new(ChatRole.System, [new FunctionResultContent("syscallid", "sysresult")]), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + ToolChatMessage toolMsg = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("syscallid", toolMsg.ToolCallId); + Assert.Equal("sysresult", Assert.Single(toolMsg.Content).Text); + } + [Fact] public void AsOpenAIResponseItems_ProducesExpectedOutput() {