.NET: Allow storage of auto-approved functions (#4950)

* Allow storage of auto-approved functions

* Address PR comments
This commit is contained in:
westey
2026-06-05 18:42:21 +01:00
committed by GitHub
Unverified
parent 9cafd7e58b
commit ab8ba8fc61
6 changed files with 934 additions and 0 deletions
@@ -181,6 +181,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to store automatically approved function calls in the session state
/// for tools that do not require approval when they are returned alongside tools that do.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/>, even for tools that do not require approval.
/// </para>
/// <para>
/// Setting this property to <see langword="true"/> injects an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// decorator above <see cref="FunctionInvokingChatClient"/> in the pipeline. This decorator identifies approval
/// requests for non-approval-required tools, removes them from the response, and stores them in the session.
/// On the next request, the stored items are automatically re-injected as approved, so the caller only needs
/// to handle approval requests for tools that truly require human approval.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableNonApprovalRequiredFunctionBypassing { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -199,5 +229,6 @@ public sealed class ChatClientAgentOptions
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
EnableNonApprovalRequiredFunctionBypassing = this.EnableNonApprovalRequiredFunctionBypassing,
};
}
@@ -148,4 +148,35 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
/// <summary>
/// Adds an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline
/// so that it can intercept approval requests for tools that do not require approval. When
/// <see cref="FunctionInvokingChatClient"/> converts all function calls to approval requests (because at
/// least one tool requires approval), this decorator removes the requests for non-approval-required tools,
/// stores them in the session, and automatically re-injects them as approved on the next request.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> with
/// an active session, and will throw an exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseNonApprovalRequiredFunctionBypassing(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
}
@@ -53,6 +53,17 @@ public static class ChatClientExtensions
{
var chatBuilder = chatClient.AsBuilder();
// NonApprovalRequiredFunctionBypassingChatClient 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 first, the resulting pipeline is:
// NonApprovalRequiredFunctionBypassingChatClient → FunctionInvokingChatClient → ChatHistoryPersistingChatClient → 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.
if (options?.EnableNonApprovalRequiredFunctionBypassing is true)
{
chatBuilder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
@@ -0,0 +1,285 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that automatically removes <see cref="ToolApprovalRequestContent"/> for tools
/// that do not actually require approval, storing auto-approved results in the session for transparent
/// re-injection on the next request.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/> — even for tools that do not require approval. This
/// decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline and transparently handles
/// the non-approval-required items so callers only see approval requests for tools that truly need them.
/// </para>
/// <para>
/// On outbound responses, the decorator identifies <see cref="ToolApprovalRequestContent"/> items for tools
/// that are not wrapped in <see cref="ApprovalRequiredAIFunction"/>, removes them from the response, and
/// stores them in the session's <see cref="AgentSessionStateBag"/>. On the next inbound request, the stored
/// items are re-injected as pre-approved <see cref="ToolApprovalResponseContent"/> so that
/// <see cref="FunctionInvokingChatClient"/> can process them alongside the caller's human-approved responses.
/// </para>
/// <para>
/// This decorator requires an active <see cref="AIAgent.CurrentRunContext"/> with a non-null
/// <see cref="AgentRunContext.Session"/>. An <see cref="InvalidOperationException"/> is thrown if no
/// run context or session is available.
/// </para>
/// </remarks>
internal sealed class NonApprovalRequiredFunctionBypassingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="AgentSessionStateBag"/> to store pending auto-approved function calls
/// between agent runs.
/// </summary>
internal const string StateBagKey = "_autoApprovedFunctionCalls";
/// <summary>
/// Initializes a new instance of the <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
public NonApprovalRequiredFunctionBypassingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
messages = InjectPendingAutoApprovals(messages, session);
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session);
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
messages = InjectPendingAutoApprovals(messages, session);
List<ToolApprovalRequestContent>? autoApproved = null;
try
{
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
{
yield return update;
}
}
}
finally
{
if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
}
}
}
/// <summary>
/// Gets the current <see cref="AgentSession"/> from the ambient run context.
/// </summary>
/// <exception cref="InvalidOperationException">No run context or session is available.</exception>
private static AgentSession GetRequiredSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
return runContext.Session
?? throw new InvalidOperationException(
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} requires a session. " +
"Ensure the agent has a resolved session before invoking the chat client.");
}
/// <summary>
/// Checks the session for stored auto-approvals from a previous turn and injects them as
/// a user message containing <see cref="ToolApprovalResponseContent"/> items appended to the input messages.
/// </summary>
/// <remarks>
/// All stored requests are unconditionally injected as approved responses regardless of whether the
/// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn.
/// </remarks>
private static IEnumerable<ChatMessage> InjectPendingAutoApprovals(
IEnumerable<ChatMessage> messages,
AgentSession session)
{
if (!session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
StateBagKey,
out var pendingRequests,
AgentJsonUtilities.DefaultOptions)
|| pendingRequests is not { Count: > 0 })
{
return messages;
}
session.StateBag.TryRemoveValue(StateBagKey);
List<AIContent> approvalResponses = [];
foreach (var request in pendingRequests)
{
approvalResponses.Add(request.CreateResponse(approved: true));
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
return messages.Concat([userMessage]);
}
/// <summary>
/// Builds a set of tool names that do not require approval and can be auto-approved,
/// by checking all available tools from <see cref="ChatOptions.Tools"/> and
/// <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
/// </summary>
private HashSet<string> GetAutoApprovableToolNames(ChatOptions? options)
{
var ficc = this.GetService<FunctionInvokingChatClient>();
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
return new HashSet<string>(
allTools
.OfType<AIFunction>()
.Where(static f => f.GetService<ApprovalRequiredAIFunction>() is null)
.Select(static f => f.Name),
StringComparer.Ordinal);
}
/// <summary>
/// Determines whether a <see cref="ToolApprovalRequestContent"/> can be auto-approved because
/// the underlying tool is not an <see cref="ApprovalRequiredAIFunction"/>.
/// </summary>
/// <returns>
/// <see langword="true"/> if the approval request is for a known tool that does not require approval
/// and can be auto-approved; <see langword="false"/> otherwise.
/// </returns>
private static bool IsAutoApprovable(ToolApprovalRequestContent approval, HashSet<string> autoApprovableNames)
{
if (approval.ToolCall is not FunctionCallContent fcc)
{
// Non-function tool calls cannot be auto-approved.
return false;
}
// Auto-approve only if the tool is known and explicitly does NOT require approval.
// Unknown tools are not in the set and are treated as approval-required (safe default).
return autoApprovableNames.Contains(fcc.Name);
}
/// <summary>
/// Scans response messages for auto-approvable <see cref="ToolApprovalRequestContent"/> items,
/// removes them from the messages, and stores them in the session for the next request.
/// </summary>
private static void RemoveAutoApprovedFromMessages(
IList<ChatMessage> messages,
HashSet<string> autoApprovableNames,
AgentSession session)
{
List<ToolApprovalRequestContent>? autoApproved = null;
foreach (var message in messages)
{
for (int i = message.Contents.Count - 1; i >= 0; i--)
{
if (message.Contents[i] is ToolApprovalRequestContent approval
&& IsAutoApprovable(approval, autoApprovableNames))
{
(autoApproved ??= []).Add(approval);
message.Contents.RemoveAt(i);
}
}
}
// Remove messages that are now empty after filtering.
for (int i = messages.Count - 1; i >= 0; i--)
{
if (messages[i].Contents.Count == 0)
{
messages.RemoveAt(i);
}
}
if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
}
}
/// <summary>
/// Filters auto-approvable <see cref="ToolApprovalRequestContent"/> items from a streaming update's
/// contents, collecting them for later storage.
/// </summary>
/// <returns>
/// <see langword="true"/> if the update should be yielded (has remaining content or had no
/// approval content to begin with); <see langword="false"/> if the update is now empty and
/// should be skipped.
/// </returns>
private static bool FilterUpdateContents(
ChatResponseUpdate update,
HashSet<string> autoApprovableNames,
ref List<ToolApprovalRequestContent>? autoApproved)
{
bool hasApprovalContent = false;
List<AIContent> filteredContents = [];
bool removedAny = false;
for (int i = 0; i < update.Contents.Count; i++)
{
var content = update.Contents[i];
if (content is ToolApprovalRequestContent approval)
{
hasApprovalContent = true;
if (IsAutoApprovable(approval, autoApprovableNames))
{
(autoApproved ??= []).Add(approval);
removedAny = true;
}
else
{
filteredContents.Add(content);
}
}
else
{
filteredContents.Add(content);
}
}
if (removedAny)
{
update.Contents = filteredContents;
}
// Yield the update unless it was purely auto-approvable approval content (now empty).
return update.Contents.Count > 0 || !hasApprovalContent;
}
}
@@ -134,6 +134,7 @@ public class ChatClientAgentOptionsTests
ClearOnChatHistoryProviderConflict = false,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
EnableNonApprovalRequiredFunctionBypassing = true,
};
// Act
@@ -150,6 +151,7 @@ public class ChatClientAgentOptionsTests
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
Assert.Equal(original.EnableNonApprovalRequiredFunctionBypassing, clone.EnableNonApprovalRequiredFunctionBypassing);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
@@ -0,0 +1,574 @@
// 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 NonApprovalRequiredFunctionBypassingChatClientTests
{
#region GetResponseAsync Tests
[Fact]
public async Task GetResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var response = await RunWithAgentContextAsync(decorator, session);
// Assert
Assert.Single(response.Messages);
Assert.Equal("Hello", response.Messages[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_AllToolsRequireApproval_PassesThroughUnchangedAsync()
{
// Arrange
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fcc = new FunctionCallContent("call1", "approvalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — approval request should remain
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_RemovesNonApprovalItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — only the approval-required item remains in the response
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
var remainingApproval = Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal("req2", remainingApproval.RequestId);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetResponseAsync_AllNonApproval_RemovesAllApprovalsAndRemovesEmptyMessageAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — the message should be removed since it's now empty
Assert.Empty(response.Messages);
}
[Fact]
public async Task GetResponseAsync_NextRequest_InjectsStoredAutoApprovalsAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the inner client should receive injected messages
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
// Original user message + user message with approved responses.
Assert.Equal(2, messagesList.Count);
Assert.Equal(ChatRole.User, messagesList[0].Role);
// User message with the auto-approved ToolApprovalResponseContent
Assert.Equal(ChatRole.User, messagesList[1].Role);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
}
[Fact]
public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored data should be cleared after successful injection
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
[Fact]
public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync()
{
// Arrange — tool is not in ChatOptions.Tools
var fccUnknown = new FunctionCallContent("call1", "unknownTool");
var approvalUnknown = new ToolApprovalRequestContent("req1", fccUnknown);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalUnknown])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — unknown tool should NOT be auto-approved
Assert.Single(response.Messages);
Assert.Single(response.Messages[0].Contents);
Assert.IsType<ToolApprovalRequestContent>(response.Messages[0].Contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_StoredRequestToolSetChanged_StillInjectsAsApprovedAsync()
{
// Arrange — tool was previously non-approval-required but is now wrapped in ApprovalRequiredAIFunction.
// The LLM still requires a complete set of responses, so we inject unconditionally.
var fccTool = new FunctionCallContent("call1", "changingTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccTool);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// The tool is now wrapped in ApprovalRequiredAIFunction — but we still inject unconditionally
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "changingTool"));
var options = new ChatOptions { Tools = [approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored request should still be injected as approved
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
Assert.Equal(2, messagesList.Count);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
// Session should be cleared
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
#endregion
#region GetStreamingResponseAsync Tests
[Fact]
public async Task GetStreamingResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates);
// Assert
Assert.Single(updates);
Assert.Equal("Hello", updates[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_FiltersNonApprovalItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — text update + filtered approval update
Assert.Equal(2, updates.Count);
Assert.Equal("text", updates[0].Text);
// Second update should only have the approval-required item
var approvalContents = updates[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(approvalContents);
Assert.Equal("req2", approvalContents[0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_AllNonApproval_SkipsEmptyUpdateAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the approval update should be skipped entirely
Assert.Single(updates);
Assert.Equal("text", updates[0].Text);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task GetResponseAsync_NoRunContext_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// Act & Assert — calling directly without agent context
await Assert.ThrowsAsync<InvalidOperationException>(
() => decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]));
}
[Fact]
public async Task GetResponseAsync_NoSession_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// Act & Assert — run with null session
await Assert.ThrowsAsync<InvalidOperationException>(
() => RunWithAgentContextAsync(decorator, session: null!));
}
#endregion
#region Builder Extension Tests
[Fact]
public void UseNonApprovalRequiredFunctionBypassing_AddsDecoratorToPipeline()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
// Act
var pipeline = innerClient.AsBuilder()
.UseNonApprovalRequiredFunctionBypassing()
.Build();
// Assert
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
[Fact]
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassing_InjectsDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = true };
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
[Fact]
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassingFalse_DoesNotInjectDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = false };
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.Null(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
#endregion
#region Helpers
private static async Task<ChatResponse> RunWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession? session,
ChatOptions? options = 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([new ChatMessage(ChatRole.User, "Hello")], session);
return capturedResponse!;
}
private static Task<ChatResponse> RunWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession session)
=> RunWithAgentContextAsync(decorator, session, options: null);
private static async Task RunStreamingWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession session,
List<ChatResponseUpdate> updates,
ChatOptions? options = null)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
{
await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
}
private static IChatClient CreateMockChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
return mock.Object;
}
private static IChatClient CreateMockStreamingChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
return mock.Object;
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
#endregion
}