Skip to content
Closed
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
70 changes: 50 additions & 20 deletions src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,33 +154,44 @@ 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)
{
foreach (AIContent item in input.Contents)
{
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);
}
}
}
Expand Down Expand Up @@ -753,6 +764,25 @@ internal static void ConvertContentParts(ChatMessageContent content, IList<AICon
_ => new ChatFinishReason(s),
};

/// <summary>Creates a ToolChatMessage from a FunctionResultContent.</summary>
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);
}

/// <summary>Sanitizes the author name to be appropriate for including as an OpenAI participant name.</summary>
private static string? SanitizeAuthorName(string? name)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessage> 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<ToolChatMessage>(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<UserChatMessage>(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<ChatMessage> 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<ToolChatMessage>(convertedMessages[0], exactMatch: false);
Assert.Equal("callid1", toolMsg1.ToolCallId);
Assert.Equal("result1", Assert.Single(toolMsg1.Content).Text);

ToolChatMessage toolMsg2 = Assert.IsType<ToolChatMessage>(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<UserChatMessage>(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<ChatMessage> messages =
[
new(ChatRole.User, [new FunctionResultContent("callid123", "theresult")]),
];

var convertedMessages = messages.AsOpenAIChatMessages().ToArray();

Assert.Single(convertedMessages);

ToolChatMessage toolMsg = Assert.IsType<ToolChatMessage>(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<ChatMessage> 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<ToolChatMessage>(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<SystemChatMessage>(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<ChatMessage> messages =
[
new(ChatRole.System, [new FunctionResultContent("syscallid", "sysresult")]),
];

var convertedMessages = messages.AsOpenAIChatMessages().ToArray();

Assert.Single(convertedMessages);

ToolChatMessage toolMsg = Assert.IsType<ToolChatMessage>(convertedMessages[0], exactMatch: false);
Assert.Equal("syscallid", toolMsg.ToolCallId);
Assert.Equal("sysresult", Assert.Single(toolMsg.Content).Text);
}

[Fact]
public void AsOpenAIResponseItems_ProducesExpectedOutput()
{
Expand Down
Loading