From 10ba2648549e1721e1aa256423b5479d415eee4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:19:22 +0000 Subject: [PATCH 1/8] Initial plan From 30a50bb4ac639616295faca517a23d29443274cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:49:06 +0000 Subject: [PATCH 2/8] Fix message ordering issue in FunctionInvokingChatClient approval processing - Track insertion index for approval request messages before removal - Insert reconstructed FunctionCallContent and FunctionResultContent at correct position - Pass insertion index through to ProcessFunctionCallsAsync for approved functions - Handle edge case where already-executed approvals exist - Add tests for rejection and approval scenarios with user messages after responses Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 119 +++++++++++++++--- ...unctionInvokingChatClientApprovalsTests.cs | 116 +++++++++++++++++ 2 files changed, 218 insertions(+), 17 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index b3885542de4..0f964023318 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -295,10 +295,10 @@ public override async Task GetResponseAsync( // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if // the inner client had returned them directly. - (responseMessages, var notInvokedApprovals) = ProcessFunctionApprovalResponses( + (responseMessages, var notInvokedApprovals, var resultInsertionIndex) = ProcessFunctionApprovalResponses( originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId: null, functionCallContentFallbackMessageId: null); (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken); + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, resultInsertionIndex, isStreaming: false, cancellationToken); if (invokedApprovedFunctionApprovalResponses is not null) { @@ -381,7 +381,7 @@ public override async Task GetResponseAsync( // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, insertionIndex: -1, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -447,7 +447,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if // the inner client had returned them directly. - var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses( + var (preDownstreamCallHistory, notInvokedApprovals, resultInsertionIndex) = ProcessFunctionApprovalResponses( originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId, functionCallContentFallbackMessageId); if (preDownstreamCallHistory is not null) { @@ -460,7 +460,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken); + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, resultInsertionIndex, isStreaming: true, cancellationToken); if (invokedApprovedFunctionApprovalResponses is not null) { @@ -604,7 +604,7 @@ public override async IAsyncEnumerable GetStreamingResponseA FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, insertionIndex: -1, isStreaming: true, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -880,13 +880,14 @@ private bool ShouldTerminateLoopBasedOnHandleableFunctions(ListThe function call contents representing the functions to be invoked. /// The iteration number of how many roundtrips have been made to the inner client. /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. + /// The index at which to insert the function result messages, or -1 to append to the end. /// Whether the function calls are being processed in a streaming context. /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( List messages, ChatOptions? options, Dictionary? toolMap, List functionCallContents, int iteration, int consecutiveErrorCount, - bool isStreaming, CancellationToken cancellationToken) + int insertionIndex, bool isStreaming, CancellationToken cancellationToken) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. @@ -905,7 +906,16 @@ private bool ShouldTerminateLoopBasedOnHandleableFunctions(List addedMessages = CreateResponseMessages([result]); ThrowIfNoFunctionResultsAdded(addedMessages); UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); + + // Insert at the specified position or append if no valid insertion index + if (insertionIndex >= 0 && insertionIndex <= messages.Count) + { + messages.InsertRange(insertionIndex, addedMessages); + } + else + { + messages.AddRange(addedMessages); + } return (result.Terminate, consecutiveErrorCount, addedMessages); } @@ -950,7 +960,16 @@ select ProcessFunctionCallAsync( IList addedMessages = CreateResponseMessages(results.ToArray()); ThrowIfNoFunctionResultsAdded(addedMessages); UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); + + // Insert at the specified position or append if no valid insertion index + if (insertionIndex >= 0 && insertionIndex <= messages.Count) + { + messages.InsertRange(insertionIndex, addedMessages); + } + else + { + messages.AddRange(addedMessages); + } return (shouldTerminate, consecutiveErrorCount, addedMessages); } @@ -1248,12 +1267,13 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// 3. Genreate failed for any rejected . /// 4. add all the new content items to and return them as the pre-invocation history. /// - private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( + private static (List? preDownstreamCallHistory, List? approvals, int insertionIndex) ProcessFunctionApprovalResponses( List originalMessages, bool hasConversationId, string? toolMessageId, string? functionCallContentFallbackMessageId) { // Extract any approval responses where we need to execute or reject the function calls. // The original messages are also modified to remove all approval requests and responses. - var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + var (notInvokedApprovalsResult, notInvokedRejectionsResult, insertionIndex) = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + var notInvokedResponses = (approvals: notInvokedApprovalsResult, rejections: notInvokedRejectionsResult); // Wrap the function call content in message(s). ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( @@ -1269,25 +1289,53 @@ private static (List? preDownstreamCallHistory, List? preDownstreamCallHistory = null; if (allPreDownstreamCallMessages is not null) { preDownstreamCallHistory = [.. allPreDownstreamCallMessages]; if (!hasConversationId) { - originalMessages.AddRange(preDownstreamCallHistory); + // If we have a valid insertion index, insert at that position. Otherwise, append to the end. + if (insertionIndex >= 0 && insertionIndex <= originalMessages.Count) + { + originalMessages.InsertRange(insertionIndex, preDownstreamCallHistory); + } + else + { + originalMessages.AddRange(preDownstreamCallHistory); + } } } // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. + // Insert immediately after the FCC messages to preserve message ordering. if (rejectedPreDownstreamCallResultsMessage is not null) { (preDownstreamCallHistory ??= []).Add(rejectedPreDownstreamCallResultsMessage); - originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + + // Calculate the insertion position: right after the FCC messages we just inserted + int rejectedInsertionIndex = insertionIndex >= 0 && insertionIndex <= originalMessages.Count + ? insertionIndex + (allPreDownstreamCallMessages?.Count ?? 0) + : originalMessages.Count; + + if (rejectedInsertionIndex >= 0 && rejectedInsertionIndex <= originalMessages.Count) + { + originalMessages.Insert(rejectedInsertionIndex, rejectedPreDownstreamCallResultsMessage); + } + else + { + originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + } } - return (preDownstreamCallHistory, notInvokedResponses.approvals); + // Calculate the insertion index for function result content (after the FCC messages and rejected FRC messages) + int resultInsertionIndex = insertionIndex >= 0 && insertionIndex <= originalMessages.Count && !hasConversationId + ? insertionIndex + (allPreDownstreamCallMessages?.Count ?? 0) + (rejectedPreDownstreamCallResultsMessage is not null ? 1 : 0) + : -1; + + return (preDownstreamCallHistory, notInvokedResponses.approvals, resultInsertionIndex); } /// @@ -1299,13 +1347,14 @@ private static (List? preDownstreamCallHistory, List - private static (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses( + private static (List? approvals, List? rejections, int insertionIndex) ExtractAndRemoveApprovalRequestsAndResponses( List messages) { Dictionary? allApprovalRequestsMessages = null; List? allApprovalResponses = null; HashSet? approvalRequestCallIds = null; HashSet? functionResultCallIds = null; + int firstApprovalRequestIndex = -1; // 1st iteration, over all messages and content: // - Build a list of all function call ids that are already executed. @@ -1330,6 +1379,13 @@ private static (List? approvals, List? approvals, List= 0) + { + for (int idx = 0; idx < firstApprovalRequestIndex; idx++) + { + if (messages[idx] is null) + { + removedBeforeInsertionIndex++; + } + } + } + _ = messages.RemoveAll(static m => m is null); + + // Adjust the insertion index + if (insertionIndex >= 0) + { + insertionIndex -= removedBeforeInsertionIndex; + } + } + + // If there are already-executed function results, insert new function calls at the end instead of at the insertion index + // to preserve the ordering of already-present function calls and results. + if (functionResultCallIds is { Count: > 0 } && insertionIndex >= 0) + { + insertionIndex = messages.Count; } // Validation: If we got an approval for each request, we should have no call ids left. @@ -1408,7 +1492,7 @@ private static (List? approvals, List @@ -1649,6 +1733,7 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => List originalMessages, ChatOptions? options, int consecutiveErrorCount, + int insertionIndex, bool isStreaming, CancellationToken cancellationToken) { @@ -1657,7 +1742,7 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => { // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. var modeAndMessages = await ProcessFunctionCallsAsync( - originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); + originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, insertionIndex, isStreaming, cancellationToken); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 93298ba2ac1..027637f1320 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -897,6 +897,122 @@ async IAsyncEnumerable YieldInnerClientUpdates( } } + [Fact] + public async Task RejectionWithUserMessageAfterApprovalResponsePreservesOrderingAsync() + { + // This test verifies that when a user adds a message after the approval response, + // the message ordering is preserved. The reconstructed FunctionCallContent and + // FunctionResultContent should be inserted at the position where the approval + // request was originally located, not at the end. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) + ]), + new ChatMessage(ChatRole.User, "2nd message"), // This should stay at the end + ]; + + // The expected input to downstream client should have messages in this order: + // 1. User "1st message" + // 2. Assistant with FunctionCallContent + // 3. Tool with rejection result + // 4. User "2nd message" (preserved at the end) + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.User, "2nd message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovalWithUserMessageAfterApprovalResponsePreservesOrderingAsync() + { + // This test verifies that when a user approves and adds a message after the approval response, + // the message ordering is preserved. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")) + ]), + new ChatMessage(ChatRole.User, "2nd message"), // This should stay at the end + ]; + + // The expected input to downstream client should have messages in this order: + // 1. User "1st message" + // 2. Assistant with FunctionCallContent + // 3. Tool with function result + // 4. User "2nd message" (preserved at the end) + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.User, "2nd message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input, From ba5e9bff79dae3b8612872999823251b8559fbeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:52:53 +0000 Subject: [PATCH 3/8] Add clarifying comments for edge case handling in approval processing Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClient.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 0f964023318..9e114378fa1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1454,7 +1454,11 @@ private static (List? approvals, List 0 } && insertionIndex >= 0) { insertionIndex = messages.Count; From e65dd4428308f8a0ceba1fbd172046533bd2f5ee Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 16 Dec 2025 21:42:40 -0500 Subject: [PATCH 4/8] Update src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClient.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 9e114378fa1..89ef3f78dff 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1459,6 +1459,15 @@ private static (List? approvals, List 0 } && insertionIndex >= 0) { insertionIndex = messages.Count; From e58a3ae2c093f088bdaf79707aac43b99367b1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:42:57 +0000 Subject: [PATCH 5/8] Fix rejectedInsertionIndex calculation to account for hasConversationId and clarify example comment - Only add FCC count to rejectedInsertionIndex when messages were actually inserted (!hasConversationId) - Update example comment to show state after extraction rather than before, making it clearer what the actual message list contains at the point the logic runs Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 89ef3f78dff..f7b2e183191 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1316,8 +1316,9 @@ private static (List? preDownstreamCallHistory, List= 0 && insertionIndex <= originalMessages.Count - ? insertionIndex + (allPreDownstreamCallMessages?.Count ?? 0) + ? insertionIndex + (!hasConversationId ? (allPreDownstreamCallMessages?.Count ?? 0) : 0) : originalMessages.Count; if (rejectedInsertionIndex >= 0 && rejectedInsertionIndex <= originalMessages.Count) @@ -1461,13 +1462,16 @@ private static (List? approvals, List 0 } && insertionIndex >= 0) { insertionIndex = messages.Count; From 1bf5d84c3f7bb6615acb31430c1307feedf1037e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:09:30 +0000 Subject: [PATCH 6/8] Fix bug where unrelated FunctionResultContent caused incorrect message insertion - Track whether any approval responses were skipped due to already being executed - Only append at end when approval requests in current batch have already been executed - Fix test to use correct rejection message format - Addresses @jozkee feedback about FRC prior to approval request/response pairs Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 49 ++++++++++--------- ...unctionInvokingChatClientApprovalsTests.cs | 4 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index c016aa1d9a2..6f4c49f4746 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1454,29 +1454,6 @@ private static (List? approvals, List 0 } && insertionIndex >= 0) - { - insertionIndex = messages.Count; - } - // Validation: If we got an approval for each request, we should have no call ids left. if (approvalRequestCallIds is { Count: > 0 }) { @@ -1489,6 +1466,7 @@ private static (List? approvals, List? approvedFunctionCalls = null, rejectedFunctionCalls = null; + bool hasAlreadyExecutedApprovals = false; if (allApprovalResponses is { Count: > 0 }) { foreach (var approvalResponse in allApprovalResponses) @@ -1496,6 +1474,7 @@ private static (List? approvals, List? approvals, List= 0) + { + insertionIndex = messages.Count; + } + return (approvedFunctionCalls, rejectedFunctionCalls, insertionIndex); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 3ca9e0610bf..1c578957e39 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1151,7 +1151,7 @@ public async Task RejectionWithUserMessageAfterApprovalResponsePreservesOrdering [ new ChatMessage(ChatRole.User, "1st message"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]), new ChatMessage(ChatRole.User, "2nd message"), ]; @@ -1163,7 +1163,7 @@ public async Task RejectionWithUserMessageAfterApprovalResponsePreservesOrdering List output = [ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]), new ChatMessage(ChatRole.Assistant, "Final response"), ]; From 7dbbdbbfd9ff1de1fcbf18ff9cb2956a4791a31d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:32:27 +0000 Subject: [PATCH 7/8] WIP: Track multiple approval request indices and process rejections individually - Modified ExtractAndRemoveApprovalRequestsAndResponses to track each approval request's original index by call ID - Refactored ProcessFunctionApprovalResponses to process rejections individually at their correct positions - Process rejections in reverse index order to avoid index shifting issues - Added test case for multiple interleaved approval/rejection pairs - Note: Approved functions still need individual processing (currently uses first index for all) Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 184 +++++++++++------- ...unctionInvokingChatClientApprovalsTests.cs | 76 ++++++++ 2 files changed, 186 insertions(+), 74 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 6f4c49f4746..0bf5f07e8f6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -295,10 +295,10 @@ public override async Task GetResponseAsync( // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if // the inner client had returned them directly. - (responseMessages, var notInvokedApprovals, var resultInsertionIndex) = ProcessFunctionApprovalResponses( + (responseMessages, var notInvokedApprovals, var approvalRequestIndices) = ProcessFunctionApprovalResponses( originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId: null, functionCallContentFallbackMessageId: null); (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, resultInsertionIndex, isStreaming: false, cancellationToken); + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, approvalRequestIndices, isStreaming: false, cancellationToken); if (invokedApprovedFunctionApprovalResponses is not null) { @@ -447,7 +447,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if // the inner client had returned them directly. - var (preDownstreamCallHistory, notInvokedApprovals, resultInsertionIndex) = ProcessFunctionApprovalResponses( + var (preDownstreamCallHistory, notInvokedApprovals, approvalRequestIndices) = ProcessFunctionApprovalResponses( originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId, functionCallContentFallbackMessageId); if (preDownstreamCallHistory is not null) { @@ -460,7 +460,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, resultInsertionIndex, isStreaming: true, cancellationToken); + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, approvalRequestIndices, isStreaming: true, cancellationToken); if (invokedApprovedFunctionApprovalResponses is not null) { @@ -1267,76 +1267,90 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// 3. Genreate failed for any rejected . /// 4. add all the new content items to and return them as the pre-invocation history. /// - private static (List? preDownstreamCallHistory, List? approvals, int insertionIndex) ProcessFunctionApprovalResponses( + private static (List? preDownstreamCallHistory, List? approvals, Dictionary? approvalRequestIndices) ProcessFunctionApprovalResponses( List originalMessages, bool hasConversationId, string? toolMessageId, string? functionCallContentFallbackMessageId) { // Extract any approval responses where we need to execute or reject the function calls. // The original messages are also modified to remove all approval requests and responses. - var (notInvokedApprovalsResult, notInvokedRejectionsResult, insertionIndex) = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + var (notInvokedApprovalsResult, notInvokedRejectionsResult, approvalRequestIndices) = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); var notInvokedResponses = (approvals: notInvokedApprovalsResult, rejections: notInvokedRejectionsResult); - // Wrap the function call content in message(s). - ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( - [.. notInvokedResponses.rejections ?? Enumerable.Empty(), .. notInvokedResponses.approvals ?? Enumerable.Empty()], - functionCallContentFallbackMessageId); + // Group approvals and rejections by their original index for proper insertion + var allResults = new List(); + if (notInvokedResponses.rejections is not null) + { + allResults.AddRange(notInvokedResponses.rejections); + } - // Generate failed function result contents for any rejected requests and wrap it in a message. - List? rejectedFunctionCallResults = GenerateRejectedFunctionResults(notInvokedResponses.rejections); - ChatMessage? rejectedPreDownstreamCallResultsMessage = rejectedFunctionCallResults is not null ? - new ChatMessage(ChatRole.Tool, rejectedFunctionCallResults) { MessageId = toolMessageId } : - null; + if (notInvokedResponses.approvals is not null) + { + allResults.AddRange(notInvokedResponses.approvals); + } + + // Sort by index in descending order so we can insert from end to start without index shifting issues + var sortedResults = allResults + .Where(r => approvalRequestIndices?.ContainsKey(r.Response.FunctionCall.CallId) == true) + .OrderByDescending(r => approvalRequestIndices![r.Response.FunctionCall.CallId]) + .ToList(); - // Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. - // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them - // into the original messages list so that they are passed to the inner client and can be used to generate a result. - // Insert at the position where the approval request was originally located to preserve message ordering. List? preDownstreamCallHistory = null; - if (allPreDownstreamCallMessages is not null) + + // Process each approval/rejection and insert at its original position + foreach (var result in sortedResults) { - preDownstreamCallHistory = [.. allPreDownstreamCallMessages]; - if (!hasConversationId) + string callId = result.Response.FunctionCall.CallId; + int insertionIndex = approvalRequestIndices![callId]; + + // Convert this specific result to FunctionCallContent message + var fccMessages = ConvertToFunctionCallContentMessages([result], functionCallContentFallbackMessageId); + if (fccMessages is not null) { - // If we have a valid insertion index, insert at that position. Otherwise, append to the end. - if (insertionIndex >= 0 && insertionIndex <= originalMessages.Count) + // Add to history + if (preDownstreamCallHistory is null) { - originalMessages.InsertRange(insertionIndex, preDownstreamCallHistory); + preDownstreamCallHistory = [.. fccMessages]; } else { - originalMessages.AddRange(preDownstreamCallHistory); + preDownstreamCallHistory.InsertRange(0, fccMessages); + } + + // Insert into original messages if not using conversation ID + if (!hasConversationId && insertionIndex >= 0 && insertionIndex <= originalMessages.Count) + { + originalMessages.InsertRange(insertionIndex, fccMessages); } } - } - // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. - // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. - // Insert immediately after the FCC messages to preserve message ordering. - if (rejectedPreDownstreamCallResultsMessage is not null) - { - (preDownstreamCallHistory ??= []).Add(rejectedPreDownstreamCallResultsMessage); + // For rejections, also insert the rejection result + if (!result.Response.Approved) + { + var rejectedContent = GenerateRejectedFunctionResults([result]); + if (rejectedContent is not null) + { + var rejectedMessage = new ChatMessage(ChatRole.Tool, rejectedContent) { MessageId = toolMessageId }; - // Calculate the insertion position: right after the FCC messages we just inserted - // Only add the FCC count if they were actually inserted (!hasConversationId) - int rejectedInsertionIndex = insertionIndex >= 0 && insertionIndex <= originalMessages.Count - ? insertionIndex + (!hasConversationId ? (allPreDownstreamCallMessages?.Count ?? 0) : 0) - : originalMessages.Count; + // Add to history + if (preDownstreamCallHistory is null) + { + preDownstreamCallHistory = [rejectedMessage]; + } + else + { + preDownstreamCallHistory.Insert(fccMessages?.Count ?? 0, rejectedMessage); + } - if (rejectedInsertionIndex >= 0 && rejectedInsertionIndex <= originalMessages.Count) - { - originalMessages.Insert(rejectedInsertionIndex, rejectedPreDownstreamCallResultsMessage); - } - else - { - originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + // Insert rejection result right after the FCC messages + int rejectedInsertionIndex = insertionIndex + (fccMessages?.Count ?? 0); + if (rejectedInsertionIndex >= 0 && rejectedInsertionIndex <= originalMessages.Count) + { + originalMessages.Insert(rejectedInsertionIndex, rejectedMessage); + } + } } } - // Calculate the insertion index for function result content (after the FCC messages and rejected FRC messages) - int resultInsertionIndex = insertionIndex >= 0 && insertionIndex <= originalMessages.Count && !hasConversationId - ? insertionIndex + (allPreDownstreamCallMessages?.Count ?? 0) + (rejectedPreDownstreamCallResultsMessage is not null ? 1 : 0) - : -1; - - return (preDownstreamCallHistory, notInvokedResponses.approvals, resultInsertionIndex); + return (preDownstreamCallHistory, notInvokedResponses.approvals, approvalRequestIndices); } /// @@ -1348,20 +1362,21 @@ private static (List? preDownstreamCallHistory, List - private static (List? approvals, List? rejections, int insertionIndex) ExtractAndRemoveApprovalRequestsAndResponses( + private static (List? approvals, List? rejections, Dictionary? approvalRequestIndices) ExtractAndRemoveApprovalRequestsAndResponses( List messages) { Dictionary? allApprovalRequestsMessages = null; List? allApprovalResponses = null; HashSet? approvalRequestCallIds = null; HashSet? functionResultCallIds = null; - int firstApprovalRequestIndex = -1; + Dictionary? approvalRequestIndices = null; // 1st iteration, over all messages and content: // - Build a list of all function call ids that are already executed. // - Build a list of all function approval requests and responses. // - Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those. // - Validate that we have an approval response for each approval request. + // - Track the original index of each approval request by call ID bool anyRemoved = false; int i = 0; for (; i < messages.Count; i++) @@ -1381,10 +1396,11 @@ private static (List? approvals, List? approvals, List= 0) + // Build a map of how many messages were removed before each index + int[] removedBeforeIndex = new int[messages.Count]; + int removedCount = 0; + for (int idx = 0; idx < messages.Count; idx++) { - for (int idx = 0; idx < firstApprovalRequestIndex; idx++) + removedBeforeIndex[idx] = removedCount; + if (messages[idx] is null) { - if (messages[idx] is null) - { - removedBeforeInsertionIndex++; - } + removedCount++; } } _ = messages.RemoveAll(static m => m is null); - // Adjust the insertion index - if (insertionIndex >= 0) + // Adjust all approval request indices + if (approvalRequestIndices is not null) { - insertionIndex -= removedBeforeInsertionIndex; + List callIds = [.. approvalRequestIndices.Keys]; + foreach (var callId in callIds) + { + int originalIndex = approvalRequestIndices[callId]; + approvalRequestIndices[callId] = originalIndex - removedBeforeIndex[originalIndex]; + } } } @@ -1489,7 +1508,7 @@ private static (List? approvals, List? approvals, List= 0) + if (hasAlreadyExecutedApprovals && approvalRequestIndices is not null) { - insertionIndex = messages.Count; + // Set all indices to append at end + List callIds = [.. approvalRequestIndices.Keys]; + foreach (var callId in callIds) + { + approvalRequestIndices[callId] = messages.Count; + } } - return (approvedFunctionCalls, rejectedFunctionCalls, insertionIndex); + return (approvedFunctionCalls, rejectedFunctionCalls, approvalRequestIndices); } /// @@ -1762,13 +1786,25 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => List originalMessages, ChatOptions? options, int consecutiveErrorCount, - int insertionIndex, + Dictionary? approvalRequestIndices, bool isStreaming, CancellationToken cancellationToken) { // Check if there are any function calls to do for any approved functions and execute them. if (notInvokedApprovals is { Count: > 0 }) { + // For now, use the first approval's index, or -1 if not found + // Future enhancement: Process each approval individually at its correct position + int insertionIndex = -1; + if (approvalRequestIndices is not null && notInvokedApprovals.Count > 0) + { + string firstCallId = notInvokedApprovals[0].Response.FunctionCall.CallId; + if (approvalRequestIndices.TryGetValue(firstCallId, out int index)) + { + insertionIndex = index; + } + } + // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. var modeAndMessages = await ProcessFunctionCallsAsync( originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, insertionIndex, isStreaming, cancellationToken); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 1c578957e39..9d9483a4cfa 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1229,6 +1229,82 @@ public async Task ApprovalWithUserMessageAfterApprovalResponsePreservesOrderingA await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); } + [Fact] + public async Task MultipleApprovalRequestResponsePairsWithInterleavedUserMessagesPreservesOrderingAsync() + { + // This test verifies that when there are multiple approval request/response pairs + // in a single call with user messages interleaved between them, the message ordering + // is preserved correctly. All approvals are processed in one invocation. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 2", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st user message"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")) + ]), + new ChatMessage(ChatRole.User, "2nd user message"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2")) + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2")) + ]), + new ChatMessage(ChatRole.User, "3rd user message"), + ]; + + // The expected input to downstream client should preserve all message ordering: + // 1. User "1st user message" - should remain in place + // 2. Assistant with FunctionCallContent(callId1) - recreated from approval + // 3. Tool with FunctionResultContent(callId1) - from executing approved function + // 4. User "2nd user message" - should remain in place + // 5. Assistant with FunctionCallContent(callId2) - recreated from approval + // 6. Tool with FunctionResultContent(callId2) - from executing approved function + // 7. User "3rd user message" - should remain at the end + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st user message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.User, "2nd user message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2")]), + new ChatMessage(ChatRole.User, "3rd user message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input, From fb92a701dd7a540f4ddc6a430e19c0a2bc524057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 9 Mar 2026 19:09:08 +0000 Subject: [PATCH 8/8] Fix message ordering in FunctionInvokingChatClient approval processing When ProcessFunctionApprovalResponses removes approval request/response content and reconstructs FCC+FRC messages, user messages added after approval responses were being incorrectly reordered because the reconstructed messages were appended at the END instead of at original positions. Changes: - Track original indices of approval requests in ExtractAndRemoveApprovalRequestsAndResponses with index adjustment after message removal - Add InsertFccMessagesAtTrackedPositions to insert FCC messages at their tracked positions using GroupBy/InsertRange for correct ordering - Group approvals by adjacent position in originalMessages so that approvals separated by user messages get separate Tool responses - Add insertion index parameter to ProcessFunctionCallsAsync to insert Tool results at correct positions - For non-streaming multi-group case, interleave Tool results into responseMessages at correct positions - For streaming multi-group case, set distinct per-group MessageIds so Tool results from different groups don't merge - Handle service threads (hasConversationId) path separately - Handle partially-consumed messages (MCP mixed case) by removing tracked indices for retained messages Tests: - Add RejectionWithUserMessageAfterApprovalResponsePreservesOrderingAsync - Add ApprovalWithUserMessageAfterApprovalResponsePreservesOrderingAsync - Add MultipleApprovalRequestResponsePairsWithInterleavedUserMessagesPreservesOrderingAsync Fixes #7156 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 309 ++++++++++++++++-- ...unctionInvokingChatClientApprovalsTests.cs | 183 +++++++++++ 2 files changed, 463 insertions(+), 29 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 1e5108f7a22..3af4383abf3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -297,7 +297,7 @@ public override async Task GetResponseAsync( (responseMessages, var notInvokedApprovals) = ProcessFunctionApprovalResponses( originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId: null, functionCallContentFallbackMessageId: null); (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken); + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken, responseMessages); if (invokedApprovedFunctionApprovalResponses is not null) { @@ -472,6 +472,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. + // Pass preDownstreamCallHistory so that Tool results can be interleaved at the correct positions. (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken); @@ -479,7 +480,7 @@ public override async IAsyncEnumerable GetStreamingResponseA { foreach (var message in invokedApprovedFunctionApprovalResponses) { - message.MessageId = toolMessageId; + message.MessageId ??= toolMessageId; yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); if (activity is not null) { @@ -1117,11 +1118,13 @@ private bool ShouldTerminateLoopBasedOnHandleableFunctions(ListThe number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. /// Whether the function calls are being processed in a streaming context. /// The to monitor for cancellation requests. + /// The index in at which to insert new messages, or -1 to append at the end. /// A value indicating how the caller should proceed. private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( List messages, ChatOptions? options, List functionCallContents, int iteration, int consecutiveErrorCount, - bool isStreaming, CancellationToken cancellationToken) + bool isStreaming, CancellationToken cancellationToken, + int insertionIndex = -1) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. @@ -1140,7 +1143,7 @@ private bool ShouldTerminateLoopBasedOnHandleableFunctions(List addedMessages = CreateResponseMessages([result]); ThrowIfNoFunctionResultsAdded(addedMessages); UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); + InsertOrAppendMessages(messages, addedMessages, insertionIndex); return (result.Terminate, consecutiveErrorCount, addedMessages); } @@ -1185,12 +1188,27 @@ select ProcessFunctionCallAsync( IList addedMessages = CreateResponseMessages(results.ToArray()); ThrowIfNoFunctionResultsAdded(addedMessages); UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); + InsertOrAppendMessages(messages, addedMessages, insertionIndex); return (shouldTerminate, consecutiveErrorCount, addedMessages); } } + /// + /// Inserts messages at the specified index, or appends if the index is invalid. + /// + private static void InsertOrAppendMessages(List messages, IList addedMessages, int insertionIndex) + { + if (insertionIndex >= 0 && insertionIndex <= messages.Count) + { + messages.InsertRange(insertionIndex, addedMessages); + } + else + { + messages.AddRange(addedMessages); + } + } + /// /// Updates the consecutive error count, and throws an exception if the count exceeds the maximum. /// @@ -1520,7 +1538,8 @@ private static bool CurrentActivityIsInvokeAgent { // Extract any approval responses where we need to execute or reject the function calls. // The original messages are also modified to remove all approval requests and responses. - var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + var (notInvokedApprovalsResult, notInvokedRejectionsResult, approvalRequestIndices) = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + var notInvokedResponses = (approvals: notInvokedApprovalsResult, rejections: notInvokedRejectionsResult); // Wrap the function call content in message(s). ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( @@ -1534,29 +1553,102 @@ private static bool CurrentActivityIsInvokeAgent null; // Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. - // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them - // into the original messages list so that they are passed to the inner client and can be used to generate a result. + // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), insert them + // at their tracked positions in the original messages list to preserve ordering relative to user messages. List? preDownstreamCallHistory = null; if (allPreDownstreamCallMessages is not null) { preDownstreamCallHistory = [.. allPreDownstreamCallMessages]; if (!hasConversationId) { - originalMessages.AddRange(preDownstreamCallHistory); + InsertFccMessagesAtTrackedPositions(originalMessages, allPreDownstreamCallMessages, approvalRequestIndices); } } + // Remove remaining null placeholders (approval response messages whose content was fully extracted). + _ = originalMessages.RemoveAll(static m => m is null); + // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. if (rejectedPreDownstreamCallResultsMessage is not null) { (preDownstreamCallHistory ??= []).Add(rejectedPreDownstreamCallResultsMessage); - originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + + // Insert right after the FCC message containing the first rejected call + int rejectedIndex = originalMessages.Count; + if (notInvokedResponses.rejections is { Count: > 0 }) + { + string firstRejectedCallId = notInvokedResponses.rejections[0].FunctionCallContent.CallId; + rejectedIndex = FindInsertionIndexAfterFcc(originalMessages, firstRejectedCallId); + } + + originalMessages.Insert(rejectedIndex, rejectedPreDownstreamCallResultsMessage); } return (preDownstreamCallHistory, notInvokedResponses.approvals); } + /// + /// Replaces null placeholders in with FCC messages at their tracked positions + /// (from ). Each FCC message replaces the null at the minimum + /// tracked index of the items it contains. FCC messages without tracked + /// indices (e.g., from partially-consumed MCP messages) are appended. + /// + private static void InsertFccMessagesAtTrackedPositions( + List messages, + ICollection fccMessages, + Dictionary? approvalRequestIndices) + { + if (approvalRequestIndices is null) + { + messages.AddRange(fccMessages); + return; + } + + foreach (var fccMessage in fccMessages) + { + int minIndex = -1; + foreach (var content in fccMessage.Contents) + { + if (content is FunctionCallContent fcc && + approvalRequestIndices.TryGetValue(fcc.CallId, out int trackedIndex) && + (minIndex < 0 || trackedIndex < minIndex)) + { + minIndex = trackedIndex; + } + } + + if (minIndex >= 0) + { + messages[minIndex] = fccMessage; + } + else + { + messages.Add(fccMessage); + } + } + } + + /// + /// Finds the index immediately after the message containing a + /// with the specified call ID, or messages.Count if not found. + /// + private static int FindInsertionIndexAfterFcc(List messages, string callId) + { + for (int i = 0; i < messages.Count; i++) + { + foreach (var content in messages[i].Contents) + { + if (content is FunctionCallContent fcc && fcc.CallId == callId) + { + return i + 1; + } + } + } + + return messages.Count; + } + /// /// This method extracts the approval requests and responses from the provided list of messages, /// validates them, filters them to ones that require execution, and splits them into approved and rejected. @@ -1566,20 +1658,20 @@ private static bool CurrentActivityIsInvokeAgent /// We can then use the metadata from these messages when we re-create the FunctionCallContent messages/updates to return to the caller. This way, when we finally do return /// the FuncionCallContent to users it's part of a message/update that contains the same metadata as originally returned to the downstream service. /// - private (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses( + private (List? approvals, List? rejections, Dictionary? approvalRequestIndices) ExtractAndRemoveApprovalRequestsAndResponses( List messages) { Dictionary? allApprovalRequestsMessages = null; List? allApprovalResponses = null; HashSet? approvalRequestCallIds = null; HashSet? functionResultCallIds = null; + Dictionary? approvalRequestIndices = null; // 1st iteration, over all messages and content: // - Build a list of all function call ids that are already executed. // - Build a list of all function approval requests and responses. // - Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those. // - Validate that we have an approval response for each approval request. - bool anyRemoved = false; int i = 0; for (; i < messages.Count; i++) { @@ -1597,6 +1689,9 @@ private static bool CurrentActivityIsInvokeAgent // Validation: Capture each call id for each approval request to ensure later we have a matching response. _ = (approvalRequestCallIds ??= []).Add(tarc.ToolCall.CallId); (allApprovalRequestsMessages ??= []).Add(tarc.RequestId, message); + + // Track the original index for each approval request by call ID + _ = (approvalRequestIndices ??= []).TryAdd(tarc.ToolCall.CallId, i); break; case ToolApprovalResponseContent tarc when tarc.ToolCall is FunctionCallContent { InformationalOnly: false }: @@ -1626,23 +1721,30 @@ private static bool CurrentActivityIsInvokeAgent var newMessage = message.Clone(); newMessage.Contents = keptContents; messages[i] = newMessage; + + // The message was only partially consumed (e.g., MCP approvals remain). + // Remove tracked indices for call IDs from this message so the FCC + // is appended at the end rather than inserted at this position. + if (approvalRequestIndices is not null) + { + foreach (var callId in approvalRequestIndices.Keys.ToList()) + { + if (approvalRequestIndices[callId] == i) + { + _ = approvalRequestIndices.Remove(callId); + } + } + } } else { - // Remove the message entirely since it has no contents left. Rather than doing an O(N) removal, which could possibly - // result in an O(N^2) overall operation, we mark the message as null and then do a single pass removal of all nulls after the loop. - anyRemoved = true; + // Mark the message as null for now. The caller will replace TARC placeholders + // with FCC messages at these tracked positions, then remove remaining nulls. messages[i] = null!; } } } - // Clean up any messages that were marked for removal during the iteration. - if (anyRemoved) - { - _ = messages.RemoveAll(static m => m is null); - } - // Validation: If we got an approval for each request, we should have no call ids left. if (approvalRequestCallIds is { Count: > 0 }) { @@ -1677,7 +1779,7 @@ private static bool CurrentActivityIsInvokeAgent } } - return (approvedFunctionCalls, rejectedFunctionCalls); + return (approvedFunctionCalls, rejectedFunctionCalls, approvalRequestIndices); } /// @@ -1925,20 +2027,169 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => ChatOptions? options, int consecutiveErrorCount, bool isStreaming, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + List? responseMessages = null) { - // Check if there are any function calls to do for any approved functions and execute them. - if (notInvokedApprovals is { Count: > 0 }) + if (notInvokedApprovals is not { Count: > 0 }) + { + return (null, false, consecutiveErrorCount); + } + + // Group approvals whose FunctionCallContent messages are adjacent in originalMessages. + // Approvals from the same position produce a single Tool response; approvals at different + // positions (separated by user messages) get separate Tool responses at each position. + var groups = GroupApprovalsByAdjacentPosition(notInvokedApprovals, originalMessages); + + List allMessagesAdded = []; + bool shouldTerminate = false; + + // When there are multiple groups and responseMessages is provided, insert Tool results + // at the correct positions within responseMessages for proper interleaving. + bool interleaveIntoResponse = responseMessages is not null && groups.Count > 1; + + foreach (var group in groups) { - // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. + // Find insertion point dynamically: right after the last FCC message in this group. + // Dynamic lookup accounts for list shifts caused by prior group insertions. + int insertionIndex = -1; + foreach (var fcc in group) + { + int idx = FindInsertionIndexAfterFcc(originalMessages, fcc.CallId); + if (idx > insertionIndex) + { + insertionIndex = idx; + } + } + + // Skip past any Tool messages (e.g., rejection results already inserted + // by ProcessFunctionApprovalResponses) so approved results come after them. + while (insertionIndex < originalMessages.Count && originalMessages[insertionIndex].Role == ChatRole.Tool) + { + insertionIndex++; + } + var modeAndMessages = await ProcessFunctionCallsAsync( - originalMessages, options, notInvokedApprovals.Select(x => x.Response.ToolCall).OfType().ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); + originalMessages, options, group, 0, consecutiveErrorCount, isStreaming, cancellationToken, insertionIndex); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); + if (interleaveIntoResponse) + { + // Insert Tool results into responseMessages right after the matching FCC messages. + int responseInsertPos = FindResponseInsertionIndex(responseMessages!, group); + responseMessages!.InsertRange(responseInsertPos, modeAndMessages.MessagesAdded); + } + else + { + // For multi-group streaming, set distinct MessageIds per group so Tool results + // from different groups don't merge into one streamed message. + if (groups.Count > 1 && isStreaming) + { + string groupToolMessageId = Guid.NewGuid().ToString("N"); + foreach (var msg in modeAndMessages.MessagesAdded) + { + msg.MessageId ??= groupToolMessageId; + } + } + + allMessagesAdded.AddRange(modeAndMessages.MessagesAdded); + } + + if (modeAndMessages.ShouldTerminate) + { + shouldTerminate = true; + break; + } + } + + // When interleaving, Tool results are already in responseMessages; return null to avoid duplication. + IList? result = interleaveIntoResponse ? null : (allMessagesAdded.Count > 0 ? allMessagesAdded : null); + return (result, shouldTerminate, consecutiveErrorCount); + } + + /// + /// Finds the index in where Tool results for a group of + /// should be inserted — right after the last FCC message + /// in the group, skipping past any existing Tool messages. + /// + private static int FindResponseInsertionIndex(List responseMessages, List group) + { + var groupCallIds = new HashSet(group.Select(f => f.CallId)); + int insertPos = responseMessages.Count; + + for (int i = responseMessages.Count - 1; i >= 0; i--) + { + if (responseMessages[i].Contents.Any(c => c is FunctionCallContent fcc && groupCallIds.Contains(fcc.CallId))) + { + // Found the FCC message for this group. Insert after it and any following Tool messages. + insertPos = i + 1; + while (insertPos < responseMessages.Count && responseMessages[insertPos].Role == ChatRole.Tool) + { + insertPos++; + } + + break; + } + } + + return insertPos; + } + + /// + /// Groups approved function calls whose messages are in the + /// same or adjacent message positions. Approvals separated by non-FCC messages (e.g., interleaved + /// user messages) are placed in separate groups. + /// + private static List> GroupApprovalsByAdjacentPosition( + List approvals, List messages) + { + var groups = new List>(); + int lastFccMessageIndex = -2; // tracks the message index of the last FCC (not insertion point) + + foreach (var approval in approvals) + { + int fccMsgIndex = FindFccMessageIndex(messages, approval.FunctionCallContent.CallId); + + // Group with current group if the FCCs are in the same message or the next message + bool adjacent = groups.Count > 0 && + ((fccMsgIndex < 0 && lastFccMessageIndex < 0) || + (fccMsgIndex >= 0 && lastFccMessageIndex >= 0 && fccMsgIndex <= lastFccMessageIndex + 1)); + + if (adjacent) + { + groups[^1].Add(approval.FunctionCallContent); + if (fccMsgIndex > lastFccMessageIndex) + { + lastFccMessageIndex = fccMsgIndex; + } + } + else + { + groups.Add([approval.FunctionCallContent]); + lastFccMessageIndex = fccMsgIndex; + } + } + + return groups; + } + + /// + /// Finds the index of the message containing a with the specified call ID, + /// or -1 if not found. + /// + private static int FindFccMessageIndex(List messages, string callId) + { + for (int i = 0; i < messages.Count; i++) + { + foreach (var content in messages[i].Contents) + { + if (content is FunctionCallContent fcc && fcc.CallId == callId) + { + return i; + } + } } - return (null, false, consecutiveErrorCount); + return -1; } [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index a80e056d238..9db33d1a4e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1418,6 +1418,189 @@ [new McpServerToolResultContent("callId2") { Outputs = [new TextContent("Result await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); } + [Fact] + public async Task RejectionWithUserMessageAfterApprovalResponsePreservesOrderingAsync() + { + // Verifies that when a user adds a message after the approval response, + // the reconstructed FCC/FRC are inserted at the original approval position, + // not at the end, preserving the user message at the end. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st message"), + 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")) + ]), + new ChatMessage(ChatRole.User, "2nd message"), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]), + new ChatMessage(ChatRole.User, "2nd message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List 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, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovalWithUserMessageAfterApprovalResponsePreservesOrderingAsync() + { + // Verifies that when a user approves and adds a message after the approval response, + // the message ordering is preserved. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st message"), + 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 ChatMessage(ChatRole.User, "2nd message"), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.User, "2nd message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task MultipleApprovalRequestResponsePairsWithInterleavedUserMessagesPreservesOrderingAsync() + { + // Verifies that when there are multiple approval request/response pairs + // with user messages interleaved between them, each FCC/FRC pair is inserted + // at its original position, preserving user message ordering. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 2", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "1st user message"), + 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 ChatMessage(ChatRole.User, "2nd user message"), + new ChatMessage(ChatRole.Assistant, + [ + new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2")) + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new ToolApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2")) + ]), + new ChatMessage(ChatRole.User, "3rd user message"), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "1st user message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.User, "2nd user message"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2")]), + new ChatMessage(ChatRole.User, "3rd user message"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + List nonStreamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + // In streaming, preDownstreamCallHistory (all FCCs) is yielded first, then all approved Tool results. + List streamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2")]), + new ChatMessage(ChatRole.Assistant, "Final response"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input,