diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 74f9bf554fa..dfc887ff591 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -335,9 +335,14 @@ public override async Task GetResponseAsync( } // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. - bool requiresFunctionInvocation = - iteration < MaximumIterationsPerRequest && - CopyFunctionCalls(response.Messages, ref functionCallContents); + // We also need to filter out any FCCs that already have a corresponding FRC in the response, + // as those have already been handled (e.g., by the inner client or an upstream FunctionInvokingChatClient). + bool requiresFunctionInvocation = false; + if (iteration < MaximumIterationsPerRequest && CopyFunctionCalls(response.Messages, ref functionCallContents)) + { + RemoveAlreadyHandledFunctionCalls(response.Messages, functionCallContents); + requiresFunctionInvocation = functionCallContents!.Count > 0; + } if (!requiresFunctionInvocation && iteration == 0) { @@ -550,41 +555,48 @@ public override async IAsyncEnumerable GetStreamingResponseA // Check if any of the function call contents in this update requires approval. (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex) = CheckForApprovalRequiringFCC( functionCallContents, approvalRequiredFunctions!, hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); - if (hasApprovalRequiringFcc) + + // Even if we've found an approval-requiring FCC, we continue buffering updates + // so we can properly filter out FCCs that have matching FRCs before yielding. + // We will yield the updates as soon as we receive a function call content that requires approval + // or when we reach the end of the updates stream. + } + + // Collect all FunctionResultContent CallIds to identify already-handled function calls. + HashSet? handledCallIds = null; + for (int i = 0; i < updates.Count; i++) + { + IList contents = updates[i].Contents; + int contentCount = contents.Count; + for (int j = 0; j < contentCount; j++) { - // If we've encountered a function call content that requires approval, - // we need to ask for approval for all functions, since we cannot mix and match. - // Convert all function call contents into approval requests from the last yielded update index - // and yield all those updates. - for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) + if (contents[j] is FunctionResultContent frc) { - var updateToYield = updates[lastYieldedUpdateIndex]; - if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents)) - { - updateToYield.Contents = updatedContents; - } - - yield return updateToYield; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + _ = (handledCallIds ??= new(StringComparer.Ordinal)).Add(frc.CallId); } - - continue; } - - // We don't have any approval requiring function calls yet, but we may receive some in future - // so we cannot yield the updates yet. We'll just keep them in the updates list for later. - // We will yield the updates as soon as we receive a function call content that requires approval - // or when we reach the end of the updates stream. } // We need to yield any remaining updates that were not yielded while looping through the streamed updates. + // If we have approval-requiring FCCs, we need to convert them to approval requests, + // but only for FCCs that don't have matching FRCs. for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) { var updateToYield = updates[lastYieldedUpdateIndex]; + + if (hasApprovalRequiringFcc && TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, handledCallIds, out var updatedContents)) + { + updateToYield.Contents = updatedContents; + } + yield return updateToYield; Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } + // Filter out any FCCs that already have a corresponding FRC in the updates, + // as those have already been handled (e.g., by the inner client or an upstream FunctionInvokingChatClient). + RemoveAlreadyHandledFunctionCalls(updates, functionCallContents); + // If there's nothing more to do, break out of the loop and allow the handling at the // end to configure the response with aggregated data from previous requests. if (iteration >= MaximumIterationsPerRequest || @@ -785,6 +797,102 @@ private static bool CopyFunctionCalls( return any; } + /// + /// Removes any from that have a corresponding + /// with the same CallId in . + /// + /// + /// This handles scenarios where the inner handles function invocation itself and returns + /// both the (indicating what was called) and the + /// (indicating the result). In such cases, the should not attempt to + /// re-invoke those functions. + /// + private static void RemoveAlreadyHandledFunctionCalls( + IList messages, List? functionCalls) + { + if (functionCalls is not { Count: > 0 }) + { + return; + } + + // Collect all FunctionResultContent CallIds from the messages into a HashSet for O(1) lookup. + HashSet? handledCallIds = null; + int messageCount = messages.Count; + for (int i = 0; i < messageCount; i++) + { + IList contents = messages[i].Contents; + int contentCount = contents.Count; + for (int j = 0; j < contentCount; j++) + { + if (contents[j] is FunctionResultContent frc) + { + _ = (handledCallIds ??= new(StringComparer.Ordinal)).Add(frc.CallId); + } + } + } + + // If there are no FRCs, nothing to filter. + if (handledCallIds is null) + { + return; + } + + // Remove any FCCs that have a matching FRC CallId. + // We iterate backwards to avoid index shifting issues when removing. + for (int i = functionCalls.Count - 1; i >= 0; i--) + { + if (handledCallIds.Contains(functionCalls[i].CallId)) + { + functionCalls.RemoveAt(i); + } + } + } + + /// + /// Removes any from that have a corresponding + /// with the same CallId in . + /// + private static void RemoveAlreadyHandledFunctionCalls( + List updates, List? functionCalls) + { + if (functionCalls is not { Count: > 0 }) + { + return; + } + + // Collect all FunctionResultContent CallIds from the updates into a HashSet for O(1) lookup. + HashSet? handledCallIds = null; + int updateCount = updates.Count; + for (int i = 0; i < updateCount; i++) + { + IList contents = updates[i].Contents; + int contentCount = contents.Count; + for (int j = 0; j < contentCount; j++) + { + if (contents[j] is FunctionResultContent frc) + { + _ = (handledCallIds ??= new(StringComparer.Ordinal)).Add(frc.CallId); + } + } + } + + // If there are no FRCs, nothing to filter. + if (handledCallIds is null) + { + return; + } + + // Remove any FCCs that have a matching FRC CallId. + // We iterate backwards to avoid index shifting issues when removing. + for (int i = functionCalls.Count - 1; i >= 0; i--) + { + if (handledCallIds.Contains(functionCalls[i].CallId)) + { + functionCalls.RemoveAt(i); + } + } + } + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) { if (options is null) @@ -1531,10 +1639,14 @@ private static (bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex) C } /// - /// Replaces all with and ouputs a new list if any of them were replaced. + /// Replaces with and outputs a new list if any of them were replaced. + /// Excludes any FCCs that have CallIds in . /// /// true if any was replaced, false otherwise. - private static bool TryReplaceFunctionCallsWithApprovalRequests(IList content, out List? updatedContent) + private static bool TryReplaceFunctionCallsWithApprovalRequests( + IList content, + HashSet? excludedCallIds, + out List? updatedContent) { updatedContent = null; @@ -1544,6 +1656,12 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList { if (content[i] is FunctionCallContent fcc) { + // Skip FCCs that are in the excluded set (already have matching FRCs) + if (excludedCallIds?.Contains(fcc.CallId) is true) + { + continue; + } + updatedContent ??= [.. content]; // Clone the list if we haven't already updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); } @@ -1555,7 +1673,8 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList /// /// Replaces all from with - /// if any one of them requires approval. + /// if any one of them requires approval. Function calls that already have a corresponding + /// are not replaced, as they have already been handled. /// private static IList ReplaceFunctionCallsWithApprovalRequests( IList messages, @@ -1563,10 +1682,26 @@ private static IList ReplaceFunctionCallsWithApprovalRequests( { var outputMessages = messages; + // First, collect all FunctionResultContent CallIds to identify already-handled function calls. + HashSet? handledCallIds = null; + for (int i = 0; i < messages.Count; i++) + { + IList contents = messages[i].Contents; + int contentCount = contents.Count; + for (int j = 0; j < contentCount; j++) + { + if (contents[j] is FunctionResultContent frc) + { + _ = (handledCallIds ??= new(StringComparer.Ordinal)).Add(frc.CallId); + } + } + } + bool anyApprovalRequired = false; List<(int, int)>? allFunctionCallContentIndices = null; - // Build a list of the indices of all FunctionCallContent items. + // Build a list of the indices of all FunctionCallContent items that need to be handled. + // Exclude any that have matching FunctionResultContent (already handled). // Also check if any of them require approval. for (int i = 0; i < messages.Count; i++) { @@ -1575,6 +1710,12 @@ private static IList ReplaceFunctionCallsWithApprovalRequests( { if (content[j] is FunctionCallContent functionCall) { + // Skip FCCs that already have a matching FRC + if (handledCallIds?.Contains(functionCall.CallId) is true) + { + continue; + } + (allFunctionCallContentIndices ??= []).Add((i, j)); if (!anyApprovalRequired) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 7c42c0edaf9..71d5e905551 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -819,23 +819,24 @@ async IAsyncEnumerable YieldInnerClientUpdates( Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId); Assert.Equal("Func1", approvalRequest1.FunctionCall.Name); - // Third content should have been buffered, since we have not yet encountered a function call that requires approval. - Assert.Equal(4, updateYieldCount); + // Third content is now yielded after the full stream is collected + // (to properly filter FCCs that have matching FRCs). + Assert.Equal(5, updateYieldCount); break; case 3: var approvalRequest2 = update.Contents.OfType().First(); Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId); Assert.Equal("Func2", approvalRequest2.FunctionCall.Name); - // Fourth content can be yielded immediately, since it is the first function call that requires approval. - Assert.Equal(4, updateYieldCount); + // Fourth content is yielded after the full stream is collected. + Assert.Equal(5, updateYieldCount); break; case 4: var approvalRequest3 = update.Contents.OfType().First(); Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId); Assert.Equal("Func3", approvalRequest3.FunctionCall.Name); - // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval. + // Fifth content is yielded after the full stream is collected. Assert.Equal(5, updateYieldCount); break; } @@ -844,6 +845,94 @@ async IAsyncEnumerable YieldInnerClientUpdates( } } + [Fact] + public async Task IgnoresApprovalRequiredFunctionCallsWithMatchingFunctionResults_NonStreaming() + { + // When an approval-required function has already been handled (FRC exists), + // it should not be converted to an approval request. + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => { funcInvocationCount++; return "should not be invoked"; }, "Func1")), + ] + }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // Inner client handles function calling itself and returns both FCC and FRC + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func1"), + new FunctionResultContent("callId1", result: "Already handled"), + new TextContent("Response based on result") + ])); + + return Task.FromResult(response); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should NOT have been invoked + Assert.Equal(0, funcInvocationCount); + + // The response should NOT contain approval requests since the function was already handled + Assert.DoesNotContain(result.Messages.SelectMany(m => m.Contents), c => c is FunctionApprovalRequestContent); + + // Should contain the original FCC and FRC + Assert.Contains(result.Messages.SelectMany(m => m.Contents), c => c is FunctionCallContent); + Assert.Contains(result.Messages.SelectMany(m => m.Contents), c => c is FunctionResultContent); + } + + [Fact] + public async Task IgnoresApprovalRequiredFunctionCallsWithMatchingFunctionResults_Streaming() + { + // When an approval-required function has already been handled (FRC exists), + // it should not be converted to an approval request. + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => { funcInvocationCount++; return "should not be invoked"; }, "Func1")), + ] + }; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // Inner client handles function calling itself and returns both FCC and FRC in streaming updates + ChatResponseUpdate[] updates = + [ + new() { Contents = [new FunctionCallContent("callId1", "Func1")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new FunctionResultContent("callId1", result: "Already handled")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new TextContent("Response based on result")], MessageId = "msg1", Role = ChatRole.Assistant } + ]; + + return YieldAsync(updates); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options).ToChatResponseAsync(); + + // The function should NOT have been invoked + Assert.Equal(0, funcInvocationCount); + + // The response should NOT contain approval requests since the function was already handled + Assert.DoesNotContain(result.Messages.SelectMany(m => m.Contents), c => c is FunctionApprovalRequestContent); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input, diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 4d086ebf61e..8a15622ce81 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -1447,6 +1447,257 @@ public async Task CreatesOrchestrateToolsSpanWhenNoInvokeAgentParent(bool stream } } + [Fact] + public async Task IgnoresFunctionCallsWithMatchingFunctionResults_NonStreaming() + { + // Inner client handles function invocation itself and returns both FCC and FRC. + // FunctionInvokingChatClient should NOT try to re-invoke the function. + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { funcInvocationCount++; return "should not be invoked"; }, "Func1")] + }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // Simulate inner client that handles function calling itself + // and returns both FCC and FRC together + var response = new ChatResponse( + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func1"), + new FunctionResultContent("callId1", result: "Result from inner client") + ]), + new ChatMessage(ChatRole.Assistant, "Final response using the result") + ]); + + return Task.FromResult(response); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should NOT have been invoked by FunctionInvokingChatClient + Assert.Equal(0, funcInvocationCount); + + // The response should include the original messages + Assert.Equal(2, result.Messages.Count); + Assert.Contains(result.Messages[0].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "callId1"); + Assert.Contains(result.Messages[0].Contents, c => c is FunctionResultContent frc && frc.CallId == "callId1"); + Assert.Equal("Final response using the result", result.Text); + } + + [Fact] + public async Task IgnoresFunctionCallsWithMatchingFunctionResults_Streaming() + { + // Inner client handles function invocation itself and returns both FCC and FRC in streaming updates. + // FunctionInvokingChatClient should NOT try to re-invoke the function. + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { funcInvocationCount++; return "should not be invoked"; }, "Func1")] + }; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // Simulate inner client that handles function calling itself + // and streams both FCC and FRC + var updates = new List + { + new() { Contents = [new FunctionCallContent("callId1", "Func1")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new FunctionResultContent("callId1", result: "Result from inner client")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new TextContent("Final response")], MessageId = "msg2", Role = ChatRole.Assistant } + }; + + return YieldAsync(updates); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options).ToChatResponseAsync(); + + // The function should NOT have been invoked by FunctionInvokingChatClient + Assert.Equal(0, funcInvocationCount); + + // The response should include the original messages + Assert.Equal("Final response", result.Text); + } + + [Fact] + public async Task InvokesFunctionCallsWithoutMatchingFunctionResults_NonStreaming() + { + // Inner client returns FCC without FRC - FunctionInvokingChatClient SHOULD invoke the function. + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { funcInvocationCount++; return "invoked result"; }, "Func1")] + }; + + int callIndex = 0; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + callIndex++; + if (callIndex == 1) + { + // First call: return FCC without FRC + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); + } + + // Second call: return final response + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function SHOULD have been invoked + Assert.Equal(1, funcInvocationCount); + Assert.Equal("done", result.Text); + } + + [Fact] + public async Task IgnoresOnlyMatchingFunctionCalls_NonStreaming() + { + // Inner client returns multiple FCCs, some with matching FRCs and some without. + // Only the ones WITHOUT matching FRCs should be invoked. + int func1InvocationCount = 0; + int func2InvocationCount = 0; + + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => { func1InvocationCount++; return "func1 result"; }, "Func1"), + AIFunctionFactory.Create(() => { func2InvocationCount++; return "func2 result"; }, "Func2") + ] + }; + + int callIndex = 0; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + callIndex++; + if (callIndex == 1) + { + // First call: return two FCCs, but only one has a matching FRC + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func1"), + new FunctionResultContent("callId1", result: "already handled"), + new FunctionCallContent("callId2", "Func2") // No FRC for this one + ]))); + } + + // Second call: return final response + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // Only Func2 should have been invoked (Func1 had a matching FRC) + Assert.Equal(0, func1InvocationCount); + Assert.Equal(1, func2InvocationCount); + Assert.Equal("done", result.Text); + } + + [Fact] + public async Task MultipleFunctionInvokingClientsInPipeline_NonStreaming() + { + // When there are multiple FunctionInvokingChatClients in the pipeline, + // the outer one should not re-invoke functions already handled by the inner one. + int func1InvocationCount = 0; + + var func1 = AIFunctionFactory.Create(() => { func1InvocationCount++; return "func1 result"; }, "Func1"); + + var options = new ChatOptions { Tools = [func1] }; + + int outerCallIndex = 0; + using var baseClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + outerCallIndex++; + if (outerCallIndex == 1) + { + // First call: return FCC + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); + } + + // Second call: return final response + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }; + + // Create a pipeline with two FunctionInvokingChatClients + using var innerFunctionInvokingClient = new FunctionInvokingChatClient(baseClient); + using var outerFunctionInvokingClient = new FunctionInvokingChatClient(innerFunctionInvokingClient); + + var result = await outerFunctionInvokingClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should have been invoked exactly ONCE (by the inner FunctionInvokingChatClient) + // The outer one should see the FRC and not re-invoke + Assert.Equal(1, func1InvocationCount); + Assert.Equal("done", result.Text); + } + + [Fact] + public async Task IgnoresFunctionCallsWithMatchingFunctionResults_Streaming_MultipleUpdates() + { + // Test streaming where FCC and FRC come in different updates + int funcInvocationCount = 0; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { funcInvocationCount++; return "should not be invoked"; }, "Func1")] + }; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // FCC in one update, FRC in another + var updates = new List + { + new() { Contents = [new FunctionCallContent("callId1", "Func1")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new TextContent("thinking...")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new FunctionResultContent("callId1", result: "Result from inner")], MessageId = "msg1", Role = ChatRole.Assistant }, + new() { Contents = [new TextContent("Final answer")], MessageId = "msg2", Role = ChatRole.Assistant } + }; + + return YieldAsync(updates); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var result = await client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options).ToChatResponseAsync(); + + // The function should NOT have been invoked by FunctionInvokingChatClient + Assert.Equal(0, funcInvocationCount); + + // The response should include the final text content + Assert.Contains("Final answer", result.Text); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state)