Skip to content

FunctionInvokingChatClient reorder messages when using FunctionApprovalResponseContent #7156

Closed as not planned

Description

@NoofSaeidh

Description

When FunctionInvokingChatClient is used together with FunctionApprovalResponseContent, the client may reorder chat messages and, in some cases, lose user messages after a function approval.

This behavior causes several problems:

  • User or system messages added during a rejection/approval flow are not reliably preserved.
  • It becomes impossible to provide additional rejection details back to the LLM (e.g., explaining why a tool call was rejected and how the model should retry with different arguments).
  • As a result, developers are forced to manually manipulate approval and chat contents, instead of relying on FunctionInvokingChatClient to manage the flow correctly.

This makes it difficult to implement robust function-calling workflows that support:

  • Partial rejections
  • Guided retries with modified arguments
  • Clear, user-visible explanations of what went wrong and what to do next

Reproduction Steps

using Microsoft.Extensions.AI;

#pragma warning disable MEAI001

internal class StepsToReproduce
{
public async Task Execute(IChatClient rawClient)
{
var client = rawClient.AsBuilder().UseFunctionInvocation().Build();

    var chatOptions = new ChatOptions
    {
        Tools =
        [
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ConvertString)),
        ],
    };
    
    string prompt = """
                    You are test engineer that need to test AI Functions.

                    As first test case, call ConvertString function with random input.

                    Respect user response if tool was rejected.
                    """;

    List<ChatMessage> messages = [new ChatMessage(ChatRole.User, prompt)];
    var response = await client.GetResponseAsync(messages, chatOptions);
    messages.AddRange(response.Messages);

    // problem 1

    messages.Add(new ChatMessage(ChatRole.User, [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(false)]));
    messages.Add(new ChatMessage(ChatRole.User, "try another input"));
    response = await client.GetResponseAsync(messages, chatOptions);

    // OR problem 2

    messages.Add(new ChatMessage(ChatRole.User, [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(true)]));
    messages.Add(new ChatMessage(ChatRole.User, "provide details whats happened"));
    response = await client.GetResponseAsync(messages, chatOptions);
}

private string ConvertString(string input) => $"Converted: {input}";

}

Expected behavior

Messages should be sent to LLM in the following order:

For problem 1.

user: 1st message
assistant: tool_call
tool_call: reject
user: 2nd message

For problem 2.

user: 1st message
assistant: tool_call
tool_call: approve
user: 2nd message

Actual behavior

Messages are sent to LLM in the following order:

For problem 1.

user: 1st message
user: 2nd message
assistant: tool_call
tool_call: reject

For problem 2.

user: 1st message
assistant: tool_call
tool_call: approve

Regression?

No response

Known Workarounds

Manually replace FunctionApprovalRequestContent with FunctionCallContent and FunctionResultContent

Configuration

No response

Other information

here are the test scenarios I used for testing with manual manipulation and default mechanism and clef log.

export-8de3c0ba9a8b892.log

using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

#pragma warning disable MEAI001

namespace AiResearch;

public class ChatService(IChatClient rawClient, ILogger<ChatService> logger, ILoggerFactory loggerFactory)
{
    public async Task RunAsync()
    {
        var sink = new MessageSink();
        var client = CreateChatClient(sink);
        foreach (var (testCase, method) in GetMethods())
        {
            await TestMethod(client, sink, testCase, method);
        }
    }


    private IChatClient CreateChatClient(MessageSink messageSink)
    {
        // build in reverse order. logging must wrap original logging to see real messages and not that in function invoking
        return rawClient.AsBuilder()
            .UseFunctionInvocation(loggerFactory)
            .Use(c => new SinkingChatClient(c, loggerFactory.CreateLogger<SinkingChatClient>(), messageSink))
            .Build();
    }


    private async Task TestMethod(IChatClient chatClient, MessageSink messageSink, string testCase, Action<IList<ChatMessage>> method)
    {
        using var scope = logger.BeginScope("{TestCase}", testCase);
        logger.LogInformation("Begin {TestCase}", testCase);

        var chatOptions = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ConvertString)),
            ],
        };

        try
        {
            string prompt = """
                                You are test engineer that need to test AI Functions.

                                As first test case, call ConvertString function with random input.

                                Respect user response if tool was rejected.
                                """;
            List<ChatMessage> messages = [new ChatMessage(ChatRole.System, prompt)];

            ChatResponse response;
            bool firstCall = true;
            while ((response = await chatClient.GetResponseAsync(messages, chatOptions)).FinishReason != ChatFinishReason.Stop)
            {
                messages.AddRange(response.Messages);
                if (response.FinishReason == ChatFinishReason.ToolCalls)
                {
                    if (firstCall)
                    {
                        method(messages);
                        firstCall = false;
                    }
                    else
                    {
                        messages.Add(new ChatMessage(ChatRole.User,
                            [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(true)]));

                        messages.Add(new ChatMessage(ChatRole.User, "this is result message"));
                    }
                }
            }

            logger.LogInformation("Prompt processed {Log}", JsonSerializer.Serialize(messageSink.ChatMessages, AIJsonUtilities.DefaultOptions));
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error occurred while getting response.");
        }
    }

    private string ConvertString(string value) => $"Converted: {value}";

    private static IList<(string, Action<IList<ChatMessage>>)> GetMethods()
    {
        const string userMessage = "Please use another input.";

        return
        [
            (nameof(Manual_AddUserMessage_AsExtraTextContent), Manual_AddUserMessage_AsExtraTextContent),
            (nameof(Manual_AddUserMessage_AsNextChatMessage), Manual_AddUserMessage_AsNextChatMessage),
            (nameof(Manual_AddUserMessage_AsToolResult), Manual_AddUserMessage_AsToolResult),
            (nameof(Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage), Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage),
            (nameof(Default_AddUserMessageAsNextMessage_ResponseAsNextMessage), Default_AddUserMessageAsNextMessage_ResponseAsNextMessage),
            (nameof(Default_AddUserMessageAsNextMessage_ResponseAsSameMessage), Default_AddUserMessageAsNextMessage_ResponseAsSameMessage),
            (nameof(Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage), Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage),
        ];


        static void Manual_AddUserMessage_AsToolResult(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.Tool, [new FunctionResultContent(approval.FunctionCall.CallId, userMessage)] ));
                }
            }
        }

        static void Manual_AddUserMessage_AsExtraTextContent(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1,
                        new ChatMessage(ChatRole.Tool,
                            [
                                new FunctionResultContent(approval.FunctionCall.CallId, "Error: Tool call invocation was rejected by user."),
                                new TextContent(userMessage)
                            ]
                        )
                    );
                }
            }
        }

        static void Manual_AddUserMessage_AsNextChatMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1,
                        new ChatMessage(ChatRole.Tool,
                            [
                                new FunctionResultContent(approval.FunctionCall.CallId, "Error: Tool call invocation was rejected by user."),
                            ]
                        )
                    );
                    allMessages.Insert(i + 2, new ChatMessage(ChatRole.User, userMessage));
                }
            }
        }

        static void Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage(IList<ChatMessage> allMessages)
        {
            foreach (var chatMessage in allMessages)
            {
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var response = approval.CreateResponse(false);
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = response;
                    chatMessage.Contents.Insert(index + 1, new TextContent(userMessage));
                }
            }
        }

        static void Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        approval.CreateResponse(false),
                        new TextContent(userMessage),
                    ]));
                }
            }
        }

        static void Default_AddUserMessageAsNextMessage_ResponseAsSameMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    chatMessage.Contents.Add(approval.CreateResponse(false));
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        new TextContent(userMessage),
                    ]));
                }
            }
        }

        static void Default_AddUserMessageAsNextMessage_ResponseAsNextMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        approval.CreateResponse(false),
                    ]));
                    allMessages.Insert(i + 2, new ChatMessage(ChatRole.User,
                    [
                        new TextContent(userMessage),
                    ]));
                }
            }
        }
    }


    private class SinkingChatClient(IChatClient innerClient, ILogger<SinkingChatClient> logger, MessageSink messageSink) : DelegatingChatClient(innerClient)
    {
        public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null,
            CancellationToken cancellationToken = default)
        {
            var log = messages.ToList();
            var result = await base.GetResponseAsync(log.ToList(), options, cancellationToken);
            log.AddRange(result.Messages);

            messageSink.ChatMessages = log;

            return result;
        }
    }

    private class MessageSink
    {
        public IList<ChatMessage> ChatMessages { get; set; }
    }

}

Activity

  1. added
    bugThis issue describes a behavior which is not expected - a bug.
    on Dec 16, 2025
  2. stephentoub commented on Dec 16, 2025

    @stephentoub
    Member

    It becomes impossible to provide additional rejection details back to the LLM (e.g., explaining why a tool call was rejected and how the model should retry with different arguments).

    This is your core concern, right? Will your issue be solved with #7140?

  3. NoofSaeidh commented on Dec 16, 2025

    @NoofSaeidh
    Author

    @stephentoub no.
    I tested different approaches in details and rejection reason is not enough for proper LLM response.
    It is required to pass extra user message after rejection.
    Basically I need both tickets for my concern.

  4. added 2 commits that reference this issue on Mar 9, 2026
    bbb4f49
    fb92a70
  5. jozkee commented on Jun 2, 2026

    @jozkee
    Member

    @NoofSaeidh the reason in ToolApprovalRequetsContent.CreateResponse is sufficient to get the model to retry the tool call with different arguments, which sidesteps the ordering concern entirely.

    IChatClient client = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey))
        .GetResponsesClient()
        .AsIChatClient("gpt-5.4-mini")
        .AsBuilder()
        .UseFunctionInvocation()
        .Build();
    
    var options = new ChatOptions
    {
        Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
            (string value) => $"Converted: {value}", "ConvertString"))],
    };
    
    List<ChatMessage> messages =
    [
        new(ChatRole.System,
            "Call ConvertString with random input. If a call is rejected, follow the rejection reason."),
    ];
    
    for (int turn = 1; turn <= 4; turn++)
    {
        var response = await client.GetResponseAsync(messages, options);
        messages.AddRange(response.Messages);
    
        Console.WriteLine($"--- Turn {turn} (finish={response.FinishReason}) ---");
        foreach (var fc in response.Messages.SelectMany(m => m.Contents).OfType<FunctionCallContent>())
            Console.WriteLine($"  call    : {fc.Name}({string.Join(", ", fc.Arguments?.Select(a => $"{a.Key}={a.Value}") ?? [])})");
        if (!string.IsNullOrWhiteSpace(response.Text))
            Console.WriteLine($"  text    : {response.Text}");
    
        var approval = response.Messages
            .SelectMany(m => m.Contents)
            .OfType<ToolApprovalRequestContent>()
            .FirstOrDefault();
        if (approval is null) break;
    
        // First approval: reject with directive reason. Subsequent ones: approve.
        bool approve = turn > 1;
        string? reason = approve ? null : "You MUST call the tool again with a different input.";
        Console.WriteLine($"  user    : {(approve ? "APPROVE" : $"REJECT — reason: \"{reason}\"")}");
        messages.Add(new(ChatRole.User, [approval.CreateResponse(approve, reason)]));
    }

    Given that, I'm going to close #7158 since it will change the timing in which the FunctionCallContent/FunctionResultContent are being produced.

  6. locked and limited conversation to collaborators on Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI librariesbugThis issue describes a behavior which is not expected - a bug.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions