diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 836d5a4110b..3cb75e3627a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -56,6 +56,16 @@ public FunctionCallContent(string callId, string name, IDictionary + /// Gets or sets a value indicating whether this function call requires invocation. + /// + /// + /// This property defaults to , indicating that the function call should be processed. + /// When set to , it indicates that the function has already been processed and + /// should be ignored by components that process function calls. + /// + public bool InvocationRequired { get; set; } = true; + /// /// Creates a new instance of parsing arguments using a specified encoding and parser. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 32faa8a1f4d..2bdc463c848 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1805,6 +1805,10 @@ "Member": "System.Exception? Microsoft.Extensions.AI.FunctionCallContent.Exception { get; set; }", "Stage": "Stable" }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionCallContent.InvocationRequired { get; set; }", + "Stage": "Experimental" + }, { "Member": "string Microsoft.Extensions.AI.FunctionCallContent.Name { get; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 74f9bf554fa..17398ce5591 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -775,7 +775,7 @@ private static bool CopyFunctionCalls( int count = content.Count; for (int i = 0; i < count; i++) { - if (content[i] is FunctionCallContent functionCall) + if (content[i] is FunctionCallContent functionCall && functionCall.InvocationRequired) { (functionCalls ??= []).Add(functionCall); any = true; @@ -1018,6 +1018,9 @@ private async Task ProcessFunctionCallAsync( { var callContent = callContents[functionCallIndex]; + // Mark the function call as no longer requiring invocation since we're handling it + callContent.InvocationRequired = false; + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. if (toolMap is null || !toolMap.TryGetValue(callContent.Name, out AITool? tool) || @@ -1107,6 +1110,9 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul functionResult = message; } + // Mark the function call as having been processed + result.CallContent.InvocationRequired = false; + return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; } } @@ -1416,10 +1422,20 @@ private static (List? approvals, List /// Any rejected approval responses. /// The for the rejected function calls. - private static List? GenerateRejectedFunctionResults(List? rejections) => - rejections is { Count: > 0 } ? - rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) : - null; + private static List? GenerateRejectedFunctionResults(List? rejections) + { + if (rejections is not { Count: > 0 }) + { + return null; + } + + return rejections.ConvertAll(static m => + { + // Mark the function call as no longer requiring invocation since we're handling it (by rejecting it) + m.Response.FunctionCall.InvocationRequired = false; + return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user."); + }); + } /// /// Extracts the from the provided to recreate the original function call messages. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs index 85dd68f42c2..c6dc821eb06 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs @@ -71,6 +71,152 @@ public void Constructor_PropsRoundtrip() Exception e = new(); c.Exception = e; Assert.Same(e, c.Exception); + + Assert.True(c.InvocationRequired); + c.InvocationRequired = false; + Assert.False(c.InvocationRequired); + } + + [Fact] + public void InvocationRequired_DefaultsToTrue() + { + FunctionCallContent c = new("callId1", "name"); + Assert.True(c.InvocationRequired); + } + + [Fact] + public void InvocationRequired_CanBeSetToFalse() + { + FunctionCallContent c = new("callId1", "name") { InvocationRequired = false }; + Assert.False(c.InvocationRequired); + } + + [Fact] + public void InvocationRequired_SerializedWhenFalse() + { + // Arrange - Set InvocationRequired to false (to allow roundtrip, it must be serialized even when false) + var sut = new FunctionCallContent("callId1", "functionName", new Dictionary { ["key"] = "value" }) + { + InvocationRequired = false + }; + + // Act + var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); + + // Assert - InvocationRequired should be in the JSON when it's false to allow roundtrip + Assert.NotNull(json); + var jsonObj = json!.AsObject(); + Assert.True(jsonObj.ContainsKey("invocationRequired") || jsonObj.ContainsKey("InvocationRequired")); + + JsonNode? invocationRequiredValue = null; + if (jsonObj.TryGetPropertyValue("invocationRequired", out var value1)) + { + invocationRequiredValue = value1; + } + else if (jsonObj.TryGetPropertyValue("InvocationRequired", out var value2)) + { + invocationRequiredValue = value2; + } + + Assert.NotNull(invocationRequiredValue); + Assert.False(invocationRequiredValue!.GetValue()); + } + + [Fact] + public void InvocationRequired_SerializedWhenTrue() + { + // Arrange - InvocationRequired defaults to true + var sut = new FunctionCallContent("callId1", "functionName", new Dictionary { ["key"] = "value" }); + + // Act + var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); + + // Assert - InvocationRequired should be in the JSON when it's true + Assert.NotNull(json); + var jsonObj = json!.AsObject(); + Assert.True(jsonObj.ContainsKey("invocationRequired") || jsonObj.ContainsKey("InvocationRequired")); + + JsonNode? invocationRequiredValue = null; + if (jsonObj.TryGetPropertyValue("invocationRequired", out var value1)) + { + invocationRequiredValue = value1; + } + else if (jsonObj.TryGetPropertyValue("InvocationRequired", out var value2)) + { + invocationRequiredValue = value2; + } + + Assert.NotNull(invocationRequiredValue); + Assert.True(invocationRequiredValue!.GetValue()); + } + + [Fact] + public void InvocationRequired_DeserializedCorrectlyWhenTrue() + { + // Test deserialization when InvocationRequired is true + var json = """{"callId":"callId1","name":"functionName","invocationRequired":true}"""; + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal("callId1", deserialized.CallId); + Assert.Equal("functionName", deserialized.Name); + Assert.True(deserialized.InvocationRequired); + } + + [Fact] + public void InvocationRequired_DeserializedCorrectlyWhenFalse() + { + // Test deserialization when InvocationRequired is false + var json = """{"callId":"callId1","name":"functionName","invocationRequired":false}"""; + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal("callId1", deserialized.CallId); + Assert.Equal("functionName", deserialized.Name); + Assert.False(deserialized.InvocationRequired); + } + + [Fact] + public void InvocationRequired_DeserializedToTrueWhenMissing() + { + // Test deserialization when InvocationRequired is not in JSON (should default to true from field initializer) + var json = """{"callId":"callId1","name":"functionName"}"""; + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal("callId1", deserialized.CallId); + Assert.Equal("functionName", deserialized.Name); + Assert.True(deserialized.InvocationRequired); + } + + [Fact] + public void InvocationRequired_RoundtripTrue() + { + // Test that InvocationRequired=true roundtrips correctly through JSON serialization + var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = true }; + var json = JsonSerializer.SerializeToNode(original, TestJsonSerializerContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal(original.CallId, deserialized.CallId); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.InvocationRequired, deserialized.InvocationRequired); + Assert.True(deserialized.InvocationRequired); + } + + [Fact] + public void InvocationRequired_RoundtripFalse() + { + // Test that InvocationRequired=false roundtrips correctly through JSON serialization + var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = false }; + var json = JsonSerializer.SerializeToNode(original, TestJsonSerializerContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal(original.CallId, deserialized.CallId); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.InvocationRequired, deserialized.InvocationRequired); + Assert.False(deserialized.InvocationRequired); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 4d086ebf61e..a0f02c303de 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -136,6 +136,7 @@ public async Task SupportsToolsProvidedByAdditionalTools(bool provideOptions) await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -169,6 +170,7 @@ public async Task PrefersToolsProvidedByChatOptions() await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -219,6 +221,7 @@ public async Task SupportsMultipleFunctionCallsPerRequestAsync(bool concurrentIn await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -267,6 +270,7 @@ public async Task ParallelFunctionCallsMayBeInvokedConcurrentlyAsync() await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -308,6 +312,7 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() await InvokeAndAssertAsync(options, plan); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan); } @@ -351,6 +356,7 @@ public async Task FunctionInvokerDelegateOverridesHandlingAsync() await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -562,6 +568,7 @@ public async Task KeepsFunctionCallingContent() #pragma warning disable SA1005, S125 Validate(await InvokeAndAssertAsync(options, plan)); + ResetPlanFunctionCallStates(plan); Validate(await InvokeAndAssertStreamingAsync(options, plan)); static void Validate(List finalChat) @@ -597,6 +604,7 @@ public async Task ExceptionDetailsOnlyReportedWhenRequestedAsync(bool detailedEr await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } @@ -1077,6 +1085,7 @@ public async Task FunctionInvocations_InvokedOnOriginalSynchronizationContext() .UseFunctionInvocation(configure: c => { c.AllowConcurrentInvocation = true; c.IncludeDetailedErrors = true; }); await InvokeAndAssertAsync(options, plan, configurePipeline: configurePipeline); + ResetPlanFunctionCallStates(plan); await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configurePipeline); } @@ -1110,6 +1119,7 @@ public async Task TerminateOnUnknownCalls_ControlsBehaviorForUnknownFunctions(bo ]; await InvokeAndAssertAsync(options, planForContinue, configurePipeline: configure); + ResetPlanFunctionCallStates(planForContinue); await InvokeAndAssertStreamingAsync(options, planForContinue, configurePipeline: configure); } else @@ -1447,6 +1457,243 @@ public async Task CreatesOrchestrateToolsSpanWhenNoInvokeAgentParent(bool stream } } + [Fact] + public async Task InvocationRequired_SetToFalseAfterProcessing() + { + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + var chat = await InvokeAndAssertAsync(options, plan); + + // Find the FunctionCallContent in the chat history + var functionCallMessage = chat.First(m => m.Contents.Any(c => c is FunctionCallContent)); + var functionCallContent = functionCallMessage.Contents.OfType().First(); + + // Verify InvocationRequired was set to false after processing + Assert.False(functionCallContent.InvocationRequired); + } + + [Fact] + public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredFalse() + { + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + // Create a function call that has already been processed + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + // Return a response with a FunctionCallContent that has InvocationRequired = false + var message = new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]); + return new ChatResponse(message); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should not have been invoked since InvocationRequired was false + Assert.Equal(0, functionInvokedCount); + + // The response should contain the FunctionCallContent but no FunctionResultContent + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && !fcc.InvocationRequired)); + Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredFalse_Streaming() + { + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + // Create a function call that has already been processed + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + // Return a response with a FunctionCallContent that has InvocationRequired = false + var message = new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]); + return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options)) + { + updates.Add(update); + } + + // The function should not have been invoked since InvocationRequired was false + Assert.Equal(0, functionInvokedCount); + + // The updates should contain the FunctionCallContent but no FunctionResultContent + Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && !fcc.InvocationRequired)); + Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task InvocationRequired_ProcessesMixedFunctionCalls() + { + var func1InvokedCount = 0; + var func2InvokedCount = 0; + + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => { func1InvokedCount++; return "Result 1"; }, "Func1"), + AIFunctionFactory.Create(() => { func2InvokedCount++; return "Result 2"; }, "Func2"), + ] + }; + + // Create one function call that needs processing and one that doesn't + var needsProcessing = new FunctionCallContent("callId1", "Func1") { InvocationRequired = true }; + var alreadyProcessed = new FunctionCallContent("callId2", "Func2") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + if (contents.Count() == 1) + { + // First call - return both function calls + var message = new ChatMessage(ChatRole.Assistant, [needsProcessing, alreadyProcessed]); + return new ChatResponse(message); + } + else + { + // Second call - return final response after processing + var message = new ChatMessage(ChatRole.Assistant, "done"); + return new ChatResponse(message); + } + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // Only Func1 should have been invoked (the one with InvocationRequired = true) + Assert.Equal(1, func1InvokedCount); + Assert.Equal(0, func2InvokedCount); + + // The response should contain FunctionResultContent for Func1 but not Func2 + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId1")); + Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId2")); + } + + [Fact] + public async Task InvocationRequired_InnerClientWithInvocationRequiredFalsePassesThroughUnprocessed() + { + // Test that FunctionCallContent with InvocationRequired=false from inner client passes through without being processed + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + var functionCallWithInvocationRequiredFalse = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + // Inner client returns a FunctionCallContent with InvocationRequired = false + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCallWithInvocationRequiredFalse])); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should NOT have been invoked since InvocationRequired was false + Assert.Equal(0, functionInvokedCount); + + // The response should contain the FunctionCallContent with InvocationRequired = false + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.CallId == "callId1" && !fcc.InvocationRequired)); + + // There should be NO FunctionResultContent since we didn't process the function call + Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task InvocationRequired_MultipleFunctionInvokingChatClientsOnlyProcessOnce() + { + // Test that when multiple FunctionInvokingChatClients are in a pipeline, + // each FunctionCallContent is only processed once (by the first one that sees it) + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + var callCount = 0; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + // First call: return a FunctionCallContent that needs processing + // Second call: return a final text response + if (callCount++ == 0) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])); + } + else + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")); + } + } + }; + + // Create a pipeline with two FunctionInvokingChatClients + using var client1 = new FunctionInvokingChatClient(innerClient); + using var client2 = new FunctionInvokingChatClient(client1); + + var response = await client2.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should have been invoked EXACTLY ONCE, not twice (once per FICC) + Assert.Equal(1, functionInvokedCount); + + // The response should contain the FunctionCallContent with InvocationRequired = false + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.CallId == "callId1" && !fcc.InvocationRequired)); + + // There should be a FunctionResultContent since the function was processed + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId1")); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) @@ -1468,6 +1715,9 @@ private static async Task> InvokeAndAssertAsync( { Assert.NotEmpty(plan); + // Reset InvocationRequired for all FunctionCallContent in the plan to allow reuse + ResetPlanFunctionCallStates(plan); + configurePipeline ??= static b => b.UseFunctionInvocation(); using CancellationTokenSource cts = new(); @@ -1538,6 +1788,9 @@ private static async Task> InvokeAndAssertStreamingAsync( { Assert.NotEmpty(plan); + // Reset InvocationRequired for all FunctionCallContent in the plan to allow reuse + ResetPlanFunctionCallStates(plan); + configurePipeline ??= static b => b.UseFunctionInvocation(); using CancellationTokenSource cts = new(); @@ -1578,4 +1831,22 @@ private static async IAsyncEnumerable YieldAsync(params IEnumerable ite yield return item; } } + + /// + /// Resets InvocationRequired to true for all FunctionCallContent in the plan. + /// This is needed when reusing a plan across multiple test invocations. + /// + private static void ResetPlanFunctionCallStates(List plan) + { + foreach (var message in plan) + { + foreach (var content in message.Contents) + { + if (content is FunctionCallContent fcc) + { + fcc.InvocationRequired = true; + } + } + } + } }