From 894a41022c4116857b4ab1bf10228cfe994a88e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 04:37:54 +0000 Subject: [PATCH 1/8] Initial plan From 7deaf76dc41b1542e852a5157c11d8fa39220e87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 04:45:19 +0000 Subject: [PATCH 2/8] Allow FunctionResultContent pass-through when CallId matches Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 7 ++ .../FunctionInvokingChatClientTests.cs | 90 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index fdc7d57b1ea..c88fe91fc17 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1208,6 +1208,13 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul object? functionResult; if (result.Status == FunctionInvocationStatus.RanToCompletion) { + // If the result is already a FunctionResultContent with a matching CallId, use it directly. + if (result.Result is FunctionResultContent frc && + frc.CallId == result.CallContent.CallId) + { + return frc; + } + functionResult = result.Result ?? "Success: Function completed."; } else diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index e9672275871..a84105ba3e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -354,6 +354,96 @@ public async Task FunctionInvokerDelegateOverridesHandlingAsync() await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } + [Fact] + public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesItDirectly() + { + 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: "Custom result from function")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + // Use FunctionInvoker to return a FunctionResultContent with matching CallId + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = (ctx, cancellationToken) => + { + // Return a FunctionResultContent with the same CallId that was passed in + var frc = new FunctionResultContent(ctx.CallContent.CallId, "Custom result from function") + { + RawRepresentation = "CustomRaw" + }; + return new ValueTask(frc); + } + }); + + var chat = await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + // Verify that the FunctionResultContent has the custom RawRepresentation, proving it was used directly + var toolMessage = chat.First(m => m.Role == ChatRole.Tool); + var frc = Assert.Single(toolMessage.Contents.OfType()); + Assert.Equal("Custom result from function", frc.Result); + Assert.Equal("CustomRaw", frc.RawRepresentation); + Assert.Equal("callId1", frc.CallId); + } + + [Fact] + public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_WrapsIt() + { + 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")]), + + // The result should be wrapped, so the outer FunctionResultContent has callId1 + // and its Result property contains the inner FunctionResultContent + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: new FunctionResultContent("differentCallId", "Result from function"))]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + // Use FunctionInvoker to return a FunctionResultContent with mismatched CallId + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = (ctx, cancellationToken) => + { + // Return a FunctionResultContent with a different CallId + var frc = new FunctionResultContent("differentCallId", "Result from function"); + return new ValueTask(frc); + } + }); + + var chat = await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + // Verify the result is wrapped - the outer FunctionResultContent has the correct CallId + var toolMessage = chat.First(m => m.Role == ChatRole.Tool); + var frc = Assert.Single(toolMessage.Contents.OfType()); + Assert.Equal("callId1", frc.CallId); + Assert.IsType(frc.Result); + var innerFrc = (FunctionResultContent)frc.Result!; + Assert.Equal("differentCallId", innerFrc.CallId); + Assert.Equal("Result from function", innerFrc.Result); + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { From 790f9799123dec7ed197da9cfd299ac446e5eb54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:18:16 +0000 Subject: [PATCH 3/8] Unseal FunctionCallContent and FunctionResultContent with derived type propagation test Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Contents/FunctionCallContent.cs | 2 +- .../Contents/FunctionResultContent.cs | 2 +- .../Microsoft.Extensions.AI.Abstractions.json | 4 +- .../FunctionInvokingChatClientTests.cs | 90 +++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 836d5a4110b..7c506a7845b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI; /// Represents a function call request. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class FunctionCallContent : AIContent +public class FunctionCallContent : AIContent { /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index 46401347b40..d5eb4884709 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.AI; /// Represents the result of a function call. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class FunctionResultContent : AIContent +public class FunctionResultContent : AIContent { /// /// Initializes a new instance of the class. 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 6362fcaa00e..ef1f2ebb496 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 @@ -1792,7 +1792,7 @@ ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent", + "Type": "class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", "Methods": [ { @@ -1824,7 +1824,7 @@ ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent", + "Type": "class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", "Methods": [ { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index a84105ba3e3..add98b5744f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -444,6 +444,96 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra Assert.Equal("Result from function", innerFrc.Result); } + [Fact] + public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient() + { + DerivedFunctionResultContent? capturedFrc = null; + + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + ] + }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, opts, ct) => + { + // Capture the FunctionResultContent passed to the inner client + var toolMessage = messages.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is not null) + { + capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); + } + + // Return a simple response to end the conversation + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient) + { + FunctionInvoker = (ctx, cancellationToken) => + { + // Return a derived FunctionResultContent + var frc = new DerivedFunctionResultContent(ctx.CallContent.CallId, "Derived result") + { + CustomProperty = "CustomValue" + }; + return new ValueTask(frc); + } + }; + + var messages = new List + { + new ChatMessage(ChatRole.User, "hello"), + }; + + // First, the client needs to return a function call to trigger function invocation + innerClient.GetResponseAsyncCallback = (msgs, opts, ct) => + { + if (msgs.Count() == 1) + { + // First call: return a function call + return Task.FromResult(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); + } + else + { + // Second call: capture and return final response + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is not null) + { + capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); + } + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }; + + await client.GetResponseAsync(messages, options); + + // Verify that the derived FunctionResultContent instance was propagated to the inner client + Assert.NotNull(capturedFrc); + Assert.IsType(capturedFrc); + Assert.Equal("callId1", capturedFrc.CallId); + Assert.Equal("Derived result", capturedFrc.Result); + Assert.Equal("CustomValue", capturedFrc.CustomProperty); + } + + /// A derived FunctionResultContent for testing purposes. + private sealed class DerivedFunctionResultContent : FunctionResultContent + { + public DerivedFunctionResultContent(string callId, object? result) + : base(callId, result) + { + } + + public string? CustomProperty { get; set; } + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { From 73dd6dd17629b8083767fb6bca218c480b8f95db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:20:55 +0000 Subject: [PATCH 4/8] Simplify test callback in FunctionReturningDerivedFunctionResultContent test Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClientTests.cs | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index add98b5744f..2e27f6f890d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -459,17 +459,25 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan using var innerClient = new TestChatClient { - GetResponseAsyncCallback = (messages, opts, ct) => + GetResponseAsyncCallback = (msgs, opts, ct) => { - // Capture the FunctionResultContent passed to the inner client - var toolMessage = messages.FirstOrDefault(m => m.Role == ChatRole.Tool); - if (toolMessage is not null) + if (msgs.Count() == 1) { - capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); + // First call: return a function call to trigger function invocation + return Task.FromResult(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); } + else + { + // Second call: capture and return final response + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is not null) + { + capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); + } - // Return a simple response to end the conversation - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } } }; @@ -491,28 +499,6 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan new ChatMessage(ChatRole.User, "hello"), }; - // First, the client needs to return a function call to trigger function invocation - innerClient.GetResponseAsyncCallback = (msgs, opts, ct) => - { - if (msgs.Count() == 1) - { - // First call: return a function call - return Task.FromResult(new ChatResponse( - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); - } - else - { - // Second call: capture and return final response - var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); - if (toolMessage is not null) - { - capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); - } - - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); - } - }; - await client.GetResponseAsync(messages, options); // Verify that the derived FunctionResultContent instance was propagated to the inner client From 70394321a02c77cf17cabcf21bc91f8939948e4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:24:41 +0000 Subject: [PATCH 5/8] Improve test callback logic by checking for tool message instead of count Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClientTests.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 2e27f6f890d..9256764ab98 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -461,7 +461,9 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan { GetResponseAsyncCallback = (msgs, opts, ct) => { - if (msgs.Count() == 1) + // Check if this is the first call (no tool messages yet) or subsequent call + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) { // First call: return a function call to trigger function invocation return Task.FromResult(new ChatResponse( @@ -470,11 +472,7 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan else { // Second call: capture and return final response - var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); - if (toolMessage is not null) - { - capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); - } + capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } From 2a836ef7c8466be2b9d12f6e3fb1607de2c35e73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:29:30 +0000 Subject: [PATCH 6/8] Add assertion that FunctionResultContent is the only one in tool message Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClientTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 9256764ab98..a37e7534f18 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -447,7 +447,7 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra [Fact] public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient() { - DerivedFunctionResultContent? capturedFrc = null; + ChatMessage? capturedToolMessage = null; var options = new ChatOptions { @@ -472,7 +472,7 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan else { // Second call: capture and return final response - capturedFrc = toolMessage.Contents.OfType().FirstOrDefault(); + capturedToolMessage = toolMessage; return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } @@ -500,11 +500,13 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan await client.GetResponseAsync(messages, options); // Verify that the derived FunctionResultContent instance was propagated to the inner client - Assert.NotNull(capturedFrc); + Assert.NotNull(capturedToolMessage); + var capturedFrc = Assert.Single(capturedToolMessage.Contents.OfType()); Assert.IsType(capturedFrc); - Assert.Equal("callId1", capturedFrc.CallId); - Assert.Equal("Derived result", capturedFrc.Result); - Assert.Equal("CustomValue", capturedFrc.CustomProperty); + var derivedFrc = (DerivedFunctionResultContent)capturedFrc; + Assert.Equal("callId1", derivedFrc.CallId); + Assert.Equal("Derived result", derivedFrc.Result); + Assert.Equal("CustomValue", derivedFrc.CustomProperty); } /// A derived FunctionResultContent for testing purposes. From 355f5d0ec1dd6a42b1a35414e98060d9c8e2585d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:21:39 +0000 Subject: [PATCH 7/8] Add reference equality assertions and streaming path tests for FunctionResultContent tests Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> --- .../FunctionInvokingChatClientTests.cs | 222 +++++++++++++----- 1 file changed, 160 insertions(+), 62 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index a37e7534f18..3e04ebe53a8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -354,9 +354,14 @@ public async Task FunctionInvokerDelegateOverridesHandlingAsync() await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } - [Fact] - public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesItDirectly() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesItDirectly(bool streaming) { + FunctionResultContent? returnedFrc = null; + ChatMessage? capturedToolMessage = null; + var options = new ChatOptions { Tools = @@ -365,42 +370,81 @@ public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesI ] }; - List plan = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Custom result from function")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - // Use FunctionInvoker to return a FunctionResultContent with matching CallId - Func configure = b => b.Use( - s => new FunctionInvokingChatClient(s) + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (msgs, opts, ct) => { - FunctionInvoker = (ctx, cancellationToken) => + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) { - // Return a FunctionResultContent with the same CallId that was passed in - var frc = new FunctionResultContent(ctx.CallContent.CallId, "Custom result from function") - { - RawRepresentation = "CustomRaw" - }; - return new ValueTask(frc); + return Task.FromResult(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); } - }); + else + { + capturedToolMessage = toolMessage; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }, + GetStreamingResponseAsyncCallback = (msgs, opts, ct) => + { + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) + { + return YieldAsync(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates()); + } + else + { + capturedToolMessage = toolMessage; + return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); + } + } + }; - var chat = await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + using var client = new FunctionInvokingChatClient(innerClient) + { + FunctionInvoker = (ctx, cancellationToken) => + { + returnedFrc = new FunctionResultContent(ctx.CallContent.CallId, "Custom result from function") + { + RawRepresentation = "CustomRaw" + }; + return new ValueTask(returnedFrc); + } + }; - // Verify that the FunctionResultContent has the custom RawRepresentation, proving it was used directly - var toolMessage = chat.First(m => m.Role == ChatRole.Tool); - var frc = Assert.Single(toolMessage.Contents.OfType()); - Assert.Equal("Custom result from function", frc.Result); - Assert.Equal("CustomRaw", frc.RawRepresentation); - Assert.Equal("callId1", frc.CallId); + var messages = new List + { + new ChatMessage(ChatRole.User, "hello"), + }; + + if (streaming) + { + await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + } + else + { + await client.GetResponseAsync(messages, options); + } + + // Verify that the FunctionResultContent was used directly (same reference) + Assert.NotNull(capturedToolMessage); + var capturedFrc = Assert.Single(capturedToolMessage.Contents.OfType()); + Assert.Same(returnedFrc, capturedFrc); + Assert.Equal("Custom result from function", capturedFrc.Result); + Assert.Equal("CustomRaw", capturedFrc.RawRepresentation); + Assert.Equal("callId1", capturedFrc.CallId); } - [Fact] - public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_WrapsIt() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_WrapsIt(bool streaming) { + FunctionResultContent? returnedFrc = null; + ChatMessage? capturedToolMessage = null; + var options = new ChatOptions { Tools = @@ -409,44 +453,79 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra ] }; - List plan = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), - - // The result should be wrapped, so the outer FunctionResultContent has callId1 - // and its Result property contains the inner FunctionResultContent - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: new FunctionResultContent("differentCallId", "Result from function"))]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - // Use FunctionInvoker to return a FunctionResultContent with mismatched CallId - Func configure = b => b.Use( - s => new FunctionInvokingChatClient(s) + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (msgs, opts, ct) => { - FunctionInvoker = (ctx, cancellationToken) => + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) { - // Return a FunctionResultContent with a different CallId - var frc = new FunctionResultContent("differentCallId", "Result from function"); - return new ValueTask(frc); + return Task.FromResult(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); } - }); + else + { + capturedToolMessage = toolMessage; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + }, + GetStreamingResponseAsyncCallback = (msgs, opts, ct) => + { + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) + { + return YieldAsync(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates()); + } + else + { + capturedToolMessage = toolMessage; + return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); + } + } + }; - var chat = await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + using var client = new FunctionInvokingChatClient(innerClient) + { + FunctionInvoker = (ctx, cancellationToken) => + { + // Return a FunctionResultContent with a different CallId + returnedFrc = new FunctionResultContent("differentCallId", "Result from function"); + return new ValueTask(returnedFrc); + } + }; + + var messages = new List + { + new ChatMessage(ChatRole.User, "hello"), + }; + + if (streaming) + { + await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + } + else + { + await client.GetResponseAsync(messages, options); + } // Verify the result is wrapped - the outer FunctionResultContent has the correct CallId - var toolMessage = chat.First(m => m.Role == ChatRole.Tool); - var frc = Assert.Single(toolMessage.Contents.OfType()); + // and the inner one is reference-equal to what was returned + Assert.NotNull(capturedToolMessage); + var frc = Assert.Single(capturedToolMessage.Contents.OfType()); Assert.Equal("callId1", frc.CallId); - Assert.IsType(frc.Result); + Assert.Same(returnedFrc, frc.Result); var innerFrc = (FunctionResultContent)frc.Result!; Assert.Equal("differentCallId", innerFrc.CallId); Assert.Equal("Result from function", innerFrc.Result); } - [Fact] - public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient(bool streaming) { + DerivedFunctionResultContent? returnedFrc = null; ChatMessage? capturedToolMessage = null; var options = new ChatOptions @@ -461,21 +540,31 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan { GetResponseAsyncCallback = (msgs, opts, ct) => { - // Check if this is the first call (no tool messages yet) or subsequent call var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); if (toolMessage is null) { - // First call: return a function call to trigger function invocation return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]))); } else { - // Second call: capture and return final response capturedToolMessage = toolMessage; - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } + }, + GetStreamingResponseAsyncCallback = (msgs, opts, ct) => + { + var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool); + if (toolMessage is null) + { + return YieldAsync(new ChatResponse( + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates()); + } + else + { + capturedToolMessage = toolMessage; + return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); + } } }; @@ -484,11 +573,11 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan FunctionInvoker = (ctx, cancellationToken) => { // Return a derived FunctionResultContent - var frc = new DerivedFunctionResultContent(ctx.CallContent.CallId, "Derived result") + returnedFrc = new DerivedFunctionResultContent(ctx.CallContent.CallId, "Derived result") { CustomProperty = "CustomValue" }; - return new ValueTask(frc); + return new ValueTask(returnedFrc); } }; @@ -497,11 +586,20 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan new ChatMessage(ChatRole.User, "hello"), }; - await client.GetResponseAsync(messages, options); + if (streaming) + { + await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + } + else + { + await client.GetResponseAsync(messages, options); + } // Verify that the derived FunctionResultContent instance was propagated to the inner client + // and is reference-equal to what was returned Assert.NotNull(capturedToolMessage); var capturedFrc = Assert.Single(capturedToolMessage.Contents.OfType()); + Assert.Same(returnedFrc, capturedFrc); Assert.IsType(capturedFrc); var derivedFrc = (DerivedFunctionResultContent)capturedFrc; Assert.Equal("callId1", derivedFrc.CallId); From f37d83c0cd1836adfbf3ca3c206d4d4021f8ae7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:59:45 +0000 Subject: [PATCH 8/8] Obtain tool message from ChatResponse instead of capturing via callback Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> --- .../FunctionInvokingChatClientTests.cs | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 3e04ebe53a8..066fb5e3427 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -360,7 +360,6 @@ public async Task FunctionInvokerDelegateOverridesHandlingAsync() public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesItDirectly(bool streaming) { FunctionResultContent? returnedFrc = null; - ChatMessage? capturedToolMessage = null; var options = new ChatOptions { @@ -382,7 +381,6 @@ public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesI } else { - capturedToolMessage = toolMessage; return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } }, @@ -396,7 +394,6 @@ public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesI } else { - capturedToolMessage = toolMessage; return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); } } @@ -419,18 +416,19 @@ public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesI new ChatMessage(ChatRole.User, "hello"), }; + ChatResponse response; if (streaming) { - await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); } else { - await client.GetResponseAsync(messages, options); + response = await client.GetResponseAsync(messages, options); } // Verify that the FunctionResultContent was used directly (same reference) - Assert.NotNull(capturedToolMessage); - var capturedFrc = Assert.Single(capturedToolMessage.Contents.OfType()); + var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool); + var capturedFrc = Assert.Single(toolMessage.Contents.OfType()); Assert.Same(returnedFrc, capturedFrc); Assert.Equal("Custom result from function", capturedFrc.Result); Assert.Equal("CustomRaw", capturedFrc.RawRepresentation); @@ -443,7 +441,6 @@ public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesI public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_WrapsIt(bool streaming) { FunctionResultContent? returnedFrc = null; - ChatMessage? capturedToolMessage = null; var options = new ChatOptions { @@ -465,7 +462,6 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra } else { - capturedToolMessage = toolMessage; return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } }, @@ -479,7 +475,6 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra } else { - capturedToolMessage = toolMessage; return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); } } @@ -500,19 +495,20 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra new ChatMessage(ChatRole.User, "hello"), }; + ChatResponse response; if (streaming) { - await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); } else { - await client.GetResponseAsync(messages, options); + response = await client.GetResponseAsync(messages, options); } // Verify the result is wrapped - the outer FunctionResultContent has the correct CallId // and the inner one is reference-equal to what was returned - Assert.NotNull(capturedToolMessage); - var frc = Assert.Single(capturedToolMessage.Contents.OfType()); + var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool); + var frc = Assert.Single(toolMessage.Contents.OfType()); Assert.Equal("callId1", frc.CallId); Assert.Same(returnedFrc, frc.Result); var innerFrc = (FunctionResultContent)frc.Result!; @@ -526,7 +522,6 @@ public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_Wra public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient(bool streaming) { DerivedFunctionResultContent? returnedFrc = null; - ChatMessage? capturedToolMessage = null; var options = new ChatOptions { @@ -548,7 +543,6 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan } else { - capturedToolMessage = toolMessage; return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); } }, @@ -562,7 +556,6 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan } else { - capturedToolMessage = toolMessage; return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates()); } } @@ -586,19 +579,20 @@ public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstan new ChatMessage(ChatRole.User, "hello"), }; + ChatResponse response; if (streaming) { - await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); + response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync(); } else { - await client.GetResponseAsync(messages, options); + response = await client.GetResponseAsync(messages, options); } // Verify that the derived FunctionResultContent instance was propagated to the inner client // and is reference-equal to what was returned - Assert.NotNull(capturedToolMessage); - var capturedFrc = Assert.Single(capturedToolMessage.Contents.OfType()); + var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool); + var capturedFrc = Assert.Single(toolMessage.Contents.OfType()); Assert.Same(returnedFrc, capturedFrc); Assert.IsType(capturedFrc); var derivedFrc = (DerivedFunctionResultContent)capturedFrc;