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,