File: ChatCompletion\FunctionInvokingChatClientApprovalsTests.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.AI.Tests\Microsoft.Extensions.AI.Tests.csproj (Microsoft.Extensions.AI.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
 
namespace Microsoft.Extensions.AI;
 
public class FunctionInvokingChatClientApprovalsTests
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools)
    {
        AITool[] tools =
        [
            new ApprovalRequiredAIFunction(
                AIFunctionFactory.Create(() => "Result 1", "Func1")),
            new ApprovalRequiredAIFunction(
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
        ];
 
        var options = new ChatOptions
        {
            Tools = useAdditionalTools ? null : tools
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
    }
 
    [Fact]
    public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
                {
                    RequiresConfirmation = false,
                }
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval)
    {
        AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1");
        AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2");
        AITool[] additionalTools =
        [
            additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1,
        ];
 
        var options = new ChatOptions
        {
            Tools =
            [
                additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
        ];
 
        // When additionalToolsRequireApproval is true: Func1 (additional tools) requires approval and Func2 (options.Tools) does not.
        // When false: Func2 (options.Tools) requires approval and Func1 (additional tools) does not.
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1"))
                {
                    RequiresConfirmation = additionalToolsRequireApproval,
                },
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
                {
                    RequiresConfirmation = !additionalToolsRequireApproval,
                }
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
    }
 
    private sealed class PassThroughDelegatingAIFunction(AIFunction inner) : DelegatingAIFunction(inner);
 
    [Fact]
    public async Task RequiresConfirmation_IsTrueForApprovalRequiredFunctionNestedInDelegatingWrapperAsync()
    {
        // Wrap the ApprovalRequiredAIFunction in another DelegatingAIFunction (e.g. a telemetry decorator).
        // FICC must still classify the call as approval-required (RequiresConfirmation = true, the default)
        // by walking the delegation chain via GetService<ApprovalRequiredAIFunction>().
        AITool[] tools =
        [
            new PassThroughDelegatingAIFunction(
                new ApprovalRequiredAIFunction(
                    AIFunctionFactory.Create(() => "Result 1", "Func1"))),
            AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
        ];
 
        var options = new ChatOptions { Tools = tools };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [
                new FunctionCallContent("callId1", "Func1"),
                new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })
            ]),
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
                {
                    RequiresConfirmation = false,
                }
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
    }
 
    [Fact]
    public async Task RequiresConfirmation_IsFalseForFunctionCallWithNoMatchingToolWhenPeerRequiresApprovalAsync()
    {
        // The downstream client emits an FCC referencing a tool name that is not in the tools list.
        // Because a peer call (Func1) requires approval, FICC still wraps the unknown call.
        // Since no matching tool is found (and therefore no ApprovalRequiredAIFunction is detected),
        // the resulting approval request must carry RequiresConfirmation = false.
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [
                new FunctionCallContent("callId1", "Func1"),
                new FunctionCallContent("callId2", "Unknown"),
            ]),
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Unknown"))
                {
                    RequiresConfirmation = false,
                }
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
    }
 
    [Fact]
    public async Task ApprovedApprovalResponsesAreExecutedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedApprovalResponsesAreGroupedWhenMessageIdIsNullAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
            ]
        };
 
        // Key difference from other tests: MessageId is NOT set on the assistant message
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]), // Note: No MessageId set - this is the bug trigger
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        // Both FCCs should be in a SINGLE assistant message, not split across multiple messages
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp2" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
            ]),
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task RejectedApprovalResponsesAreFailedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
            ]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
            ]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> nonStreamingOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> streamingOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
                new FunctionResultContent("callId2", result: "Result 2: 42")
            ]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task RejectedApprovalResponsesWithCustomReasonAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1"))
                {
                    Reason = "User denied permission for this operation"
                },
                new ToolApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
                {
                    Reason = "Function Func2 is not allowed at this time"
                }
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
            ]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
            ]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task MixedApprovalResponsesWithCustomAndDefaultReasonsAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
                AIFunctionFactory.Create((string s) => $"Result 3: {s}", "Func3"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })),
                new ToolApprovalRequestContent("ficc_callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "Custom rejection for Func1" },
                new ToolApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })),
                new ToolApprovalResponseContent("ficc_callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func1"),
                new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
                new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
            ]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> nonStreamingOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func1"),
                new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
                new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
            ]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> streamingOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func1"),
                new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
                new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
                new FunctionResultContent("callId2", result: "Tool call invocation rejected."),
                new FunctionResultContent("callId3", result: "Result 3: test")
            ]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task RejectedApprovalResponsesWithEmptyOrWhitespaceReasonUsesDefaultMessageAsync(string? reason)
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1"))
                {
                    Reason = reason
                },
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
            ]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
            ]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                AIFunctionFactory.Create(() => "Result 1", "Func1"),
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } })]),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } }))
            ]),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task AlreadyExecutedApprovalsAreIgnoredAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                AIFunctionFactory.Create(() => "Result 1", "Func1"),
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId3", new FunctionCallContent("callId3", "Func1")),
            ]) { MessageId = "resp2" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId3", true, new FunctionCallContent("callId3", "Func1")),
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "World"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "World"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    /// <summary>
    /// After serialization/deserialization, the TARC and TAResp may contain separate FCC object instances
    /// for the same call. When a rejection is processed, GenerateRejectedFunctionResults must set
    /// InformationalOnly=true on BOTH the TAResp's FCC and the TARC's FCC to ensure consistency
    /// across serialization boundaries. This test verifies that both FCC instances are correctly
    /// marked after rejection processing.
    /// </summary>
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task RejectionSetsInformationalOnlyOnBothRequestAndResponseFccInstancesAsync(bool streaming)
    {
        // Create two separate FCC objects for the same call — simulating deserialization
        // where TARC and TAResp hold different FCC instances with the same CallId.
        var requestFcc = new FunctionCallContent("callId1", "Func1");
        var responseFcc = new FunctionCallContent("callId1", "Func1");
 
        Assert.False(requestFcc.InformationalOnly);
        Assert.False(responseFcc.InformationalOnly);
 
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", requestFcc),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, responseFcc),
            ]),
        ];
 
        using var innerClient = new TestChatClient
        {
            GetResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
                Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")])),
            GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
                YieldAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")]).ToChatResponseUpdates()),
        };
 
        IChatClient service = innerClient.AsBuilder()
            .Use(s => new FunctionInvokingChatClient(s))
            .Build();
 
        if (streaming)
        {
            await service.GetStreamingResponseAsync(input, options).ToChatResponseAsync();
        }
        else
        {
            await service.GetResponseAsync(input, options);
        }
 
        // The fix ensures both FCC instances are marked InformationalOnly=true,
        // even when they are separate objects (as happens after serialization).
        Assert.True(requestFcc.InformationalOnly);
        Assert.True(responseFcc.InformationalOnly);
    }
 
    /// <summary>
    /// After serialization/deserialization, the TARC and TAResp may contain separate FCC object instances
    /// for the same call. When a rejection is processed, GenerateRejectedFunctionResults must set
    /// InformationalOnly=true on BOTH the TAResp's FCC and the TARC's FCC to ensure consistency
    /// across serialization boundaries. Previously, this was not always happening, so adding
    /// a test to ensure that this case does not throw.
    /// See https://github.com/dotnet/extensions/pull/7468.
    /// Workaround: Use a middleware to normalize InformationalOnly flags on deserialized sessions.
    /// </summary>
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task MixedInformationalOnlyWorkaroundWithMiddlewareAsync(bool streaming)
    {
        // Create two separate FCC objects for the same call — simulating deserialization
        // where TARC and TAResp hold different FCC instances with the same CallId.
        var request1Fcc = new FunctionCallContent("callId1", "Func1");
        var response1Fcc = new FunctionCallContent("callId1", "Func1") { InformationalOnly = true };
 
        var request2Fcc = new FunctionCallContent("callId2", "Func1");
 
        Assert.False(request1Fcc.InformationalOnly);
        Assert.True(response1Fcc.InformationalOnly);
 
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", request1Fcc),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", false, response1Fcc),
            ]),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId2", request2Fcc),
            ]) { MessageId = "resp2" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId2", false, request2Fcc),
            ]),
        ];
 
        using var innerClient = new TestChatClient
        {
            GetResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
                Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")])),
            GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
                YieldAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")]).ToChatResponseUpdates()),
        };
 
        // Use a middleware to normalize InformationalOnly flags before FICC processes the messages.
        IChatClient service = innerClient.AsBuilder()
            .Use(s => new ApprovalHistoryNormalizingChatClient(s))
            .Use(s => new FunctionInvokingChatClient(s))
            .Build();
 
        if (streaming)
        {
            await service.GetStreamingResponseAsync(input, options).ToChatResponseAsync();
        }
        else
        {
            await service.GetResponseAsync(input, options);
        }
    }
 
    /// <summary>
    /// This verifies the following scenario:
    /// 1. We are streaming (also including non-streaming in the test for completeness).
    /// 2. There is one function that requires approval and one that does not.
    /// 3. We only get back FCC for the function that does not require approval.
    /// 4. This means that once we receive this FCC, we need to buffer all updates until the end, because we might receive more FCCs and some may require approval.
    /// 5. We then need to verify that we will still stream all updates once we reach the end, including the buffered FCC.
    /// </summary>
    [Fact]
    public async Task MixedApprovalRequiredToolsWithNonApprovalRequiringFunctionCallAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        Func<Queue<List<ChatMessage>>> expectedDownstreamClientInput = () => new Queue<List<ChatMessage>>(
        [
            new List<ChatMessage>
            {
                new ChatMessage(ChatRole.User, "hello"),
            },
            new List<ChatMessage>
            {
                new ChatMessage(ChatRole.User, "hello"),
                new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
                new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")])
            }
        ]);
 
        Func<Queue<List<ChatMessage>>> downstreamClientOutput = () => new Queue<List<ChatMessage>>(
        [
            new List<ChatMessage>
            {
                new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            },
            new List<ChatMessage>
            {
                new ChatMessage(ChatRole.Assistant, "World again"),
            }
        ]);
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "World again"),
        ];
 
        await InvokeAndAssertMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput());
 
        await InvokeAndAssertStreamingMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput());
    }
 
    [Fact]
    public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
        ];
 
        var invokeException = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await InvokeAndAssertAsync(options, input, [], [], []));
        Assert.Equal("ToolApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching ToolApprovalResponseContent.", invokeException.Message);
 
        var invokeStreamingException = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await InvokeAndAssertStreamingAsync(options, input, [], [], []));
        Assert.Equal("ToolApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching ToolApprovalResponseContent.", invokeStreamingException.Message);
    }
 
    [Fact]
    public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ],
            ConversationId = "test-conversation",
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedResponsesStayAdjacentToToolCallWhenTrailingMessagesPresentWithServiceThreadsAsync()
    {
        // Service-managed history: only the new messages are passed and a ConversationId is set. The service
        // holds the assistant tool-call, so the reconstructed tool result must be positioned ahead of any
        // trailing caller-supplied message, otherwise the effective ordering seen by the provider becomes
        // assistant(tool_calls) -> user(trailing) -> tool(result), which breaks tool_calls->tool adjacency.
        var options = new ChatOptions
        {
            Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1"))],
            ConversationId = "test-conversation",
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
            ]),
 
            // A caller-supplied message after the approval response.
            new ChatMessage(ChatRole.User, "By the way, please keep the answer concise."),
        ];
 
        // The tool result must come before the trailing caller message, and the assistant tool-call is not
        // re-sent because the service already holds it.
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.User, "By the way, please keep the answer concise."),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedResponseStaysAdjacentToToolCallWhenApprovalMessageHasOtherContentWithServiceThreadsAsync()
    {
        // Regression: the approval response shares a message with other caller content. Extraction removes the
        // approval response but leaves the other content ("please proceed") in place. In service-managed mode the
        // service holds the assistant tool-call, so the reconstructed tool result must still be placed ahead of that
        // residual content, otherwise the effective ordering becomes
        // assistant(tool_calls) -> user("please proceed") -> tool(result), which breaks tool_calls->tool adjacency.
        var options = new ChatOptions
        {
            Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1"))],
            ConversationId = "test-conversation",
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new TextContent("please proceed"),
            ]),
        ];
 
        // The tool result is positioned at the front, ahead of the residual "please proceed" content that was left
        // behind in the approval message after the approval response was extracted.
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.User, "please proceed"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedResponsesStayAdjacentToToolCallWhenTrailingMessagesPresentWithClientHistoryAsync()
    {
        // Client-managed history: the full history is passed and there is no ConversationId. The reconstructed
        // assistant tool-call and tool result are inserted at the approval anchor, so trailing caller-supplied
        // messages end up after the tool result while adjacency is preserved.
        var options = new ChatOptions
        {
            Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1"))],
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
            ]),
 
            // A caller-supplied message after the approval response.
            new ChatMessage(ChatRole.User, "By the way, please keep the answer concise."),
        ];
 
        // The reconstructed assistant tool-call and tool result are inserted at the approval anchor, so they
        // remain adjacent and the trailing caller message follows the tool result.
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.User, "By the way, please keep the answer concise."),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedResponseStaysAdjacentToToolCallWhenApprovalMessageHasOtherContentWithClientHistoryAsync()
    {
        // Client-managed history where the approval response shares a message with other caller content. The
        // reconstructed tool-call/result block is inserted just before that approval message, so the residual
        // content ("please proceed") that survives extraction ends up after the reconstructed assistant tool-call
        // and tool result rather than wedged before them.
        var options = new ChatOptions
        {
            Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1"))],
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
                new TextContent("please proceed"),
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.User, "please proceed"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
 
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
    }
 
    [Fact]
    public async Task ApprovedResponseThatRequestsTerminationStopsBeforeCallingInnerClientAsync()
    {
        // An approved function requests termination of the processing loop. Approval responses are handled before
        // the main loop, so processing must stop right after the approval block - yielding the reconstructed
        // tool-call and tool result but never calling the inner client. This covers the streaming yield break and
        // non-streaming return that fire even though the approval block produced messages.
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() =>
                {
                    FunctionInvokingChatClient.CurrentContext!.Terminate = true;
                    return "Result 1";
                }, "Func1")),
            ],
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")),
            ]),
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]),
        ];
 
        using (var innerClient = new TestChatClient
        {
            GetResponseAsyncCallback = (_, _, _) =>
                throw new InvalidOperationException("The inner client must not be called after the approved function requests termination."),
        })
        {
            using var service = new FunctionInvokingChatClient(innerClient);
            var result = await service.GetResponseAsync(CloneInput(input), options);
            AssertExtensions.EqualMessageLists(expectedOutput, result.Messages.ToList());
        }
 
        using (var innerClient = new TestChatClient
        {
            GetStreamingResponseAsyncCallback = (_, _, _) =>
                throw new InvalidOperationException("The inner client must not be called after the approved function requests termination."),
        })
        {
            using var service = new FunctionInvokingChatClient(innerClient);
            var result = await service.GetStreamingResponseAsync(CloneInput(input), options).ToChatResponseAsync();
            AssertExtensions.EqualMessageLists(expectedOutput, result.Messages.ToList());
        }
    }
 
    /// for matching FunctionResultContent from server-handled function calls and mark those FCCs as
    /// InformationalOnly. This means FCCs are not yielded immediately, even when no approval is required.
    /// </summary>
    [Fact]
    public async Task FunctionCallContentIsBufferedUntilEndOfStreamWhenStreamingAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                AIFunctionFactory.Create(() => "Result 1", "Func1"),
                AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
            ]
        };
 
        List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
 
        Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s));
        using CancellationTokenSource cts = new();
 
        var updateYieldCount = 0;
 
        async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(
            IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
        {
            Assert.Equal(cts.Token, actualCancellationToken);
            await Task.Yield();
            var messageId = Guid.NewGuid().ToString("N");
 
            updateYieldCount++;
            yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
            updateYieldCount++;
            yield return
                new ChatResponseUpdate(
                    ChatRole.Assistant,
                    [
                        new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })
                    ])
                { MessageId = messageId };
        }
 
        using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
        IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
 
        var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
 
        var updateCount = 0;
        await foreach (var update in updates)
        {
            if (updateCount < 2)
            {
                var functionCall = update.Contents.OfType<FunctionCallContent>().First();
                if (functionCall.CallId == "callId1")
                {
                    Assert.Equal("Func1", functionCall.Name);
 
                    // FCCs are now buffered until the end of the stream to check for
                    // matching FunctionResultContent from server-handled function calls.
                    Assert.Equal(2, updateYieldCount);
                }
                else if (functionCall.CallId == "callId2")
                {
                    Assert.Equal("Func2", functionCall.Name);
                    Assert.Equal(2, updateYieldCount);
                }
            }
 
            updateCount++;
        }
    }
 
    /// <summary>
    /// Since we do not have a way of supporting both functions that require approval and those that do not
    /// in one invocation, we always require all function calls to be approved if any require approval.
    /// If we are therefore unsure as to whether we will encounter a function call that requires approval,
    /// we have to wait until we find one before yielding any function call content.
    /// We can however, yield any other content until we encounter the first function call.
    /// </summary>
    [Fact]
    public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                AIFunctionFactory.Create(() => "Result 1", "Func1"),
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
                AIFunctionFactory.Create(() => "Result 3", "Func3"),
            ]
        };
 
        List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
 
        Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s));
        using CancellationTokenSource cts = new();
 
        var updateYieldCount = 0;
 
        async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(
            IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
        {
            Assert.Equal(cts.Token, actualCancellationToken);
            await Task.Yield();
            var messageId = Guid.NewGuid().ToString("N");
 
            updateYieldCount++;
            yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId };
            updateYieldCount++;
            yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId };
            updateYieldCount++;
            yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
            updateYieldCount++;
            yield return new ChatResponseUpdate(
                ChatRole.Assistant,
                [
                    new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })
                ])
            { MessageId = messageId };
            updateYieldCount++;
            yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId };
        }
 
        using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
        IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
 
        var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
 
        var updateCount = 0;
        await foreach (var update in updates)
        {
            switch (updateCount)
            {
                case 0:
                    Assert.Equal("Text 1", update.Contents.OfType<TextContent>().First().Text);
 
                    // First content should be yielded immedately, since we don't have any function calls yet.
                    Assert.Equal(1, updateYieldCount);
                    break;
                case 1:
                    Assert.Equal("Text 2", update.Contents.OfType<TextContent>().First().Text);
 
                    // Second content should be yielded immedately, since we don't have any function calls yet.
                    Assert.Equal(2, updateYieldCount);
                    break;
                case 2:
                    var approvalRequest1 = update.Contents.OfType<ToolApprovalRequestContent>().First();
                    Assert.Equal("callId1", approvalRequest1.ToolCall.CallId);
                    Assert.Equal("Func1", ((FunctionCallContent)approvalRequest1.ToolCall).Name);
 
                    // Third content should have been buffered, since we have not yet encountered a function call that requires approval.
                    Assert.Equal(4, updateYieldCount);
                    break;
                case 3:
                    var approvalRequest2 = update.Contents.OfType<ToolApprovalRequestContent>().First();
                    Assert.Equal("callId2", approvalRequest2.ToolCall.CallId);
                    Assert.Equal("Func2", ((FunctionCallContent)approvalRequest2.ToolCall).Name);
 
                    // Fourth content can be yielded immediately, since it is the first function call that requires approval.
                    Assert.Equal(4, updateYieldCount);
                    break;
                case 4:
                    var approvalRequest3 = update.Contents.OfType<ToolApprovalRequestContent>().First();
                    Assert.Equal("callId1", approvalRequest3.ToolCall.CallId);
                    Assert.Equal("Func3", ((FunctionCallContent)approvalRequest3.ToolCall).Name);
 
                    // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval.
                    Assert.Equal(5, updateYieldCount);
                    break;
            }
 
            updateCount++;
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task FunctionCallsWithInformationalOnlyTrueAreNotReplacedWithApprovalsAsync(bool streaming)
    {
        var functionInvokedCount = 0;
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(
                    AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")),
            ]
        };
 
        List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
 
        // FunctionCallContent with InformationalOnly = true should pass through unchanged
        var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InformationalOnly = true };
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]),
        ];
 
        // Expected output should contain the same FunctionCallContent, not a ToolApprovalRequestContent
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]),
        ];
 
        if (streaming)
        {
            await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
        }
        else
        {
            await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
        }
 
        // The function should NOT have been invoked since InformationalOnly was true
        Assert.Equal(0, functionInvokedCount);
    }
 
    [Fact]
    public async Task ApprovalResponsePreservesOriginalRequestMessageMetadata()
    {
        var options = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
            ]
        };
 
        const string OriginalMessageId = "original-message-id";
 
        // Create input with approval request containing a known MessageId on the containing message
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("approval-request-id", new FunctionCallContent("function-call-id", "Func1"))
            ]) { MessageId = OriginalMessageId }, // This MessageId should be preserved
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("approval-request-id", true, new FunctionCallContent("function-call-id", "Func1"))
            ]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        // The reconstructed function call message should preserve the original MessageId
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("function-call-id", "Func1")]) { MessageId = OriginalMessageId },
            new ChatMessage(ChatRole.Tool, [new FunctionResultContent("function-call-id", result: "Result 1")]),
            new ChatMessage(ChatRole.Assistant, "world"),
        ];
 
        var actualOutput = await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
 
        // Verify that the reconstructed function call message has the original MessageId, not a synthetic one
        Assert.Equal(OriginalMessageId, actualOutput[0].MessageId);
 
        actualOutput = await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
        Assert.Equal(OriginalMessageId, actualOutput[0].MessageId);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task FunctionCallReplacedWithApproval_MixedWithMcpApprovalAsync(bool useAdditionalTools)
    {
        AITool[] tools =
        [
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")),
            new HostedMcpServerTool("myServer", "https://localhost/mcp")
        ];
 
        var options = new ChatOptions
        {
            Tools = useAdditionalTools ? null : tools
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func"),
                new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ])
        ];
 
        List<ChatMessage> expectedOutput =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func")),
                new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ApprovedApprovalResponseIsExecuted_MixedWithMcpApprovalAsync(bool useAdditionalTools)
    {
        AITool[] tools =
        [
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")),
            new HostedMcpServerTool("myServer", "https://localhost/mcp")
        ];
 
        var options = new ChatOptions
        {
            Tools = useAdditionalTools ? null : tools
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func")),
                new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func")),
                new ToolApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]),
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func")
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Result 1")
            ]),
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]),
        ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [
                new McpServerToolResultContent("callId2") { Outputs = [new TextContent("Result 2")] },
                new TextContent("world")
            ])
        ];
 
        List<ChatMessage> output =
        [
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func")
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                new FunctionResultContent("callId1", result: "Result 1")
            ]),
            new ChatMessage(ChatRole.Assistant, [
                new McpServerToolResultContent("callId2") { Outputs = [new TextContent("Result 2")] },
                new TextContent("world")
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null);
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null);
    }
 
    [Theory]
    [InlineData(false, true, false)]
    [InlineData(false, false, true)]
    [InlineData(true, true, false)]
    [InlineData(true, false, true)]
    public async Task RejectedApprovalResponses_MixedWithMcpApprovalAsync(bool useAdditionalTools, bool approveFuncCall, bool approveMcpCall)
    {
        Assert.NotEqual(approveFuncCall, approveMcpCall);
 
        AITool[] tools =
        [
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")),
            new HostedMcpServerTool("myServer", "https://localhost/mcp")
        ];
 
        var options = new ChatOptions
        {
            Tools = useAdditionalTools ? null : tools
        };
 
        List<ChatMessage> input =
        [
            new ChatMessage(ChatRole.User, "hello"),
            new ChatMessage(ChatRole.Assistant,
            [
                new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func")),
                new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]) { MessageId = "resp1" },
            new ChatMessage(ChatRole.User,
            [
                new ToolApprovalResponseContent("ficc_callId1", approveFuncCall, new FunctionCallContent("callId1", "Func")),
                new ToolApprovalResponseContent("callId2", approveMcpCall, new McpServerToolCallContent("callId2", "McpCall", "myServer"))
            ]),
        ];
 
        List<ChatMessage> expectedDownstreamClientInput = [
                new ChatMessage(ChatRole.User, "hello"),
                new ChatMessage(ChatRole.Assistant,
                [
                    new ToolApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer"))
                ]),
                new ChatMessage(ChatRole.Assistant,
                [
                    new FunctionCallContent("callId1", "Func")
                ]),
                new ChatMessage(ChatRole.Tool,
                [
                    approveFuncCall ?
                        new FunctionResultContent("callId1", result: "Result 1") :
                        new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
                ]),
                new ChatMessage(ChatRole.User,
                [
                    new ToolApprovalResponseContent("callId2", approveMcpCall, new McpServerToolCallContent("callId2", "McpCall", "myServer"))
                ]),
            ];
 
        List<ChatMessage> downstreamClientOutput =
        [
            new ChatMessage(ChatRole.Assistant, [
                new TextContent("world"),
                .. approveMcpCall ?
                    [new McpServerToolResultContent("callId2") { Outputs = [new TextContent("Result 2")] }] :
                    Array.Empty<AIContent>()
            ])
        ];
 
        List<ChatMessage> output = [
            new ChatMessage(ChatRole.Assistant,
            [
                new FunctionCallContent("callId1", "Func"),
            ]),
            new ChatMessage(ChatRole.Tool,
            [
                approveFuncCall ?
                    new FunctionResultContent("callId1", result: "Result 1") :
                    new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
            ]),
            new ChatMessage(ChatRole.Assistant, [
                new TextContent("world"),
                .. approveMcpCall ?
                    [new McpServerToolResultContent("callId2") { Outputs = [new TextContent("Result 2")] }] :
                    Array.Empty<AIContent>()
            ])
        ];
 
        await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null);
        await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null);
    }
 
    private static Task<List<ChatMessage>> InvokeAndAssertAsync(
        ChatOptions? options,
        List<ChatMessage> input,
        List<ChatMessage> downstreamClientOutput,
        List<ChatMessage> expectedOutput,
        List<ChatMessage>? expectedDownstreamClientInput = null,
        Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
        AITool[]? additionalTools = null)
        => InvokeAndAssertMultiRoundAsync(
            options,
            input,
            new Queue<List<ChatMessage>>(new[] { downstreamClientOutput }),
            expectedOutput,
            expectedDownstreamClientInput is null ? null : new Queue<List<ChatMessage>>(new[] { expectedDownstreamClientInput }),
            configurePipeline,
            additionalTools);
 
    private static async Task<List<ChatMessage>> InvokeAndAssertMultiRoundAsync(
        ChatOptions? options,
        List<ChatMessage> input,
        Queue<List<ChatMessage>> downstreamClientOutput,
        List<ChatMessage> expectedOutput,
        Queue<List<ChatMessage>>? expectedDownstreamClientInput = null,
        Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
        AITool[]? additionalTools = null)
    {
        Assert.NotEmpty(input);
 
        configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
 
        using CancellationTokenSource cts = new();
        long expectedTotalTokenCounts = 0;
 
        using var innerClient = new TestChatClient
        {
            GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) =>
            {
                Assert.Equal(cts.Token, actualCancellationToken);
                if (expectedDownstreamClientInput is not null)
                {
                    AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList());
                }
 
                await Task.Yield();
 
                var usage = CreateRandomUsage();
                expectedTotalTokenCounts += usage.InputTokenCount!.Value;
 
                var output = downstreamClientOutput.Dequeue();
                output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
                return new ChatResponse(output) { Usage = usage };
            }
        };
 
        IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
 
        var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(CloneInput(input)), options, cts.Token);
        Assert.NotNull(result);
 
        var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
        AssertExtensions.EqualMessageLists(expectedOutput, actualOutput);
 
        // Usage should be aggregated over all responses, including AdditionalUsage
        var actualUsage = result.Usage!;
        Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount);
        Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount);
        Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount);
        Assert.Equal(2, actualUsage.AdditionalCounts!.Count);
        Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]);
        Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]);
 
        return actualOutput;
    }
 
    private static UsageDetails CreateRandomUsage()
    {
        // We'll set the same random number on all the properties so that, when determining the
        // correct sum in tests, we only have to total the values once
        var value = new Random().Next(100);
        return new UsageDetails
        {
            InputTokenCount = value,
            OutputTokenCount = value,
            TotalTokenCount = value,
            AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value },
        };
    }
 
    private static Task<List<ChatMessage>> InvokeAndAssertStreamingAsync(
        ChatOptions? options,
        List<ChatMessage> input,
        List<ChatMessage> downstreamClientOutput,
        List<ChatMessage> expectedOutput,
        List<ChatMessage>? expectedDownstreamClientInput = null,
        Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
        AITool[]? additionalTools = null)
        => InvokeAndAssertStreamingMultiRoundAsync(
            options,
            input,
            new Queue<List<ChatMessage>>(new[] { downstreamClientOutput }),
            expectedOutput,
            expectedDownstreamClientInput is null ? null : new Queue<List<ChatMessage>>(new[] { expectedDownstreamClientInput }),
            configurePipeline,
            additionalTools);
 
    private static async Task<List<ChatMessage>> InvokeAndAssertStreamingMultiRoundAsync(
        ChatOptions? options,
        List<ChatMessage> input,
        Queue<List<ChatMessage>> downstreamClientOutput,
        List<ChatMessage> expectedOutput,
        Queue<List<ChatMessage>>? expectedDownstreamClientInput = null,
        Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
        AITool[]? additionalTools = null)
    {
        Assert.NotEmpty(input);
 
        configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
 
        using CancellationTokenSource cts = new();
 
        using var innerClient = new TestChatClient
        {
            GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
            {
                Assert.Equal(cts.Token, actualCancellationToken);
                if (expectedDownstreamClientInput is not null)
                {
                    AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList());
                }
 
                var output = downstreamClientOutput.Dequeue();
                output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
                return YieldAsync(new ChatResponse(output).ToChatResponseUpdates());
            }
        };
 
        IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
 
        var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(CloneInput(input)), options, cts.Token).ToChatResponseAsync();
        Assert.NotNull(result);
 
        var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
 
        expectedOutput ??= input;
        AssertExtensions.EqualMessageLists(expectedOutput, actualOutput);
 
        return actualOutput;
    }
 
    private static async IAsyncEnumerable<T> YieldAsync<T>(params T[] items)
    {
        await Task.Yield();
        foreach (var item in items)
        {
            yield return item;
        }
    }
 
    private static List<ChatMessage> CloneInput(List<ChatMessage> input) =>
        input.Select(m => new ChatMessage(m.Role, m.Contents.Select(CloneFcc).ToList()) { MessageId = m.MessageId }).ToList();
 
    private static AIContent CloneFcc(AIContent c) => c switch
    {
        McpServerToolCallContent mstcc => new McpServerToolCallContent(mstcc.CallId, mstcc.Name, mstcc.ServerName)
        {
            Arguments = mstcc.Arguments,
        },
        FunctionCallContent fcc => new FunctionCallContent(fcc.CallId, fcc.Name, fcc.Arguments)
        {
            InformationalOnly = fcc.InformationalOnly
        },
        ToolApprovalRequestContent tarc =>
            new ToolApprovalRequestContent(tarc.RequestId, (ToolCallContent)CloneFcc(tarc.ToolCall))
            {
                RequiresConfirmation = tarc.RequiresConfirmation,
            },
        ToolApprovalResponseContent tarc =>
            new ToolApprovalResponseContent(tarc.RequestId, tarc.Approved, (ToolCallContent)CloneFcc(tarc.ToolCall))
            {
                Reason = tarc.Reason
            },
        _ => c
    };
 
    /// <summary>
    /// Workaround middleware for sessions serialized before
    /// https://github.com/dotnet/extensions/pull/7468.
    /// Normalizes InformationalOnly flags so TARC/TAResp pairs stay consistent.
    /// </summary>
#pragma warning disable SA1402 // File may only contain a single type
    private sealed class ApprovalHistoryNormalizingChatClient(IChatClient inner) : DelegatingChatClient(inner)
#pragma warning restore SA1402
    {
        public override Task<ChatResponse> GetResponseAsync(
            IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
        {
            NormalizeApprovalFlags(messages);
            return base.GetResponseAsync(messages, options, cancellationToken);
        }
 
        public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
            IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
        {
            NormalizeApprovalFlags(messages);
            return base.GetStreamingResponseAsync(messages, options, cancellationToken);
        }
 
        private static void NormalizeApprovalFlags(IEnumerable<ChatMessage> messages)
        {
            var allContents = messages.SelectMany(m => m.Contents);
 
            var processedCallIds = new HashSet<string>(
                allContents
                    .OfType<ToolApprovalResponseContent>()
                    .Where(t => t.ToolCall is FunctionCallContent { InformationalOnly: true })
                    .Select(t => t.ToolCall.CallId));
 
            if (processedCallIds.Count == 0)
            {
                return;
            }
 
            foreach (var fcc in allContents
                .OfType<ToolApprovalRequestContent>()
                .Select(t => t.ToolCall)
                .OfType<FunctionCallContent>()
                .Where(fcc => !fcc.InformationalOnly && processedCallIds.Contains(fcc.CallId)))
            {
                fcc.InformationalOnly = true;
            }
        }
    }
}