diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 42de3eec882..19fef739cdd 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -238,6 +240,41 @@ public sealed class ChatClientAgentOptions
///
public bool DisableApprovalResponseBinding { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to enable bypassing that stores invocable (backend) function
+ /// calls in the session state and executes them on the next request when they are returned alongside
+ /// declaration-only (frontend) function calls in the same response.
+ ///
+ ///
+ ///
+ /// terminates the function-calling loop as soon as it encounters
+ /// a non-invocable (declaration-only) , returning every
+ /// in that iteration — including invocable backend calls — to the caller
+ /// unexecuted. When the caller only resolves the declaration-only call (for example an AG-UI frontend
+ /// tool), the backend call's call_id is left orphaned, which causes the AI provider to reject the
+ /// next request.
+ ///
+ ///
+ /// When this property is set to , an
+ /// decorator is injected above in the pipeline. For responses that
+ /// contain both invocable and declaration-only function calls, the decorator removes the invocable calls,
+ /// stores them in the session, and returns only the declaration-only calls to the caller. On the next
+ /// request the stored calls are re-injected as pre-approved responses so
+ /// reconstructs and executes them.
+ ///
+ ///
+ /// This option has no effect when is .
+ /// When using a custom chat client stack, you can add an
+ /// manually via the
+ /// extension method.
+ ///
+ ///
+ ///
+ /// Default is .
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public bool EnableExecutableFunctionBypassing { get; set; }
+
///
/// Creates a new instance of with the same values as this instance.
///
@@ -258,5 +295,6 @@ public ChatClientAgentOptions Clone()
EnableMessageInjection = this.EnableMessageInjection,
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
+ EnableExecutableFunctionBypassing = this.EnableExecutableFunctionBypassing,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
index 30d846f29da..9e92722e3e8 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
@@ -2,9 +2,11 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -221,4 +223,50 @@ public static ChatClientBuilder UseApprovalResponseBinding(this ChatClientBuilde
return builder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService()));
}
+
+ ///
+ /// Adds an to the chat client pipeline.
+ ///
+ ///
+ ///
+ /// This decorator should be positioned above the in the pipeline.
+ /// When returns a response containing both an invocable (backend)
+ /// and a declaration-only (frontend) in
+ /// the same iteration, this decorator removes the invocable calls, stores them in the session, and returns
+ /// only the declaration-only calls to the caller. On the next request the stored calls are re-injected as
+ /// pre-approved responses so reconstructs and executes them.
+ ///
+ ///
+ /// If the pipeline also contains an , this decorator must be
+ /// positioned below it. That client drops any that does not
+ /// correspond to a request it recorded, so that a forged approval cannot execute. The responses this decorator
+ /// injects are synthetic and have no such request, so placing the binding client below this decorator would
+ /// silently discard them and prevent the stored calls from ever executing.
+ ///
+ ///
+ /// This extension method is intended for use with custom chat client stacks when
+ /// is .
+ /// When is (the default),
+ /// the automatically injects this decorator when
+ /// is .
+ ///
+ ///
+ /// This decorator is intended for use within the context of a running with
+ /// an active session. When invoked outside of an agent run (for example when the built chat client is used
+ /// directly), the decorator becomes a no-op, passing the request through unchanged and logging a warning.
+ ///
+ ///
+ /// The to add the decorator to.
+ ///
+ /// An optional used to create a logger for the decorator. When not provided,
+ /// the factory is resolved from the pipeline's ; if none is available,
+ /// logging is a no-op.
+ ///
+ /// The for chaining.
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public static ChatClientBuilder UseExecutableFunctionBypassing(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
+ {
+ return builder.Use((innerClient, services) =>
+ new ExecutableFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService()));
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index 57db8910863..59db2b8f900 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -68,7 +68,7 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
// ApprovalNotRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding this decorator here, the resulting pipeline is:
- // [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → FunctionInvokingChatClient
+ // [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → [ExecutableFunctionBypassingChatClient] → FunctionInvokingChatClient
// → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → leaf IChatClient
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
// that don't actually require approval, storing them for automatic re-injection on the next request.
@@ -78,6 +78,18 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, services.GetService()));
}
+ // ExecutableFunctionBypassingChatClient is opt-in via EnableExecutableFunctionBypassing. It is
+ // registered after the approval decorators and immediately before FunctionInvokingChatClient, so it
+ // sits directly above FICC (ChatClientBuilder.Build applies factories in reverse order). It intercepts
+ // FICC responses that contain both invocable (backend) and declaration-only (frontend) function calls,
+ // removes the invocable calls, stores them in the session, and re-injects them as pre-approved
+ // responses on the next request so FICC reconstructs and executes them.
+ if (options?.EnableExecutableFunctionBypassing is true)
+ {
+ chatBuilder.Use((innerClient, services) =>
+ new ExecutableFunctionBypassingChatClient(innerClient, services.GetService()));
+ }
+
if (chatClient.GetService() is null)
{
chatBuilder.Use((innerClient, services) =>
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ExecutableFunctionBypassingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ExecutableFunctionBypassingChatClient.cs
new file mode 100644
index 00000000000..0889b87ab82
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ExecutableFunctionBypassingChatClient.cs
@@ -0,0 +1,501 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating chat client that lets an agent expose both invocable (backend) tools and
+/// declaration-only (frontend) tools at the same time, working around
+/// terminating the function-calling loop before invoking
+/// sibling backend tool calls when a declaration-only tool call appears in the same iteration.
+///
+///
+///
+/// terminates the loop as soon as it encounters a
+/// non-invocable (declaration-only) , returning every
+/// in that iteration — including invocable backend calls — to the
+/// caller unexecuted.
+///
+///
+/// This decorator sits above in the pipeline. On outbound
+/// responses that contain both an invocable (backend) and a
+/// declaration-only , it removes the invocable calls from the response,
+/// stores them in the session's , and returns only the declaration-only
+/// calls to the caller to resolve. On the next request — after the caller has resolved the
+/// declaration-only calls — the stored invocable calls are re-injected as pre-approved
+/// so that reconstructs
+/// and executes them, producing the missing .
+///
+///
+/// The stored calls are re-injected as approved rather than as
+/// bare because only re-executes
+/// calls that arrive from incoming history as approval responses; it does not execute bare
+/// present in the history. This is the same mechanism used by
+/// , and a lone approved response without a
+/// matching request in the history is accepted.
+///
+///
+/// This decorator operates within the context of a running with an active
+/// . When invoked without an ambient run context or session
+/// (for example when the chat client is used directly outside of an agent run), the decorator becomes
+/// a no-op: it passes the request through to the inner client unchanged and logs a warning.
+///
+///
+/// When the pipeline also contains an , this decorator must sit
+/// below it. That client drops any without a request it
+/// recorded, so that a forged approval cannot execute; the responses injected here are synthetic and have no
+/// such request. The default agent pipeline already orders them correctly.
+///
+///
+internal sealed partial class ExecutableFunctionBypassingChatClient : DelegatingChatClient
+{
+ ///
+ /// The key used in to store bypassed executable function calls
+ /// between agent runs.
+ ///
+ internal const string StateBagKey = "_bypassedExecutableFunctionCalls";
+
+ private readonly ILogger _logger;
+
+ private bool _warnedNoSession;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying chat client (typically a ).
+ /// An optional used to create a logger for diagnostics.
+ public ExecutableFunctionBypassingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null)
+ : base(innerClient)
+ {
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!this.TryGetSession(out var session))
+ {
+ return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ messages = InjectPendingBypassedCalls(messages, session, out var pendingCalls);
+
+ ChatResponse response;
+ try
+ {
+ response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ RestorePendingBypassedCalls(session, pendingCalls);
+ throw;
+ }
+
+ this.RemoveAndStoreBypassableExecutableCalls(response.Messages, options, session);
+
+ return response;
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (!this.TryGetSession(out var session))
+ {
+ await foreach (var passthrough in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return passthrough;
+ }
+
+ yield break;
+ }
+
+ messages = InjectPendingBypassedCalls(messages, session, out var pendingCalls);
+
+ // Stream updates live until a surfaced (non-informational) FunctionCallContent appears, then hold the
+ // tail so the strip/store decision can observe every call in the same batch before re-emitting.
+ // No whole-response coalescing is required because FunctionInvokingChatClient emits each call as a
+ // complete FunctionCallContent (it never splits a call across updates).
+ //
+ // FunctionInvokingChatClient buffers per iteration: it yields its buffered updates at the end of each
+ // iteration and then streams the next iteration live. When it invokes a call locally it flips
+ // FunctionCallContent.InformationalOnly to true in place, on the very instances held here. A buffered
+ // tail whose calls have all become informational therefore has nothing left to strip, so it is
+ // released and live streaming resumes. Only a genuinely bypassable batch (where the loop terminated,
+ // leaving the calls non-informational) is held to the end of the stream.
+ List? tail = null;
+
+ // The enumerator is driven manually so that a failure anywhere in the stream can restore the pending
+ // calls, which cannot be done with an await foreach because yield return is not allowed inside a try
+ // with a catch clause. Acquiring the enumerator is guarded too: the inner client may validate its
+ // arguments eagerly and throw synchronously, before the first MoveNextAsync.
+ IAsyncEnumerator stream;
+ try
+ {
+ stream = base.GetStreamingResponseAsync(messages, options, cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+ }
+ catch
+ {
+ RestorePendingBypassedCalls(session, pendingCalls);
+ throw;
+ }
+
+ try
+ {
+ while (true)
+ {
+ ChatResponseUpdate update;
+ try
+ {
+ if (!await stream.MoveNextAsync().ConfigureAwait(false))
+ {
+ break;
+ }
+
+ update = stream.Current;
+ }
+ catch
+ {
+ RestorePendingBypassedCalls(session, pendingCalls);
+ throw;
+ }
+
+ if (tail is not null && !ContainsNonInformationalFunctionCall(tail))
+ {
+ foreach (var buffered in tail)
+ {
+ yield return buffered;
+ }
+
+ tail = null;
+ }
+
+ if (tail is null && !UpdateHasNonInformationalFunctionCall(update))
+ {
+ yield return update;
+ continue;
+ }
+
+ (tail ??= []).Add(update);
+ }
+ }
+ finally
+ {
+ await stream.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (tail is null)
+ {
+ yield break;
+ }
+
+ var contentLists = new IList[tail.Count];
+ for (int i = 0; i < tail.Count; i++)
+ {
+ contentLists[i] = tail[i].Contents;
+ }
+
+ this.StripAndStoreBypassableExecutableCalls(contentLists, options, session);
+
+ // Every buffered update is surfaced, including any left with no contents by the stripping above. An
+ // update carries metadata beyond its contents — ConversationId, ContinuationToken, ResponseId,
+ // MessageId, RawRepresentation and more — so dropping one would discard state the caller needs, and a
+ // content-free update is unremarkable in a stream. This differs from the non-streaming path, where an
+ // emptied message is removed because it would otherwise be persisted to history and resent to the
+ // provider on the next turn.
+ foreach (var update in tail)
+ {
+ yield return update;
+ }
+ }
+
+ ///
+ /// Attempts to get the current from the ambient run context. When no run
+ /// context or session is available, logs a warning (once per instance) and returns
+ /// so the caller can pass the request through without applying bypassing.
+ ///
+ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
+ {
+ session = AIAgent.CurrentRunContext?.Session;
+
+ if (session is null)
+ {
+ if (!this._warnedNoSession)
+ {
+ this._warnedNoSession = true;
+ LogBypassingSkipped(this._logger);
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ [LoggerMessage(LogLevel.Warning, "ExecutableFunctionBypassingChatClient was invoked without an active agent run context or session. Executable function bypassing is skipped and all function calls are surfaced to the caller. Invoke the chat client through AIAgent.RunAsync or AIAgent.RunStreamingAsync to enable bypassing.")]
+ private static partial void LogBypassingSkipped(ILogger logger);
+
+ ///
+ /// Checks the session for executable function calls stored on a previous turn and injects them as
+ /// a user message containing pre-approved items appended to
+ /// the input messages, so that reconstructs and executes them.
+ ///
+ /// The outgoing messages.
+ /// The session holding any calls bypassed on a previous turn.
+ ///
+ /// The calls removed from the session, so that they can be restored if the request fails; or
+ /// when there was nothing pending.
+ ///
+ private static IEnumerable InjectPendingBypassedCalls(
+ IEnumerable messages,
+ AgentSession session,
+ out List? pendingCalls)
+ {
+ if (!session.StateBag.TryGetValue(
+ StateBagKey,
+ out pendingCalls,
+ AgentJsonUtilities.DefaultOptions)
+ || pendingCalls is not { Count: > 0 })
+ {
+ pendingCalls = null;
+ return messages;
+ }
+
+ session.StateBag.TryRemoveValue(StateBagKey);
+
+ List approvalResponses = [];
+ foreach (var call in pendingCalls)
+ {
+ // FunctionInvokingChatClient reconstructs and executes the call from the approval response
+ // itself; the request is synthetic and does not need to be present in the history.
+ var request = new ToolApprovalRequestContent(ComposeApprovalRequestId(call.CallId), call);
+ approvalResponses.Add(request.CreateResponse(approved: true));
+ }
+
+ var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
+ return messages.Concat([userMessage]);
+ }
+
+ ///
+ /// Restores calls removed by when the request failed, so that a
+ /// transient error does not silently drop a bypassed call that was never executed. Only called on the
+ /// failure path, where the inner client cannot have stored a newer set.
+ ///
+ private static void RestorePendingBypassedCalls(AgentSession session, List? pendingCalls)
+ {
+ if (pendingCalls is { Count: > 0 })
+ {
+ session.StateBag.SetValue(StateBagKey, pendingCalls, AgentJsonUtilities.DefaultOptions);
+ }
+ }
+
+ ///
+ /// Composes the approval-request id for a bypassed call. The prefix deliberately differs from the
+ /// ficc_ prefix uses for its own approval requests, so
+ /// that a synthetic id can never collide with a genuine one.
+ ///
+ private static string ComposeApprovalRequestId(string callId) => $"efbcc_{callId}";
+
+ ///
+ /// Builds the set of invocable (backend) tool names and the set of declaration-only (frontend) tool
+ /// names from and .
+ ///
+ private (HashSet Executable, HashSet DeclarationOnly) GetToolNameSets(ChatOptions? options)
+ {
+ var ficc = this.GetService();
+
+ var allTools = (options?.Tools ?? Enumerable.Empty())
+ .Concat(ficc?.AdditionalTools ?? Enumerable.Empty());
+
+ HashSet executable = new(StringComparer.Ordinal);
+ HashSet declarationOnly = new(StringComparer.Ordinal);
+
+ foreach (var tool in allTools)
+ {
+ // AIFunction derives from AIFunctionDeclaration, so check the invocable type first.
+ if (tool is AIFunction function)
+ {
+ executable.Add(function.Name);
+ }
+ else if (tool is AIFunctionDeclaration declaration)
+ {
+ declarationOnly.Add(declaration.Name);
+ }
+ }
+
+ return (executable, declarationOnly);
+ }
+
+ ///
+ /// Returns if the update contains a non-informational
+ /// (a call that was surfaced to the caller rather than executed by
+ /// ).
+ ///
+ private static bool UpdateHasNonInformationalFunctionCall(ChatResponseUpdate update)
+ => update.Contents.Any(c => c is FunctionCallContent { InformationalOnly: false });
+
+ ///
+ /// Returns if any content list contains a non-informational
+ /// (a call that was surfaced to the caller rather than executed by
+ /// ).
+ ///
+ private static bool ContainsNonInformationalFunctionCall(IList[] contentLists)
+ => contentLists.Any(contents => contents.Any(c => c is FunctionCallContent { InformationalOnly: false }));
+
+ ///
+ /// Returns if any buffered update still contains a non-informational
+ /// . Once has invoked a call it
+ /// flips to in place, so a
+ /// buffer for which this returns holds nothing that can be bypassed.
+ ///
+ private static bool ContainsNonInformationalFunctionCall(List updates)
+ => updates.Any(UpdateHasNonInformationalFunctionCall);
+
+ ///
+ /// When a response contains both an invocable (backend) and a
+ /// declaration-only (frontend) , removes the invocable calls from the
+ /// response and stores them in the session for re-injection and execution on the next request.
+ ///
+ private void RemoveAndStoreBypassableExecutableCalls(
+ IList messages,
+ ChatOptions? options,
+ AgentSession session)
+ {
+ var contentLists = new IList[messages.Count];
+ for (int i = 0; i < messages.Count; i++)
+ {
+ contentLists[i] = messages[i].Contents;
+ }
+
+ var emptied = this.StripAndStoreBypassableExecutableCalls(contentLists, options, session);
+ if (emptied is null)
+ {
+ return;
+ }
+
+ // Remove messages that were emptied by stripping bypassed content (high index first). Messages that
+ // were already empty (for example metadata-only messages) are left untouched. Unlike a streaming
+ // update, an emptied message is worth removing because it would otherwise be persisted to the
+ // conversation history and resent to the provider on the next turn.
+ for (int i = messages.Count - 1; i >= 0; i--)
+ {
+ if (emptied.Contains(i))
+ {
+ messages.RemoveAt(i);
+ }
+ }
+ }
+
+ ///
+ /// Applies the both-kinds gate over the supplied content lists and, when both an invocable and a
+ /// declaration-only are present, removes the invocable calls in place
+ /// (in document order) and stores them in the session for re-injection on the next request.
+ ///
+ ///
+ /// The set of content-list indices that were emptied by the removal, or when
+ /// nothing was bypassed.
+ ///
+ private HashSet? StripAndStoreBypassableExecutableCalls(
+ IList[] contentLists,
+ ChatOptions? options,
+ AgentSession session)
+ {
+ // Cheap first pass: the common case is a text-only response with no function calls. Avoid allocating
+ // the tool-name sets unless the response actually contains at least one non-informational call.
+ if (!ContainsNonInformationalFunctionCall(contentLists))
+ {
+ return null;
+ }
+
+ var (executable, declarationOnly) = this.GetToolNameSets(options);
+
+ if (executable.Count == 0 || declarationOnly.Count == 0)
+ {
+ // The mixed backend/frontend scenario is impossible without at least one of each kind of tool.
+ return null;
+ }
+
+ bool hasExecutableCall = false;
+ bool hasDeclarationOnlyCall = false;
+
+ foreach (var contents in contentLists)
+ {
+ foreach (var content in contents)
+ {
+ if (content is FunctionCallContent { InformationalOnly: false } fcc)
+ {
+ if (declarationOnly.Contains(fcc.Name))
+ {
+ hasDeclarationOnlyCall = true;
+ }
+ else if (executable.Contains(fcc.Name))
+ {
+ hasExecutableCall = true;
+ }
+ }
+ }
+ }
+
+ // Only bypass when the two kinds coexist in the same response. An all-executable response is already
+ // handled by FunctionInvokingChatClient (never surfaced unexecuted), and an all-declaration-only
+ // response is the normal frontend-tools flow that must pass through unchanged.
+ if (!hasExecutableCall || !hasDeclarationOnlyCall)
+ {
+ return null;
+ }
+
+ List? bypassed = null;
+ HashSet? emptied = null;
+
+ for (int i = 0; i < contentLists.Length; i++)
+ {
+ var contents = contentLists[i];
+ bool removedFromList = false;
+
+ // Forward scan collects the bypassed calls in document order.
+ for (int j = 0; j < contents.Count;)
+ {
+ if (contents[j] is FunctionCallContent { InformationalOnly: false } fcc
+ && executable.Contains(fcc.Name)
+ && !declarationOnly.Contains(fcc.Name))
+ {
+ (bypassed ??= []).Add(fcc);
+ contents.RemoveAt(j);
+ removedFromList = true;
+ }
+ else
+ {
+ j++;
+ }
+ }
+
+ if (removedFromList && contents.Count == 0)
+ {
+ (emptied ??= []).Add(i);
+ }
+ }
+
+ if (bypassed is { Count: > 0 })
+ {
+ session.StateBag.SetValue(StateBagKey, bypassed, AgentJsonUtilities.DefaultOptions);
+ }
+
+ return emptied;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
index 9a557e497a3..af2665e5ee9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
@@ -135,6 +135,7 @@ public void Clone_CreatesDeepCopyWithSameValues()
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
DisableApprovalNotRequiredFunctionBypassing = true,
+ EnableExecutableFunctionBypassing = true,
};
// Act
@@ -152,6 +153,7 @@ public void Clone_CreatesDeepCopyWithSameValues()
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
Assert.Equal(original.DisableApprovalNotRequiredFunctionBypassing, clone.DisableApprovalNotRequiredFunctionBypassing);
+ Assert.Equal(original.EnableExecutableFunctionBypassing, clone.EnableExecutableFunctionBypassing);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ExecutableFunctionBypassingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ExecutableFunctionBypassingChatClientTests.cs
new file mode 100644
index 00000000000..7f5fbaf303f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ExecutableFunctionBypassingChatClientTests.cs
@@ -0,0 +1,758 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.UnitTests;
+
+public class ExecutableFunctionBypassingChatClientTests
+{
+ private const string BackendToolName = "backendTool";
+ private const string FrontendToolName = "frontendTool";
+
+ private static AIFunction CreateBackendTool()
+ => AIFunctionFactory.Create(() => "result", BackendToolName);
+
+ private static AIFunctionDeclaration CreateFrontendTool()
+ => AIFunctionFactory.CreateDeclaration(FrontendToolName, "Frontend tool", AIFunctionFactory.Create(() => true).JsonSchema);
+
+ private static ChatOptions CreateMixedToolOptions()
+ => new() { Tools = [CreateBackendTool(), CreateFrontendTool()] };
+
+ #region GetResponseAsync Tests
+
+ [Fact]
+ public async Task GetResponseAsync_NoFunctionCalls_PassesThroughUnchangedAsync()
+ {
+ // Arrange
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var response = await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert
+ Assert.Single(response.Messages);
+ Assert.Equal("Hello", response.Messages[0].Text);
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_OnlyExecutableCalls_PassesThroughUnchangedAsync()
+ {
+ // Arrange — no declaration-only sibling, so nothing should be bypassed.
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [backendCall])])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var response = await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert
+ var contents = Assert.Single(response.Messages).Contents;
+ var fcc = Assert.IsType(Assert.Single(contents));
+ Assert.Equal(BackendToolName, fcc.Name);
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_OnlyDeclarationOnlyCalls_PassesThroughUnchangedAsync()
+ {
+ // Arrange — the normal frontend-tools flow with no backend sibling.
+ var frontendCall = new FunctionCallContent("call1", FrontendToolName);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [frontendCall])])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var response = await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert
+ var contents = Assert.Single(response.Messages).Contents;
+ var fcc = Assert.IsType(Assert.Single(contents));
+ Assert.Equal(FrontendToolName, fcc.Name);
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_MixedCalls_RemovesExecutableCallFromResponseAsync()
+ {
+ // Arrange
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([
+ new ChatMessage(ChatRole.Assistant, [backendCall, frontendCall])
+ ])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var response = await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert — only the declaration-only call remains in the response.
+ var contents = Assert.Single(response.Messages).Contents;
+ var remaining = Assert.IsType(Assert.Single(contents));
+ Assert.Equal(FrontendToolName, remaining.Name);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_MixedCalls_StoresExecutableCallInSessionAsync()
+ {
+ // Arrange
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([
+ new ChatMessage(ChatRole.Assistant, [backendCall, frontendCall])
+ ])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert — the backend call is stored in the session for the next turn.
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
+ Assert.NotNull(stored);
+ var storedCall = Assert.Single(stored!);
+ Assert.Equal("call1", storedCall.CallId);
+ Assert.Equal(BackendToolName, storedCall.Name);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_MixedCallsInSeparateMessages_RemovesEmptyAssistantMessageAsync()
+ {
+ // Arrange — backend and frontend calls in separate assistant messages.
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([
+ new ChatMessage(ChatRole.Assistant, [backendCall]),
+ new ChatMessage(ChatRole.Assistant, [frontendCall])
+ ])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var response = await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert — the message that only held the backend call is removed.
+ var message = Assert.Single(response.Messages);
+ var remaining = Assert.IsType(Assert.Single(message.Contents));
+ Assert.Equal(FrontendToolName, remaining.Name);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_NextRequest_InjectsStoredCallAsApprovedResponseAsync()
+ {
+ // Arrange — a full chat history round-trip: the backend call was stored on the previous turn, and the
+ // history now only contains the resolved frontend call (the backend call was stripped, not persisted).
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+ var history = new List
+ {
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, [frontendCall]),
+ new(ChatRole.Tool, [new FunctionResultContent("call2", "Amsterdam")]),
+ };
+
+ IEnumerable? capturedMessages = null;
+ var innerClient = CreateMockChatClient((messages, _, _) =>
+ {
+ capturedMessages = messages.ToList();
+ return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
+ });
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions(), history);
+
+ // Assert — exactly one approved ToolApprovalResponseContent for the backend call is injected, with no
+ // matching approval request and no duplicate backend FunctionCallContent in the incoming history.
+ Assert.NotNull(capturedMessages);
+ var messagesList = capturedMessages!.ToList();
+
+ var approvalResponses = messagesList
+ .SelectMany(m => m.Contents)
+ .OfType()
+ .ToList();
+ var approvalResponse = Assert.Single(approvalResponses);
+ Assert.True(approvalResponse.Approved);
+ Assert.Equal($"efbcc_{approvalResponse.ToolCall.CallId}", approvalResponse.RequestId);
+ var approvedCall = Assert.IsType(approvalResponse.ToolCall);
+ Assert.Equal("call1", approvedCall.CallId);
+ Assert.Equal(BackendToolName, approvedCall.Name);
+
+ Assert.DoesNotContain(messagesList.SelectMany(m => m.Contents), c => c is ToolApprovalRequestContent);
+ Assert.DoesNotContain(
+ messagesList.SelectMany(m => m.Contents).OfType(),
+ c => c.Name == BackendToolName);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
+ {
+ // Arrange
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ await RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions());
+
+ // Assert — the stored data is cleared after successful injection.
+ Assert.False(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
+ }
+
+ #endregion
+
+ #region GetStreamingResponseAsync Tests
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_NoFunctionCalls_PassesThroughUnchangedAsync()
+ {
+ // Arrange
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ToAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert
+ Assert.Equal("Hello", updates.ToChatResponse().Messages.Single().Text);
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_MixedCalls_RemovesAndStoresExecutableCallAsync()
+ {
+ // Arrange
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ToAsyncEnumerableAsync(
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [backendCall] },
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [frontendCall] }));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert — only the frontend call is surfaced, and the backend call is stored.
+ var surfacedCalls = updates.ToChatResponse().Messages
+ .SelectMany(m => m.Contents)
+ .OfType()
+ .ToList();
+ var surfaced = Assert.Single(surfacedCalls);
+ Assert.Equal(FrontendToolName, surfaced.Name);
+
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
+ var storedCall = Assert.Single(stored!);
+ Assert.Equal(BackendToolName, storedCall.Name);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_NextRequest_InjectsStoredCallAsApprovedResponseAsync()
+ {
+ // Arrange
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ IEnumerable? capturedMessages = null;
+ var innerClient = CreateMockStreamingChatClient((messages, _, _) =>
+ {
+ capturedMessages = messages.ToList();
+ return ToAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, "Done"));
+ });
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert
+ Assert.NotNull(capturedMessages);
+ var approvalResponses = capturedMessages!
+ .SelectMany(m => m.Contents)
+ .OfType()
+ .ToList();
+ var approvalResponse = Assert.Single(approvalResponses);
+ Assert.True(approvalResponse.Approved);
+ Assert.Equal("call1", Assert.IsType(approvalResponse.ToolCall).CallId);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_StreamsPreFunctionCallUpdatesLiveAsync()
+ {
+ // Arrange — a text update precedes the function-call updates; it must pass through untouched and
+ // ahead of the buffered call tail.
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ToAsyncEnumerableAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "Thinking..."),
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [backendCall] },
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [frontendCall] }));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert — the leading text update is emitted first and unchanged; only the frontend call surfaces.
+ Assert.Equal("Thinking...", updates[0].Text);
+ Assert.DoesNotContain(updates[0].Contents, c => c is FunctionCallContent);
+
+ var surfacedCalls = updates.SelectMany(u => u.Contents).OfType().ToList();
+ var surfaced = Assert.Single(surfacedCalls);
+ Assert.Equal(FrontendToolName, surfaced.Name);
+
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
+ Assert.Equal(BackendToolName, Assert.Single(stored!).Name);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_ExecutableOnly_PassesThroughWithoutBypassingAsync()
+ {
+ // Arrange — two executable calls and no declaration-only call: the both-kinds gate must not trigger.
+ var firstCall = new FunctionCallContent("call1", BackendToolName);
+ var secondCall = new FunctionCallContent("call2", BackendToolName);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ToAsyncEnumerableAsync(
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [firstCall] },
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [secondCall] }));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert — both executable calls are surfaced and nothing is stored.
+ var surfacedCalls = updates.SelectMany(u => u.Contents).OfType().ToList();
+ Assert.Equal(2, surfacedCalls.Count);
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_ReleasesBufferOnceCallsAreExecutedAsync()
+ {
+ // Arrange — mimic FunctionInvokingChatClient executing a backend call mid-stream: it yields the call,
+ // flips InformationalOnly in place once invoked, then streams the final answer. The decorator must
+ // release the buffered call and resume live streaming rather than withholding it to end-of-stream.
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var log = new List();
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ExecuteCallMidStreamAsync(backendCall, log));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(
+ decorator, session, updates, CreateMixedToolOptions(), u => log.Add($"consumed:{u.Contents.Count}"));
+
+ // Assert — the buffered call is released before the stream ends, so the caller receives updates while
+ // the response is still being produced rather than in one batch at the end.
+ Assert.True(
+ log.IndexOf("produced:answer") > log.FindIndex(entry => entry.StartsWith("consumed:", StringComparison.Ordinal)),
+ $"Expected consumption to interleave with production, but got: {string.Join(", ", log)}");
+
+ // Every update is still surfaced, in order, and nothing is stored for bypassing.
+ Assert.Collection(
+ updates,
+ u => Assert.Same(backendCall, Assert.Single(u.Contents)),
+ u => Assert.IsType(Assert.Single(u.Contents)),
+ u => Assert.Equal("The answer", u.Text));
+ Assert.Equal(0, session.StateBag.Count);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_EmptiedUpdateRetainsMetadataAsync()
+ {
+ // Arrange — the update carrying the bypassed backend call carries nothing else, so stripping empties
+ // it. It still holds stream metadata (conversation/response ids) that the caller needs. Deliberately
+ // no FinishReason, since that alone is not what makes an emptied update worth keeping.
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) =>
+ ToAsyncEnumerableAsync(
+ new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [frontendCall] },
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = [backendCall],
+ ConversationId = "conv-1",
+ ResponseId = "resp-1",
+ }));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var updates = new List();
+ await RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions());
+
+ // Assert — the backend call is stripped, but the emptied update is still surfaced with its metadata.
+ Assert.DoesNotContain(
+ updates.SelectMany(u => u.Contents).OfType(),
+ c => c.Name == BackendToolName);
+
+ var emptied = Assert.Single(updates, u => u.Contents.Count == 0);
+ Assert.Equal("conv-1", emptied.ConversationId);
+ Assert.Equal("resp-1", emptied.ResponseId);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_InnerClientThrowsSynchronously_RestoresPendingCallsAsync()
+ {
+ // Arrange — the inner client validates eagerly and throws before any update is produced, so the
+ // failure happens while the enumerator is being acquired rather than during enumeration.
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) => throw new InvalidOperationException("invalid"));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ var updates = new List();
+ await Assert.ThrowsAsync(
+ () => RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions()));
+
+ // Assert — the pending call survives the failure so a retry can still execute it.
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var restored, AgentJsonUtilities.DefaultOptions));
+ Assert.Equal("call1", Assert.Single(restored!).CallId);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_InnerClientThrows_RestoresPendingCallsAsync()
+ {
+ // Arrange — a call bypassed on a previous turn is pending, and the next request fails part-way.
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ var innerClient = CreateMockStreamingChatClient((_, _, _) => ThrowAfterFirstUpdateAsync());
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ var updates = new List();
+ await Assert.ThrowsAsync(
+ () => RunStreamingWithAgentContextAsync(decorator, session, updates, CreateMixedToolOptions()));
+
+ // Assert — the pending call survives the failure so a retry can still execute it.
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var restored, AgentJsonUtilities.DefaultOptions));
+ Assert.Equal("call1", Assert.Single(restored!).CallId);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_InnerClientThrows_RestoresPendingCallsAsync()
+ {
+ // Arrange — a call bypassed on a previous turn is pending, and the next request fails.
+ var storedBackendCall = new FunctionCallContent("call1", BackendToolName);
+
+ var session = new ChatClientAgentSession();
+ session.StateBag.SetValue(
+ ExecutableFunctionBypassingChatClient.StateBagKey,
+ new List { storedBackendCall },
+ AgentJsonUtilities.DefaultOptions);
+
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromException(new InvalidOperationException("transient")));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act
+ await Assert.ThrowsAsync(
+ () => RunWithAgentContextAsync(decorator, session, CreateMixedToolOptions()));
+
+ // Assert — the pending call survives the failure so a retry can still execute it.
+ Assert.True(session.StateBag.TryGetValue>(
+ ExecutableFunctionBypassingChatClient.StateBagKey, out var restored, AgentJsonUtilities.DefaultOptions));
+ Assert.Equal("call1", Assert.Single(restored!).CallId);
+ }
+
+ #endregion
+
+ #region No-Context Pass-Through Tests
+
+ [Fact]
+ public async Task GetResponseAsync_NoRunContext_PassesThroughWithoutBypassingAsync()
+ {
+ // Arrange
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [backendCall, frontendCall])])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act — calling directly without agent context; the decorator should no-op and pass through.
+ var response = await decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")], CreateMixedToolOptions());
+
+ // Assert — both calls are surfaced to the caller unchanged (not bypassed).
+ var calls = Assert.Single(response.Messages).Contents.OfType().ToList();
+ Assert.Equal(2, calls.Count);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_NoSession_PassesThroughWithoutBypassingAsync()
+ {
+ // Arrange
+ var backendCall = new FunctionCallContent("call1", BackendToolName);
+ var frontendCall = new FunctionCallContent("call2", FrontendToolName);
+ var innerClient = CreateMockChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [backendCall, frontendCall])])));
+
+ var decorator = new ExecutableFunctionBypassingChatClient(innerClient);
+
+ // Act — run with an agent context but a null session; the decorator should no-op and pass through.
+ var response = await RunWithAgentContextAsync(decorator, session: null!, CreateMixedToolOptions());
+
+ // Assert — both calls are surfaced to the caller unchanged (not bypassed).
+ var calls = Assert.Single(response.Messages).Contents.OfType().ToList();
+ Assert.Equal(2, calls.Count);
+ }
+
+ #endregion
+
+ #region Builder Extension Tests
+
+ [Fact]
+ public void UseExecutableFunctionBypassing_AddsDecoratorToPipeline()
+ {
+ // Arrange
+ var innerClient = new Mock().Object;
+
+ // Act
+ var pipeline = innerClient.AsBuilder()
+ .UseExecutableFunctionBypassing()
+ .Build();
+
+ // Assert
+ Assert.NotNull(pipeline.GetService());
+ }
+
+ [Fact]
+ public void WithDefaultAgentMiddleware_EnableExecutableFunctionBypassing_InjectsDecorator()
+ {
+ // Arrange
+ var innerClient = new Mock().Object;
+ var options = new ChatClientAgentOptions { EnableExecutableFunctionBypassing = true };
+
+ // Act
+ var pipeline = innerClient.WithDefaultAgentMiddleware(options);
+
+ // Assert
+ Assert.NotNull(pipeline.GetService());
+ }
+
+ [Fact]
+ public void WithDefaultAgentMiddleware_ByDefault_DoesNotInjectDecorator()
+ {
+ // Arrange
+ var innerClient = new Mock().Object;
+ var options = new ChatClientAgentOptions();
+
+ // Act
+ var pipeline = innerClient.WithDefaultAgentMiddleware(options);
+
+ // Assert
+ Assert.Null(pipeline.GetService());
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static async Task RunWithAgentContextAsync(
+ ExecutableFunctionBypassingChatClient decorator,
+ AgentSession? session,
+ ChatOptions? options = null,
+ IList? inputMessages = null)
+ {
+ ChatResponse? capturedResponse = null;
+
+ var agent = new TestAIAgent
+ {
+ RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
+ {
+ capturedResponse = await decorator.GetResponseAsync(messages, options, ct);
+ return new AgentResponse(capturedResponse);
+ }
+ };
+
+ await agent.RunAsync(inputMessages ?? [new ChatMessage(ChatRole.User, "Hello")], session);
+ return capturedResponse!;
+ }
+
+ private static async Task RunStreamingWithAgentContextAsync(
+ ExecutableFunctionBypassingChatClient decorator,
+ AgentSession session,
+ List updates,
+ ChatOptions? options = null,
+ Action? onUpdate = null)
+ {
+ var agent = new TestAIAgent
+ {
+ RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
+ {
+ await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
+ {
+ updates.Add(update);
+ onUpdate?.Invoke(update);
+ }
+
+ return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
+ }
+ };
+
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
+ }
+
+ private static IChatClient CreateMockChatClient(
+ Func, ChatOptions?, CancellationToken, Task> onGetResponse)
+ {
+ var mock = new Mock();
+ mock.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
+ return mock.Object;
+ }
+
+ private static IChatClient CreateMockStreamingChatClient(
+ Func, ChatOptions?, CancellationToken, IAsyncEnumerable> onGetStreamingResponse)
+ {
+ var mock = new Mock();
+ mock.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
+ return mock.Object;
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
+ {
+ foreach (var update in updates)
+ {
+ yield return update;
+ }
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Mimics FunctionInvokingChatClient executing a call mid-stream: the call is yielded while still
+ /// pending, then flipped to InformationalOnly in place (as FICC does) before the result and the final
+ /// answer are streamed. Each yield is recorded in so that a test can assert the
+ /// decorator interleaves consumption with production rather than withholding to end-of-stream.
+ ///
+ private static async IAsyncEnumerable ExecuteCallMidStreamAsync(
+ FunctionCallContent call,
+ List log)
+ {
+ log.Add("produced:call");
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [call] };
+
+ call.InformationalOnly = true;
+
+ log.Add("produced:result");
+ yield return new ChatResponseUpdate { Role = ChatRole.Tool, Contents = [new FunctionResultContent(call.CallId, "42")] };
+
+ log.Add("produced:answer");
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "The answer");
+
+ await Task.CompletedTask;
+ }
+
+ private static async IAsyncEnumerable ThrowAfterFirstUpdateAsync()
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "Working");
+
+ await Task.CompletedTask;
+ throw new InvalidOperationException("transient");
+ }
+
+ #endregion
+}