From 60c4a5524af1cc0396568a9ea21c3bdc1031effd Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:21:48 +0100 Subject: [PATCH 1/2] Also set InformationOnly=true on ToolApprovalRequestContent.FunctionCallContent --- .../FunctionInvokingChatClient.cs | 36 +++++++--- ...unctionInvokingChatClientApprovalsTests.cs | 68 +++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 0645d98edf2..e0ff736a68d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1349,7 +1349,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul private (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses( List messages) { - Dictionary? allApprovalRequestsMessages = null; + Dictionary? allApprovalRequestsMessages = null; List? allApprovalResponses = null; HashSet? approvalRequestCallIds = null; HashSet? functionResultCallIds = null; @@ -1376,7 +1376,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul case ToolApprovalRequestContent tarc when tarc.ToolCall is FunctionCallContent { InformationalOnly: false }: // 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); + (allApprovalRequestsMessages ??= []).Add(tarc.RequestId, (message, tarc)); break; case ToolApprovalResponseContent tarc when tarc.ToolCall is FunctionCallContent { InformationalOnly: false }: @@ -1451,9 +1451,14 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul ref List? targetList = ref approvalResponse.Approved ? ref approvedFunctionCalls : ref rejectedFunctionCalls; ChatMessage? requestMessage = null; - _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.RequestId, out requestMessage); + ToolApprovalRequestContent? requestContent = null; + if (allApprovalRequestsMessages?.TryGetValue(approvalResponse.RequestId, out var requestInfo) is true) + { + requestMessage = requestInfo.Message; + requestContent = requestInfo.RequestContent; + } - (targetList ??= []).Add(new() { Response = approvalResponse, RequestMessage = requestMessage }); + (targetList ??= []).Add(new() { Response = approvalResponse, Request = requestContent, RequestMessage = requestMessage }); } } @@ -1469,7 +1474,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul rejections is { Count: > 0 } ? rejections.ConvertAll(m => { - LogFunctionRejected(m.FunctionCallContent.Name, m.Response.Reason); + LogFunctionRejected(m.ResponseFunctionCallContent.Name, m.Response.Reason); string result = "Tool call invocation rejected."; if (!string.IsNullOrWhiteSpace(m.Response.Reason)) @@ -1477,9 +1482,13 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul result = $"{result} {m.Response.Reason}"; } - // Mark the function call as purely informational since we're handling it (by rejecting it) - m.FunctionCallContent.InformationalOnly = true; - return (AIContent)new FunctionResultContent(m.FunctionCallContent.CallId, result); + // Mark the function call as purely informational since we're handling it (by rejecting it). + // We mark both the response and request FunctionCallContent to ensure consistency + // across serialization boundaries where they may be separate object instances. + m.ResponseFunctionCallContent.InformationalOnly = true; + _ = m.RequestFunctionCallContent?.InformationalOnly = true; + + return (AIContent)new FunctionResultContent(m.ResponseFunctionCallContent.CallId, result); }) : null; @@ -1708,6 +1717,13 @@ private IList ReplaceFunctionCallsWithApprovalRequests( originalMessages, options, notInvokedApprovals.Select(x => x.Response.ToolCall).OfType().ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + // Also mark the request's FCC as InformationalOnly to ensure consistency + // across serialization boundaries where they may be separate object instances. + foreach (var approval in notInvokedApprovals) + { + _ = approval.RequestFunctionCallContent?.InformationalOnly = true; + } + return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); } @@ -1788,7 +1804,9 @@ public enum FunctionInvocationStatus private readonly struct ApprovalResultWithRequestMessage { public ToolApprovalResponseContent Response { get; init; } + public ToolApprovalRequestContent? Request { get; init; } public ChatMessage? RequestMessage { get; init; } - public FunctionCallContent FunctionCallContent => (FunctionCallContent)Response.ToolCall; + public FunctionCallContent ResponseFunctionCallContent => (FunctionCallContent)Response.ToolCall; + public FunctionCallContent? RequestFunctionCallContent => Request?.ToolCall as FunctionCallContent; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index a80e056d238..c30ff880cba 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -754,6 +754,74 @@ public async Task AlreadyExecutedApprovalsAreIgnoredAsync() await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); } + /// + /// After serialization/deserialization, the TARC and TAResp may contain separate FCC object instances + /// for the same call. When a rejection is processed, GenerateRejectedFunctionResults must set + /// InformationalOnly=true on BOTH the TAResp's FCC and the TARC's FCC to ensure consistency + /// across serialization boundaries. This test verifies that both FCC instances are correctly + /// marked after rejection processing. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RejectionSetsInformationalOnlyOnBothRequestAndResponseFccInstancesAsync(bool streaming) + { + // Create two separate FCC objects for the same call — simulating deserialization + // where TARC and TAResp hold different FCC instances with the same CallId. + var requestFcc = new FunctionCallContent("callId1", "Func1"); + var responseFcc = new FunctionCallContent("callId1", "Func1"); + + Assert.False(requestFcc.InformationalOnly); + Assert.False(responseFcc.InformationalOnly); + + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new ToolApprovalRequestContent("ficc_callId1", requestFcc), + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new ToolApprovalResponseContent("ficc_callId1", false, responseFcc), + ]), + ]; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")])), + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + YieldAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")]).ToChatResponseUpdates()), + }; + + IChatClient service = innerClient.AsBuilder() + .Use(s => new FunctionInvokingChatClient(s)) + .Build(); + + if (streaming) + { + await service.GetStreamingResponseAsync(input, options).ToChatResponseAsync(); + } + else + { + await service.GetResponseAsync(input, options); + } + + // The fix ensures both FCC instances are marked InformationalOnly=true, + // even when they are separate objects (as happens after serialization). + Assert.True(requestFcc.InformationalOnly); + Assert.True(responseFcc.InformationalOnly); + } + /// /// This verifies the following scenario: /// 1. We are streaming (also including non-streaming in the test for completeness). From d67e84f8157b87e5600c31baefb317a64d7a326b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:41:45 +0100 Subject: [PATCH 2/2] Update test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index c30ff880cba..6a484e0018f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -766,7 +766,7 @@ public async Task AlreadyExecutedApprovalsAreIgnoredAsync() [InlineData(true)] public async Task RejectionSetsInformationalOnlyOnBothRequestAndResponseFccInstancesAsync(bool streaming) { - // Create two separate FCC objects for the same call — simulating deserialization + // Create two separate FCC objects for the same call — simulating deserialization // where TARC and TAResp hold different FCC instances with the same CallId. var requestFcc = new FunctionCallContent("callId1", "Func1"); var responseFcc = new FunctionCallContent("callId1", "Func1");