From 283dde979c70717f4d01ced0653f37e194a29a57 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Wed, 27 May 2026 16:19:54 -0500 Subject: [PATCH] Remove backward-compat InformationalOnly case from FICC; suggest middleware workaround Remove the special case in ExtractAndRemoveApprovalRequestsAndResponses that handled ToolApprovalResponseContent with InformationalOnly=true for sessions serialized before PR #7468. Instead, provide a middleware workaround (ApprovalHistoryNormalizingChatClient) that normalizes the InformationalOnly flags on TARC/TAResp pairs before FICC processes them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 6 --- ...unctionInvokingChatClientApprovalsTests.cs | 54 ++++++++++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 95e5a26a722..e0ff736a68d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1385,12 +1385,6 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul (allApprovalResponses ??= []).Add(tarc); break; - case ToolApprovalResponseContent tarc when tarc.ToolCall is FunctionCallContent { InformationalOnly: true }: - // Remove from validation set to handle sessions serialized before the fix - // for https://github.com/dotnet/extensions/pull/7468. - _ = approvalRequestCallIds?.Remove(tarc.ToolCall.CallId); - goto default; - case FunctionResultContent frc: // Maintain a list of function calls that have already been invoked to avoid invoking them twice. _ = (functionResultCallIds ??= []).Add(frc.CallId); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 5735b3d5461..1fcf22fa292 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -829,11 +829,12 @@ public async Task RejectionSetsInformationalOnlyOnBothRequestAndResponseFccInsta /// across serialization boundaries. Previously, this was not always happening, so adding /// a test to ensure that this case does not throw. /// See https://github.com/dotnet/extensions/pull/7468. + /// Workaround: Use a middleware to normalize InformationalOnly flags on deserialized sessions. /// [Theory] [InlineData(false)] [InlineData(true)] - public async Task MixedInformationalOnlyDoesNotThrowAsync(bool streaming) + public async Task MixedInformationalOnlyWorkaroundWithMiddlewareAsync(bool streaming) { // Create two separate FCC objects for the same call — simulating deserialization // where TARC and TAResp hold different FCC instances with the same CallId. @@ -882,7 +883,9 @@ public async Task MixedInformationalOnlyDoesNotThrowAsync(bool streaming) YieldAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "world")]).ToChatResponseUpdates()), }; + // Use a middleware to normalize InformationalOnly flags before FICC processes the messages. IChatClient service = innerClient.AsBuilder() + .Use(s => new ApprovalHistoryNormalizingChatClient(s)) .Use(s => new FunctionInvokingChatClient(s)) .Build(); @@ -1740,5 +1743,54 @@ private static List CloneInput(List input) => }, _ => c }; + + /// + /// Workaround middleware for sessions serialized before + /// https://github.com/dotnet/extensions/pull/7468. + /// Normalizes InformationalOnly flags so TARC/TAResp pairs stay consistent. + /// +#pragma warning disable SA1402 // File may only contain a single type + private sealed class ApprovalHistoryNormalizingChatClient(IChatClient inner) : DelegatingChatClient(inner) +#pragma warning restore SA1402 + { + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + NormalizeApprovalFlags(messages); + return base.GetResponseAsync(messages, options, cancellationToken); + } + + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + NormalizeApprovalFlags(messages); + return base.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + private static void NormalizeApprovalFlags(IEnumerable messages) + { + var allContents = messages.SelectMany(m => m.Contents); + + var processedCallIds = new HashSet( + allContents + .OfType() + .Where(t => t.ToolCall is FunctionCallContent { InformationalOnly: true }) + .Select(t => t.ToolCall.CallId)); + + if (processedCallIds.Count == 0) + { + return; + } + + foreach (var fcc in allContents + .OfType() + .Select(t => t.ToolCall) + .OfType() + .Where(fcc => !fcc.InformationalOnly && processedCallIds.Contains(fcc.CallId))) + { + fcc.InformationalOnly = true; + } + } + } }